diff --git a/AGENTS.md b/AGENTS.md index 6ccc5686..aad29c94 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,7 +114,9 @@ as PhotoCraft does with `cargo xtask corpus`. ## See also -- [docs/gaps.md](docs/gaps.md): where EffectCraft falls short of After Effects and the prioritised workstreams (G1–G9); read it before choosing work +- [ROADMAP.md](ROADMAP.md): stage, headline numbers and what's next +- [docs/gaps.md](docs/gaps.md): every known shortfall against After Effects, ranked, and the prioritised workstreams (G1–G9); read it before choosing work +- [docs/target-app-parity.md](docs/target-app-parity.md): the parity numbers and how they were measured, with the per-area docs it links (UI, file formats, hardware, localization, effects, scripting); progress docs follow [craftrules `standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md) - [CONTRIBUTING.md](CONTRIBUTING.md): setup, gates, commits, how to add things - [docs/architecture.md](docs/architecture.md): layers, data model, commands, pipeline - [docs/testing.md](docs/testing.md): oracle tests, criteria, benchmarks diff --git a/CLAUDE.md b/CLAUDE.md index da31c48f..a7c47fe3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ EffectCraft is a clean-room, open-source, pure-Rust motion graphics and visual e `plan/` is maintainer-local (gitignored). Public equivalents: [`ROADMAP.md`](ROADMAP.md), [`docs/`](docs/). 1. Read `plan/STATUS.md` (current milestone, next task, running agents, blockers). 2. Read the task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the crate README/docs you touch. Feature ids: `plan/aftereffects/feature-catalog.md`; effects: `plan/aftereffects/effects-list.md`; UI reference: `plan/aftereffects/README.md`. -3. Read [`docs/gaps.md`](docs/gaps.md): the honest assessment of where we fall short and the prioritised workstreams. Fidelity, reliability and stability (G1–G3) come before new features. +3. Read [`docs/gaps.md`](docs/gaps.md): every known shortfall, ranked, and the prioritised workstreams; the numbers are in [`docs/target-app-parity.md`](docs/target-app-parity.md). Fidelity, reliability and stability (G1–G3) come before new features. 4. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask. ## Non-negotiables diff --git a/Cargo.lock b/Cargo.lock index 5cba40df..1e6f65c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1157,6 +1157,7 @@ dependencies = [ "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", + "pollster 0.4.0", "rfd", "serde_json", "winit", @@ -1359,6 +1360,7 @@ dependencies = [ "effectcraft-svg", "effectcraft-time", "exr", + "gif", "image", "rayon", "serde_json", diff --git a/README.md b/README.md index 616994ae..96aa9a17 100644 --- a/README.md +++ b/README.md @@ -234,8 +234,10 @@ what breaks. It isn't yet a replacement for After Effects on client work. In par Settings ▸ Roto Brush) and face tracking can use Google's MediaPipe Face Landmarker (an optional 3.8 MB download in Settings ▸ Face Tracking). Neither is compared with After Effects yet. -The [ROADMAP](ROADMAP.md) has the plan and [docs/gaps.md](docs/gaps.md) the full, honest -assessment. The web build ([docs/web.md](docs/web.md)) runs the full app in the browser. +EffectCraft is in **alpha**: about 91% of After Effects' features exist, and we estimate it is +about 45% of the way to replacing After Effects for professional work. The [ROADMAP](ROADMAP.md) +has the plan, [docs/target-app-parity.md](docs/target-app-parity.md) the measurement and +[docs/gaps.md](docs/gaps.md) every known shortfall. The web build ([docs/web.md](docs/web.md)) runs the full app in the browser. [Bug reports](https://github.com/storytold/effectcraft/issues) are the most useful thing you can send us right now. diff --git a/ROADMAP.md b/ROADMAP.md index 560ced57..aa363bea 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,128 @@ -# Roadmap +# EffectCraft Roadmap +**Stage: alpha** · next: beta, ~30 points of "ready for real work" and ~350–620 h away + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (full re-measure against After Effects 2026 26.5; standard progress-docs layout) · **Target:** Adobe After Effects 2026 (26.5.0.89) + +EffectCraft aims to do what After Effects does, with the same panels, menus and behaviour, written +from scratch in Rust. This page is the one-page summary; the evidence is in +[docs/target-app-parity.md](docs/target-app-parity.md), the work list in +[docs/gaps.md](docs/gaps.md), and the plan in [docs/roadmap.md](docs/roadmap.md). + +## Headline numbers + +| | Value | Kind | +|---|---|---| +| **Feature breadth** | **≈ 91%** | Estimated, weighted by area; the self-graded 92-item checklist says ≈ 99% | +| **Ready for real work** | **≈ 45%** (40–50%) | Estimated; one live fidelity measurement so far | +| **Mainstream practitioner** | **≈ 47%** | Estimated: the typical motion designer's weekly work ([how](docs/target-app-parity.md#mainstream-practitioner-and-essentials-user)) | +| **Essentials user** | **≈ 52%** | Estimated: core features only ([how](docs/target-app-parity.md#essentials-user)) | +| Remaining to **beta** | **≈ 350–620 Opus 5.5 agent-hours** | Estimated | +| Remaining to **full parity** | **≈ 850–1,500 Opus 5.5 agent-hours** | Estimated; ≈ 70% parallelises | + +| Audience | Ready % | Hours to ~95% | Work that dominates | +|---|---:|---:|---| +| Full target | ≈ 45% | ≈ 850–1,500 h | `.aep`, fidelity corpus, bug backlog, performance, formats, hardware, localization | +| Mainstream practitioner | ≈ 47% | ≈ 280–530 h | `.aep` exchange, stability, everyday fidelity and bugs, preview speed | +| Essentials user | ≈ 52% | ≈ 150–300 h | Opening tutorial `.aep` files, start-up stability, core bugs, preview with sound | + +About 60–70% of these hours parallelise ([detail](docs/target-app-parity.md#readiness-by-audience)). + +Hours are one Opus 5.5 agent working sequentially, calibrated on this repository's history +([how](docs/target-app-parity.md#calibration-of-the-hours)). + +### Stage + +**Alpha**, because all six core workflows of the [alpha gate](docs/roadmap.md#alpha-gate) +(set up, animate, text and shapes, composite, preview, save and deliver) work end to end on macOS +and the work saves and reopens, and ready for real work (≈ 45%) is above the ≈ 40% bar. But: After Effects projects (`.aep`) can't be opened, which +alone rules out beta; fidelity against After Effects is measured for one narrow case (scalar ease, +168 samples); 134 issues are open, eight of them crash reports. Beta needs ≈ 75% ready and `.aep` +opening reliably: the `.aep` owner decision and importer (80–160 h), the fidelity harness and +P0/P1 corpus (60–100 h), the crash and bug backlog (40–80 h), performance (40–70 h), +Linux/Windows reliability (30–50 h) and depth in animation, masks, effects and text (100–160 h). + +## By dimension + +| Dimension | % | Hours | Doc | +|---|---:|---:|---| +| Features: breadth | 91% | — | [target-app-parity.md](docs/target-app-parity.md#by-feature-area) | +| Features: ready for real work | 50% | 360–660 | [target-app-parity.md](docs/target-app-parity.md#by-feature-area) | +| UI/UX fidelity | 65% | 60–120 | [ui-parity.md](docs/ui-parity.md) | +| File formats | 50% | 130–230 | [file-format-parity.md](docs/file-format-parity.md) | +| Hardware | 40% | 60–120 | [hardware-parity.md](docs/hardware-parity.md) | +| Localization | 12% | 60–110 + review | [localization-parity.md](docs/localization-parity.md) | +| Performance | 35% | 50–100 | [gaps.md](docs/gaps.md#g5-performance-at-real-world-scale) | +| Stability | 45% | 40–80 | [gaps.md](docs/gaps.md#g3-stability-and-a-green-main) | +| Platforms | 65% | 30–60 | [gaps.md](docs/gaps.md#g2-real-user-reliability-on-every-platform) | +| Ecosystem and plug-ins | 20% | 40–80 | [plugins.md](docs/plugins.md), [scripting-parity.md](docs/scripting-parity.md) | +| AI features | 35% | 40–80 | [gaps.md](docs/gaps.md) | +| Agent control (beyond After Effects) | ahead | — | [agents.md](docs/agents.md) | + +## Features + +| Area | Breadth | Ready | Hours | +|---|---:|---:|---:| +| Project, footage and import | 92% | 55% | 25–45 | +| Compositions, layers and compositing | 98% | 60% | 30–55 | +| Animation: keyframes, graph editor, motion paths, puppet | 97% | 55% | 35–60 | +| Expressions and scripting ([detail](docs/scripting-parity.md)) | 93% | 65% | 20–40 | +| Shapes, masks and roto | 93% | 45% | 30–50 | +| Text and typography | 94% | 50% | 20–35 | +| Effects and animation presets ([detail](docs/effects-parity.md)) | 85% | 40% | 70–130 | +| 3D (Classic and Advanced) | 82% | 35% | 35–65 | +| Tracking and AI-assisted tools | 80% | 35% | 35–70 | +| Preview and cache | 92% | 40% | 25–50 | +| Render queue and export | 85% | 55% | 25–45 | +| Audio | 85% | 45% | 6–12 | + +Weights and evidence per row: [target-app-parity.md](docs/target-app-parity.md#by-feature-area). + +## Languages + +UI strings translated out of ≈ 3,900 (detail: [localization-parity.md](docs/localization-parity.md)). + +| Language | Status | % | +|---|---|---:| +| English | full | 100% | +| Simplified Chinese | partial (menus, part of the panels) | 28% | +| Spanish | menus only | 16% | +| Hindi | none | 0% | +| Arabic | none | 0% | +| French | none | 0% | +| Portuguese (Brazil) | menus only | 16% | +| Indonesian | none | 0% | +| Japanese | partial (menus, part of the panels) | 26% | +| German | none | 0% | +| Korean | none | 0% | +| Vietnamese | none | 0% | + +Also shipped: Traditional Chinese (partial, 28%) and Ukrainian (partial, 26%). No language has had +native-speaker review. + +## Upcoming + +Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gaps.md). + +| | Next | Hours | +|---|---|---:| +| 1 | Fix the open crash reports; run `cargo xtask ci` on every pull request (G3) | 15–30 | +| 2 | Fidelity harness against After Effects: rendered frames, a corpus case per P0/P1 feature (G1) | 60–100 | +| 3 | Real-user reliability on Linux and Windows; the open mask, effect, text and 3D bugs (G2) | 100–190 | +| 4 | `.aep` / `.aepx` import, after the owner's clean-room decision (G4) | 80–160 | +| 5 | Performance: benchmarks at 1080p / 4K, real-time cached playback, hardware video (G5) | 50–100 | +| 6 | Localization depth: effect names, all panels, French / German / Korean (G9) | 60–110 | + +Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). + +## Progress log + +- 2026-10-10: Full parity re-measure against After Effects 2026 26.5 (installed bundle, 26.2–26.5 + release notes, repository counts): breadth ≈ 91%, ready ≈ 45%, stage alpha. Progress docs + follow the shared standard: `docs/parity.md` became `docs/target-app-parity.md`; new + `docs/roadmap.md`, `localization-parity.md`, `file-format-parity.md`, `hardware-parity.md`, + `ui-parity.md`, `effects-parity.md` and `scripting-parity.md`. +- 2026-10-10: Spanish (#460) and Brazilian Portuguese (#217) menu translations. - 2026-10-10: Japanese now translates the interface below the menu bar too: `crates/ui-egui/src/i18n/ui.rs` has a Japanese table with the same 396 rows as the Chinese ones (panels, dialogs, buttons and tooltips; the shape test keeps the three catalogs listing the same source strings). Menus were already Japanese. - 2026-10-09: Ukrainian now covers all 396 current panel-catalog strings, including dialogs, buttons and tooltips, alongside its menu translation. The catalog keeps the same source keys and placeholder counts as the Chinese catalogs; missing or engine-generated text still falls back to English. @@ -9,70 +132,17 @@ - 2026-10-09: Settings ▸ General ▸ Language now translates the interface below the menu bar as well: panels, dialogs, buttons and tooltips read their text through `crates/ui-egui/src/i18n/ui.rs`, keyed by the English source string. Simplified and Traditional Chinese are complete (384 strings); Japanese rows can follow in the same file. A test fails when a converted panel gains a hard-coded string, and another keeps the catalog free of unused rows. - 2026-10-08: `effectcraft-cli render --out` resolves a relative path against the working directory, like `--project` (it used the project's folder, which doubled a path that already named it). Render Queue outputs set in a project are unchanged. - 2026-10-08: Long-press a grouped toolbar button to choose its tools, including Horizontal/Vertical Type; releasing the hold keeps the menu open. Existing point/paragraph text creation is unchanged. - - 2026-10-06: Settings ▸ General ▸ Language persists English/Japanese menu labels (`general.language`); native UI reuses installed Japanese font fallback, with no bundled CJK font. Dialog and panel contents remain English. +- 2026-10-05: Honest assessment ([docs/gaps.md](docs/gaps.md)): breadth ≈ 99% by our own checklist, real use ≈ 30–50%; workstreams G1–G9. +- 2026-10-04: Checklist audit at `d39c0e8`: 89 done / 3 partial / 0 missing of 92 features. +- 2026-10-01: First commit. -EffectCraft aims to do what After Effects does, with the same panels, menus and behaviour, written -from scratch in Rust. This is where it stands. The milestones overlap; several are worked on at -once. +## Revision history -| | Milestone | State | +| Date | Change | Summary | |---|---|---| -| M0 | Skeleton: crates, compositor, the After Effects style shell, control channel, `cargo xtask` | Done (web build: [docs/web.md](docs/web.md)) | -| M1 | Keyframes: temporal and spatial interpolation, Easy Ease, roving, velocity | Done | -| M2 | Compositing: 38 blend modes, track mattes, parenting, adjustment layers | Done | -| M3 | Project operations: settings dialogs, layer commands, `.ecproj`, undo, After Effects menu bar | Done (unsaved-changes prompts, Solid / Layer Settings, lock-aware layer commands, safe project files; View ▸ New Viewer still opens only the one locked viewer) | -| M4 | Preview: precomps, motion blur, cached playback | Done (Preserve frame rate / resolution when nested, one motion blur gate, RAM preview fixes, Cache Frames When Idle, motion blur of collapsed precomps and of animated shape / text content, content-keyed RAM preview frames) | -| M5 | Timeline depth: graph editor, keyframe clipboard and dialogs, time remapping, pick-whips, expression editor | Done | -| M6 | Shapes, masks and footage: shape operators, masks and the pen tool, video and image import | Done | -| M7 | 3D: 3D layers, cameras, lights, shadows, depth of field, 3D views and camera tools | In review | -| M8 | Expressions with the After Effects object model | Done | -| M9 | Text and effects: text animators, layer styles, 306 effects (all 298 of After Effects') incl. time and audio effects | Done (range, wiggly and expression selectors, per-character 3D, text on a path) | -| M10 | Export: render queue, H.264, ProRes, image sequences, GIF, audio | Done | -| M11 | Animation tools: audio playback, meters and waveforms, Lottie import and export, presets, Motion Sketch, Wiggler, Smoother | Done | -| M12 | Performance: layer cache, parallel and GPU compositing, disk cache, motion tracking (done); GPU versions of the remaining CPU-only effects | Mostly done | -| M13 | Puppet tools, paint, Roto Brush, motion tracking, a plug-in API, Timeline depth | Done (trained models, open source, downloaded on demand, in a swappable model module: MobileSAM for Roto Brush 2.0 / 3.0, MediaPipe Face Landmarker for face tracking) | -| M15 | The web app (WebAssembly, WebGPU) | Done: browser storage, Web Audio, renders and analyses in Web Workers, viewer frames and GPU effects in frame workers with their own WebGPU devices, the disk cache in the Origin Private File System, a storage manager, offline install ([docs/web.md](docs/web.md)) | -| M14 | Built for agents: MCP server, command-line tool, control channel; Settings, keyboard shortcut editor, auto-save and crash recovery ([docs/preferences.md](docs/preferences.md)) | Done | - -## How far from full parity - -There are two honest answers, and they are far apart. The full assessment, with the evidence, is -in [docs/gaps.md](docs/gaps.md). - -- **Breadth: ≈ 99%.** Almost every After Effects feature exists: 89 of 92 catalogued features - done and 3 partial, all 306 effects implemented, 280 of them on the GPU - ([docs/parity.md](docs/parity.md)). This is our own checklist, graded by the agents that built - the features. -- **Real use: ≈ 30–50% (estimated, not yet measured).** Whether someone who uses After Effects for - a living can do client work in EffectCraft. What holds it back: - - **Fidelity is unmeasured.** No test compares our output with After Effects itself, and users - are still finding behaviour bugs in features marked done. - - **After Effects projects can't be opened.** EffectCraft can't read `.aep` / `.aepx` files, - and third-party After Effects plug-ins can't run. - - **Reliability is uneven across platforms.** Early Linux users hit basic problems: viewer - panning, panel docking, drag-and-drop import. A Windows user found the font menus offered only - the bundled fonts (fixed 6 October: they list every installed font). Reports of 6 October - covered drag and drop between panels, a Render Queue menu that ignored clicks, angle - revolutions, and preview with audio and with little video memory (all fixed 7 October). - - **AI tools are new.** Roto Brush's trained model (MobileSAM, M13.35) and face tracking's - (MediaPipe Face Landmarker, M13.36) are optional downloads, not yet compared with After - Effects. - -## Where we're going - -In priority order (details and "done" criteria in [docs/gaps.md](docs/gaps.md)): - -| | Workstream | Why | -|---|---|---| -| G1 | Measure fidelity against After Effects: a corpus of test projects rendered and sampled in both apps, scored per feature | Turns every estimate into a measurement; a feature that behaves differently is not done | -| G2 | Real-user reliability on macOS, Windows and Linux; fix every open user issue | Basic workflows must work everywhere before more features matter | -| G3 | Stability: a green gate on the current toolchain, fuzzing of every input | A crash loses people's work | -| G4 | Open After Effects projects (`.aep` / `.aepx`, pending an owner decision on clean-room scope); relinking moved footage | The biggest barrier to switching | -| G5 | Performance benchmarks at 1080p and 4K on real-world-sized projects | Unknown today | -| G6 | Media depth: encoder efficiency, camera and phone formats | Files are larger than from mature encoders; camera formats are unverified | -| G7 | Learned models: Roto Brush (M13.35: MobileSAM) and face tracking (M13.36: MediaPipe Face Landmarker), open source, optional downloads, swappable model module; next, measure them | Quality against After Effects is unmeasured | -| G8 | Plug-in ecosystem on our WebAssembly plug-in API | After Effects plug-ins can't run here | -| G9 | Localisation, accessibility, user documentation | Reach beyond English-speaking power users | - -Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). +| 2026-10-10 | minor | Hours to ≈ 95% for each audience | +| 2026-10-10 | minor | Headline adds mainstream practitioner (≈ 47%) and essentials user (≈ 52%) | +| 2026-10-10 | minor | Stage checked against the new core-workflow gate (all six pass; still alpha) | +| 2026-10-10 | major | Rewritten to the shared progress-docs standard: stage, two numbers, dimensions, features, languages, upcoming, progress log; milestones moved to docs/roadmap.md | +| 2026-10-05 | major | Two numbers (breadth vs real use) and workstreams G1–G9 | diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index a653b26b..fe523807 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -117,6 +117,9 @@ const USAGE: &str = "usage: effectcraft-cli : id or name, '-' = active comp; : id, '#n' or name; : JSON or bare string"; @@ -163,6 +166,9 @@ const VALUED: &[&str] = &[ "--webm-codec", "--audio-bitrate", "--opus-app", + // mcp + "--automation-read-root", + "--automation-write-root", ]; /// Options without a value. Any other `--option` is a usage error. @@ -232,6 +238,10 @@ impl Args { fn flag(&self, k: &str) -> bool { self.opts.iter().any(|(o, _)| o == k) } + /// Every value of a repeatable option, in order. + fn opts(&self, k: &str) -> Vec<&str> { + self.opts.iter().filter(|(o, _)| o == k).filter_map(|(_, v)| v.as_deref()).collect() + } fn opt(&self, k: &str) -> Option<&str> { self.opts.iter().rev().find(|(o, _)| o == k).and_then(|(_, v)| v.as_deref()) } @@ -595,7 +605,12 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> { if args.flag("--autosave") && args.opt("--bridge").is_some() { return usage_err("--autosave is for headless MCP; the bridged app owns its auto-saves"); } - let b = backend(args, false)?; + let mut b = backend(args, false)?; + let (read, write) = (args.opts("--automation-read-root"), args.opts("--automation-write-root")); + if !read.is_empty() || !write.is_empty() { + let roots = |r: Vec<&str>| r.into_iter().map(std::path::PathBuf::from).collect::>(); + b.confine(effectcraft_automation::confine::Confinement::new(&roots(read), &roots(write))?); + } let server = McpServer::new(b); let mut server = if args.flag("--autosave") { let root = diff --git a/apps/effectcraft/Cargo.toml b/apps/effectcraft/Cargo.toml index ad3fc8b5..5fabc10c 100644 --- a/apps/effectcraft/Cargo.toml +++ b/apps/effectcraft/Cargo.toml @@ -18,6 +18,7 @@ egui = { workspace = true } eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "links", "wayland", "x11"] } serde_json = { workspace = true } rfd = { workspace = true } +pollster = { workspace = true } cpal = { workspace = true } log = { workspace = true } diff --git a/apps/effectcraft/src/audio_out.rs b/apps/effectcraft/src/audio_out.rs index a7929b6d..7d0ec4b2 100644 --- a/apps/effectcraft/src/audio_out.rs +++ b/apps/effectcraft/src/audio_out.rs @@ -10,11 +10,32 @@ use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::mpsc; use std::thread::JoinHandle; +use std::time::Duration; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use cpal::{FromSample, SizedSample}; use effectcraft_ui_egui::audio::{AudioDevice, AudioFeed, AudioOutput}; +/// Device callback period: about 10 ms, clamped so Pulse/PipeWire/ALSA does not open a +/// multi-hundred-millisecond default buffer that plays as silence at the start of preview (#554). +fn preferred_buffer_frames(rate: u32) -> u32 { + (rate / 100).clamp(256, 2048) +} + +/// Wait until the feeder has mixed a block, or until preview is stopped. Starting the stream +/// before that fills the device with silence, so the first real samples are heard late (#554). +fn wait_to_play(feed: &AudioFeed, stop: &mpsc::Receiver<()>) -> bool { + loop { + if feed.queued_frames() > 0 { + return true; + } + match stop.recv_timeout(Duration::from_millis(2)) { + Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => return false, + Err(mpsc::RecvTimeoutError::Timeout) => {} + } + } +} + pub struct CpalOut { out: AudioOutput, rate: u32, @@ -131,21 +152,44 @@ impl AudioDevice for CpalOut { }; let mut cfg: cpal::StreamConfig = sup.config(); cfg.sample_rate = cpal::SampleRate(rate); - let stream = match sup.sample_format() { - cpal::SampleFormat::F32 => run::(&dev, &cfg, feed, latency, map), - cpal::SampleFormat::I16 => run::(&dev, &cfg, feed, latency, map), - cpal::SampleFormat::U16 => run::(&dev, &cfg, feed, latency, map), - cpal::SampleFormat::I32 => run::(&dev, &cfg, feed, latency, map), + // A short period only where the default one is long (ALSA/PulseAudio on Linux, #554), + // and only within what the device reports it takes. + if cfg!(target_os = "linux") { + let want = preferred_buffer_frames(rate); + cfg.buffer_size = match sup.buffer_size() { + cpal::SupportedBufferSize::Range { min, max } if *min <= *max => cpal::BufferSize::Fixed(want.clamp(*min, *max)), + _ => cpal::BufferSize::Default, + }; + } + let build = |cfg: &cpal::StreamConfig| match sup.sample_format() { + cpal::SampleFormat::F32 => run::(&dev, cfg, feed.clone(), latency.clone(), map), + cpal::SampleFormat::I16 => run::(&dev, cfg, feed.clone(), latency.clone(), map), + cpal::SampleFormat::U16 => run::(&dev, cfg, feed.clone(), latency.clone(), map), + cpal::SampleFormat::I32 => run::(&dev, cfg, feed.clone(), latency.clone(), map), f => Err(format!("unsupported sample format {f:?}")), }; - let stream = match stream.and_then(|s| s.play().map(|_| s).map_err(|e| e.to_string())) { + let stream = match build(&cfg) { Ok(s) => s, - Err(e) => { - let _ = ready_tx.send(Err(e)); - return; + Err(_) => { + // Some hosts reject a fixed period; a large default still plays, just later. + cfg.buffer_size = cpal::BufferSize::Default; + match build(&cfg) { + Ok(s) => s, + Err(e) => { + let _ = ready_tx.send(Err(e)); + return; + } + } } }; let _ = ready_tx.send(Ok(())); + if !wait_to_play(&feed, &stop_rx) { + return; + } + if let Err(e) = stream.play() { + eprintln!("effectcraft: audio output: {e}"); + return; + } let _ = stop_rx.recv(); drop(stream); }) @@ -174,3 +218,37 @@ impl Drop for CpalOut { self.stop(); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preferred_buffer_is_about_ten_milliseconds() { + assert_eq!(preferred_buffer_frames(48_000), 480); + assert_eq!(preferred_buffer_frames(8_000), 256); + assert_eq!(preferred_buffer_frames(192_000), 1920); + } + + #[test] + fn wait_to_play_returns_once_the_feeder_has_samples() { + let feed = Arc::new(AudioFeed::default()); + let (_tx, rx) = mpsc::channel(); + let f = feed.clone(); + let h = std::thread::spawn(move || wait_to_play(&f, &rx)); + assert_eq!(feed.queued_frames(), 0); + std::thread::sleep(Duration::from_millis(20)); + feed.push(&[0.1, 0.2]); + assert!(h.join().unwrap()); + assert_eq!(feed.queued_frames(), 1); + } + + #[test] + fn wait_to_play_stops_without_samples() { + let feed = AudioFeed::default(); + let (tx, rx) = mpsc::channel(); + let h = std::thread::spawn(move || wait_to_play(&feed, &rx)); + tx.send(()).unwrap(); + assert!(!h.join().unwrap()); + } +} diff --git a/apps/effectcraft/src/launch_guard.rs b/apps/effectcraft/src/launch_guard.rs index 14c4c440..fac91c05 100644 --- a/apps/effectcraft/src/launch_guard.rs +++ b/apps/effectcraft/src/launch_guard.rs @@ -1,39 +1,52 @@ //! Settings ▸ Startup & Repair ▸ Window Graphics, and the launch guard behind it. //! //! Some graphics drivers crash EffectCraft as its window's device is created (an access -//! violation in an older Intel UHD driver with DirectX 12 / Vulkan, #243). A crash in a driver -//! can't be caught, so each launch leaves a marker in the settings folder until its window has -//! drawn a frame. A launch that finds the previous one's marker switches Window Graphics to -//! OpenGL, which those drivers run, and says so once the window is up. `WGPU_BACKEND` (wgpu's -//! own override) still wins over both. The marker is locked while its launch runs, so a second -//! copy opened meanwhile isn't taken for a crash; and a window OpenGL can't open either puts -//! Window Graphics back to Automatic ([`opengl_failed`]) rather than failing every launch. +//! violation in an older Intel UHD driver with DirectX 12 / Vulkan, #243), or a moment after +//! its first frame, as the window is shown and its surface re-created (AMD's Vulkan driver for +//! a Radeon RX 6800M, Adrenalin 26.9.2: an access violation in amdvlk64.dll on every launch). +//! A crash in a driver can't be caught, so each launch leaves a marker in the settings folder +//! until its window has kept drawing for [`SETTLE`] (or the app quits normally before then). A +//! launch that finds the previous one's marker switches Window Graphics to OpenGL, which those +//! drivers run, and says so once the window is up. `WGPU_BACKEND` (wgpu's own override) still +//! wins over both. The marker is locked while its launch runs, so a second copy opened meanwhile +//! isn't taken for a crash; and a window OpenGL can't open either puts Window Graphics back to +//! Automatic ([`opengl_failed`]) rather than failing every launch. use std::fs::File; use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; use effectcraft_engine::prefs::{PREFS_FILE, Prefs}; use eframe::wgpu::Backends; -/// Left in the settings folder from a launch until its window draws. +/// Left in the settings folder from a launch until its window has settled. const MARKER: &str = "launch-pending"; +/// How long the window keeps drawing after its first frame before the launch counts as made. +/// A driver that fails as the window is shown and its surface re-created does so within the +/// first second; a user quitting sooner than this is told the next launch uses OpenGL and can +/// switch back in Settings. +pub const SETTLE: Duration = Duration::from_secs(3); + /// This launch's window graphics. #[derive(Debug, Default)] pub struct Launch { /// The graphics APIs the window may use (`None`: eframe's default). pub backends: Option, - /// Switched to OpenGL because the previous launch never drew: the message to show. + /// Switched to OpenGL because the previous launch stopped as its window opened: the message + /// to show. pub notice: Option, marker: Option, - /// Holds the marker's lock until the window draws (or the process ends). + /// Holds the marker's lock until the launch settles (or the process ends). lock: Option, + /// When the window drew its first frame, until the launch settles. + first_frame: Option, } impl Launch { /// Read Window Graphics in the settings folder `dir`, switch it to OpenGL when the previous - /// launch never drew (where OpenGL exists: not macOS), and leave this launch's marker. - /// `env_override`: `WGPU_BACKEND` is set. + /// launch stopped as its window opened (where OpenGL exists: not macOS), and leave this + /// launch's marker. `env_override`: `WGPU_BACKEND` is set. pub fn begin(dir: Option<&Path>, env_override: bool) -> Launch { let Some(dir) = dir.filter(|_| !env_override) else { return Launch::default() }; let prefs_path = dir.join(PREFS_FILE); @@ -45,11 +58,11 @@ impl Launch { if marker.exists() && !held && prefs.startup.window_graphics == "auto" && cfg!(not(target_os = "macos")) { prefs.startup.window_graphics = "gl".into(); match std::fs::create_dir_all(dir).and_then(|()| std::fs::write(&prefs_path, prefs.to_json())) { - Ok(()) => log::warn!("the last launch never drew its window: switching Window Graphics to OpenGL"), - Err(e) => log::warn!("the last launch never drew its window; saving Window Graphics failed: {e}"), + Ok(()) => log::warn!("the last launch stopped as its window opened: switching Window Graphics to OpenGL"), + Err(e) => log::warn!("the last launch stopped as its window opened; saving Window Graphics failed: {e}"), } notice = Some( - "EffectCraft's last launch stopped before its window opened, so it now draws with OpenGL \ + "EffectCraft's last launch stopped as its window opened, so it now draws with OpenGL \ (Settings ▸ Startup & Repair ▸ Window Graphics). Updating the graphics driver may let it use \ Automatic again." .to_string(), @@ -64,11 +77,33 @@ impl Launch { (None, None) } }; - Launch { backends, notice, marker, lock } + Launch { backends, notice, marker, lock, first_frame: None } } - /// The window drew its first frame: the launch made it. - pub fn drawn(&mut self) { + /// The window drew a frame. Until the launch has settled, returns how long that still takes + /// (ask for a repaint then, so that an idle window settles too). + pub fn frame_drawn(&mut self) -> Option { + self.frame_drawn_at(Instant::now()) + } + + fn frame_drawn_at(&mut self, now: Instant) -> Option { + self.marker.as_ref()?; + let first = *self.first_frame.get_or_insert(now); + let left = SETTLE.saturating_sub(now.saturating_duration_since(first)); + if left.is_zero() { + self.settled(); + return None; + } + Some(left) + } + + /// The app quits normally: however soon after opening, that's no crash. + pub fn exited(&mut self) { + self.settled(); + } + + /// The launch made it: release and remove its marker. + fn settled(&mut self) { self.lock = None; if let Some(m) = self.marker.take() && let Err(e) = std::fs::remove_file(&m) @@ -103,12 +138,18 @@ mod tests { fn a_launch_that_never_drew_switches_the_next_to_opengl() { let dir = std::env::temp_dir().join(format!("ec-launch-guard-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); - // A first launch that draws: nothing changes. + // A first launch that draws: nothing changes. Its first frames don't settle it (a driver + // may still crash as the window is shown); frames for `SETTLE` do. let mut l = Launch::begin(Some(&dir), false); assert_eq!((l.backends, l.notice.is_some()), (None, false)); assert!(dir.join(MARKER).exists()); - l.drawn(); + let t0 = Instant::now(); + assert_eq!(l.frame_drawn_at(t0), Some(SETTLE)); + assert!(l.frame_drawn_at(t0 + SETTLE / 2).is_some_and(|left| left <= SETTLE / 2)); + assert!(dir.join(MARKER).exists()); + assert_eq!(l.frame_drawn_at(t0 + SETTLE), None); assert!(!dir.join(MARKER).exists()); + assert_eq!(l.frame_drawn_at(t0 + SETTLE), None, "settled stays settled"); // A launch that crashes before drawing (it never calls `drawn`)… drop(Launch::begin(Some(&dir), false)); // …makes the next one use OpenGL and say so, once. @@ -119,7 +160,7 @@ mod tests { assert_eq!(l.backends, Some(Backends::GL)); assert!(l.notice.is_some()); assert_eq!(window_graphics(&dir), "gl", "saved in Settings"); - l.drawn(); + l.exited(); let l = Launch::begin(Some(&dir), false); assert_eq!((l.backends, l.notice.is_some()), (Some(Backends::GL), false), "it stays, without the notice"); } @@ -130,6 +171,34 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// A launch that stops moments after its first frame (AMD's Vulkan driver crashing as the + /// window is shown and its surface re-created) switches the next one to OpenGL too; a + /// normal quit in that time doesn't. + #[test] + fn a_launch_that_stops_after_its_first_frame_switches_the_next_to_opengl() { + let dir = std::env::temp_dir().join(format!("ec-launch-guard-3-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut l = Launch::begin(Some(&dir), false); + assert!(l.frame_drawn().is_some()); + drop(l); + let l = Launch::begin(Some(&dir), false); + if cfg!(target_os = "macos") { + assert_eq!(l.backends, None, "no OpenGL on macOS"); + } else { + assert_eq!(l.backends, Some(Backends::GL)); + assert!(l.notice.is_some()); + } + drop(l); + let _ = std::fs::remove_dir_all(&dir); + // Quitting right after the window opened leaves no marker. + let mut l = Launch::begin(Some(&dir), false); + assert!(l.frame_drawn().is_some()); + l.exited(); + assert!(!dir.join(MARKER).exists()); + assert_eq!(Launch::begin(Some(&dir), false).backends, None); + let _ = std::fs::remove_dir_all(&dir); + } + /// A second copy opened while the first is still opening isn't a crash; OpenGL that can't /// open a window either goes back to Automatic instead of failing every launch. #[test] diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index a7b24f3e..1b5f74f3 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -21,6 +21,9 @@ mod control_server; mod launch_guard; #[cfg(target_os = "macos")] mod native_menu; +// Built everywhere so it stays checked; only the macOS app uses it. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +mod sheet_dialogs; use effectcraft_ui_egui::EffectcraftApp; use serde_json::json; @@ -97,8 +100,8 @@ fn main() -> eframe::Result { // Wayland shows the window's icon from its desktop entry: an AppImage brings its own. #[cfg(target_os = "linux")] appimage::integrate_from_env(ICON_PNG); - // Settings ▸ Startup & Repair ▸ Window Graphics, switched to OpenGL when the last launch's - // window never drew (a crashing graphics driver, #243). + // Settings ▸ Startup & Repair ▸ Window Graphics, switched to OpenGL when the last launch + // stopped as its window opened (a crashing graphics driver, #243). let mut launch = launch_guard::Launch::begin(effectcraft_host::config_dir().as_deref(), std::env::var_os("WGPU_BACKEND").is_some()); let on_gl = launch.backends == Some(eframe::wgpu::Backends::GL); let notice = launch.notice.take(); @@ -207,6 +210,22 @@ fn main() -> eframe::Result { app.hooks.pick_save = Some(Box::new(|name: &str| { rfd::FileDialog::new().add_filter("EffectCraft Project", &["ecproj"]).set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()) })); + // The window closes after a lost graphics device (#591); say why, unless an agent drives + // the app through the control channel. + if control_port.is_none() { + app.hooks.device_lost = Some(Box::new(|saved: Option>| { + let saved = match saved { + Some(Ok(path)) => format!("\n\nYour unsaved changes were saved to:\n{path}\n\nReopen EffectCraft to recover them."), + Some(Err(e)) => format!("\n\nYour unsaved changes could not be saved ({e})."), + None => String::new(), + }; + rfd::MessageDialog::new() + .set_level(rfd::MessageLevel::Error) + .set_title("EffectCraft: graphics device lost") + .set_description(format!("The graphics driver stopped responding, so EffectCraft has to close.{saved}")) + .show(); + })); + } app.hooks.pick_open_project = Some(Box::new(|| { rfd::FileDialog::new().add_filter("EffectCraft Project", &["ecproj", "ecprojx"]).pick_file().map(|p| p.to_string_lossy().to_string()) })); @@ -227,6 +246,9 @@ fn main() -> eframe::Result { })); #[cfg(target_os = "macos")] { + // The commands' file dialogs open as sheets instead of nested modal loops (#511). + let ctx = cc.egui_ctx.clone(); + app.hooks.file_dialog_async = Some(Box::new(move |dialog, done| sheet_dialogs::show(dialog, done, ctx.clone()))); app.hooks.app_action = Some(Box::new(native_menu::app_action)); app.hooks.clipboard_text = Some(Box::new(native_menu::clipboard_text)); } @@ -327,12 +349,17 @@ impl eframe::App for Desktop { self.app.ui(ui, frame); if !self.drawn { self.drawn = true; - self.launch.drawn(); log::info!("first frame drawn"); } + // The launch guard's marker goes once the window has kept drawing for a few seconds: a + // driver may still crash as the window is shown and its surface re-created. + if let Some(left) = self.launch.frame_drawn() { + ui.ctx().request_repaint_after(left); + } } fn on_exit(&mut self) { + self.launch.exited(); self.app.on_exit(); } diff --git a/apps/effectcraft/src/sheet_dialogs.rs b/apps/effectcraft/src/sheet_dialogs.rs new file mode 100644 index 00000000..577eb0b6 --- /dev/null +++ b/apps/effectcraft/src/sheet_dialogs.rs @@ -0,0 +1,76 @@ +//! File dialogs that don't block the frame (macOS), for `Hooks::file_dialog_async`. +//! +//! A blocking `rfd::FileDialog` runs a nested modal event loop inside eframe's frame, that is +//! inside winit's event handler. On macOS a file dragged over the window while that loop ran +//! re-entered winit's handler, which panics across the Objective-C boundary and aborts the app +//! (#511). `rfd::AsyncFileDialog` shows the dialog as a sheet on the window and returns at once; +//! the answer arrives when the sheet closes, outside the frame, and the app runs the command then. + +use effectcraft_ui_egui::{DialogDone, FileDialog}; +use rfd::{AsyncFileDialog, FileHandle}; +use std::future::Future; +use std::pin::Pin; + +type Answer = Pin> + Send>>; + +/// Show `dialog` (on the main thread: the sheet opens here) and hand its paths to `done` once it +/// closes, then wake the UI. +pub fn show(dialog: FileDialog, done: DialogDone, ctx: egui::Context) { + let answer = open(dialog); + let spawned = std::thread::Builder::new().name("ec-file-dialog".into()).spawn(move || { + done(pollster::block_on(answer)); + ctx.request_repaint(); + }); + if let Err(e) = spawned { + log::error!("file dialog: {e}"); + } +} + +fn paths(files: Option>) -> Vec { + files.unwrap_or_default().into_iter().map(|f| f.path().to_string_lossy().to_string()).collect() +} + +fn path(file: Option) -> Vec { + paths(file.map(|f| vec![f])) +} + +/// The dialog, as the blocking hooks in `main.rs` show it. +fn open(dialog: FileDialog) -> Answer { + match dialog { + FileDialog::Open { exts, many } => { + let d = AsyncFileDialog::new().add_filter("Media", &exts); + if many { + let f = d.pick_files(); + Box::pin(async move { paths(f.await) }) + } else { + let f = d.pick_file(); + Box::pin(async move { path(f.await) }) + } + } + FileDialog::OpenProject => { + let f = AsyncFileDialog::new().add_filter("EffectCraft Project", &["ecproj", "ecprojx"]).pick_file(); + Box::pin(async move { path(f.await) }) + } + FileDialog::Folder => { + let f = AsyncFileDialog::new().pick_folder(); + Box::pin(async move { path(f.await) }) + } + FileDialog::Save { default, ext, project } => { + let at = std::path::Path::new(&default); + let name = at.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + let mut d = if project { + AsyncFileDialog::new().add_filter("EffectCraft Project", &["ecproj"]) + } else if ext.is_empty() { + AsyncFileDialog::new() + } else { + AsyncFileDialog::new().add_filter(&ext, &[ext.as_str()]) + }; + // A default with a folder that exists opens the dialog there. + if let Some(dir) = at.parent().filter(|d| d.is_dir()) { + d = d.set_directory(dir); + } + let f = d.set_file_name(name).save_file(); + Box::pin(async move { path(f.await) }) + } + } +} diff --git a/contributors/people.toml b/contributors/people.toml index 72e247c8..03f8c667 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -49,3 +49,8 @@ sync_github_name = false real_name = "Julen Llop" display_name = "Julen Llop" sync_github_name = false + +[people.vbasky] +real_name = "Vikram Bhaskaran" +display_name = "Vikram Bhaskaran" +sync_github_name = false diff --git a/crates/automation/src/backend.rs b/crates/automation/src/backend.rs index 685c978f..c3947c87 100644 --- a/crates/automation/src/backend.rs +++ b/crates/automation/src/backend.rs @@ -4,9 +4,16 @@ use effectcraft_engine::Session; use effectcraft_engine::time::Tick; use serde_json::{Value, json}; +use crate::confine::Confinement; use crate::{BridgeClient, Error, Result, base64, encode_png, recompress_png}; -pub enum Backend { +pub struct Backend { + target: Target, + /// File access confined to folders (`--automation-read-root` / `--automation-write-root`). + confinement: Option, +} + +enum Target { /// An in-process session (no window): engine commands, inspection and rendering. Headless(Box), /// A running desktop app reached over its control channel: everything, plus the live UI. @@ -24,43 +31,66 @@ pub struct Frame { impl Backend { pub fn headless(session: Session) -> Self { - Backend::Headless(Box::new(session)) + Backend { target: Target::Headless(Box::new(session)), confinement: None } } /// Bridge to the app's control channel at `addr` (`9877` or `127.0.0.1:9877`). pub fn bridge(addr: &str) -> Result { - Ok(Backend::Bridge(BridgeClient::new(addr)?)) + Ok(Backend { target: Target::Bridge(BridgeClient::new(addr)?), confinement: None }) + } + + /// Confine the files the commands run here may read and write ([`Confinement`]). + pub fn confine(&mut self, confinement: Confinement) { + self.confinement = Some(confinement); + } + + pub fn confinement(&self) -> Option<&Confinement> { + self.confinement.as_ref() } pub fn is_bridge(&self) -> bool { - matches!(self, Backend::Bridge(_)) + matches!(self.target, Target::Bridge(_)) } pub fn session(&mut self) -> Option<&mut Session> { - match self { - Backend::Headless(s) => Some(s), - Backend::Bridge(_) => None, + match &mut self.target { + Target::Headless(s) => Some(s), + Target::Bridge(_) => None, } } - /// Run an engine command by id. + /// Run an engine command by id (checked against the [`Confinement`], if any). pub fn exec(&mut self, id: &str, params: Value) -> Result { let params = if params.is_null() { json!({}) } else { params }; - match self { - Backend::Headless(s) => { + if let Some(c) = self.confinement.clone() { + let lookup = match c.check_command(id, ¶ms)? { + Some(crate::confine::Lookup::ProjectFile) => Some((crate::confine::Lookup::ProjectFile, self.run("project.summary", json!({}))?)), + Some(crate::confine::Lookup::RenderOutputs) => Some((crate::confine::Lookup::RenderOutputs, self.run("renderQueue.list", json!({}))?)), + None => None, + }; + if let Some((lookup, found)) = lookup { + c.check_lookup(&lookup, &found)?; + } + } + self.run(id, params) + } + + fn run(&mut self, id: &str, params: Value) -> Result { + match &mut self.target { + Target::Headless(s) => { let r = s.execute_checked(id, params); s.drain_events(); Ok(r?) } - Backend::Bridge(b) => b.call("engine.execute", json!({"command": id, "params": params})), + Target::Bridge(b) => b.call("engine.execute", json!({"command": id, "params": params})), } } /// Call a raw control-channel method (bridge only). pub fn control(&mut self, method: &str, params: Value) -> Result { - match self { - Backend::Bridge(b) => b.call(method, if params.is_null() { json!({}) } else { params }), - Backend::Headless(_) => Err(Error::BadArgs(NEED_BRIDGE.into())), + match &mut self.target { + Target::Bridge(b) => b.call(method, if params.is_null() { json!({}) } else { params }), + Target::Headless(_) => Err(Error::BadArgs(NEED_BRIDGE.into())), } } @@ -73,15 +103,15 @@ impl Backend { /// [`Backend::render`]; `transparent` keeps the frame's alpha instead of compositing it over /// the comp's background colour. pub fn render_with(&mut self, comp: Option<&Value>, time: Option, max_side: u32, transparent: bool) -> Result { - match self { - Backend::Headless(s) => { + match &mut self.target { + Target::Headless(s) => { let cid = s.resolve_comp(comp).map_err(|_| no_comp(comp))?; let t = time.map(Tick::from_seconds_f64).unwrap_or_else(|| s.time()); let (w, h, rgba) = s.render_rgba8_alpha(cid, t, max_side, transparent)?; let png = encode_png(w, h, rgba, max_side)?; Ok(Frame { png, comp: json!(cid.0), time: t.seconds(), width: w, height: h }) } - Backend::Bridge(b) => { + Target::Bridge(b) => { let mut p = json!({"max_side": max_side, "base64": true}); if transparent { p["transparent"] = json!(true); diff --git a/crates/automation/src/confine.rs b/crates/automation/src/confine.rs new file mode 100644 index 00000000..f396b3da --- /dev/null +++ b/crates/automation/src/confine.rs @@ -0,0 +1,276 @@ +//! File access confinement for automation (`effectcraft-cli mcp --automation-read-root DIR +//! --automation-write-root DIR`, as in PhotoCraft): an agent may only read files under a read +//! root (or a write root) and only write under a write root. +//! +//! Every engine command an agent runs ([`crate::Backend::exec`], also each step of an +//! `engine.batch`) is checked before it runs: its file parameters must be absolute paths inside +//! the roots (symbolic links resolved, no `..`), any other absolute path in its parameters too, +//! and commands that write to a file they are not given (Save, Increment and Save, rendering the +//! Render Queue) are checked against where they would write. Commands that run code or open +//! other applications (scripts, Execute File, Reveal in Finder…) are refused while confined. + +use std::path::{Component, Path, PathBuf}; + +use serde_json::Value; + +use crate::{Error, Result}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Access { + Read, + Write, +} + +/// The folders an agent may read and write. +#[derive(Clone, Debug)] +pub struct Confinement { + read: Vec, + write: Vec, +} + +/// Commands that run code, act outside the project or open other applications. +const REFUSED: &[&str] = &[ + "script.run", + "file.runScript", + "file.installScript", + "file.installScriptUIPanel", + "file.uninstallScript", + "file.executeFile", + "file.revealInFinder", + "layer.openSource", + "help.revealLogFile", + "file.autoSave", +]; + +/// Commands that read files named by these parameters. +const READS: &[(&str, &[&str])] = &[ + ("file.open", &["path"]), + ("file.openRecent", &["path"]), + ("file.import", &["path", "paths"]), + ("file.importMultiple", &["paths"]), + ("file.importRecent", &["path"]), + ("file.importLottie", &["path"]), + ("file.importTimeline", &["path"]), + ("file.replaceFootage", &["path"]), + ("file.relinkFootage", &["folder"]), + ("file.setProxy", &["path"]), + ("file.watchFolder", &["folder"]), + ("anim.applyPreset", &["path"]), + ("anim.applyRecentPreset", &["path"]), + ("keys.rpfCameraImport", &["path"]), + ("essential.importTemplate", &["path"]), + ("view.importGuides", &["path"]), + ("shortcuts.import", &["path"]), + ("effect.plugins.load", &["path", "folder"]), + ("mediaBrowser.go", &["path"]), + ("mediaBrowser.addFavorite", &["path"]), +]; + +/// Commands that write files named by these parameters. +const WRITES: &[(&str, &[&str])] = &[ + ("file.save", &["path"]), + ("file.saveAs", &["path"]), + ("file.saveCopy", &["path"]), + ("file.saveCopyAsXml", &["path"]), + ("file.collectFiles", &["folder"]), + ("file.exportLottie", &["path"]), + ("comp.saveFrameAs", &["path"]), + ("comp.saveFrameAsPsd", &["path"]), + ("comp.saveFrameAsExr", &["path"]), + ("anim.savePreset", &["path"]), + ("view.exportGuides", &["path"]), + ("render.saveCurrentPreview", &["path"]), + ("render.preRender", &["output"]), + ("essential.exportTemplate", &["path"]), + ("shortcuts.export", &["path"]), + ("renderQueue.add", &["output", "path"]), +]; + +/// Commands that write where they are not told: the project's file, the Render Queue's outputs. +const IMPLICIT_WRITES: &[&str] = &["file.save", "file.incrementAndSave", "renderQueue.render", "render.preRender"]; + +/// What must be looked up before an implicitly writing command may run. +#[derive(Debug, PartialEq, Eq)] +pub enum Lookup { + /// The open project's file (`project.summary` → `path`). + ProjectFile, + /// Every output of the queued Render Queue items (`renderQueue.list`). + RenderOutputs, +} + +impl Confinement { + /// Roots that must exist (each resolved to its real path). With no write root nothing may be + /// written; write roots may also be read. + pub fn new(read: &[PathBuf], write: &[PathBuf]) -> Result { + let real = |p: &PathBuf| p.canonicalize().map_err(|e| Error::BadArgs(format!("automation root {}: {e}", p.display()))); + let read = read.iter().map(real).collect::>>()?; + let write = write.iter().map(real).collect::>>()?; + Ok(Self { read, write }) + } + + fn roots(&self, access: Access) -> impl Iterator { + let read = if access == Access::Read { &self.read[..] } else { &[] }; + read.iter().chain(&self.write) + } + + /// `path` resolved, if it is inside the roots for `access`. + pub fn check(&self, path: &str, access: Access) -> Result { + let denied = |why: &str| { + let kind = if access == Access::Read { "read" } else { "write" }; + let roots: Vec = self.roots(access).map(|r| r.display().to_string()).collect(); + let roots = if roots.is_empty() { "none".to_string() } else { roots.join(", ") }; + Error::BadArgs(format!("cannot {kind} `{path}`: {why} (automation {kind} roots: {roots})")) + }; + let p = Path::new(path); + if !p.is_absolute() { + return Err(denied("give an absolute path")); + } + if p.components().any(|c| matches!(c, Component::ParentDir)) { + return Err(denied("`..` is not allowed")); + } + let real = resolve(p).ok_or_else(|| denied("it can't be resolved"))?; + if self.roots(access).any(|r| real.starts_with(r)) { Ok(real) } else { Err(denied("it is outside the automation roots")) } + } + + /// Check engine command `id` with `params` before it runs. Returns what must still be looked + /// up and passed to [`Confinement::check_lookup`] for commands that write implicitly. + pub fn check_command(&self, id: &str, params: &Value) -> Result> { + if REFUSED.contains(&id) { + return Err(Error::BadArgs(format!("`{id}` is not available while file access is confined (--automation-read-root/--automation-write-root)"))); + } + if id == "engine.batch" { + for step in params.get("steps").and_then(Value::as_array).into_iter().flatten() { + let Some(inner) = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str) else { continue }; + let inner_params = step.get("params").cloned().unwrap_or(Value::Null); + let named = keys_of(READS, inner).or_else(|| keys_of(WRITES, inner)).unwrap_or(&[]); + // A step's `$N` reference is resolved only as the batch runs: never for a file. + if named.iter().filter_map(|k| inner_params.get(*k)).any(has_reference) { + return Err(Error::BadArgs(format!("`{inner}` in a batch: file paths can't be `$N` references while file access is confined"))); + } + if self.check_command(inner, &inner_params)?.is_some() { + return Err(Error::BadArgs(format!("`{inner}` writes to its project's or queue's files: run it on its own while file access is confined"))); + } + } + return Ok(None); + } + let (named, access) = match (keys_of(WRITES, id), keys_of(READS, id)) { + (Some(k), _) => (k, Access::Write), + (None, Some(k)) => (k, Access::Read), + (None, None) if id.starts_with("renderQueue.") || id == "prefs.set" => (&[][..], Access::Write), + (None, None) => (&[][..], Access::Read), + }; + let mut given = false; + for key in named { + let Some(v) = params.get(*key).filter(|v| !v.is_null()) else { continue }; + given = true; + let paths: Vec<&Value> = match v { + Value::Array(a) => a.iter().collect(), + v => vec![v], + }; + for p in paths { + // A Render Queue output may name a template instead of a file. + match p.as_str() { + Some(s) if id == "render.preRender" && !looks_like_path(s) => {} + Some(s) => { + self.check(s, access)?; + } + None => return Err(Error::BadArgs(format!("`{id}`: `{key}` must be a path"))), + } + } + } + // Any other absolute path the command is given. + let mut other = vec![]; + absolute_strings(params, named, &mut other); + for s in other { + self.check(&s, access)?; + } + // An entry of a recent list by index is a path the agent didn't give. + if matches!(id, "file.openRecent" | "file.importRecent" | "anim.applyRecentPreset") && !given { + return Err(Error::BadArgs(format!("`{id}`: give `path` while file access is confined"))); + } + if IMPLICIT_WRITES.contains(&id) && !(given && id == "file.save") { + return Ok(Some(if id == "file.save" || id == "file.incrementAndSave" { Lookup::ProjectFile } else { Lookup::RenderOutputs })); + } + Ok(None) + } + + /// Check what a [`Lookup`] found: `project.summary` or `renderQueue.list`'s result. + pub fn check_lookup(&self, lookup: &Lookup, found: &Value) -> Result<()> { + match lookup { + Lookup::ProjectFile => match found.get("path").and_then(Value::as_str) { + // Increment and Save writes beside it. + Some(p) => self.check(p, Access::Write).map(drop), + None => Err(Error::BadArgs("the project has no file yet: give `path` (Save As) inside an automation write root".into())), + }, + Lookup::RenderOutputs => { + let items = found.get("items").or(Some(found)).and_then(Value::as_array).cloned().unwrap_or_default(); + for it in items.iter().filter(|it| it.get("render").and_then(Value::as_bool) != Some(false)) { + let extra = it.get("extraOutputs").and_then(Value::as_array).into_iter().flatten().map(|o| o.get("outputPath")); + for p in std::iter::once(it.get("outputPath")).chain(extra).chain([it.get("logPath")]).flatten() { + if let Some(p) = p.as_str() { + self.check(p, Access::Write)?; + } + } + } + Ok(()) + } + } + } +} + +fn keys_of(table: &[(&str, &'static [&'static str])], id: &str) -> Option<&'static [&'static str]> { + table.iter().find(|(c, _)| *c == id).map(|(_, k)| *k) +} + +fn has_reference(v: &Value) -> bool { + match v { + Value::String(s) => s.starts_with('$'), + Value::Array(a) => a.iter().any(has_reference), + _ => false, + } +} + +/// A file path rather than a template name: has a separator or an extension. +fn looks_like_path(s: &str) -> bool { + s.contains(['/', '\\']) || Path::new(s).extension().is_some() +} + +/// Strings in `v` (recursively, except under the top-level `skip` keys) that are absolute paths, +/// `~` paths or `file:` URLs. +fn absolute_strings(v: &Value, skip: &[&str], out: &mut Vec) { + fn walk(v: &Value, out: &mut Vec, depth: usize) { + if depth > 64 { + return; + } + match v { + Value::String(s) => { + let t = s.trim(); + let drive = t.len() > 2 && t.as_bytes().get(1) == Some(&b':') && t.as_bytes().first().is_some_and(u8::is_ascii_alphabetic); + if t.starts_with('/') || t.starts_with('\\') || t.starts_with('~') || drive || t.to_ascii_lowercase().starts_with("file:") { + out.push(t.to_string()); + } + } + Value::Array(a) => a.iter().for_each(|x| walk(x, out, depth + 1)), + Value::Object(o) => o.values().for_each(|x| walk(x, out, depth + 1)), + _ => {} + } + } + match v { + Value::Object(o) => o.iter().filter(|(k, _)| !skip.contains(&k.as_str())).for_each(|(_, x)| walk(x, out, 0)), + v => walk(v, out, 0), + } +} + +/// `p` with symbolic links resolved: its longest existing ancestor's real path plus the rest +/// (which doesn't exist yet, so can't be a link). +fn resolve(p: &Path) -> Option { + let mut rest = vec![]; + let mut at = p; + loop { + if let Ok(real) = at.canonicalize() { + return Some(rest.iter().rev().fold(real, |acc: PathBuf, c| acc.join(c))); + } + rest.push(at.file_name()?.to_os_string()); + at = at.parent()?; + } +} diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 6a673309..a755e0dc 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -19,6 +19,7 @@ mod autosave; pub mod backend; pub mod base64; pub mod bridge; +pub mod confine; mod long_job; pub mod server; pub mod tools; diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 51d15694..37de10a5 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -1181,3 +1181,74 @@ fn command_tools_accept_id_as_an_alias() { let (_, err) = call(&mut s, "execute_command", json!({})); assert!(err, "neither key is an error"); } + +/// #500: `--automation-read-root` / `--automation-write-root` confine the files an agent reads +/// and writes: inside the roots works, outside (or relative, `..`, through a symbolic link, by +/// a batch step, by Save to the project's own file, from a script) is refused. +#[test] +fn automation_roots_confine_file_access() { + use crate::confine::Confinement; + let base = std::env::temp_dir().join(format!("ec-mcp-confine-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let (read, write, outside) = (base.join("in"), base.join("out"), base.join("elsewhere")); + for d in [&read, &write, &outside] { + std::fs::create_dir_all(d).unwrap(); + } + let p = |d: &std::path::Path, f: &str| d.join(f).to_string_lossy().into_owned(); + // Unconfined (the default): anywhere. + let mut s = server(); + call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "A", "width": 64, "height": 64}})); + call_json(&mut s, "save_project", json!({"path": p(&outside, "free.ecproj")})); + call_json(&mut s, "save_project", json!({"path": p(&read, "source.ecproj")})); + + let mut s = server(); + s.backend().confine(Confinement::new(std::slice::from_ref(&read), std::slice::from_ref(&write)).unwrap()); + let denied = |s: &mut McpServer, name: &str, args: Value| { + let (c, err) = call(s, name, args.clone()); + assert!(err, "{name} {args} should be refused: {c:?}"); + c[0]["text"].as_str().unwrap_or_default().to_string() + }; + // Reading: inside a read root, not outside. + call_json(&mut s, "open_project", json!({"path": p(&read, "source.ecproj")})); + let msg = denied(&mut s, "open_project", json!({"path": p(&outside, "free.ecproj")})); + assert!(msg.contains("outside the automation roots"), "{msg}"); + // Writing: inside a write root only; not into a read root, not relative, not with `..`. + call_json(&mut s, "save_project", json!({"path": p(&write, "saved.ecproj")})); + assert!(write.join("saved.ecproj").is_file()); + denied(&mut s, "save_project", json!({"path": p(&read, "copy.ecproj")})); + denied(&mut s, "save_project", json!({"path": "relative.ecproj"})); + denied(&mut s, "save_project", json!({"path": p(&write, "../elsewhere/up.ecproj")})); + denied(&mut s, "execute_command", json!({"command": "comp.saveFrameAs", "params": {"path": p(&outside, "f.png")}})); + // Any other absolute path a command is given. + denied(&mut s, "execute_command", json!({"command": "renderQueue.add", "params": {"output": p(&outside, "movie.mp4")}})); + // A batch step. + let steps = json!([{"command": "comp.new", "params": {"name": "B"}}, {"command": "file.saveAs", "params": {"path": p(&outside, "b.ecproj")}}]); + denied(&mut s, "batch", json!({"steps": steps})); + let steps = json!([{"command": "file.saveAs", "params": {"path": "$1.path"}}]); + denied(&mut s, "batch", json!({"steps": steps})); + // Save (and Increment and Save) write the open project's own file: it must be writable. + call_json(&mut s, "open_project", json!({"path": p(&read, "source.ecproj")})); + denied(&mut s, "save_project", json!({})); + denied(&mut s, "execute_command", json!({"command": "file.incrementAndSave"})); + // Rendering the queue writes its items' outputs (by default beside the project). + call_json(&mut s, "execute_command", json!({"command": "renderQueue.add", "params": {"comp": "A"}})); + let queue = call_json(&mut s, "execute_command", json!({"command": "renderQueue.list"})); + assert!(queue["items"][0]["outputPath"].as_str().is_some_and(|o| o.starts_with(&*read.to_string_lossy())), "{queue}"); + let msg = denied(&mut s, "execute_command", json!({"command": "renderQueue.render"})); + assert!(msg.contains("cannot write"), "{msg}"); + call_json(&mut s, "open_project", json!({"path": p(&write, "saved.ecproj")})); + call_json(&mut s, "save_project", json!({})); + // Through a symbolic link out of a root. + #[cfg(unix)] + { + std::os::unix::fs::symlink(&outside, write.join("link")).unwrap(); + denied(&mut s, "save_project", json!({"path": p(&write.join("link"), "escaped.ecproj")})); + assert!(!outside.join("escaped.ecproj").exists()); + } + // Scripts can read and write anything: refused. + denied(&mut s, "run_script", json!({"code": "1 + 1"})); + denied(&mut s, "execute_command", json!({"command": "file.runScript", "params": {"path": p(&read, "x.jsx")}})); + // Writes to the files the agent named: none outside. + assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 1, "only the unconfined save"); + let _ = std::fs::remove_dir_all(&base); +} diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 12ea3167..fa86095b 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -103,12 +103,19 @@ pub fn available(bridge: bool) -> impl Iterator { TOOLS.iter().filter(move |t| bridge || !t.bridge_only) } +/// Tools refused while file access is confined: they drive the live app like a person (menus, +/// shortcuts, Open Recent…) or call control-channel methods directly, past the command checks. +const UNCONFINED_ONLY: &[&str] = &["control", "ui_click", "ui_drag", "ui_key", "ui_type", "ui_set", "script_ui"]; + /// Run a tool by name. pub fn run(b: &mut Backend, name: &str, args: &Value) -> Result { let t = find(name).ok_or_else(|| Error::BadArgs(format!("unknown tool `{name}`")))?; if t.bridge_only && !b.is_bridge() { return Err(Error::BadArgs(NEED_BRIDGE.into())); } + if b.confinement().is_some() && UNCONFINED_ONLY.contains(&name) { + return Err(Error::BadArgs(format!("`{name}` is not available while file access is confined (--automation-read-root/--automation-write-root)"))); + } let empty = json!({}); (t.run)(b, if args.is_null() { &empty } else { args }) } @@ -389,6 +396,9 @@ fn render_frame(b: &mut Backend, a: &Value) -> Result { let f = b.render_with(get(a, "comp"), get(a, "time").and_then(Value::as_f64), max_side, transparent)?; let mut info = json!({"comp": f.comp, "time": f.time, "width": f.width, "height": f.height}); if let Some(p) = get(a, "path").and_then(Value::as_str) { + if let Some(c) = b.confinement() { + c.check(p, crate::confine::Access::Write)?; + } std::fs::write(p, &f.png).map_err(|e| Error::Other(format!("cannot write {p}: {e}")))?; info["path"] = json!(p); } @@ -482,6 +492,9 @@ fn batch_tool(b: &mut Backend, a: &Value) -> Result { fn screenshot(b: &mut Backend, a: &Value) -> Result { let max_side = get(a, "max_side").and_then(Value::as_u64).unwrap_or(1600) as u32; let keep = get(a, "path").and_then(Value::as_str).map(str::to_string); + if let (Some(c), Some(p)) = (b.confinement(), &keep) { + c.check(p, crate::confine::Access::Write)?; + } let path = keep.clone().unwrap_or_else(|| std::env::temp_dir().join(format!("effectcraft-mcp-shot-{}.png", std::process::id())).to_string_lossy().into_owned()); b.control("ui.screenshot", obj(&[("path", Some(&json!(path))), ("panel", get(a, "panel")), ("id", get(a, "id"))]))?; diff --git a/crates/color/src/blend.rs b/crates/color/src/blend.rs index 2305f12a..0fecf4e9 100644 --- a/crates/color/src/blend.rs +++ b/crates/color/src/blend.rs @@ -159,7 +159,8 @@ impl BlendMode { /// Modes whose formulas are defined for over-range (HDR, 32 bpc) values. The others clamp /// their inputs to 0..1 before blending, as After Effects documents for 32 bpc projects - /// (in 8/16 bpc everything is already in range). + /// (in 8/16 bpc everything is already in range). Overlay, Exclusion, Luminosity and the + /// Color Burns keep their plain formula above 1 in After Effects (#494). pub fn supports_hdr(self) -> bool { matches!( self, @@ -168,6 +169,8 @@ impl BlendMode { | BlendMode::DancingDissolve | BlendMode::Darken | BlendMode::Multiply + | BlendMode::ColorBurn + | BlendMode::ClassicColorBurn | BlendMode::LinearBurn | BlendMode::DarkerColor | BlendMode::Add @@ -177,6 +180,9 @@ impl BlendMode { | BlendMode::LighterColor | BlendMode::Difference | BlendMode::ClassicDifference + | BlendMode::Overlay + | BlendMode::Exclusion + | BlendMode::Luminosity | BlendMode::Subtract | BlendMode::Divide | BlendMode::StencilAlpha @@ -188,6 +194,17 @@ impl BlendMode { ) } + /// The source colour a classic mode leaves the backdrop unchanged with. After Effects fades + /// the source toward it by the layer's opacity before applying the classic formula at full + /// strength, rather than mixing the full-strength result by opacity (#494). + fn classic_neutral(self) -> Option { + match self { + BlendMode::ClassicColorDodge | BlendMode::ClassicDifference => Some(0.0), + BlendMode::ClassicColorBurn => Some(1.0), + _ => None, + } + } + pub fn next(self) -> BlendMode { let i = BlendMode::ALL.iter().position(|m| *m == self).unwrap_or(0); BlendMode::ALL[(i + 1) % BlendMode::ALL.len()] @@ -242,11 +259,8 @@ fn separable(mode: BlendMode, cb: f32, cs: f32) -> f32 { BlendMode::Multiply => cb * cs, BlendMode::ColorBurn => burn(cb, cs), BlendMode::ClassicColorBurn => { - if cs <= 0.0 { - 0.0 - } else { - (1.0 - (1.0 - cb) / cs).max(0.0) - } + // (negative results are kept, as in After Effects) + if cs <= 0.0 { 0.0 } else { 1.0 - (1.0 - cb) / cs } } BlendMode::LinearBurn => (cb + cs - 1.0).max(0.0), BlendMode::Add | BlendMode::LinearDodge => cb + cs, @@ -401,11 +415,19 @@ pub fn blend_pixel(mode: BlendMode, dst: [f32; 4], src: [f32; 4], noise: f32) -> cs = cs.map(|v| v.clamp(0.0, 1.0)); cb = cb.map(|v| v.clamp(0.0, 1.0)); } - let b = non_separable(mode, cb, cs); let ao = sa + da - sa * da; let mut o = [0.0f32; 4]; - for i in 0..3 { - o[i] = (1.0 - da) * src[i] + (1.0 - sa) * dst[i] + sa * da * b[i]; + if let Some(neutral) = mode.classic_neutral() { + // Fade the source toward the neutral colour by its coverage, then apply the formula. + let b = non_separable(mode, cb, cs.map(|v| neutral + sa * (v - neutral))); + for i in 0..3 { + o[i] = (1.0 - da) * src[i] + da * b[i]; + } + } else { + let b = non_separable(mode, cb, cs); + for i in 0..3 { + o[i] = (1.0 - da) * src[i] + (1.0 - sa) * dst[i] + sa * da * b[i]; + } } o[3] = ao; o @@ -469,12 +491,41 @@ mod tests { #[test] fn hdr_modes() { - // Add and Screen keep over-range values (32 bpc); Overlay clamps its inputs. + // Add and Screen keep over-range values (32 bpc); Soft Light clamps its inputs. let d = [2.0, 2.0, 2.0, 1.0]; let s = [0.5, 0.5, 0.5, 1.0]; assert!((blend_pixel(BlendMode::Add, d, s, 0.5)[0] - 2.5).abs() < 1e-6); assert!((blend_pixel(BlendMode::Screen, d, s, 0.5)[0] - 1.5).abs() < 1e-6); - assert!(blend_pixel(BlendMode::Overlay, d, s, 0.5)[0] <= 1.0); + assert!(blend_pixel(BlendMode::SoftLight, d, s, 0.5)[0] <= 1.0); + } + + /// #494: After Effects' 32 bpc formulas, measured by the reporter. b = bottom, o = top, + /// k = opacity (premultiplied into the source, as the compositor passes it). + #[test] + fn after_effects_32bpc_formulas() { + let at = |m, b: f32, o: f32, k: f32| blend_pixel(m, opaque(b), [o * k, o * k, o * k, k], 0.5)[0]; + let close = |a: f32, e: f32| (a - e).abs() < 1e-5; + // Classic modes fade the source toward their neutral colour first. + for (b, o) in [(0.4, 0.5), (0.8, 0.9), (0.1, 0.3)] { + let k = 0.15; + assert!(close(at(BlendMode::ClassicColorDodge, b, o, k), (b / (1.0 - k * o)).min(1.0))); + assert!(close(at(BlendMode::ClassicDifference, b, o, k), (b - k * o).abs())); + assert!(close(at(BlendMode::ClassicColorBurn, b, o, k), 1.0 - (1.0 - b) / (1.0 - k * (1.0 - o)))); + } + // At full opacity the classic formulas are unchanged. + assert!(close(at(BlendMode::ClassicDifference, 0.6, 0.3, 1.0), 0.3)); + // Over-range top layers keep the plain formulas. + let (b, o) = (0.7, 3.0); + assert!(close(at(BlendMode::Exclusion, b, o, 1.0), b + o - 2.0 * b * o)); + let s = 2.0 * b - 1.0; + assert!(close(at(BlendMode::Overlay, b, o, 1.0), o + s - o * s)); + assert!(close(at(BlendMode::ColorBurn, 0.0, 1.35, 0.15), 0.15 * (1.0 - 1.0 / 1.35))); + assert!(close(at(BlendMode::ClassicColorBurn, 0.0, 0.1, 1.0), -9.0), "negative results are kept"); + // Luminosity: SetLum + ClipColor on the unclamped top layer. + let l = blend_pixel(BlendMode::Luminosity, [0.2, 0.4, 0.6, 1.0], [2.0, 2.0, 2.0, 1.0], 0.5); + let e = set_lum([0.2, 0.4, 0.6], 2.0); + assert!((0..3).all(|i| close(l[i], e[i])), "{l:?} {e:?}"); + assert!((l[0] - 1.0).abs() > 0.1, "not the white of a top layer clamped to 1: {l:?}"); } #[test] diff --git a/crates/effects/src/audio_fx.rs b/crates/effects/src/audio_fx.rs index a5e1fc4e..cd9153d6 100644 --- a/crates/effects/src/audio_fx.rs +++ b/crates/effects/src/audio_fx.rs @@ -245,10 +245,8 @@ pub fn process(id: &str, params: &Params, buf: &mut [f32], rate: u32, start: f64 } let sr = rate as f64; match id { - "ec.audio.backwards" => { - if params.b("swapChannels") { - buf.as_chunks_mut::<2>().0.iter_mut().for_each(|f| f.swap(0, 1)); - } + "ec.audio.backwards" if params.b("swapChannels") => { + buf.as_chunks_mut::<2>().0.iter_mut().for_each(|f| f.swap(0, 1)); } "ec.audio.basstreble" => { let bass = params.f("bass") / 100.0 * 12.0; diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 009220c8..82678eb8 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -86,6 +86,7 @@ const CPU_ONLY_CONTROLS: &[(&str, &[&str])] = &[ ("ec.blur.cameralens", &["irisProperties/diffractionFringe", "blurMap/blurMapLayer"]), ("ec.noise.removegrain", &["temporalFiltering/enabled"]), ("ec.distort.liquify", &["viewFreezeAreaMask", "viewMesh"]), + ("ec.distort.ccpowerpin", &["unstretch"]), ("ec.matte.refinesoft", &["reduceChatter", "useMotionBlur", "invert"]), ("ec.matte.refinehard", &["reduceChatter", "useMotionBlur", "invert"]), ]; @@ -201,6 +202,8 @@ mod tests { assert!(ok("ec.generate.cellpattern", &[("cellPattern", Value::Enum(3))])); assert!(!ok("ec.generate.cellpattern", &[("cellPattern", Value::Enum(9))])); assert!(!ok("ec.distort.turbulentdisplace", &[("pinning", Value::Enum(9))])); + assert!(ok("ec.distort.ccpowerpin", &[])); + assert!(!ok("ec.distort.ccpowerpin", &[("unstretch", Value::Bool(true))])); assert!(ok("ec.blur.gaussian", &[])); } diff --git a/crates/effects/src/distort2.rs b/crates/effects/src/distort2.rs index f672d149..10ec5973 100644 --- a/crates/effects/src/distort2.rs +++ b/crates/effects/src/distort2.rs @@ -20,7 +20,7 @@ use effectcraft_raster::{Image, Px}; use rayon::prelude::*; use crate::generate::value_noise; -use crate::util::{SRC_NAMES, Src, fit_layer, layer_rect, lerp4, map_xy, pick, premul, remap, smoothstep, src_at, unpremul}; +use crate::util::{SRC_NAMES, Src, fit_layer, layer_cover_pad, layer_rect, lerp4, map_xy, pick, premul, remap, smoothstep, src_at, unpremul}; use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { @@ -678,6 +678,9 @@ fn page_turn(ctx: &EffectCtx, mut b: Buf) -> Buf { fn cc_tiler(ctx: &EffectCtx, mut b: Buf) -> Buf { let s = (ctx.params.f("scale") / 100.0).max(0.001); + if !ctx.adjustment { + b.pad(layer_cover_pad(ctx, [b.img.width, b.img.height], b.offset, b.scale)); + } let c = b.to_px(ctx.params.v2("center")); let blend = (ctx.params.f("blendWithOriginal") / 100.0) as f32; let (x0, y0, w, h) = layer_rect(ctx, &b); diff --git a/crates/effects/src/distort3.rs b/crates/effects/src/distort3.rs index cbba856c..99139483 100644 --- a/crates/effects/src/distort3.rs +++ b/crates/effects/src/distort3.rs @@ -182,8 +182,9 @@ fn cc_power_pin(ctx: &EffectCtx, mut b: Buf) -> Buf { let (x0, y0) = (-ex[1] / 100.0 * lw, -ex[0] / 100.0 * lh); let (x1, y1) = (lw + ex[2] / 100.0 * lw, lh + ex[3] / 100.0 * lh); let corners_layer = [ctx.params.v2("topLeft"), ctx.params.v2("topRight"), ctx.params.v2("bottomRight"), ctx.params.v2("bottomLeft")]; - // Grow the buffer to hold the pinned quad. - if !ctx.adjustment { + let unstretch = ctx.params.b("unstretch"); + // Grow the buffer to hold the pinned quad (Unstretch writes inside the layer rectangle, so it needs no room). + if !ctx.adjustment && !unstretch { let mut need = 0.0f64; for c in &corners_layer { let (px, py) = b.to_px(*c); @@ -198,7 +199,8 @@ fn cc_power_pin(ctx: &EffectCtx, mut b: Buf) -> Buf { vec2(p.0, p.1) }); let persp = (ctx.params.f("perspective") / 100.0).clamp(0.0, 1.0); - let Some(inv) = Mat3::square_to_quad(q).inverse() else { return b }; + let fwd = Mat3::square_to_quad(q); + let Some(inv) = fwd.inverse() else { return b }; let src = b.img.clone(); let (sc, off) = (b.scale, b.offset); let bil = move |u: f64, v: f64| -> (f64, f64) { @@ -208,6 +210,17 @@ fn cc_power_pin(ctx: &EffectCtx, mut b: Buf) -> Buf { }; b.img = crate::util::gen_image(src.width, src.height, |x, y| { let (px, py) = (x as f64 + 0.5, y as f64 + 0.5); + if unstretch { + // Reverse pinning: the layer rectangle is the target, and the pinned quad is the region that fills it. + let u = ((px - off[0]) / sc - x0) / (x1 - x0); + let v = ((py - off[1]) / sc - y0) / (y1 - y0); + if !(-1e-9..=1.0 + 1e-9).contains(&u) || !(-1e-9..=1.0 + 1e-9).contains(&v) { + return [0.0; 4]; + } + let m = fwd.apply(vec2(u, v)); + let (bx, by) = bil(u, v); + return src.sample_bilinear(bx + (m.x - bx) * persp, by + (m.y - by) * persp); + } let m = inv.apply(vec2(px, py)); let (mut u, mut v) = (m.x, m.y); if persp < 1.0 { @@ -317,9 +330,12 @@ fn cc_slant(ctx: &EffectCtx, mut b: Buf) -> Buf { // ---------------------------------------------------------------- CC Smear fn cc_smear(ctx: &EffectCtx, mut b: Buf) -> Buf { + let reach = ctx.params.f("reach") / 100.0; + if !ctx.adjustment { + b.pad(cc_smear_pad(ctx, [b.img.width, b.img.height], b.offset, b.scale)); + } let from = b.to_px(ctx.params.v2("from")); let to = b.to_px(ctx.params.v2("to")); - let reach = ctx.params.f("reach") / 100.0; let radius = (ctx.params.f("radius") * b.scale).max(0.5); let (dx, dy) = (to.0 - from.0, to.1 - from.1); let l2 = dx * dx + dy * dy; @@ -338,6 +354,21 @@ fn cc_smear(ctx: &EffectCtx, mut b: Buf) -> Buf { b } +/// The transparent pixels to add on every side of a buffer `size` pixels big (with `offset` and +/// `scale`) for CC Smear: it moves pixels by up to Reach × From→To, and they may move out to the +/// layer bounds. A text or shape layer's buffer ends at its content while its bounds are +/// comp-sized, so the smear was cut off at the content's box (#659). +pub fn cc_smear_pad(ctx: &EffectCtx, size: [u32; 2], offset: [f64; 2], scale: f64) -> u32 { + let reach = ctx.params.f("reach") / 100.0; + let (from, to) = (ctx.params.v2("from"), ctx.params.v2("to")); + let len = ((to[0] - from[0]).powi(2) + (to[1] - from[1]).powi(2)).sqrt() * scale; + let moved = (reach.abs() * len).ceil(); + if !moved.is_finite() || moved <= 0.0 { + return 0; + } + (moved.min(4096.0) as u32).min(crate::util::layer_cover_pad(ctx, size, offset, scale)) +} + // ---------------------------------------------------------------- CC Split / CC Split 2 fn split_impl(ctx: &EffectCtx, mut b: Buf, s1: f64, s2: f64) -> Buf { @@ -1096,6 +1127,43 @@ mod tests { } } + /// A text layer's buffer: 20 × 10 opaque pixels at (90, 45)–(110, 55) of its comp-sized + /// (200 × 100) layer bounds, after `id` with `set`. + fn on_text(id: &str, set: &[(&str, Value)]) -> Buf { + let s = crate::find(id).unwrap(); + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for (k, v) in set { + assert!(params.values.contains_key(*k), "{id}: {k}"); + params.values.insert(k.to_string(), v.clone()); + } + let mut im = Image::new(20, 10); + im.data.iter_mut().for_each(|p| *p = [1.0; 4]); + let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [200.0, 100.0], seed: 1, adjustment: false, env: Default::default() }; + crate::apply(s, &ctx, Buf { img: im, offset: [-90.0, -45.0], scale: 1.0 }) + } + + /// Alpha at layer point `p` (0 outside the buffer). + fn alpha_at(b: &Buf, p: [f64; 2]) -> f32 { + let (x, y) = b.to_px(p); + b.img.get(x.floor() as i64, y.floor() as i64)[3] + } + + /// #659: CC Smear and CC Tiler on a text layer reach past the text's box to the layer bounds + /// (comp-sized, as in After Effects), not cut off where the glyphs end. + #[test] + fn effects_on_text_reach_past_its_box() { + let smear = on_text("ec.distort.ccsmear", &[("from", pt(100.0, 50.0)), ("to", pt(140.0, 50.0)), ("reach", num(100.0)), ("radius", num(30.0))]); + assert!(alpha_at(&smear, [130.0, 50.0]) > 0.9, "the smear is cut off at the text's box"); + assert!(smear.img.width < 200, "it grows only as far as pixels move"); + let tiles = on_text("ec.distort.cctiler", &[("scale", num(50.0)), ("center", pt(100.0, 50.0))]); + assert!(alpha_at(&tiles, [100.0, 50.0]) > 0.9); + assert!(alpha_at(&tiles, [2.0, 50.0]) > 0.9, "tiles fill the layer bounds"); + assert!(alpha_at(&tiles, [50.0, 50.0]) < 0.1); + // A footage layer's buffer is its bounds already: no change. + let o = run("ec.distort.ccsmear", &[], img(40, 30), &[]); + assert_eq!((o.img.width, o.img.height, o.offset), (40, 30, [0.0, 0.0])); + } + #[test] fn every_warp_style_runs_and_bends() { let im = img(40, 30); @@ -1127,6 +1195,23 @@ mod tests { assert!((px[0] - 0.5).abs() < 0.1, "{px:?}"); } + #[test] + fn power_pin_unstretch_expands_pinned_region() { + let im = crate::util::gen_image(24, 16, |_, _| [1.0, 0.0, 0.0, 1.0]); + let inset = [("topLeft", pt(6.0, 4.0)), ("topRight", pt(18.0, 4.0)), ("bottomRight", pt(18.0, 12.0)), ("bottomLeft", pt(6.0, 12.0))]; + let opaque = |o: &Buf| o.img.data.iter().filter(|p| p[3] > 0.99).count(); + let off = run("ec.distort.ccpowerpin", &inset, im.clone(), &[]); + assert_eq!(opaque(&off), 96); + let mut set = inset.to_vec(); + set.push(("unstretch", Value::Bool(true))); + let on = run("ec.distort.ccpowerpin", &set, im.clone(), &[]); + assert_eq!(opaque(&on), 24 * 16); + assert!(on.img.get(0, 0)[3] > 0.99); + // Default corners are the identity either way. + let id = run("ec.distort.ccpowerpin", &[("unstretch", Value::Bool(true))], im.clone(), &[]); + assert!(maxd(&id.img, &im) < 1e-4); + } + #[test] fn split_opens_a_gap() { let im = img(40, 30); diff --git a/crates/effects/src/generate3.rs b/crates/effects/src/generate3.rs index 926846a9..6beec4cb 100644 --- a/crates/effects/src/generate3.rs +++ b/crates/effects/src/generate3.rs @@ -15,7 +15,7 @@ use effectcraft_raster::{Image, Px}; use rayon::prelude::*; use crate::util::{Plane, gauss_plane, hash1, layer_or_self, morph_frac, poly_length, premul, smoothstep, unpremul}; -use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; +use crate::{Buf, EffectCtx, EffectSpec, Params, col, num, p, popup, slider}; fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { EffectSpec { id, name, category: "Generate", params, render, gpu: false, float: true } @@ -503,10 +503,8 @@ fn scan_runs(polys: &[(Vec<[f64; 2]>, bool, bool)], fill: u32, ew: f64, d: [f64; let entering = k % 2 == 0; let l = ew / sin; match mode { - 0 => { - if entering && k + 1 < xs.len() { - out.push((t, xs[k + 1].0)); - } + 0 if entering && k + 1 < xs.len() => { + out.push((t, xs[k + 1].0)); } 1 => out.push((t - l * 0.5, t + l * 0.5)), 2 => out.push(if entering { (t, t + l) } else { (t - l, t) }), @@ -1074,23 +1072,66 @@ fn vegas_plan(ctx: &EffectCtx, b: &Buf) -> PaintPlan { // ---------------------------------------------------------------- Write-on / Glue Gun -/// Brush dab history: only the brush position at the current time is known to an effect -/// (parameter histories are not passed in), so the stroke is drawn as the dabs laid along -/// the current position — a single dab when the brush is static. Deterministic. +/// Write-on draws a stroke along the animated Brush Position, as in After Effects: a dab every +/// Brush Spacing seconds from the layer's start up to now, each one lasting Stroke Length +/// seconds (0: for ever). The dabs' positions come from the host's parameters at their times; +/// with Paint Time Properties ▸ Opacity or Brush Time Properties ▸ Size each dab keeps its own +/// opacity or size, else the current ones apply to every dab. (Colour and hardness are one per +/// stroke: the current ones.) Without a host (or dabs) the current dab draws. Deterministic. fn write_on(ctx: &EffectCtx, mut b: Buf) -> Buf { let plan = write_on_plan(ctx, &b); draw_paint(&mut b, &plan); b } +/// The most dabs one Write-on frame draws; longer strokes lay them further apart, joined. +const WRITE_ON_MAX_DABS: i64 = 4096; + fn write_on_plan(ctx: &EffectCtx, b: &Buf) -> PaintPlan { let pr = ctx.params; - let q = b.to_px(pr.v2("brushPosition")); - let r = (pr.f("brushSize") * b.scale * 0.5).max(0.05); + let own_opacity = pr.e("paintTimeProperties") == 1; + let own_size = matches!(pr.e("brushTimeProperties"), 1 | 3); + let opacity = |p: &Params| (p.f("brushOpacity") / 100.0).clamp(0.0, 1.0) as f32; + let radius = |p: &Params| (p.f("brushSize") * b.scale * 0.5).max(0.05); + let dab = |p: &Params| { + let q = b.to_px(p.v2("brushPosition")); + ([q.0, q.1], radius(if own_size { p } else { pr }), if own_opacity { opacity(p) } else { 1.0 }) + }; + let mut segs = vec![]; + let t = ctx.time; + let spacing = pr.f("brushSpacing").max(0.001); + let life = pr.f("strokeLength").max(0.0); + if let Some(host) = ctx.env.host + && t.is_finite() + && t >= 0.0 + { + let start = if life > 0.0 { (t - life).max(0.0) } else { 0.0 }; + let k0 = (start / spacing - 1e-9).ceil().max(0.0) as i64; + let k1 = (t / spacing + 1e-9).floor().max(0.0) as i64; + let n = k1.saturating_sub(k0).saturating_add(1); + let stride = (n / WRITE_ON_MAX_DABS).saturating_add(1).max(1); + let mut prev: Option<[f64; 2]> = None; + let mut k = k0; + while k <= k1 { + let tk = k as f64 * spacing; + let at = if (tk - t).abs() < 1e-9 { Some(dab(pr)) } else { host.params_at(tk).map(|p| dab(&p)) }; + if let Some((q, r, v)) = at { + // Dabs laid further apart than the brush spacing are joined into a line. + let a = if stride > 1 { prev.unwrap_or(q) } else { q }; + segs.push(Seg { a, b: q, r, v }); + prev = Some(q); + } + k = k.saturating_add(stride); + } + } + if segs.is_empty() { + let (q, r, v) = dab(pr); + segs.push(Seg { a: q, b: q, r, v }); + } PaintPlan { - cov: Coverage::Segs { segs: vec![Seg { a: [q.0, q.1], b: [q.0, q.1], r, v: 1.0 }], hardness: pr.f("brushHardness") / 100.0 }, + cov: Coverage::Segs { segs, hardness: pr.f("brushHardness") / 100.0 }, color: pr.color("color"), - opacity: (pr.f("brushOpacity") / 100.0) as f32, + opacity: if own_opacity { 1.0 } else { opacity(pr) }, style: pr.e("paintStyle"), } } @@ -2204,6 +2245,75 @@ mod tests { assert_eq!(g.img.get(0, 0), src.get(0, 0)); } + /// #536: an animated Brush Position draws a stroke that stays (Stroke Length 0), expires + /// after Stroke Length, and lays its dabs Brush Spacing seconds apart. + #[test] + fn write_on_accumulates_a_stroke_along_the_animated_brush() { + // Brush Position from [8, 16] at 0 s to [56, 16] at 1 s; red, hard, 6 px. + struct Brush(Vec<(&'static str, Value)>); + impl Brush { + fn at(&self, t: f64) -> Vec<(&'static str, Value)> { + let mut v = self.0.clone(); + v.push(("brushPosition", Value::Vec2([8.0 + 48.0 * t.clamp(0.0, 1.0), 16.0]))); + v + } + } + impl EffectHost for Brush { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn params_at(&self, t: f64) -> Option { + let s = crate::find("ec.generate.writeon").unwrap(); + let mut p = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for (k, v) in self.at(t) { + p.values.insert(k.to_string(), v); + } + Some(p) + } + } + let render = |extra: &[(&'static str, Value)], t: f64| { + let mut set = vec![("color", col(1.0, 0.0, 0.0)), ("brushSize", num(6.0)), ("brushHardness", num(100.0)), ("brushSpacing", num(0.05))]; + set.extend(extra.iter().cloned()); + let host = Brush(set); + let env = EffectEnv { host: Some(&host), ..Default::default() }; + let at = host.at(t); + run_env("ec.generate.writeon", &at, Image::new(64, 32), t, env).img + }; + let on = |img: &Image, x: i64| img.get(x, 16)[3] > 0.9; + let half = render(&[], 0.5); + assert!(on(&half, 8) && on(&half, 20) && on(&half, 32) && !on(&half, 44) && !on(&half, 56)); + let end = render(&[], 1.0); + assert!([8, 20, 32, 44, 56].iter().all(|x| on(&end, *x))); + // Stroke Length 0.25 s: only the last quarter second's trail. + let trail = render(&[("strokeLength", num(0.25))], 1.0); + assert!(!on(&trail, 8) && !on(&trail, 32) && on(&trail, 48) && on(&trail, 56)); + // Brush Spacing 0.5 s: three separate marks. + let spaced = render(&[("brushSpacing", num(0.5))], 1.0); + assert!(on(&spaced, 8) && on(&spaced, 32) && on(&spaced, 56) && !on(&spaced, 20) && !on(&spaced, 44)); + // Paint Time Properties ▸ Opacity: each dab keeps the opacity it was laid with. + struct Fade; + impl EffectHost for Fade { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn params_at(&self, t: f64) -> Option { + let mut p = Brush(vec![("brushHardness", num(100.0)), ("brushSpacing", num(0.05)), ("paintTimeProperties", Value::Enum(1))]).params_at(t)?; + p.values.insert("brushOpacity".into(), num(if t < 0.5 { 100.0 } else { 20.0 })); + Some(p) + } + } + let p1 = Fade.params_at(1.0).unwrap(); + let set: Vec<(&str, Value)> = p1.values.iter().map(|(k, v)| (k.as_str(), v.clone())).collect(); + let faded = run_env("ec.generate.writeon", &set, Image::new(64, 32), 1.0, EffectEnv { host: Some(&Fade), ..Default::default() }).img; + assert!(faded.get(8, 16)[3] > 0.9 && (faded.get(56, 16)[3] - 0.2).abs() < 0.05, "{:?} {:?}", faded.get(8, 16), faded.get(56, 16)); + } + #[test] fn paint_bucket_fills_enclosed_region_only() { let mut img = Image::filled(32, 32, [1.0, 1.0, 1.0, 1.0]); diff --git a/crates/effects/src/keying2.rs b/crates/effects/src/keying2.rs index 07d14c6e..f594bfbb 100644 --- a/crates/effects/src/keying2.rs +++ b/crates/effects/src/keying2.rs @@ -340,6 +340,24 @@ fn wire_removal(ctx: &EffectCtx, mut b: Buf) -> Buf { let (nx, ny) = (-ty, tx); let src = b.img.clone(); let soft_w = half * slope as f64; + // Frame Offset: the same pixels from the previous or next frame, resampled into this grid. + let offset = ctx.params.f("frameOffset").round(); + let neighbour = if style == 1 && offset != 0.0 { + ctx.env.host.and_then(|h| h.self_at(ctx.time + offset / ctx.fps(), ctx.env.effect_index)).map(|nb| { + if nb.img.width == b.img.width && nb.img.height == b.img.height && nb.offset == b.offset && nb.scale == b.scale { + nb.img + } else { + let k = nb.scale / b.scale.max(1e-9); + crate::util::gen_image(b.img.width, b.img.height, |x, y| { + let lx = (x as f64 + 0.5 - b.offset[0]) * k + nb.offset[0]; + let ly = (y as f64 + 0.5 - b.offset[1]) * k + nb.offset[1]; + nb.img.sample_bilinear(lx, ly) + }) + } + }) + } else { + None + }; b.img.rows_mut().for_each(|(y, row)| { for (x, px) in row.iter_mut().enumerate() { let (qx, qy) = (x as f64 + 0.5 - ax, y as f64 + 0.5 - ay); @@ -357,7 +375,9 @@ fn wire_removal(ctx: &EffectCtx, mut b: Buf) -> Buf { let t = ((d + edge) / (2.0 * edge)) as f32; lerp4(pos(-edge), pos(edge), t.clamp(0.0, 1.0)) } - // Displace: shift the image across the wire from the nearer side. + 1 if neighbour.is_some() => neighbour.as_ref().map_or(*px, |n| n.get(x as i64, y as i64)), + // Displace: shift the image across the wire from the nearer side (also Frame Offset + // when no other frame is available). 2 | 1 => { let s = if d >= 0.0 { d + 2.0 * (half - ad) + 1.0 } else { d - 2.0 * (half - ad) - 1.0 }; let sh = pos(s); @@ -560,6 +580,49 @@ mod tests { } } + #[test] + fn wire_removal_frame_offset_uses_neighbour_frame() { + use crate::{EffectHost, LayerPixels}; + struct Frames; + impl EffectHost for Frames { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn self_at(&self, t: f64, _: usize) -> Option { + // Frame n (at 10 fps) is a flat grey of n / 10. + let v = (t * 10.0).round() as f32 / 10.0; + Some(Buf { img: Image::filled(20, 20, [v, v, v, 1.0]), offset: [0.0; 2], scale: 1.0 }) + } + } + let mut img = Image::filled(20, 20, [0.5, 0.5, 0.5, 1.0]); + for y in 0..20 { + img.set(10, y, [0.0, 0.0, 0.0, 1.0]); + } + let host = Frames; + let run = |style: u32, off: f64| { + let env = EffectEnv { host: Some(&host), frame_rate: 10.0, ..Default::default() }; + let vals = [ + ("pointA", Value::Vec2([10.5, 0.0])), + ("pointB", Value::Vec2([10.5, 20.0])), + ("removalStyle", Value::Enum(style)), + ("thickness", num(3.0)), + ("frameOffset", num(off)), + ]; + run_fx("ec.key.ccsimplewireremoval", &vals, img.clone(), 0.5, env).img + }; + // At 0.5 s (frame 5) offset -1 shows frame 4 and +1 shows frame 6 inside the strip. + let (prev, next) = (run(1, -1.0), run(1, 1.0)); + assert!((prev.get(10, 10)[0] - 0.4).abs() < 1e-4, "{:?}", prev.get(10, 10)); + assert!((next.get(10, 10)[0] - 0.6).abs() < 1e-4, "{:?}", next.get(10, 10)); + // Outside the strip the current frame stays. + assert_eq!(prev.get(2, 10), img.get(2, 10)); + // Displace ignores Frame Offset. + assert_eq!(run(2, -1.0).data, run(2, 1.0).data); + } + #[test] fn inner_outer_key_more_masks_and_cleanup_strokes() { let sq = |x0: f64, x1: f64| MaskShape { name: String::new(), points: vec![[x0, 2.0], [x1, 2.0], [x1, 8.0], [x0, 8.0]], closed: true, inverted: false }; diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 4bce0636..7e968297 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -78,7 +78,7 @@ pub use color2::{Curve, auto_correct_settings, color_link_mode, color_link_sampl pub use color3::OffsetCurve; pub use distort::transform_shutter; pub use distort2::{MAGNIFY_MODES, bezier_grid, parse_mesh}; -pub use distort3::{RESHAPE_POINTS, ReshapeSetup, SmearSetup, reshape_setup, smear_setup, warp_inverse_map}; +pub use distort3::{RESHAPE_POINTS, ReshapeSetup, SmearSetup, cc_smear_pad, reshape_setup, smear_setup, warp_inverse_map}; pub use distort4::liquify_mesh; use effectcraft_keyframe::Value; use effectcraft_project::build::Ids; @@ -430,6 +430,10 @@ pub(crate) fn p(id: &'static str, name: &'static str, default: Value, ui: ParamU pub(crate) fn slider(min: f64, max: f64, smin: f64, smax: f64, decimals: u8) -> ParamUi { ParamUi::Slider { min, max, slider_min: smin, slider_max: smax, decimals } } +/// A file parameter offering files with these extensions (Effect Controls' Choose…). +pub(crate) fn file_ui(exts: &[&str]) -> ParamUi { + ParamUi::File { filters: exts.iter().map(|s| s.to_string()).collect() } +} pub(crate) fn popup(opts: &[&str]) -> ParamUi { ParamUi::Popup { options: opts.iter().map(|s| s.to_string()).collect() } } diff --git a/crates/effects/src/migrate.rs b/crates/effects/src/migrate.rs index c161677b..0c0dad9d 100644 --- a/crates/effects/src/migrate.rs +++ b/crates/effects/src/migrate.rs @@ -236,6 +236,9 @@ fn refresh(pr: &mut Property, ps: &ParamSpec, spec: &EffectSpec) { } // A parameter that became internal (superseded by a new one) is hidden. (_, ParamUi::Hidden) => pr.ui = ParamUi::Hidden, + // A path typed as text, or set only by commands, that got a Choose… button (Apply Color + // LUT's LUT, the OCIO files, #514). + (ParamUi::Hidden | ParamUi::Text, ParamUi::File { .. }) => pr.ui = ps.ui.clone(), // Same kind of value: take the spec's hints (units, ranges). (a, b) if std::mem::discriminant(a) == std::mem::discriminant(b) || is_numeric_ui(a) && is_numeric_ui(b) => pr.ui = ps.ui.clone(), _ => {} @@ -494,6 +497,23 @@ mod tests { assert_eq!(pr.ui, popup.ui); } + /// #514: Apply Color LUT's LUT was hidden (set only by commands) and the OCIO files were + /// plain text; old projects get the Choose… file parameter, keeping the path. + #[test] + fn hidden_and_text_paths_become_file_parameters() { + for (fx, pid, old) in [("ec.utility.applylut", "lut", ParamUi::Hidden), ("ec.color.ociofile", "file", ParamUi::Text)] { + let mut next = 1; + let (spec, mut g) = instance(fx, &mut next); + let pr = g.get_mut(pid).unwrap(); + pr.ui = old; + pr.value = Value::Str("/luts/look.cube".into()); + assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 100.0]), "{fx}"); + let pr = g.get(pid).unwrap(); + assert!(matches!(&pr.ui, ParamUi::File { filters } if filters.iter().any(|f| f == "cube")), "{fx}: {:?}", pr.ui); + assert_eq!(pr.value, Value::Str("/luts/look.cube".into())); + } + } + #[test] fn upgrade_moves_params_into_twirl_down_groups() { let spec = find(crate::warp_stab::ID).unwrap(); diff --git a/crates/effects/src/ocio.rs b/crates/effects/src/ocio.rs index c8fcf848..74816b11 100644 --- a/crates/effects/src/ocio.rs +++ b/crates/effects/src/ocio.rs @@ -1427,7 +1427,7 @@ pub fn specs() -> Vec { cc, vec![ p("config", "Configuration", Value::Enum(0), popup(CONFIGS)), - p("configFile", "Config File (.ocio)", Value::Str(String::new()), ParamUi::Text), + p("configFile", "Config File (.ocio)", Value::Str(String::new()), crate::file_ui(&["ocio"])), space_param("source", "Source", lin709), p("sourceName", "Source (config name)", Value::Str(String::new()), ParamUi::Text), space_param("destination", "Destination", srgb), @@ -1442,7 +1442,7 @@ pub fn specs() -> Vec { cc, vec![ p("config", "Configuration", Value::Enum(0), popup(CONFIGS)), - p("configFile", "Config File (.ocio)", Value::Str(String::new()), ParamUi::Text), + p("configFile", "Config File (.ocio)", Value::Str(String::new()), crate::file_ui(&["ocio"])), space_param("source", "Input Color Space", lin709), p("sourceName", "Source (config name)", Value::Str(String::new()), ParamUi::Text), p("display", "Display Device", Value::Enum(0), popup(&DISPLAYS.iter().map(|d| d.0).collect::>())), @@ -1459,7 +1459,7 @@ pub fn specs() -> Vec { "OCIO File Transform", cc, vec![ - p("file", "File", Value::Str(String::new()), ParamUi::Text), + p("file", "File", Value::Str(String::new()), crate::file_ui(&["cube", "3dl", "csp", "cc", "ccc", "cdl", "spi1d", "spi3d", "spimtx"])), p("cccId", "CCC ID", Value::Str(String::new()), ParamUi::Text), p("interpolation", "Interpolation", Value::Enum(3), popup(&["Nearest", "Linear", "Tetrahedral", "Best"])), direction(), diff --git a/crates/effects/src/util.rs b/crates/effects/src/util.rs index c3416618..1527e221 100644 --- a/crates/effects/src/util.rs +++ b/crates/effects/src/util.rs @@ -433,6 +433,17 @@ pub fn layer_rect_at(ctx: &crate::EffectCtx, offset: [f64; 2], scale: f64) -> (f (offset[0], offset[1], ctx.layer_size[0] * scale, ctx.layer_size[1] * scale) } +/// Transparent pixels to add on every side ([`crate::Buf::pad`]) of a buffer `size` pixels big, +/// with `offset` and `scale`, so it covers the whole layer rectangle (at most 4096). A text or +/// shape layer's buffer only surrounds its content, but its layer bounds are comp-sized (see +/// the renderer's `effect_bounds`): an effect that fills the layer, such as CC Tiler, fills +/// those as in After Effects (#659). +pub fn layer_cover_pad(ctx: &crate::EffectCtx, size: [u32; 2], offset: [f64; 2], scale: f64) -> u32 { + let (x0, y0, w, h) = layer_rect_at(ctx, offset, scale); + let need = [-x0, -y0, x0 + w - f64::from(size[0]), y0 + h - f64::from(size[1])].into_iter().fold(0.0, f64::max); + need.ceil().min(4096.0) as u32 +} + /// Resample another layer (a layer parameter) into `b`'s pixel grid. With `stretch` the other /// layer is scaled to this layer's size ("Stretch to Fit"); otherwise it is centred. pub fn fit_layer(ctx: &crate::EffectCtx, b: &crate::Buf, other: &crate::LayerPixels, stretch: bool) -> Image { diff --git a/crates/effects/src/utility.rs b/crates/effects/src/utility.rs index 46780aec..1d3b3ee4 100644 --- a/crates/effects/src/utility.rs +++ b/crates/effects/src/utility.rs @@ -385,8 +385,9 @@ pub fn specs() -> Vec { ], cineon, ), - // `lut`: a path to a `.cube` file, or the `.cube` text itself (set via commands/automation). - spec("ec.utility.applylut", "Apply Color LUT", vec![p("lut", "LUT", Value::Str(String::new()), ParamUi::Hidden)], apply_lut), + // `lut`: a path to a `.cube` file (Effect Controls' Choose…, asked for when the effect is + // applied, as in After Effects), or the `.cube` text itself (commands/automation). + spec("ec.utility.applylut", "Apply Color LUT", vec![p("lut", "LUT", Value::Str(String::new()), crate::file_ui(&["cube"]))], apply_lut), ] } diff --git a/crates/engine/src/commands/align.rs b/crates/engine/src/commands/align.rs index c225b1e6..2c2b3458 100644 --- a/crates/engine/src/commands/align.rs +++ b/crates/engine/src/commands/align.rs @@ -265,6 +265,27 @@ mod tests { assert!(s.execute("layer.align", json!({"edge": "diagonal", "layers": [a]})).is_err()); } + /// #352: shape layers align and snap by their visual edges (half the stroke, no + /// anti-aliasing padding), so two rectangles lined up edge to edge touch with no gap. + #[test] + fn shape_layers_align_by_their_visual_edges() { + let mut s = session(); + let shape = |s: &mut Session, extra: Value| { + let mut p = json!({"kind": "rect", "size": [100, 100], "fill": "#ff0000"}); + p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + s.execute("layer.newShape", p).unwrap()["layer"].as_u64().unwrap() + }; + let a = shape(&mut s, json!({})); + let b = shape(&mut s, json!({"stroke": "#ffffff", "strokeWidth": 10})); + let width = |s: &Session, id| { + let r = bounds(s, id); + r[2] - r[0] + }; + assert!((width(&s, a) - 100.0).abs() < 1e-6 && (width(&s, b) - 110.0).abs() < 1e-6, "{} {}", width(&s, a), width(&s, b)); + s.execute("layer.align", json!({"edge": "left", "layers": [a, b]})).unwrap(); + assert!(bounds(&s, a)[0].abs() < 1e-6 && bounds(&s, b)[0].abs() < 1e-6); + } + #[test] fn align_honours_parent_scale_and_rotation() { let mut s = session(); diff --git a/crates/engine/src/commands/anim.rs b/crates/engine/src/commands/anim.rs index 3d087e07..3ea27b0d 100644 --- a/crates/engine/src/commands/anim.rs +++ b/crates/engine/src/commands/anim.rs @@ -105,7 +105,7 @@ fn paste(s: &mut Session, p: &Value) -> Result { if plan.is_empty() { return Err(bad("keys.paste", "no compatible property to paste into (select a property of the same type)")); } - s.edit("Paste Keyframes", None, |proj, st| { + s.edit("Paste Keyframes", merge_p(p), |proj, st| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let mut sel = vec![]; for (lid, uid, ci) in &plan { diff --git a/crates/engine/src/commands/app_more.rs b/crates/engine/src/commands/app_more.rs index 34780274..b6101c5a 100644 --- a/crates/engine/src/commands/app_more.rs +++ b/crates/engine/src/commands/app_more.rs @@ -328,7 +328,10 @@ pub fn system_report(s: &Session) -> Value { let budgets = s.prefs.cache_budgets(mem.or(s.sys_memory)); let mut issues = vec![]; if gpu.is_none() { - issues.push("No GPU compositor: rendering uses the CPU (all features still work).".to_string()); + issues.push(match &s.accel_note { + Some(why) => format!("No GPU compositor ({why}): rendering uses the CPU (all features still work)."), + None => "No GPU compositor: rendering uses the CPU (all features still work).".to_string(), + }); } if mem.is_some_and(|m| m.total < 8 << 30) { issues.push("Less than 8 GB of memory: use lower preview resolutions for long or large comps.".to_string()); @@ -344,6 +347,8 @@ pub fn system_report(s: &Session) -> Value { "cores": crate::sysinfo::cpu_cores(), "memory": mem.map(|m| json!({"total": gb(m.total), "available": gb(m.available)})), "gpu": gpu, + "gpuNote": if gpu.is_none() { s.accel_note.clone() } else { None }, + "windowGraphics": s.window_adapter, "caches": {"layer": gb(budgets.layer as u64), "footage": gb(budgets.media as u64), "preview": gb(budgets.preview as u64)}, "import": IMPORT_FORMATS, "export": export, @@ -369,7 +374,14 @@ pub fn report_text(r: &Value) -> String { Some(m) => t.push_str(&format!("Memory: {} ({} available)\n", m["total"].as_str().unwrap_or("?"), m["available"].as_str().unwrap_or("?"))), None => t.push_str("Memory: unknown\n"), } - t.push_str(&format!("GPU: {}\n", r.get("gpu").and_then(Value::as_str).unwrap_or("none (CPU compositing)"))); + match (r.get("gpu").and_then(Value::as_str), r.get("gpuNote").and_then(Value::as_str)) { + (Some(gpu), _) => t.push_str(&format!("GPU: {gpu}\n")), + (None, Some(why)) => t.push_str(&format!("GPU: none (CPU compositing): {why}\n")), + (None, None) => t.push_str("GPU: none (CPU compositing)\n"), + } + if let Some(w) = r.get("windowGraphics").and_then(Value::as_str) { + t.push_str(&format!("Window graphics: {w}\n")); + } let c = &r["caches"]; t.push_str(&format!( "Cache budgets: layer {}, footage {}, preview {}\n", diff --git a/crates/engine/src/commands/comp.rs b/crates/engine/src/commands/comp.rs index a3267fa1..bf1fd530 100644 --- a/crates/engine/src/commands/comp.rs +++ b/crates/engine/src/commands/comp.rs @@ -36,19 +36,8 @@ fn apply_settings(c: &mut Comp, p: &Value) { let dy = (c.height as f64 - oh as f64) * (a / 3) as f64 / 2.0; if dx != 0.0 || dy != 0.0 { for l in c.layers.iter_mut().filter(|l| l.parent.is_none()) { - let Some(pr) = l.props.prop_mut("transform/position") else { continue }; - let shift = |v: &mut effectcraft_keyframe::Value| { - if let effectcraft_keyframe::Value::Vec2(x) = v { - x[0] += dx; - x[1] += dy; - } else if let effectcraft_keyframe::Value::Vec3(x) = v { - x[0] += dx; - x[1] += dy; - } - }; - shift(&mut pr.value); - for k in &mut pr.keys { - shift(&mut k.value); + if let Some(tr) = l.transform_mut() { + super::layer::offset_position(tr, dx, dy); } } } @@ -76,6 +65,8 @@ fn apply_settings(c: &mut Comp, p: &Value) { c.work_area.1 = c.duration; } c.work_area.1 = c.work_area.1.min(c.duration); + // A shorter composition must not leave the start past the end. + keep_work_area_ordered(c); } if let Some(pa) = f_p(p, "pixelAspect") { c.pixel_aspect = pa.max(0.1); @@ -191,22 +182,46 @@ fn work_area(s: &mut Session, p: &Value) -> Result { _ => {} } if let Some(b) = begin { - c.work_area.0 = b.clamp(Tick::ZERO, c.duration - fd); + // min/max, not clamp: the bounds cross on a comp shorter than a frame or an inverted area. + c.work_area.0 = b.min(c.duration - fd).max(Tick::ZERO); } if let Some(e) = end { - c.work_area.1 = e.clamp(c.work_area.0 + fd, c.duration); + c.work_area.1 = e.max(c.work_area.0 + fd).min(c.duration); } + // A start on its own can land after the current end. Pull it back so the interval + // still covers one frame, the same rule as `set: "begin"`. + keep_work_area_ordered(c); Ok(()) })?; let c = s.project.comp(cid).ok_or(EngineError::NoComp)?; Ok(json!({"start": c.work_area.0.seconds(), "end": c.work_area.1.seconds()})) } +/// Keep `work_area` inside the composition and at least one frame long. +fn keep_work_area_ordered(c: &mut Comp) { + let fd = c.frame_duration(); + c.work_area.1 = c.work_area.1.min(c.duration); + if c.work_area.0 < Tick::ZERO { + c.work_area.0 = Tick::ZERO; + } + if c.work_area.0 + fd > c.work_area.1 { + c.work_area.0 = (c.work_area.1 - fd).max(Tick::ZERO); + } + if c.work_area.0 + fd > c.work_area.1 { + c.work_area.1 = (c.work_area.0 + fd).min(c.duration); + } +} + fn trim_to_wa(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; s.edit("Trim Comp to Work Area", None, |proj, _| { let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let (a, b) = c.work_area; + let fd = c.frame_duration(); + // An interval that is already inverted must not become a negative duration. + if a < Tick::ZERO || b > c.duration || a + fd > b { + return Err(bad("comp.trimToWorkArea", "the work area must cover at least one frame inside the composition")); + } for l in &mut c.layers { l.start_time -= a; l.in_point = (l.in_point - a).max(Tick::ZERO); diff --git a/crates/engine/src/commands/comp_more.rs b/crates/engine/src/commands/comp_more.rs index 0f07fd67..2c4ca409 100644 --- a/crates/engine/src/commands/comp_more.rs +++ b/crates/engine/src/commands/comp_more.rs @@ -1,7 +1,6 @@ //! Composition menu, part two: crop to region of interest / layer bounds, Save Frame As ▸ File, //! Responsive Design — Time. -use effectcraft_keyframe::Value as KV; use effectcraft_project::Marker; use effectcraft_time::Tick; use serde_json::{Value, json}; @@ -24,14 +23,8 @@ fn crop(s: &mut Session, cid: effectcraft_project::ItemId, r: [f64; 4], label: & c.width = w; c.height = h; for l in c.layers.iter_mut().filter(|l| l.parent.is_none()) { - let Some(pos) = l.transform_mut().and_then(|tr| tr.get_mut("position")) else { continue }; - let mv = |v: &KV| { - let a = v.as_vec3(); - KV::Vec3([a[0] - x, a[1] - y, a[2]]) - }; - pos.value = mv(&pos.value); - for k in &mut pos.keys { - k.value = mv(&k.value); + if let Some(tr) = l.transform_mut() { + super::layer::offset_position(tr, -x, -y); } } for g in &mut c.guides { diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index 45ab1395..e5d2f912 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -6,7 +6,7 @@ use effectcraft_time::Tick; use serde_json::{Value, json}; use super::{CommandSpec, has_comp, has_layers, layers_p, match_path_of, selected_leaf_props, str_p}; -use crate::{EngineError, LinkClip, Result, Session, cmd}; +use crate::{EngineError, KeyRef, LinkClip, Result, Session, cmd}; fn can_undo(s: &Session) -> std::result::Result<(), String> { if s.history.undo.is_empty() { Err("nothing to undo".into()) } else { Ok(()) } @@ -25,6 +25,7 @@ fn has_clip(s: &Session) -> std::result::Result<(), String> { && s.state.effect_clipboard.is_empty() && s.state.contents_clipboard.is_empty() && s.state.link_clipboard.is_none() + && s.state.value_clipboard.is_empty() { Err("the clipboard is empty".into()) } else { @@ -210,14 +211,21 @@ fn clear_clipboards(s: &mut Session) { s.state.effect_clipboard.clear(); s.state.contents_clipboard.clear(); s.state.link_clipboard = None; + s.state.value_clipboard.clear(); s.state.clip_is_keys = false; } -/// Edit ▸ Copy: selected keyframes (when any) go to the keyframe clipboard, else layers. +/// Edit ▸ Copy: selected properties copy their keyframes and values, selected keyframes go +/// to the keyframe clipboard, else layers. fn copy(s: &mut Session, p: &Value) -> Result { if s.state.text_edit.is_some() && p.get("layers").is_none() { return super::text_edit::copy(s); } + if p.get("layers").is_none() + && let Some(r) = copy_properties(s)? + { + return Ok(r); + } // Keyframes selected → copy keys (pasted at the CTI). if !s.state.selected_keys.is_empty() && p.get("layers").is_none() { s.state.link_clipboard = None; @@ -247,6 +255,101 @@ fn copy(s: &mut Session, p: &Value) -> Result { Ok(json!(s.state.clipboard.len())) } +/// Edit ▸ Copy with properties selected (Position, or the Transform group), as in After +/// Effects: an animated property copies its keyframes (the selected ones, else all of them) and +/// an unanimated one its value, for Paste onto the same properties of other layers (#493). +/// `None` when no property (or Transform group) is selected. +fn copy_properties(s: &mut Session) -> Result> { + let Some(comp) = s.active_comp() else { return Ok(None) }; + let mut leaves = vec![]; + for (lid, uid) in &s.state.selected_props { + let Some(l) = comp.layer(*lid) else { continue }; + if l.props.find(*uid).is_some() { + leaves.push((*lid, *uid)); + } else if let Some(g) = l.props.find_group(*uid) + && l.props.match_path_of(*uid).as_deref() == Some("transform") + { + g.walk("", &mut |_, pr| leaves.push((*lid, pr.uid))); + } + } + if leaves.is_empty() { + return Ok(None); + } + let mut keys = s.state.selected_keys.clone(); + let mut values = vec![]; + for (lid, uid) in &leaves { + let Some(l) = comp.layer(*lid) else { continue }; + let Some(pr) = l.props.find(*uid) else { continue }; + if pr.keys.is_empty() { + if let Some(path) = l.props.match_path_of(*uid) + && !values.iter().any(|(p, _)| *p == path) + { + values.push((path, pr.value.clone())); + } + } else if !keys.iter().any(|k| k.layer == *lid && k.prop == *uid) { + keys.extend(pr.keys.iter().map(|k| KeyRef { layer: *lid, prop: *uid, time: k.time })); + } + } + clear_clipboards(s); + let mut n = 0; + if !keys.is_empty() { + let selected = std::mem::replace(&mut s.state.selected_keys, keys); + let r = s.execute("keys.copy", json!({})); + s.state.selected_keys = selected; + n = r?.as_u64().unwrap_or(0); + } + let v = values.len(); + s.state.value_clipboard = values; + // Keys alone answer as `keys.copy` does (their count). + Ok(Some(if v == 0 { json!(n) } else { json!({"keys": n, "values": v}) })) +} + +/// Paste copied property values (and keyframes, at the CTI) onto the same properties of the +/// selected layers, as one undo step. +fn paste_properties(s: &mut Session, p: &Value) -> Result { + let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; + let ids = s.state.selected_layers.clone(); + if ids.is_empty() { + return Err(EngineError::Other("select a layer to paste into".into())); + } + let values = s.state.value_clipboard.clone(); + let keys = s.state.clip_is_keys && !s.state.key_clipboard.is_empty(); + let t = s.time(); + // The values and the keys are one step: the keys' paste merges into the values' step. + let merge = "edit.paste.properties"; + s.history.merge_key = None; + let n = s.edit("Paste", keys.then_some(merge), |proj, _| { + let mut n = 0; + for lid in &ids { + let l = super::layer_mut(proj, cid, *lid)?; + if l.switches.locked { + continue; + } + let lt = l.layer_time(t); + for (path, v) in &values { + if let Some(pr) = l.props.prop_mut(path) + && let Some(v) = pr.value.coerce_json(&v.to_json()) + { + pr.set_value_at(lt, v); + n += 1; + } + } + } + Ok(n) + })?; + let mut r = json!({"values": n}); + if keys { + let mut kp = p.clone(); + if let Some(o) = kp.as_object_mut() { + o.insert("merge".into(), json!(merge)); + } + let pasted = s.execute("keys.paste", kp); + s.history.merge_key = None; + r["keys"] = pasted?; + } + Ok(r) +} + /// The expression accessor for a property, e.g. `comp("Main").layer("Solid").transform("Position")`. pub(crate) fn link_expression(s: &Session, lid: LayerId, uid: effectcraft_project::Uid, relative: bool) -> Option { let cid = s.active_comp_id()?; @@ -392,6 +495,9 @@ fn paste(s: &mut Session, p: &Value) -> Result { if let Some(clip) = s.state.link_clipboard.clone() { return paste_links(s, clip); } + if !s.state.value_clipboard.is_empty() { + return paste_properties(s, p); + } if s.state.clip_is_keys && !s.state.key_clipboard.is_empty() { return s.execute("keys.paste", p.clone()); } diff --git a/crates/engine/src/commands/effect.rs b/crates/engine/src/commands/effect.rs index 68684650..3c597ebd 100644 --- a/crates/engine/src/commands/effect.rs +++ b/crates/engine/src/commands/effect.rs @@ -387,7 +387,8 @@ fn reset(s: &mut Session, p: &Value) -> Result { s.edit(&format!("Reset {name}"), None, |proj, _| { let g = layer_mut(proj, cid, lid)?.props.find_group_mut(uid).ok_or_else(|| bad("effect.reset", "gone"))?; for ps in &spec.params { - if let Some(pr) = g.get_mut(ps.id) { + // (`group/param` ids live in twirl-down groups.) + if let Some(pr) = g.prop_mut(ps.id) { pr.set_value_at(lt, effectcraft_effects::default_value(ps, size)); } } @@ -415,7 +416,24 @@ fn list(_: &mut Session, p: &Value) -> Result { filter.as_ref().is_none_or(|f| effectcraft_effects::name_matches(e, f) || e.id.contains(f.as_str()) || e.category.to_ascii_lowercase().contains(f)) }) .map(|e| { - json!({"id": e.id, "name": e.name, "category": e.category, "gpu": e.gpu, "float": e.float, "params": e.params.iter().map(|p| json!({"id": p.id, "name": p.name, "default": p.default.to_json()})).collect::>()}) + json!({"id": e.id, "name": e.name, "category": e.category, "gpu": e.gpu, "float": e.float, "params": e.params.iter().map(|p| { + let mut out = json!({"id": p.id, "name": p.name, "type": p.default.kind_name(), "default": p.default.to_json()}); + match &p.ui { + effectcraft_project::ParamUi::Popup { options } => { + // Engine/MCP popup values are zero-based; JSX exposes one-based values. + out["options"] = json!(options.iter().enumerate().map(|(value, label)| json!({"value": value, "label": label})).collect::>()); + } + effectcraft_project::ParamUi::Slider { min, max, slider_min, slider_max, decimals } => { + out["min"] = json!(min); + out["max"] = json!(max); + out["sliderMin"] = json!(slider_min); + out["sliderMax"] = json!(slider_max); + out["decimals"] = json!(decimals); + } + _ => {} + } + out + }).collect::>()}) }) .collect(); Ok(json!(v)) @@ -469,7 +487,12 @@ pub fn specs() -> Vec { cmd!("effect.copy", "Copy Effects", [], None, "{layer?, effect? | effects?: [uid|name|index]} (default: the selected effects)", has_layers, copy), cmd!("effect.paste", "Paste Effects", [], None, "{layers?} — adds the copied effects to the layers", has_layers, paste), cmd!("effect.reset", "Reset Effect", [], None, "{layer?, effect}", has_layers, reset), - crate::query!("effect.list", "List Effects", "{filter?}", list), + crate::query!( + "effect.list", + "List Effects", + "{filter?} → [{params: [{id, name, type, default, options?, min?, max?, sliderMin?, sliderMax?, decimals?}]}]; popup indices are zero-based (JSX uses one-based values)", + list + ), crate::query!( "effect.warning", "Effect Warning", @@ -493,3 +516,36 @@ pub fn specs() -> Vec { ), ] } + +#[cfg(test)] +mod list_metadata_tests { + use super::*; + + #[test] + fn effect_list_exposes_popup_labels_and_numeric_slider_limits() { + let mut s = Session::default(); + let menus = s.execute("effect.list", json!({"filter": "ec.control.dropdown"})).unwrap(); + assert_eq!(menus.as_array().map(Vec::len), Some(1)); + let menu = &menus[0]["params"][0]; + assert_eq!(menu["type"], "enum"); + assert_eq!(menu["default"], 0); + assert_eq!( + menu["options"], + json!([ + {"value": 0, "label": "Item 1"}, + {"value": 1, "label": "Item 2"}, + {"value": 2, "label": "Item 3"} + ]) + ); + let sliders = s.execute("effect.list", json!({"filter": "ec.control.slider"})).unwrap(); + assert_eq!(sliders.as_array().map(Vec::len), Some(1)); + let slider = &sliders[0]["params"][0]; + assert_eq!(slider["type"], "scalar"); + assert_eq!(slider["min"], -1_000_000.0); + assert_eq!(slider["max"], 1_000_000.0); + assert_eq!(slider["sliderMin"], 0.0); + assert_eq!(slider["sliderMax"], 100.0); + assert_eq!(slider["decimals"], 2); + assert!(slider["options"].is_null()); + } +} diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index f0396e5d..6f3d2e70 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -43,7 +43,8 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result { let text = String::from_utf8(bytes).map_err(|_| EngineError::Other("not a text project file".into()))?; // XML copies (File ▸ Save a Copy As XML…) open like the JSON project. let text = if crate::xml_project::is_xml(&text) { crate::xml_project::from_xml(&text).map_err(EngineError::Other)? } else { text }; - let proj = Project::from_json(&text).map_err(|e| EngineError::Other(format!("{path} is not a project EffectCraft can open: {e}")))?; + let mut proj = Project::from_json(&text).map_err(|e| EngineError::Other(format!("{path} is not a project EffectCraft can open: {e}")))?; + resolve_relative_footage(&mut proj, path, s.services.as_ref()); s.replace_project(proj, Some(path.to_string())); s.note_project_path(path); // Written by a newer EffectCraft: what this version doesn't know would be lost on saving. @@ -62,6 +63,28 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result { Ok(json!({"path": path, "savedBy": saved_by})) } +/// Footage saved with a relative path (imported by the CLI or an agent as `clip.mp4`, or written +/// by hand) is found next to the project file, as After Effects looks for footage relative to +/// the project. It used to be looked up from the working directory: the CLI started in the +/// project's folder found it, the desktop app (started from the Finder, Explorer or a launcher, +/// in another folder) showed and rendered nothing (#652). A path that exists from the working +/// directory is kept. +fn resolve_relative_footage(p: &mut Project, project_path: &str, services: &dyn crate::Services) { + use std::path::Path; + let Some(dir) = Path::new(project_path).parent().filter(|d| !d.as_os_str().is_empty()) else { return }; + let beside = |f: &str| dir.join(f).to_string_lossy().to_string(); + for item in p.items.values_mut() { + let ItemKind::Footage(f) = &mut item.kind else { continue }; + if f.path.is_empty() || !Path::new(&f.path).is_relative() || services.exists(&f.path) || !services.exists(&beside(&f.path)) { + continue; + } + f.path = beside(&f.path); + for frame in f.sequence.iter_mut().filter(|q| Path::new(q.as_str()).is_relative()) { + *frame = beside(frame); + } + } +} + /// The project file's text for `path`: `.ecproj` JSON, or XML for `.ecprojx` / `.xml` (a /// project opened from an XML copy saves as XML again). Fails, writing nothing, when the project /// wouldn't read back. @@ -376,7 +399,7 @@ fn prepare_import(s: &mut Session, p: &Value) -> Result { } // Numbered stills as image sequences (the Import dialog's " Sequence" and Force // Alphabetical Order), at `frameRate` or Settings ▸ Import ▸ Sequence Footage. - let opts = SequenceOptions::from_params(p); + let opts = SequenceOptions::for_import(p); let fps = match p.get("frameRate") { None | Some(Value::Null) => s.prefs.import.sequence_fps, Some(v) => v.as_f64().filter(|x| *x > 0.0 && *x <= 999.0).ok_or_else(|| bad("file.import", "frameRate: frames per second, up to 999"))?, @@ -577,6 +600,14 @@ fn project_settings(s: &mut Session, p: &Value) -> Result { ), }; } + if let Some(g) = p.get("workingGamma") { + // After Effects takes 2.2 or 2.4 only (`app.project.workingGamma`). + proj.settings.working_gamma = match g.as_f64() { + Some(g) if (g - 2.2).abs() < 1e-6 => 2.2, + Some(g) if (g - 2.4).abs() < 1e-6 => 2.4, + _ => return Err(bad(cmd, format!("workingGamma: 2.2|2.4, not `{g}`"))), + }; + } if proj.settings.color_engine == ColorEngine::Ocio && !proj.settings.working_space.is_some_and(|w| w.is_linear()) { return Err(bad(cmd, "the OCIO built-in config's working spaces are acescg and aces2065")); } @@ -680,7 +711,7 @@ pub fn specs() -> Vec { "File...", ["File", "Import"], Some("Cmd+I"), - "{paths: [string] (files or folders), sequence?: bool (default true: numbered stills of one run import as one image sequence; one picked file brings its whole run, several picked files that range), alphabetical?: bool (Force Alphabetical Order: every image of that type in the folder, by name), frameRate?: fps (image sequences; default Settings ▸ Import ▸ Sequence Footage), importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}", + "{paths: [string] (files or folders), sequence?: bool (the Sequence checkbox: true imports numbered stills of one run as one image sequence, one picked file bringing its whole run and several picked files that range; default: a numbered still brings its run, except dropped files (drag), which import as stills as in After Effects; folders' runs import as sequences; false imports stills and a folder's files individually), alphabetical?: bool (Force Alphabetical Order: every image of that type in the folder, by name), frameRate?: fps (image sequences; default Settings ▸ Import ▸ Sequence Footage), importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As; files import as stills unless sequence), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}", always, import_cmd ), @@ -689,7 +720,7 @@ pub fn specs() -> Vec { "Project Settings...", ["File"], Some("Cmd+Alt+Shift+K"), - "{bitDepth?: 8|16|32, colorEngine?: adobe|ocio, workingSpace?: none|srgb|rec709|rec2020|p3|acescg|aces2065, linearize?, blendLinear?, hdr?: clip|compand|toneMap, outputSpace?: srgb|rec709|rec2020|p3|rec2100pq|rec2100hlg, renderer?: gpu|software, timeDisplay?: timecode|frames|feet35|feet16}", + "{bitDepth?: 8|16|32, colorEngine?: adobe|ocio, workingSpace?: none|srgb|rec709|rec2020|p3|acescg|aces2065, workingGamma?: 2.2|2.4 (with no working space), linearize?, blendLinear?, hdr?: clip|compand|toneMap, outputSpace?: srgb|rec709|rec2020|p3|rec2100pq|rec2100hlg, renderer?: gpu|software, timeDisplay?: timecode|frames|feet35|feet16}", always, project_settings ), diff --git a/crates/engine/src/commands/file_more.rs b/crates/engine/src/commands/file_more.rs index aa9e1be9..9aecd8d6 100644 --- a/crates/engine/src/commands/file_more.rs +++ b/crates/engine/src/commands/file_more.rs @@ -465,6 +465,8 @@ fn run_script(s: &mut Session, p: &Value) -> Result { } _ => return Err(bad("file.runScript", "missing `path` (or inline `steps`)")), }; + // A command script read from a file is a script: it can't change what scripts may do. + let from_file = p.get("steps").is_none(); let mut results = vec![]; for (i, step) in steps.iter().enumerate() { let (id, params) = match (step.get("command").or(step.get("id")).and_then(Value::as_str), step.get("method").and_then(Value::as_str)) { @@ -478,7 +480,8 @@ fn run_script(s: &mut Session, p: &Value) -> Result { if id == "file.runScript" || id == "script.run" { return Err(bad("file.runScript", "scripts can't run scripts")); } - let r = s.execute(&id, params).map_err(|e| EngineError::Other(format!("step {} ({id}): {e}", i + 1)))?; + let r = if from_file { s.execute_for_script(&id, params) } else { s.execute(&id, params) }; + let r = r.map_err(|e| EngineError::Other(format!("step {} ({id}): {e}", i + 1)))?; results.push(r); } Ok(json!({"steps": results.len(), "results": results})) diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 11dcb4dc..09bad38f 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -1270,6 +1270,37 @@ fn set_text(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } +/// Move Position by `(dx, dy)`, including every key. Separated dimensions move X and Y as well +/// as the combined property the channels were split from. +pub(crate) fn offset_position(tr: &mut PropGroup, dx: f64, dy: f64) { + if dx == 0.0 && dy == 0.0 { + return; + } + if tr.get("positionX").is_some() { + for (name, d) in [("positionX", dx), ("positionY", dy)] { + if let Some(pr) = tr.get_mut(name) { + pr.value = KV::Scalar(pr.value.as_f64() + d); + for k in &mut pr.keys { + k.value = KV::Scalar(k.value.as_f64() + d); + } + } + } + } + if let Some(pr) = tr.get_mut("position") { + let shift = |v: &KV| match v { + KV::Vec2(a) => KV::Vec2([a[0] + dx, a[1] + dy]), + other => { + let a = other.as_vec3(); + KV::Vec3([a[0] + dx, a[1] + dy, a[2]]) + } + }; + pr.value = shift(&pr.value); + for k in &mut pr.keys { + k.value = shift(&k.value); + } + } +} + /// Set a layer's Position at layer time `lt` to `f(current)`: X/Y/Z Position when its dimensions /// are separated. pub(crate) fn update_position(tr: &mut PropGroup, lt: Tick, f: impl Fn([f64; 3]) -> [f64; 3]) { diff --git a/crates/engine/src/commands/layer_menu.rs b/crates/engine/src/commands/layer_menu.rs index 5ad0c1fc..ba00db38 100644 --- a/crates/engine/src/commands/layer_menu.rs +++ b/crates/engine/src/commands/layer_menu.rs @@ -192,6 +192,32 @@ fn set_transform(s: &mut Session, p: &Value) -> Result { for lid in &ids { let l = layer_mut(proj, cid, *lid)?; let lt = l.layer_time(t); + // Separate Dimensions: the picture follows X/Y/Z Position, not the combined property. + if m == "position" && l.transform().is_some_and(|tr| tr.get("positionX").is_some()) { + let cur = l + .transform() + .map(|tr| { + [ + tr.get("positionX").map(|p| p.value_at(lt).as_f64()).unwrap_or(0.0), + tr.get("positionY").map(|p| p.value_at(lt).as_f64()).unwrap_or(0.0), + tr.get("positionZ").map(|p| p.value_at(lt).as_f64()).unwrap_or(0.0), + ] + }) + .unwrap_or([0.0; 3]); + // Two numbers keep the current z. + let j = match &v { + Value::Array(a) if a.len() == 2 => json!([a[0], a[1], cur[2]]), + _ => v.clone(), + }; + let nv = KV::Vec3(cur).coerce_json(&j).ok_or_else(|| bad("layer.setTransform", format!("can't use {v} for {label}")))?; + let xyz = nv.as_vec3(); + let Some(tr) = l.transform_mut() else { continue }; + super::layer::update_position(tr, lt, |_| xyz); + if let Some(pr) = tr.get_mut("position") { + pr.set_value_at(lt, KV::Vec3(xyz)); + } + continue; + } let Some(pr) = l.transform_mut().and_then(|tr| tr.get_mut(m)) else { continue }; let cur = pr.value_at(lt); // Two numbers for a 3D value keep the current z. diff --git a/crates/engine/src/commands/panels_cmds.rs b/crates/engine/src/commands/panels_cmds.rs index bcdb7a73..40241e6f 100644 --- a/crates/engine/src/commands/panels_cmds.rs +++ b/crates/engine/src/commands/panels_cmds.rs @@ -105,7 +105,10 @@ fn browser_import(s: &mut Session, p: &Value) -> Result { return Ok(json!({"pending": true, "paths": list})); } let target = s.active_comp_id(); - let mut r = s.execute("file.import", json!({"paths": paths}))?; + // A numbered still picked in the browser still brings its image sequence (`sequence: false` + // for the picked stills alone); `file.import` alone imports stills. + let sequence = p.get("sequence").and_then(Value::as_bool).unwrap_or(true); + let mut r = s.execute("file.import", json!({"paths": paths, "sequence": sequence}))?; // Dragged into the Timeline or the Composition viewer: into the comp, where they were dropped. let errors = super::file::add_to_comp(s, &r, target, p); if !errors.is_empty() @@ -236,7 +239,7 @@ pub fn specs() -> Vec { "Import", [], None, - "{paths, addToComp?, time?, index?, position? (where the layers go, as in layer.addItem)}", + "{paths, sequence? (default true: a numbered still brings its image sequence), addToComp?, time?, index?, position? (where the layers go, as in layer.addItem)}", browser_enabled, browser_import ), diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index cf459393..46804c87 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -165,6 +165,145 @@ fn property_reply(p: &Value, layer: &Layer, pr: &Property, time: Tick, value: Va } } +// ---------------------------------------------------------------- several selected layers + +/// With `selected: true` (the Timeline, Properties and Effect Controls panels pass it): the same +/// property (by match path, `transform/position`, `effects/#n/`) of every other +/// selected, unlocked layer of the comp whose value is of the same kind, as (layer, uid). Empty +/// when `selected` is not set or the edited layer isn't selected. After Effects applies a value +/// edit, the stopwatch and the keyframe button to all selected layers (#355, #491). +fn selected_twins(s: &Session, p: &Value, cmd: &str) -> Result> { + if b_p(p, "selected") != Some(true) { + return Ok(vec![]); + } + let (cid, lid, uid) = prop_ref(s, p, cmd)?; + let Some(comp) = s.project.comp(cid) else { return Ok(vec![]) }; + let Some(layer) = comp.layer(lid) else { return Ok(vec![]) }; + let (Some(path), Some(pr)) = (layer.props.match_path_of(uid), layer.props.find(uid)) else { return Ok(vec![]) }; + if !s.state.selected_layers.contains(&lid) { + return Ok(vec![]); + } + let kind = (pr.value.kind_name(), pr.value.components().len()); + Ok(s.state + .selected_layers + .iter() + .filter(|o| **o != lid) + .filter_map(|o| comp.layer(*o)) + .filter(|l| !l.switches.locked) + .filter_map(|l| l.props.prop(&path).filter(|q| (q.value.kind_name(), q.value.components().len()) == kind).map(|q| (l.id, q.uid))) + .collect()) +} + +/// `p` aimed at property `uid` of layer `lid` instead. +fn twin_params(p: &Value, lid: LayerId, uid: Uid) -> Value { + let mut q = p.clone(); + if let Some(o) = q.as_object_mut() { + o.remove("path"); + o.remove("selected"); + o.remove("offset"); + } + q["layer"] = json!(lid.0); + q["prop"] = json!(uid); + q +} + +/// The value of property `uid` of `lid` at the CTI as numbers (Position with Separate +/// Dimensions: its X / Y / Z Position). +fn current_components(s: &Session, cid: ItemId, lid: LayerId, uid: Uid) -> Option> { + let l = s.project.comp(cid)?.layer(lid)?; + let lt = l.layer_time(s.time_of(cid)); + if let Some(tr) = l.transform() + && tr.get("position").is_some_and(|q| q.uid == uid) + && tr.get("positionX").is_some() + { + return Some(["positionX", "positionY", "positionZ"].iter().filter_map(|m| tr.get(m)).map(|q| q.value_at(lt).as_f64()).collect()); + } + Some(l.props.find(uid)?.value_at(lt).components()) +} + +/// A JSON number or array of numbers. +fn json_numbers(v: &Value) -> Option> { + match v { + Value::Number(n) => Some(vec![n.as_f64()?]), + Value::Array(a) => a.iter().map(Value::as_f64).collect(), + _ => None, + } +} + +/// `prop.set`; with `selected`, also on the other selected layers: the same value, or with +/// `offset` (a scrub) the same change from each layer's own value. One undo step (with `merge`, +/// one that a continuing scrub keeps folding into). +fn set_selected(s: &mut Session, p: &Value) -> Result { + let twins = selected_twins(s, p, "prop.set")?; + if twins.is_empty() { + return set(s, p); + } + let (cid, lid, uid) = prop_ref(s, p, "prop.set")?; + let new = p.get("value").and_then(json_numbers); + let delta = match (b_p(p, "offset"), &new, current_components(s, cid, lid, uid)) { + (Some(true), Some(n), Some(c)) => Some(n.iter().zip(c.iter().chain(std::iter::repeat(&0.0))).map(|(a, b)| a - b).collect::>()), + _ => None, + }; + let run = |s: &mut Session| -> Result { + let out = set(s, p)?; + for (olid, ouid) in twins { + let mut q = twin_params(p, olid, ouid); + if let Some(d) = &delta + && let Some(c) = current_components(s, cid, olid, ouid) + { + let v: Vec = c.iter().enumerate().map(|(i, x)| x + d.get(i).copied().unwrap_or(0.0)).collect(); + q["value"] = if p.get("value").is_some_and(Value::is_number) { json!(v.first().copied().unwrap_or(0.0)) } else { json!(v) }; + } + // (a layer that can't take it, e.g. a clamped or static property, keeps its value) + let _ = set(s, &q); + } + Ok(out) + }; + if merge_p(p).is_some() { run(s) } else { super::app_more::grouped(s, "Change Property", run) } +} + +/// `prop.toggleAnimation`; with `selected`, the other selected layers' same property follows. +fn toggle_anim_selected(s: &mut Session, p: &Value) -> Result { + let twins = selected_twins(s, p, "prop.toggleAnimation")?; + if twins.is_empty() { + return toggle_anim(s, p); + } + let run = |s: &mut Session| -> Result { + let on = toggle_anim(s, p)?; + for (olid, ouid) in twins { + let mut q = twin_params(p, olid, ouid); + q["value"] = on.clone(); + let _ = toggle_anim(s, &q); + } + Ok(on) + }; + // (a stopwatch drag over several rows keeps folding into the step its `merge` names) + if merge_p(p).is_some() { run(s) } else { super::app_more::grouped(s, "Toggle Animation", run) } +} + +/// `prop.toggleKey`; with `selected`, the other selected layers' same property gets (or loses) +/// its keyframe at the current time too. +fn toggle_key_selected(s: &mut Session, p: &Value) -> Result { + let twins = selected_twins(s, p, "prop.toggleKey")?; + if twins.is_empty() { + return toggle_key(s, p); + } + let cid = prop_ref(s, p, "prop.toggleKey")?.0; + super::app_more::grouped(s, "Add/Remove Keyframe", |s| { + let added = toggle_key(s, p)?; + let want = added.as_bool().unwrap_or(true); + for (olid, ouid) in twins { + let _ = with_prop(s, "Add/Remove Keyframe", None, cid, olid, ouid, |pr, lt| { + if key_at(&pr.keys, lt).is_some() != want { + toggle_key_at(pr, lt); + } + Ok(()) + }); + } + Ok(added) + }) +} + fn set(s: &mut Session, p: &Value) -> Result { let (cid, lid, uid) = prop_ref(s, p, "prop.set")?; // Position with Separate Dimensions: write the X/Y/Z Position properties instead. @@ -981,6 +1120,8 @@ fn convert_expr_to_keys(s: &mut Session, p: &Value) -> Result { keys.push(Keyframe::new(layer.layer_time(t), ctx.value(&layer, &pr))); t += fd; } + // A time-reversed layer maps ascending comp times to descending layer times; keys must stay ordered by layer time. + keys.sort_by_key(|k| k.time); let n = keys.len(); with_prop(s, "Convert Expression to Keyframes", None, cid, lid, uid, move |pr, _| { pr.keys = keys; @@ -999,13 +1140,13 @@ pub fn specs() -> Vec { "Set Property Value", [], None, - "{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?}", + "{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?, selected? (also the other selected layers), offset? (them by the same change)}", has_layers, - set + set_selected ), - cmd!("prop.toggleAnimation", "Toggle Stopwatch", [], None, "{layer?, path|prop, value?, merge?}", has_layers, toggle_anim), + cmd!("prop.toggleAnimation", "Toggle Stopwatch", [], None, "{layer?, path|prop, value?, merge?, selected?}", has_layers, toggle_anim_selected), cmd!("prop.addKey", "Add Keyframe", [], None, "{layer?, path|prop, time? (layer s by default), timeBase?: layer|comp, value?}", has_layers, add_key), - cmd!("prop.toggleKey", "Add or Remove Keyframe at Current Time", [], None, "{layer?, path|prop}", has_layers, toggle_key), + cmd!("prop.toggleKey", "Add or Remove Keyframe at Current Time", [], None, "{layer?, path|prop, selected?}", has_layers, toggle_key_selected), cmd!( "keys.toggleTransform", "Add or Remove Transform Keyframe", diff --git a/crates/engine/src/commands/puppet.rs b/crates/engine/src/commands/puppet.rs index a333031e..320e1d41 100644 --- a/crates/engine/src/commands/puppet.rs +++ b/crates/engine/src/commands/puppet.rs @@ -21,6 +21,10 @@ use serde_json::{Value, json}; use super::{CommandSpec, bad, f_p, has_comp, layer_mut, layer_p, merge_p, str_p}; use crate::{EngineError, Result, Session, cmd}; +fn dist2(a: [f64; 2], b: [f64; 2]) -> f64 { + (a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) +} + fn pt(v: Option<&Value>) -> Option<[f64; 2]> { let a = v?.as_array()?; Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?]) @@ -77,7 +81,8 @@ fn add_pin(s: &mut Session, p: &Value) -> Result { let fx_uid = find_puppet(s, cid, lid); let want_mesh = p.get("mesh").and_then(Value::as_u64); let new_mesh = p.get("newMesh").and_then(Value::as_bool).unwrap_or(false); - let mut target: Option<(Uid, [f64; 2])> = None; + // (mesh, the pin's rest point, where it is now) + let mut target: Option<(Uid, [f64; 2], [f64; 2])> = None; if let Some(fu) = fx_uid && !new_mesh && let Some((buf, params)) = puppet_eval(s, cid, lid, fu, t) @@ -90,7 +95,22 @@ fn add_pin(s: &mut Session, p: &Value) -> Result { let inside = puppet::contains(mesh, def, at); let near = def.iter().map(|v| (v[0] - at[0]).hypot(v[1] - at[1])).fold(f64::MAX, f64::min) < 12.0; if inside || near || want_mesh.is_some() { - target = Some((*uid, puppet::unmap(mesh, def, at))); + let rest = puppet::unmap(mesh, def, at); + // A moving pin holds its nearest mesh vertex at `position + (vertex - rest)` + // (puppet::solve). Choose the position that keeps that vertex where it is now, so + // adding a pin to a bent mesh moves nothing (After Effects, #318); on an unbent + // mesh that is the click itself. + let now = (0..mesh.verts.len().min(def.len())) + .filter(|_| kind.moves()) + .min_by(|a, b| dist2(mesh.verts[*a], rest).total_cmp(&dist2(mesh.verts[*b], rest))) + .and_then(|v| { + let (r, d) = (mesh.verts.get(v)?, def.get(v)?); + Some([d[0] - (r[0] - rest[0]), d[1] - (r[1] - rest[1])]) + }) + // Round-off only (an unbent mesh): exactly the click. + .filter(|n| dist2(*n, at) > 1e-12) + .unwrap_or(at); + target = Some((*uid, rest, now)); break; } } @@ -131,14 +151,14 @@ fn add_pin(s: &mut Session, p: &Value) -> Result { } }; let pg = fxg.find_group_mut(fx_uid).ok_or_else(|| bad(cmd, "Puppet effect gone"))?; - let (mesh_uid, rest) = match target { - Some((m, r)) => (m, r), + let (mesh_uid, rest, now) = match target { + Some(t) => t, None => { let n = puppet::meshes(pg).count(); let g = puppet::mesh_group(&mut ids, &format!("Mesh {}", n + 1), at, &opts); let u = g.uid; pg.children.push(g.into()); - (u, at) + (u, at, at) } }; let name = puppet::next_pin_name(pg, kind); @@ -146,7 +166,7 @@ fn add_pin(s: &mut Session, p: &Value) -> Result { let mut pin = puppet::pin_group(&mut ids, &name, kind, rest); if let Some(pos) = pin.get_mut("position") { if kind.moves() { - pos.value = KV::Vec2(at); + pos.value = KV::Vec2(now); pos.set_animated(true, lt); } else { pos.value = KV::Vec2(rest); diff --git a/crates/engine/src/commands/query.rs b/crates/engine/src/commands/query.rs index 27588907..56139b6c 100644 --- a/crates/engine/src/commands/query.rs +++ b/crates/engine/src/commands/query.rs @@ -185,13 +185,13 @@ fn capabilities(s: &mut Session, _: &Value) -> Result { "parity": { "target": "Adobe After Effects 2026", "summary": PARITY, - "doc": "docs/parity.md", + "doc": "docs/target-app-parity.md", }, })) } -/// The headline of `docs/parity.md` (kept in sync by hand at each audit). -const PARITY: &str = "≈94% weighted feature parity (80 done / 12 partial / 0 missing of 92 features); 298 of 298 After Effects 2026 effects present"; +/// The headline of `docs/target-app-parity.md` (kept in sync by hand at each re-measure). +const PARITY: &str = "≈91% feature breadth and ≈45% ready for real work (estimated parity with After Effects 2026 26.5, stage alpha); all After Effects effects present (306 registered)"; fn state(s: &mut Session, _: &Value) -> Result { Ok(serde_json::to_value(&s.state).unwrap_or_default()) diff --git a/crates/engine/src/commands/settings.rs b/crates/engine/src/commands/settings.rs index 320a6c18..1a5789e2 100644 --- a/crates/engine/src/commands/settings.rs +++ b/crates/engine/src/commands/settings.rs @@ -9,6 +9,10 @@ use crate::{EngineError, Result, Session, cmd, query}; // ---------------------------------------------------------------- prefs +/// Why a script can't change Application Scripting settings. +const SCRIPT_PERMISSIONS: &str = + "Scripts can't change what scripts are allowed to do. Change it yourself in Settings ▸ Scripting & Expressions ▸ Application Scripting."; + fn prefs_get(s: &mut Session, p: &Value) -> Result { let key = str_p(p, "key").unwrap_or(""); s.prefs.get(key).ok_or_else(|| bad("prefs.get", format!("unknown setting `{key}`"))) @@ -30,6 +34,9 @@ fn prefs_set(s: &mut Session, p: &Value) -> Result { for (k, v) in &pairs { next.set(k, v.clone()).map_err(|e| bad("prefs.set", e))?; } + if s.running_for_script() && !next.scripting.same_permissions(&s.prefs.scripting) { + return Err(EngineError::Other(SCRIPT_PERMISSIONS.into())); + } s.prefs = next; s.prefs_changed(); s.save_prefs(); @@ -39,7 +46,12 @@ fn prefs_set(s: &mut Session, p: &Value) -> Result { fn prefs_reset(s: &mut Session, p: &Value) -> Result { let page = str_p(p, "page"); + let before = s.prefs.scripting.clone(); s.prefs.reset(page).map_err(|e| bad("prefs.reset", e))?; + // A script's reset leaves what scripts may do as the user set it. + if s.running_for_script() { + s.prefs.scripting.keep_permissions(&before); + } s.prefs_changed(); s.save_prefs(); s.toast(match page { diff --git a/crates/engine/src/commands/time.rs b/crates/engine/src/commands/time.rs index 9ed41f47..f98ea5ed 100644 --- a/crates/engine/src/commands/time.rs +++ b/crates/engine/src/commands/time.rs @@ -38,7 +38,24 @@ pub fn parse_display_time(s: &Session, comp: &Comp, text: &str, current: i64) -> let start = fr.frame_at(comp.display_start) + st.frame_start; effectcraft_time::parse_feet_frames(text, pf, current + start).map(|f| f - start).map_err(|e| e.0) } - None => effectcraft_time::parse_timecode(text, fr, fr.supports_drop_frame(), current).map_err(|e| e.0), + None => { + let parsed = effectcraft_time::parse_timecode(text, fr, fr.supports_drop_frame(), current).map_err(|e| e.0)?; + let relative = text.trim().starts_with('+') || text.trim().starts_with('-'); + if relative { + Ok(parsed) + } else if st.time_display == TimeDisplayStyle::Frames + && let Ok(frame) = text.trim().parse::() + { + // Displayed digits are a plain frame count (00100 is frame 100, not 1 s) plus + // Start Numbering Frames At. + Ok(frame.saturating_sub(st.frame_start)) + } else if st.time_display == TimeDisplayStyle::Frames { + Ok(parsed.saturating_sub(st.frame_start)) + } else { + // Displayed timecode includes the composition Start Timecode offset. + Ok(parsed.saturating_sub(fr.frame_at(comp.display_start))) + } + } } } diff --git a/crates/engine/src/commands/vpe.rs b/crates/engine/src/commands/vpe.rs index f4932bc0..c4a348be 100644 --- a/crates/engine/src/commands/vpe.rs +++ b/crates/engine/src/commands/vpe.rs @@ -1,7 +1,7 @@ //! File ▸ Import ▸ Vanishing Point (.vpe): present in the menu like After Effects', but always //! disabled. The `.vpe` exchange format Photoshop's Vanishing Point filter writes has no public //! specification, and EffectCraft implements formats only from published specs (clean room), so -//! the entry explains that instead of importing. See docs/parity.md. +//! the entry explains that instead of importing. See docs/file-format-parity.md. use serde_json::Value; diff --git a/crates/engine/src/footage_check.rs b/crates/engine/src/footage_check.rs index c3765f1e..f5db284e 100644 --- a/crates/engine/src/footage_check.rs +++ b/crates/engine/src/footage_check.rs @@ -193,6 +193,30 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + /// #652: footage saved with a relative path is found next to the project, wherever the + /// app was started (the desktop app doesn't start in the project's folder, the CLI often + /// does). + #[test] + fn relative_footage_is_found_next_to_the_project() { + let dir = std::env::temp_dir().join(format!("ec-relative-{}", std::process::id())); + std::fs::create_dir_all(dir.join("media")).unwrap(); + let clip = dir.join("media").join("ec-relative-clip.png"); + std::fs::write(&clip, b"x").unwrap(); + let mut s = session_with(&["media/ec-relative-clip.png", "/nonexistent/ec/abs.png", "media/ec-relative-gone.png"]); + let path = dir.join("p.ecproj"); + s.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap(); + let mut o = Session::default(); + o.execute("file.open", json!({"path": path.to_string_lossy()})).unwrap(); + let mut paths: Vec = + o.project.items.values().filter_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.path.clone()) } else { None }).collect(); + paths.sort(); + assert_eq!(paths, ["/nonexistent/ec/abs.png", clip.to_str().unwrap(), "media/ec-relative-gone.png"]); + assert!(!o.is_dirty(), "finding the files is no edit"); + let r = o.execute("footage.check", json!({"wait": true})).unwrap(); + assert_eq!(r["missing"], 2); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn open_starts_the_check_when_asked() { let mut s = session_with(&["/nonexistent/ec/a.mov"]); diff --git a/crates/engine/src/footage_reload.rs b/crates/engine/src/footage_reload.rs index 3414cc00..a8f32a37 100644 --- a/crates/engine/src/footage_reload.rs +++ b/crates/engine/src/footage_reload.rs @@ -190,8 +190,8 @@ fn forget(s: &mut Session, paths: &BTreeSet) { /// time is only remembered; one whose stamp moved is reloaded: the items reading it get their /// size and duration from it again and its pixels are read again. A file that can't be stamped /// right now (an app saving by delete and rename) keeps its last stamp, and one that can't be -/// read yet (still being written) keeps its old stamp so the next scan tries again; missing -/// files are left to the footage check. Returns `{files, reloaded}`. +/// read (still being written) is tried again once it changes again; missing files are left to +/// the footage check. Returns `{files, reloaded}`. pub fn apply_stamps(s: &mut Session, now: Vec<(String, Option)>) -> crate::Result { let before = std::mem::take(&mut s.footage_stamps); let mut changed = BTreeSet::new(); @@ -215,10 +215,9 @@ pub fn apply_stamps(s: &mut Session, now: Vec<(String, Option)>) -> crate } let ids: Vec = s.project.items.values().filter(|i| matches!(&i.kind, ItemKind::Footage(f) if changed.contains(&f.path))).map(|i| i.id).collect(); let (reloaded, failed) = if ids.is_empty() { (0, BTreeSet::new()) } else { reload(s, &ids, false)? }; + // Not readable (still being written, or not a file the importer reads): its new stamp is kept, + // so it is tried again once it changes again, not on every look. for p in &failed { - if let Some(old) = before.get(p) { - s.footage_stamps.insert(p.clone(), *old); - } changed.remove(p); } if changed.is_empty() { diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index 36630969..6d905a83 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -206,19 +206,26 @@ impl History { self.branches.push(Branch { parent: from.clone(), steps }); } - /// Apply the undo-levels limit: drop the oldest working-line states, the branches that grew - /// from them, and the oldest branches when branches hold more states than `levels`. + /// Apply the undo-levels limit: keep the nearest past and future states on the working + /// line, drop branches whose parents were removed, and bound branch states separately. pub fn trim(&mut self, levels: usize, current: &Arc) { let levels = levels.max(1); if self.undo.len() > levels { let extra = self.undo.len() - levels; self.undo.drain(..extra); } - let mut total: usize = self.branches.iter().map(|b| b.steps.len()).sum(); - while total > levels && !self.branches.is_empty() { - total -= self.branches.remove(0).steps.len(); + if self.redo.len() > levels { + // The next redo is last: discard the farthest future states first. + let extra = self.redo.len() - levels; + self.redo.drain(..extra); } + // Orphans from truncated line states must not consume the reachable branch budget. self.prune(current); + while self.branch_states() > levels && !self.branches.is_empty() { + self.branches.remove(0); + // Removing a parent branch also removes its descendants before counting again. + self.prune(current); + } } /// Drop branches whose parent state is no longer in the history (`current` = the current diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a9ee6b89..a9e9b606 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -216,6 +216,10 @@ pub struct EditorState { /// The last copy was keyframes (Paste pastes keys at the CTI). #[serde(skip)] pub clip_is_keys: bool, + /// Values of selected unanimated properties (match path, value), copied with Edit ▸ Copy; + /// Paste sets the same properties of the selected layers. + #[serde(skip)] + pub value_clipboard: Vec<(String, effectcraft_keyframe::Value)>, /// Copied effect instances (Edit ▸ Copy with effects selected); Paste adds them to the /// selected layers. #[serde(skip)] @@ -368,6 +372,9 @@ pub struct Session { /// Why there is no [`Session::accel`] (`render.backend`'s `why`): the host's reason, e.g. a /// headless start without `--gpu` or the compositor's setup error. pub accel_note: Option, + /// The graphics adapter and backend the window draws with (`NVIDIA GeForce RTX 4070 + /// (Vulkan)`), as the host reports it: the System Compatibility Report shows it. + pub window_adapter: Option, /// Expression syntax checker (set by the host that links the expression engine). pub expr_check: Option std::result::Result<(), String>>, pub importer: Option>, @@ -472,8 +479,16 @@ pub struct Session { pub footage_stamps: std::collections::HashMap, /// The stamps being taken on a worker thread ([`footage_reload::start_scan`]). pub footage_scan: Option, + /// How many script-issued commands are running ([`Session::execute_for_script`]): while one + /// is, settings that guard what scripts may do can't change (see + /// [`prefs::Scripting::same_permissions`]). + pub script_calls: ScriptCalls, } +/// The count behind [`Session::running_for_script`]; only the engine changes it. +#[derive(Clone, Copy, Debug, Default)] +pub struct ScriptCalls(u32); + /// A thumbnail render ([`Session::thumbnail_job`]): (width, height, RGBA8). pub type ThumbnailJob = Box Option<(u32, u32, Vec)> + Send>; @@ -511,6 +526,7 @@ impl Default for Session { expr: None, accel: None, accel_note: None, + window_adapter: None, expr_check: None, importer: None, exporter: None, @@ -559,6 +575,7 @@ impl Default for Session { check_footage_on_open: false, footage_stamps: Default::default(), footage_scan: None, + script_calls: ScriptCalls::default(), } } } @@ -601,6 +618,22 @@ impl Session { self.execute(id, params) } + /// [`Session::execute`] for a command a script asked for (the JavaScript engine's + /// `app.run`, ScriptUI callbacks…). The command and everything it runs count as the script's: + /// it may not change the settings that limit scripts (Settings ▸ Scripting & Expressions ▸ + /// Application Scripting), which only the user can, from the UI, CLI or control channel. + pub fn execute_for_script(&mut self, id: &str, params: Value) -> Result { + self.script_calls.0 = self.script_calls.0.saturating_add(1); + let r = self.execute(id, params); + self.script_calls.0 = self.script_calls.0.saturating_sub(1); + r + } + + /// Whether the command running was issued by a script ([`Session::execute_for_script`]). + pub fn running_for_script(&self) -> bool { + self.script_calls.0 > 0 + } + pub fn is_enabled(&self, id: &str) -> bool { commands::find(id).is_some_and(|c| (c.enabled)(self).is_ok()) } diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 0bfa04a5..8f6af9c2 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -121,8 +121,8 @@ page!(Startup { /// After a crash, offer to open the most recent auto-save. offer_crash_recovery: bool = true, /// What the desktop window draws with, from the next launch: `auto` (the platform's best - /// graphics API) or `gl` (OpenGL, for drivers that crash with the others). A launch whose - /// window never drew switches it to `gl` (#243). + /// graphics API) or `gl` (OpenGL, for drivers that crash with the others). A launch that + /// stops as its window opens switches it to `gl` (#243). window_graphics: String = "auto".into(), }); @@ -309,6 +309,25 @@ page!(Scripting { error_banner: bool = true, }); +impl Scripting { + /// Whether `other` grants scripts the same as this: Application Scripting (file and network + /// access, the warning before executing files, the debugger). Scripts can't change these + /// ([`crate::Session::execute_for_script`]): a script could otherwise give itself the + /// access the user withheld. + pub fn same_permissions(&self, other: &Scripting) -> bool { + self.allow_scripts_write_files == other.allow_scripts_write_files + && self.warn_executing_files == other.warn_executing_files + && self.enable_js_debugger == other.enable_js_debugger + } + + /// Take `from`'s Application Scripting settings (see [`Scripting::same_permissions`]). + pub fn keep_permissions(&mut self, from: &Scripting) { + self.allow_scripts_write_files = from.allow_scripts_write_files; + self.warn_executing_files = from.warn_executing_files; + self.enable_js_debugger = from.enable_js_debugger; + } +} + /// One editable label: name and `#rrggbb` colour. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct LabelPref { @@ -1081,7 +1100,7 @@ pub fn pages() -> Vec { s("startup.offerCrashRecovery", "Offer to Open the Latest Auto-Save After a Crash", B, true), s("startup.windowGraphics", "Window Graphics", Kind::Choice(WINDOW_GRAPHICS), true), Note( - "Window Graphics applies from the next launch. When the graphics driver stops EffectCraft before its window draws, the next launch switches to OpenGL.", + "Window Graphics applies from the next launch. When the graphics driver stops EffectCraft as its window opens, the next launch switches to OpenGL.", ), Section("Repair"), Button { label: "Reset Settings", command: "prefs.reset", params: "{}" }, diff --git a/crates/engine/src/render_queue.rs b/crates/engine/src/render_queue.rs index 1bf95577..666a56cc 100644 --- a/crates/engine/src/render_queue.rs +++ b/crates/engine/src/render_queue.rs @@ -157,6 +157,14 @@ struct Work { nested_switches: bool, } +/// Where a relative render output goes when there is no default folder and the project is unsaved: +/// `~/Movies` when it exists, else the home folder itself. +pub(crate) fn default_output_base(home: Option) -> Option { + let home = home.filter(|h| !h.as_os_str().is_empty())?; + let movies = home.join("Movies"); + Some(if movies.is_dir() { movies } else { home }) +} + /// Bits per channel an output format writes for a project bit depth (named in the file when /// Settings ▸ Export ▸ Append Bit Depth to File Name is on). pub fn output_bits(format: OutputFormat, depth: effectcraft_project::BitDepth) -> u32 { @@ -324,7 +332,7 @@ impl Session { /// The output path of a queue item: template tokens expanded, ending in the format's /// extension exactly once ([`OutputFormat::with_extension`]), relative paths resolved against - /// the project's folder (or the working directory). `None` when the comp is gone or the + /// the project's folder (an unsaved project's against ~/Movies or home). `None` when the comp is gone or the /// output is empty ("Needs Output"). pub fn resolve_output(&self, item: &RenderQueueItem) -> Option { let comp = self.project.comp(item.comp)?; @@ -351,6 +359,10 @@ impl Session { let base = (!folder.is_empty()).then(|| std::path::PathBuf::from(folder)); let base = base.or_else(|| self.path.as_deref().and_then(|s| std::path::Path::new(s).parent().map(|d| d.to_path_buf())).filter(|d| !d.as_os_str().is_empty())); + // Unsaved project: ~/Movies, else the home folder (as FilmCraft's exports do). The working directory + // is `/` for an app started from the macOS Finder, where nothing can be written (#446). + let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(std::path::PathBuf::from); + let base = base.or_else(|| default_output_base(home)); // (the web has no working directory: relative outputs land in its virtual root) let base = base.or_else(|| std::env::current_dir().ok()).or_else(|| cfg!(target_arch = "wasm32").then(|| "/".into()))?; Some(base.join(path).to_string_lossy().to_string()) diff --git a/crates/engine/src/sequence.rs b/crates/engine/src/sequence.rs index 22180350..c98aba1d 100644 --- a/crates/engine/src/sequence.rs +++ b/crates/engine/src/sequence.rs @@ -20,15 +20,17 @@ use crate::{Importer, Services}; /// How an import treats numbered stills. #[derive(Clone, Copy, Debug)] pub struct SequenceOptions { - /// The " Sequence" checkbox: numbered stills of one run import as a sequence. + /// The " Sequence" checkbox: picked numbered stills of one run import as a sequence. pub sequence: bool, + /// The numbered stills inside a picked or dropped folder import as sequences. + pub folders: bool, /// Force Alphabetical Order. pub alphabetical: bool, } impl Default for SequenceOptions { fn default() -> Self { - SequenceOptions { sequence: true, alphabetical: false } + SequenceOptions { sequence: true, folders: true, alphabetical: false } } } @@ -36,7 +38,21 @@ impl SequenceOptions { /// From command parameters `sequence?` (default on) and `alphabetical?`. pub fn from_params(p: &serde_json::Value) -> SequenceOptions { let flag = |k: &str| p.get(k).and_then(serde_json::Value::as_bool); - SequenceOptions { sequence: flag("sequence").unwrap_or(true), alphabetical: flag("alphabetical").unwrap_or(false) } + let sequence = flag("sequence").unwrap_or(true); + SequenceOptions { sequence, folders: sequence, alphabetical: flag("alphabetical").unwrap_or(false) } + } + + /// `file.import`'s options. Dropped files (`drag: true`) import as stills, as in After + /// Effects, while a folder's numbered stills import as sequences unless `sequence: false` + /// (Alt-drag of a folder). The Import dialog passes its " Sequence" checkbox (off + /// 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 { + let flag = |k: &str| p.get(k).and_then(serde_json::Value::as_bool); + let (sequence, alphabetical) = (flag("sequence"), flag("alphabetical").unwrap_or(false)); + let dropped = flag("drag").unwrap_or(false); + // Force Alphabetical Order is part of the Sequence option. + SequenceOptions { sequence: sequence.unwrap_or(alphabetical || !dropped), folders: sequence.unwrap_or(true), alphabetical } } } @@ -120,28 +136,27 @@ fn run_files(services: &dyn Services, path: &str, alphabetical: bool) -> Vec Vec { - let mut files: Vec = vec![]; + // Each file, and whether it may join a sequence. + let mut files: Vec<(String, bool)> = vec![]; for p in paths { if services.is_dir(p) { let mut inside: Vec = services.list_dir(p).into_iter().filter(|f| matches!(crate::media_browser::kind_of(f), Some(k) if k != "project")).collect(); inside.sort_by_key(|f| file_name(f).to_lowercase()); - files.extend(inside); + files.extend(inside.into_iter().map(|f| (f, opts.folders))); } else { - files.push(p.clone()); + files.push((p.clone(), opts.sequence)); } } - if !opts.sequence { - return files.into_iter().map(Source::File).collect(); - } // Runs in the order their first file was picked. let mut runs: Vec<((String, String, String), Vec)> = vec![]; let mut out: Vec> = vec![]; let mut slot: Vec = vec![]; - for f in files { - match run_key(&f, opts.alphabetical) { + for (f, joins) in files { + match run_key(&f, opts.alphabetical).filter(|_| joins) { Some(key) => match runs.iter_mut().find(|(k, _)| *k == key) { Some((_, run)) => run.push(f), None => { @@ -155,8 +170,10 @@ pub fn group(services: &dyn Services, paths: &[String], opts: SequenceOptions) - } for ((_, mut picked), at) in runs.into_iter().zip(slot) { sort_run(&mut picked, opts.alphabetical); - // One file: its whole run from the folder. - if let [one] = picked.as_slice() { + // One picked file: its whole run from the folder. + if let [one] = picked.as_slice() + && opts.sequence + { let all = run_files(services, one, opts.alphabetical); if all.len() >= 2 { picked = all; @@ -176,7 +193,7 @@ pub fn group(services: &dyn Services, paths: &[String], opts: SequenceOptions) - /// The image sequences importing `paths` would make (the Import dialog asks about them). pub fn sequences(services: &dyn Services, paths: &[String], alphabetical: bool) -> Vec> { - group(services, paths, SequenceOptions { sequence: true, alphabetical }) + group(services, paths, SequenceOptions { sequence: true, folders: true, alphabetical }) .into_iter() .filter_map(|s| match s { Source::Sequence { files, .. } => Some(files), @@ -282,7 +299,10 @@ mod tests { ] ); // Sequence off: every file on its own. - assert_eq!(group(&t, &strs(&["/s/a_0001.png"]), SequenceOptions { sequence: false, alphabetical: false }), [Source::File("/s/a_0001.png".into())]); + assert_eq!( + group(&t, &strs(&["/s/a_0001.png"]), SequenceOptions { sequence: false, folders: false, alphabetical: false }), + [Source::File("/s/a_0001.png".into())] + ); // A folder: its media files, runs as sequences. let g = group(&t, &strs(&["/s"]), on); assert_eq!(g.len(), 4, "{g:?}"); @@ -290,12 +310,29 @@ mod tests { assert_eq!(g[0].name(), "a_[0001-0010].png"); assert!(!g.iter().any(|s| s.path().ends_with(".txt"))); // Force Alphabetical Order: every PNG in the folder, by name. - let a = group(&t, &strs(&["/s/c.png"]), SequenceOptions { sequence: true, alphabetical: true }); + let a = group(&t, &strs(&["/s/c.png"]), SequenceOptions { sequence: true, folders: true, alphabetical: true }); assert_eq!(a, [Source::Sequence { files: strs(&["/s/a_0001.png", "/s/a_0002.png", "/s/a_0010.png", "/s/b_1.png", "/s/c.png"]), alphabetical: true }]); assert_eq!(a[0].name(), "a_0001.png"); assert_eq!(sequences(&t, &strs(&["/s/a_0001.png", "/s/c.png"]), false).len(), 1); } + /// #431: `file.import` brings picked or dropped numbered stills in as stills; only a folder or + /// an explicit `sequence: true` makes a sequence, as After Effects' Sequence checkbox does. + #[test] + fn import_takes_stills_unless_asked_for_a_sequence() { + let t = Table(vec!["/s/a_0001.png", "/s/a_0002.png", "/s/a_0003.png", "/s/IMG_0001.jpg", "/s/IMG_0042.jpg"]); + let opts = |p: serde_json::Value| SequenceOptions::for_import(&p); + let none = opts(serde_json::json!({"drag": true})); + assert_eq!(group(&t, &strs(&["/s/a_0002.png"]), none), [Source::File("/s/a_0002.png".into())]); + assert_eq!(group(&t, &strs(&["/s/IMG_0001.jpg", "/s/IMG_0042.jpg"]), none).len(), 2); + // A dropped folder: its runs as sequences (Alt-drag: `sequence: false`, files on their own). + assert_eq!(group(&t, &strs(&["/s"]), none).len(), 2); + assert_eq!(group(&t, &strs(&["/s"]), opts(serde_json::json!({"sequence": false}))).len(), 5); + // The Sequence checkbox: one picked file brings its whole run. + let on = group(&t, &strs(&["/s/a_0002.png"]), opts(serde_json::json!({"sequence": true}))); + assert!(matches!(on.as_slice(), [Source::Sequence { files, .. }] if files.len() == 3), "{on:?}"); + } + #[test] fn windows_paths_and_reports() { let t = Table(vec!["C:\\r\\f_01.exr", "C:\\r\\f_02.exr", "C:\\r\\f_05.exr"]); diff --git a/crates/engine/src/tests_effects.rs b/crates/engine/src/tests_effects.rs index 2f164991..82f51036 100644 --- a/crates/engine/src/tests_effects.rs +++ b/crates/engine/src/tests_effects.rs @@ -110,6 +110,29 @@ fn reset_effect_restores_defaults_in_one_undo_step() { assert_eq!(g.get("start").unwrap().value, KV::Vec2([10.0, 20.0])); } +#[test] +fn reset_restores_parameters_inside_twirl_down_groups() { + let (mut s, a, _) = comp_with_two_solids(); + // Fractal and Scribble keep most parameters in groups (`mandelbrot/…`, `edgeOptions/…`). + for (effect, changes) in [ + ("Fractal", [("setChoice", json!(1)), ("mandelbrot/mandelbrotX", json!(1.25)), ("fractalColor/hue", json!(90.0))]), + ("Scribble", [("scribble", json!(2)), ("edgeOptions/edgeWidth", json!(33.0)), ("strokeOptions/spacing", json!(9.0))]), + ] { + let fx = apply(&mut s, a, effect); + let group = |s: &Session| layer(s, a).effects().unwrap().groups().find(|g| g.uid == fx).unwrap().clone(); + let defaults: Vec<_> = changes.iter().map(|(id, _)| group(&s).prop(id).unwrap().value.clone()).collect(); + for (id, v) in &changes { + let uid = group(&s).prop(id).unwrap().uid; + s.execute("prop.set", json!({"layer": a, "prop": uid, "value": v})).unwrap(); + assert_ne!(group(&s).prop(id).unwrap().value, defaults[changes.iter().position(|(i, _)| i == id).unwrap()], "{effect} {id} changed"); + } + s.execute("effect.reset", json!({"layer": a, "effect": fx})).unwrap(); + for ((id, _), d) in changes.iter().zip(&defaults) { + assert_eq!(&group(&s).prop(id).unwrap().value, d, "{effect} {id} back to its default"); + } + } +} + #[test] fn reset_keys_animated_params_at_the_cti() { let (mut s, a, _) = comp_with_two_solids(); diff --git a/crates/engine/src/tests_history.rs b/crates/engine/src/tests_history.rs index c67e7c14..d53f6ef3 100644 --- a/crates/engine/src/tests_history.rs +++ b/crates/engine/src/tests_history.rs @@ -1,9 +1,13 @@ //! Branching history: undo then a new step keeps the undone line as a branch; the tree lists //! every state; jumping to any state (on any branch) restores it and keeps everything else. +use std::sync::Arc; + +use effectcraft_project::Project; use serde_json::{Value, json}; use crate::Session; +use crate::history::{Branch, History}; fn names(s: &Session) -> Vec { s.project.comps().next().map(|(_, c)| c.layers.iter().map(|l| l.name.clone()).collect()).unwrap_or_default() @@ -190,3 +194,46 @@ fn a_session_restored_with_unsaved_changes_stays_modified() { s.execute("edit.undo", json!({})).unwrap(); assert!(s.is_dirty(), "undo doesn't reach a saved state"); } + +#[test] +fn dropped_redo_parent_does_not_discard_reachable_branches() { + let state = || Arc::new(Project::default()); + let current = state(); + let futures: Vec<_> = (0..5).map(|_| state()).collect(); + let reachable = state(); + let mut history = History { + redo: futures.iter().rev().map(|p| ("future".into(), p.clone())).collect(), + branches: vec![ + Branch { parent: current.clone(), steps: vec![("reachable".into(), reachable.clone())] }, + Branch { parent: futures[4].clone(), steps: vec![("orphan1".into(), state()), ("orphan2".into(), state())] }, + ], + ..Default::default() + }; + history.trim(2, ¤t); + assert_eq!(history.redo.len(), 2); + assert_eq!(history.branches.len(), 1); + assert!(Arc::ptr_eq(&history.branches[0].steps[0].1, &reachable)); + assert_eq!(history.branch_states(), 1); + assert!(history.tree(¤t).iter().any(|node| node.label == "reachable")); +} + +#[test] +fn evicted_branch_parent_does_not_discard_other_reachable_branches() { + let state = || Arc::new(Project::default()); + let current = state(); + let parent = state(); + let reachable = state(); + let mut history = History { + branches: vec![ + Branch { parent: current.clone(), steps: vec![("parent".into(), parent.clone())] }, + Branch { parent: current.clone(), steps: vec![("reachable".into(), reachable.clone())] }, + Branch { parent, steps: vec![("orphan1".into(), state()), ("orphan2".into(), state())] }, + ], + ..Default::default() + }; + history.trim(2, ¤t); + assert_eq!(history.branches.len(), 1); + assert!(Arc::ptr_eq(&history.branches[0].steps[0].1, &reachable)); + assert_eq!(history.branch_states(), 1); + assert_eq!(history.tree(¤t).len(), 2); +} diff --git a/crates/engine/src/tests_m145.rs b/crates/engine/src/tests_m145.rs index b55442eb..183ae65f 100644 --- a/crates/engine/src/tests_m145.rs +++ b/crates/engine/src/tests_m145.rs @@ -455,6 +455,15 @@ fn compatibility_report_and_logging() { } let text = crate::commands::report_text_for_tests(&r); assert!(text.contains("Operating system") && text.contains("Import:")); + // #596: without a GPU compositor the report says why, and names the window's adapter. + s.accel_note = Some("GPU compositor unavailable: no storage textures".into()); + s.window_adapter = Some("NVIDIA GeForce RTX 4070 (Gl, DiscreteGpu)".into()); + let r = s.execute("help.systemReport", json!({"quiet": true})).unwrap(); + assert_eq!(r["gpuNote"], "GPU compositor unavailable: no storage textures"); + let text = crate::commands::report_text_for_tests(&r); + assert!(text.contains("GPU: none (CPU compositing): GPU compositor unavailable: no storage textures"), "{text}"); + assert!(text.contains("Window graphics: NVIDIA GeForce RTX 4070 (Gl, DiscreteGpu)"), "{text}"); + assert!(r["issues"][0].as_str().is_some_and(|i| i.contains("no storage textures")), "{r}"); s.drain_events(); s.execute("help.systemReport", json!({})).unwrap(); assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::Frontend { command, .. } if command == "app.showReport"))); @@ -682,3 +691,13 @@ fn dynamic_menus_and_window_viewer_labels() { assert!(labels.iter().any(|x| x == l), "{l}"); } } + +#[test] +fn unsaved_project_renders_to_movies_or_home_not_the_working_directory() { + // A Finder-launched app has `/` as its working directory, where renders fail (#446). + let home = tmp("home446"); + assert_eq!(crate::render_queue::default_output_base(Some(home.clone())), Some(home.clone())); + std::fs::create_dir_all(home.join("Movies")).unwrap(); + assert_eq!(crate::render_queue::default_output_base(Some(home.clone())), Some(home.join("Movies"))); + assert_eq!(crate::render_queue::default_output_base(None), None); +} diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index c342f72f..c7252acb 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -3,6 +3,7 @@ use effectcraft_keyframe::Value as KV; use effectcraft_project::{FrameBlend, ItemKind, MatteKind, Quality, Sampling}; +use effectcraft_time::Tick; use serde_json::{Value, json}; use crate::{EngineError, Event, Session}; @@ -103,6 +104,152 @@ fn transform_dialog_values_and_center_anchor() { assert_eq!(layer(&s, a).auto_orient, effectcraft_project::AutoOrient::AlongPath); } +/// Inclusive bounds of samples whose alpha is above one half, plus the frame size and the count. +fn opaque_bounds(s: &Session, t: Tick) -> (u32, u32, i64, i64, i64, i64, u32) { + let img = s.render(s.active_comp_id().unwrap(), t, Default::default()); + let mut n = 0u32; + let mut x0 = i64::MAX; + let mut y0 = i64::MAX; + let mut x1 = i64::MIN; + let mut y1 = i64::MIN; + for y in 0..img.height as i64 { + for x in 0..img.width as i64 { + if img.get(x, y)[3] > 0.5 { + n += 1; + x0 = x0.min(x); + y0 = y0.min(y); + x1 = x1.max(x); + y1 = y1.max(y); + } + } + } + (img.width, img.height, x0, y0, x1, y1, n) +} + +#[test] +fn separated_position_follows_canvas_resize_and_crop() { + let square = |separated: bool| { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Test", "width": 100, "height": 100, "frameRate": 10, "duration": 2})).unwrap(); + let a = s.execute("layer.newSolid", json!({"name": "Square", "color": "#ffffff", "width": 10, "height": 10})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setTransform", json!({"layer": a, "prop": "position", "value": [40, 50]})).unwrap(); + if separated { + s.execute("prop.separateDimensions", json!({"layer": a, "value": true})).unwrap(); + } + (s, a) + }; + let placed = |s: &Session, a: u64, t: f64, x: f64, y: f64| { + let l = layer(s, a); + let tr = l.transform().unwrap(); + let at = Tick::from_seconds_f64(t); + if tr.get("positionX").is_some() { + assert!((tr.get("positionX").unwrap().value_at(at).as_f64() - x).abs() < 1e-6); + assert!((tr.get("positionY").unwrap().value_at(at).as_f64() - y).abs() < 1e-6); + } + let p = tr.get("position").unwrap().value_at(at).as_vec3(); + assert!((p[0] - x).abs() < 1e-6 && (p[1] - y).abs() < 1e-6, "combined {p:?}"); + }; + + let (mut combined, a) = square(false); + let (mut separated, b) = square(true); + assert_eq!(opaque_bounds(&combined, Tick::ZERO), opaque_bounds(&separated, Tick::ZERO)); + combined.execute("comp.settings", json!({"width": 200})).unwrap(); + separated.execute("comp.settings", json!({"width": 200})).unwrap(); + placed(&separated, b, 0.0, 90.0, 50.0); + placed(&combined, a, 0.0, 90.0, 50.0); + assert_eq!(opaque_bounds(&separated, Tick::ZERO), (200, 100, 85, 45, 94, 54, 100)); + assert_eq!(opaque_bounds(&combined, Tick::ZERO), opaque_bounds(&separated, Tick::ZERO)); + + let (mut separated, b) = square(true); + separated.execute("view.setRegionOfInterest", json!({"rect": [10, 20, 70, 70]})).unwrap(); + separated.execute("comp.cropToRegionOfInterest", json!({})).unwrap(); + placed(&separated, b, 0.0, 30.0, 30.0); + assert_eq!(opaque_bounds(&separated, Tick::ZERO), (70, 70, 25, 25, 34, 34, 100)); + + let (mut separated, b) = square(true); + separated.execute("layer.select", json!({"layers": [b]})).unwrap(); + separated.execute("comp.cropToLayerBounds", json!({})).unwrap(); + let bounds = opaque_bounds(&separated, Tick::ZERO); + assert_eq!((bounds.0, bounds.1, bounds.6), (10, 10, 100)); + assert!(bounds.2 == 0 && bounds.3 == 0, "the square fills the cropped frame: {bounds:?}"); + + // Two Position keys keep their spacing after the same canvas offset. + let (mut separated, b) = square(true); + separated.execute("edit.undo", json!({})).unwrap(); + separated.execute("prop.addKey", json!({"layer": b, "path": "transform/position", "time": 0, "value": [40, 50]})).unwrap(); + separated.execute("prop.addKey", json!({"layer": b, "path": "transform/position", "time": 1, "value": [60, 50]})).unwrap(); + separated.execute("prop.separateDimensions", json!({"layer": b, "value": true})).unwrap(); + separated.execute("comp.settings", json!({"width": 200})).unwrap(); + placed(&separated, b, 0.0, 90.0, 50.0); + placed(&separated, b, 1.0, 110.0, 50.0); + assert_eq!(opaque_bounds(&separated, Tick::ZERO).2, 85); + assert_eq!(opaque_bounds(&separated, Tick::from_seconds_f64(1.0)).2, 105); + + let (mut separated, b) = square(true); + separated.execute("edit.undo", json!({})).unwrap(); + separated.execute("prop.addKey", json!({"layer": b, "path": "transform/position", "time": 0, "value": [40, 50]})).unwrap(); + separated.execute("prop.addKey", json!({"layer": b, "path": "transform/position", "time": 1, "value": [60, 50]})).unwrap(); + separated.execute("prop.separateDimensions", json!({"layer": b, "value": true})).unwrap(); + separated.execute("view.setRegionOfInterest", json!({"rect": [10, 20, 70, 70]})).unwrap(); + separated.execute("comp.cropToRegionOfInterest", json!({})).unwrap(); + placed(&separated, b, 0.0, 30.0, 30.0); + placed(&separated, b, 1.0, 50.0, 30.0); + assert_eq!(opaque_bounds(&separated, Tick::ZERO), (70, 70, 25, 25, 34, 34, 100)); + assert_eq!(opaque_bounds(&separated, Tick::from_seconds_f64(1.0)), (70, 70, 45, 25, 54, 34, 100)); +} + +/// Pixel-centre of the opaque samples, so a 10×10 solid centred on (11, 22) reports (11, 22). +fn opaque_centroid(s: &Session) -> (f64, f64, u32) { + let img = s.render(s.active_comp_id().unwrap(), Tick::ZERO, Default::default()); + let mut n = 0u32; + let mut sx = 0.0; + let mut sy = 0.0; + for y in 0..img.height { + for x in 0..img.width { + if img.get(x as i64, y as i64)[3] > 0.5 { + n += 1; + sx += x as f64 + 0.5; + sy += y as f64 + 0.5; + } + } + } + assert!(n > 0, "the layer rendered nothing"); + (sx / n as f64, sy / n as f64, n) +} + +#[test] +fn separated_position_set_transform_moves_the_layer() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Scene", "width": 100, "height": 100, "frameRate": 30, "duration": 2})).unwrap(); + let a = s.execute("layer.newSolid", json!({"name": "Square", "color": "#ff0000", "width": 10, "height": 10})).unwrap()["layer"].as_u64().unwrap(); + let (x, y, n) = opaque_centroid(&s); + assert_eq!(n, 100); + assert!((x - 50.0).abs() < 0.6 && (y - 50.0).abs() < 0.6, "starts centred, centroid ({x}, {y})"); + + s.execute("prop.separateDimensions", json!({"layer": a, "value": true})).unwrap(); + s.execute("layer.setTransform", json!({"layers": [a], "prop": "position", "value": [11, 22]})).unwrap(); + let l = layer(&s, a); + let tr = l.transform().unwrap(); + assert!((tr.get("positionX").unwrap().value.as_f64() - 11.0).abs() < 1e-6); + assert!((tr.get("positionY").unwrap().value.as_f64() - 22.0).abs() < 1e-6); + assert_eq!(tr.get("position").unwrap().value, KV::Vec3([11.0, 22.0, 0.0])); + let (x, y, n) = opaque_centroid(&s); + assert_eq!(n, 100); + assert!((x - 11.0).abs() < 0.6 && (y - 22.0).abs() < 0.6, "separated position renders at ({x}, {y})"); + + s.execute("edit.undo", json!({})).unwrap(); + let (x, y, _) = opaque_centroid(&s); + assert!((x - 50.0).abs() < 0.6 && (y - 50.0).abs() < 0.6, "undo puts it back at ({x}, {y})"); + + // Combined Position, the control, still moves. + s.execute("edit.undo", json!({})).unwrap(); + assert!(layer(&s, a).transform().unwrap().get("positionX").is_none()); + s.execute("layer.setTransform", json!({"prop": "position", "value": [11, 22]})).unwrap(); + let (x, y, n) = opaque_centroid(&s); + assert_eq!(n, 100); + assert!((x - 11.0).abs() < 0.6 && (y - 22.0).abs() < 0.6, "combined position renders at ({x}, {y})"); +} + #[test] fn mask_menu_on_existing_masks() { let mut s = comp(); @@ -199,6 +346,53 @@ fn keyframe_clipboard_paste_and_paste_reversed() { assert_eq!((keys[0].value.as_f64(), keys[1].value.as_f64()), (0.0, 100.0)); } +/// #493: Edit ▸ Copy with a property selected copies that property (its value when it isn't +/// animated, its keys when it is), and Paste sets the same property of the selected layer, as +/// in After Effects, instead of copying the whole layer. +#[test] +fn copy_a_selected_property_pastes_onto_the_same_property() { + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + let b = solid(&mut s, "#00ff00"); + let n_layers = s.active_comp().unwrap().layers.len(); + s.execute("prop.set", json!({"layer": a, "path": "transform/position", "value": [10, 20]})).unwrap(); + s.execute("prop.set", json!({"layer": a, "path": "transform/rotation", "value": 30})).unwrap(); + s.execute("prop.select", json!({"layer": a, "path": "transform/position"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + assert!(s.state.clipboard.is_empty(), "the layer isn't copied"); + s.execute("layer.select", json!({"layers": [b]})).unwrap(); + let undo = s.history.undo.len(); + s.execute("edit.paste", json!({})).unwrap(); + assert_eq!(s.active_comp().unwrap().layers.len(), n_layers); + assert_eq!(layer(&s, b).props.prop("transform/position").unwrap().value.as_vec3()[..2], [10.0, 20.0]); + assert_eq!(layer(&s, b).props.prop("transform/rotation").unwrap().value.as_f64(), 0.0, "only the copied property"); + s.execute("edit.undo", json!({})).unwrap(); + assert_ne!(layer(&s, b).props.prop("transform/position").unwrap().value.as_vec3()[..2], [10.0, 20.0]); + assert_eq!(s.history.undo.len(), undo); + // The Transform group with an animated Opacity: its keys (at the CTI) and the other values, + // in one undo step. + s.execute("prop.toggleAnimation", json!({"layer": a, "path": "transform/opacity"})).unwrap(); + s.execute("time.set", json!({"time": 1.0})).unwrap(); + s.execute("prop.set", json!({"layer": a, "path": "transform/opacity", "value": 0})).unwrap(); + let transform = layer(&s, a).props.sub("transform").unwrap().uid; + s.execute("prop.select", json!({"layer": a, "prop": transform})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + s.execute("layer.select", json!({"layers": [b]})).unwrap(); + s.execute("time.set", json!({"time": 2.0})).unwrap(); + let undo = s.history.undo.len(); + s.execute("edit.paste", json!({})).unwrap(); + assert_eq!(s.history.undo.len(), undo + 1); + let l = layer(&s, b); + let keys = &l.props.prop("transform/opacity").unwrap().keys; + assert_eq!(keys.len(), 2); + assert!((keys[0].time.seconds() - 2.0).abs() < 1e-6 && keys[1].value.as_f64() == 0.0); + assert_eq!(l.props.prop("transform/rotation").unwrap().value.as_f64(), 30.0); + assert_eq!(s.active_comp().unwrap().layers.len(), n_layers); + s.execute("edit.undo", json!({})).unwrap(); + let l = layer(&s, b); + assert!(l.props.prop("transform/opacity").unwrap().keys.is_empty() && l.props.prop("transform/rotation").unwrap().value.as_f64() == 0.0); +} + #[test] fn property_links_and_expression_only() { let mut s = comp(); @@ -253,6 +447,82 @@ fn lift_and_extract_work_area() { assert!((spans[1].0 - 2.0).abs() < 1e-6 && (spans[1].1 - 8.0).abs() < 1e-6, "{spans:?}"); } +/// A project saved with an inverted work area (by an older build) is edited without a panic: +/// `clamp` with crossed bounds would abort. +#[test] +fn an_inverted_saved_work_area_takes_an_end_edit() { + let mut s = comp(); + let cid = s.active_comp_id().unwrap(); + std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().work_area = (Tick::from_seconds_f64(9.0), Tick::from_seconds_f64(2.0)); + s.execute("comp.workArea", json!({"end": 1.0})).unwrap(); + s.execute("comp.workArea", json!({"start": 50.0})).unwrap(); + let c = s.active_comp().unwrap(); + assert!(c.work_area.0 >= Tick::ZERO && c.work_area.0 + c.frame_duration() <= c.work_area.1 && c.work_area.1 <= c.duration); +} + +#[test] +fn work_area_stays_ordered_when_the_start_passes_the_end() { + let ordered = |s: &Session| { + let c = s.active_comp().unwrap(); + let fd = c.frame_duration(); + assert!(c.duration >= fd, "duration {}", c.duration.seconds()); + assert!(c.work_area.0 >= Tick::ZERO); + assert!(c.work_area.0 + fd <= c.work_area.1, "start {} end {}", c.work_area.0.seconds(), c.work_area.1.seconds()); + assert!(c.work_area.1 <= c.duration); + (c.duration, c.work_area) + }; + let one_frame_at_two = |s: &Session| { + let end = Tick::from_seconds_f64(2.0); + (end - s.active_comp().unwrap().frame_duration(), end) + }; + + let mut s = comp(); + s.execute("comp.workArea", json!({"end": 2.0})).unwrap(); + s.execute("comp.workArea", json!({"start": 8.0})).unwrap(); + assert_eq!(ordered(&s).1, one_frame_at_two(&s)); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(ordered(&s).1, (Tick::ZERO, Tick::from_seconds_f64(2.0))); + + s.execute("comp.workArea", json!({"start": 0.0, "end": 2.0})).unwrap(); + s.execute("comp.trimToWorkArea", json!({})).unwrap(); + assert_eq!(ordered(&s), (Tick::from_seconds_f64(2.0), (Tick::ZERO, Tick::from_seconds_f64(2.0)))); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(ordered(&s).0, Tick::from_seconds_f64(10.0)); + + let mut s = comp(); + s.execute("comp.workArea", json!({"start": 9.0, "end": 10.0})).unwrap(); + let before = ordered(&s); + s.execute("comp.settings", json!({"duration": 2.0})).unwrap(); + let (dur, wa) = ordered(&s); + assert_eq!(dur, Tick::from_seconds_f64(2.0)); + assert_eq!(wa, one_frame_at_two(&s)); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(ordered(&s), before); + + let mut s = comp(); + s.execute("comp.workArea", json!({"end": 2.0})).unwrap(); + s.execute("time.set", json!({"time": 8.0})).unwrap(); + s.execute("comp.workArea", json!({"set": "begin"})).unwrap(); + assert_eq!(ordered(&s).1, one_frame_at_two(&s)); + let path = tmp("WorkArea.ecproj"); + s.execute("file.saveAs", json!({"path": path})).unwrap(); + s.execute("file.open", json!({"path": path})).unwrap(); + assert_eq!(ordered(&s).1, one_frame_at_two(&s)); + + // A project that already has an inverted interval must not trim into a negative duration. + let mut s = comp(); + let cid = s.state.active_comp.unwrap(); + { + let c = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap(); + c.work_area = (Tick::from_seconds_f64(8.0), Tick::from_seconds_f64(2.0)); + } + let err = s.execute("comp.trimToWorkArea", json!({})).unwrap_err(); + assert!(err.to_string().contains("work area"), "{err}"); + let c = s.active_comp().unwrap(); + assert_eq!(c.duration, Tick::from_seconds_f64(10.0)); + assert_eq!(c.work_area, (Tick::from_seconds_f64(8.0), Tick::from_seconds_f64(2.0))); +} + #[test] fn label_group_purge_and_edit_original() { crate::tests_roto::hold_roto_cache_test_lock(); diff --git a/crates/engine/src/tests_paint.rs b/crates/engine/src/tests_paint.rs index 4e05027a..db831ca9 100644 --- a/crates/engine/src/tests_paint.rs +++ b/crates/engine/src/tests_paint.rs @@ -203,6 +203,28 @@ fn first_puppet_pin_leaves_the_layer_still() { } } +/// #318: a pin added to an already-bent mesh holds the mesh where it is: nothing jumps. +#[test] +fn a_pin_added_to_a_bent_mesh_moves_nothing() { + let (mut s, id) = setup(); + s.execute("puppet.addPin", json!({"layer": id, "position": [5, 30]})).unwrap(); + s.execute("puppet.addPin", json!({"layer": id, "position": [95, 30]})).unwrap(); + s.set_time(Tick::from_seconds_f64(1.0)); + s.execute("puppet.movePin", json!({"layer": id, "pin": "Puppet Pin 2", "position": [80, 70]})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let t = Tick::from_seconds_f64(1.0); + let before = s.render(cid, t, RenderOpts::default()); + for at in [[50, 54], [30, 42], [70, 62]] { + s.execute("puppet.addPin", json!({"layer": id, "position": at})).unwrap(); + let after = s.render(cid, t, RenderOpts::default()); + let moved = after.data.iter().zip(&before.data).filter(|(a, b)| (a[3] - b[3]).abs() > 0.25).count(); + // (Before #318 the pin at [50, 54] moved about 250 edge pixels; the solver's round-off + // still touches a few.) + assert!(moved <= 16, "a pin at {at:?} moved {moved} pixels"); + s.undo(); + } +} + #[test] fn puppet_pin_kinds_and_mesh_options() { let (mut s, id) = setup(); diff --git a/crates/engine/src/tests_sequence.rs b/crates/engine/src/tests_sequence.rs index cd406726..a89485f9 100644 --- a/crates/engine/src/tests_sequence.rs +++ b/crates/engine/src/tests_sequence.rs @@ -54,7 +54,7 @@ const RUN: [&str; 5] = ["shot_0001.png", "shot_0002.png", "shot_0004.png", "shot fn one_picked_numbered_file_imports_its_run_as_one_sequence() { let dir = folder(&RUN); let mut s = session(); - let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0002.png")]})).unwrap(); + let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0002.png")], "sequence": true})).unwrap(); assert_eq!(r["items"].as_array().unwrap().len(), 1, "{r}"); let (name, f) = footage(&s, &r["items"][0]); assert_eq!(name, "shot_[0001-0005].png"); @@ -75,17 +75,37 @@ fn picking_every_frame_imports_one_sequence() { let dir = folder(&RUN); let mut s = session(); let all: Vec = RUN[..4].iter().map(|n| at(&dir, n)).collect(); - let r = s.execute("file.import", json!({"paths": all, "frameRate": 24})).unwrap(); + let r = s.execute("file.import", json!({"paths": all, "sequence": true, "frameRate": 24})).unwrap(); assert_eq!(r["items"].as_array().unwrap().len(), 1, "{r}"); let (_, f) = footage(&s, &r["items"][0]); assert_eq!(f.sequence.len(), 4); // `frameRate` sets the sequence's rate on import. assert_eq!((f.frame_rate, f.duration), (FrameRate::FPS_24, FrameRate::FPS_24.tick_of(5))); // A picked range is just that range. - let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0004.png"), at(&dir, "shot_0005.png")]})).unwrap(); + let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0004.png"), at(&dir, "shot_0005.png")], "sequence": true})).unwrap(); let (name, f) = footage(&s, &r["items"][0]); assert_eq!((name.as_str(), f.sequence.len()), ("shot_[0004-0005].png", 2)); - assert!(s.execute("file.import", json!({"paths": all, "frameRate": 0})).is_err()); + assert!(s.execute("file.import", json!({"paths": all, "sequence": true, "frameRate": 0})).is_err()); +} + +/// #431: without the Sequence option, picked or dropped numbered stills import as stills (one +/// item each), as in After Effects; a dropped folder still makes its runs sequences. +#[test] +fn numbered_stills_import_as_stills_by_default() { + let dir = folder(&RUN); + let mut s = session(); + let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0002.png")], "drag": true})).unwrap(); + let (name, f) = footage(&s, &r["items"][0]); + assert_eq!((name.as_str(), f.kind), ("shot_0002.png", FootageKind::Still)); + let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png"), at(&dir, "shot_0004.png")], "drag": true})).unwrap(); + assert_eq!(r["items"].as_array().unwrap().len(), 2, "{r}"); + let r = s.execute("file.import", json!({"paths": [dir.clone()], "drag": true})).unwrap(); + let (name, f) = footage(&s, &r["items"][0]); + assert_eq!((name.as_str(), f.kind), ("shot_[0001-0005].png", FootageKind::Sequence)); + // Scripts and agents naming one frame without `sequence` still get its run. + let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0003.png")]})).unwrap(); + let (_, f) = footage(&s, &r["items"][0]); + assert_eq!(f.kind, FootageKind::Sequence); } #[test] @@ -127,7 +147,7 @@ fn force_alphabetical_order_takes_every_image_of_the_type() { fn interpret_footage_start_timecode_relabels_source_time() { let dir = folder(&RUN); let mut s = session(); - let item = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png")]})).unwrap()["items"][0].clone(); + let item = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png")], "sequence": true})).unwrap()["items"][0].clone(); s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 32, "frameRate": 30, "duration": 10})).unwrap(); let layer = s.execute("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap(); let out = |s: &Session| s.active_comp().unwrap().layer(effectcraft_project::LayerId(layer)).unwrap().out_point; diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index de746f66..6a74a23b 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -163,20 +163,24 @@ fn velocity_dialog_values_per_dimension_and_continuous() { undo_redo_roundtrip(&mut s, after, |s| assert_eq!(prop(s, l, "transform/scale").keys[1].in_interp, Interp::Linear)); } -/// Spatial Bezier on a straight two-key path gives both keys handles a third of the way along it -/// (they had none), so the path can be pulled into a curve; the motion stays the same (#290). +/// Spatial Bezier keeps the handles a key has and gives a straight linear path handles a third of +/// the way along it (they had none), so the path can be pulled into a curve; the motion stays the +/// same (#290). Auto-Bezier keys keep their tangents, a sixth of the way to the neighbour here. #[test] fn spatial_bezier_gives_a_straight_path_handles() { let (mut s, l) = setup(); animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0])), (2.0, json!([300, 0]))]); let mid = prop(&s, l, "transform/position").value_at(effectcraft_time::Tick::from_seconds_f64(0.7)).as_vec3(); select_prop_keys(&mut s, l, "transform/position"); - s.execute("keys.interpolation", json!({"spatial": "bezier"})).unwrap(); - let p = prop(&s, l, "transform/position"); - assert!(p.keys.iter().all(|k| !k.spatial_auto)); - assert_eq!((p.keys[0].spatial_out, p.keys[1].spatial_in), ([100.0, 0.0, 0.0], [-100.0, 0.0, 0.0])); - let now = p.value_at(effectcraft_time::Tick::from_seconds_f64(0.7)).as_vec3(); - assert!((now[0] - mid[0]).abs() < 0.01 && now[1].abs() < 1e-9, "{mid:?} → {now:?}"); + for (from, handle) in [("autoBezier", 50.0), ("linear", 100.0)] { + s.execute("keys.interpolation", json!({"spatial": from})).unwrap(); + s.execute("keys.interpolation", json!({"spatial": "bezier"})).unwrap(); + let p = prop(&s, l, "transform/position"); + assert!(p.keys.iter().all(|k| !k.spatial_auto)); + assert_eq!((p.keys[0].spatial_out, p.keys[1].spatial_in), ([handle, 0.0, 0.0], [-handle, 0.0, 0.0]), "from {from}"); + let now = p.value_at(effectcraft_time::Tick::from_seconds_f64(0.7)).as_vec3(); + assert!((now[0] - mid[0]).abs() < 0.01 && now[1].abs() < 1e-9, "from {from}: {mid:?} → {now:?}"); + } } #[test] @@ -242,6 +246,25 @@ fn graph_editor_key_and_handle_edits() { assert_eq!(prop(&s, l, "transform/opacity").keys[0].out_interp, Interp::Linear); } +#[test] +fn convert_expression_to_keyframes_on_a_reversed_layer_keeps_values() { + let (mut s, l) = setup(); + // These tests run without an expression host, so the conversion bakes the keyed ramp under the expression. + animate(&mut s, l, "transform/rotation", &[(0.0, json!(0)), (3.0, json!(30))]); + s.execute("prop.setExpression", json!({"layer": l, "path": "transform/rotation", "expression": "value"})).unwrap(); + s.execute("layer.timeReverse", json!({"layers": [l]})).unwrap(); + let at = |t: f64| json!({"layer": l, "path": "transform/rotation", "time": t}); + let sample = |s: &mut Session| [0.5, 1.0, 2.0].map(|t| s.execute("prop.get", at(t)).unwrap()["value"].as_f64().unwrap()); + let before = sample(&mut s); + assert!(before[0] > before[1] && before[1] > before[2], "{before:?}"); + s.execute("prop.convertExpressionToKeyframes", json!({"layer": l, "path": "transform/rotation"})).unwrap(); + let keys = prop(&s, l, "transform/rotation").keys; + assert!(keys.len() > 1); + assert!(keys.windows(2).all(|w| w[0].time < w[1].time)); + let after = sample(&mut s); + assert!(before.iter().zip(after).all(|(b, a)| (b - a).abs() < 1e-6), "{before:?} -> {after:?}"); +} + #[test] fn time_reverse_keyframes() { let (mut s, l) = setup(); @@ -391,6 +414,36 @@ fn time_set_snaps_to_the_nearest_frame_and_ae_timecode() { assert_eq!(effectcraft_time::format_timecode_ae(75, fr, true), "0;00;02;15"); } +#[test] +fn time_set_keeps_the_frame_shown_in_the_current_time_field() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Example", "width": 64, "height": 64, "frameRate": 30, "duration": 0.6, "startTimecode": "0:00:00:05"})).unwrap(); + let r = s.execute("time.set", json!({"frame": 0})).unwrap(); + assert_eq!(r["display"], "0:00:00:05"); + let r = s.execute("time.set", json!({"timecode": "0:00:00:05"})).unwrap(); + assert_eq!((r["frame"].as_i64(), r["time"].as_f64()), (Some(0), Some(0.0))); + + s.execute("file.projectSettings", json!({"timeDisplay": "frames"})).unwrap(); + std::sync::Arc::make_mut(&mut s.project).settings.frame_start = 1; + let r = s.execute("time.set", json!({"frame": 12})).unwrap(); + assert_eq!(r["display"], "00013"); + let r = s.execute("time.set", json!({"timecode": "00013"})).unwrap(); + assert_eq!((r["frame"].as_i64(), r["time"].as_f64()), (Some(12), Some(0.4))); + let r = s.execute("time.set", json!({"timecode": "+1"})).unwrap(); + assert_eq!(r["frame"], 13); +} + +/// Frames display style: typed digits are a frame count, so 00100 is frame 100, not 1 s (#530). +#[test] +fn time_set_reads_frames_style_digits_as_a_frame_count() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Long", "width": 64, "height": 64, "frameRate": 30, "duration": 10})).unwrap(); + s.execute("file.projectSettings", json!({"timeDisplay": "frames"})).unwrap(); + let r = s.execute("time.set", json!({"timecode": "00100"})).unwrap(); + assert_eq!(r["frame"], 100); + assert_eq!(r["display"], "00100"); +} + #[test] fn separate_dimensions_split_and_rejoin() { let (mut s, l) = setup(); @@ -782,3 +835,51 @@ fn toggle_transform_key_covers_selected_layers_separated_position_and_3d_rotatio } assert!(s.execute("keys.toggleTransform", json!({"prop": "skew"})).is_err()); } + +/// #355, #491: with several layers selected, the stopwatch, the keyframe button and value edits +/// (`selected: true`, as the panels send them) apply to every selected layer in one undo step; a +/// scrub (`offset`) moves each layer by the same amount, a typed value sets them all. +#[test] +fn property_edits_apply_to_every_selected_layer() { + let (mut s, a) = setup(); + let b = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap(); + let c = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": b, "path": "transform/position", "value": [10, 20]})).unwrap(); + s.execute("layer.select", json!({"layers": [a, b]})).unwrap(); + let pos = |s: &Session, l: u64| prop(s, l, "transform/position").value_at(effectcraft_time::Tick::ZERO).components(); + let uid = prop(&s, a, "transform/position").uid; + + // Stopwatch on all selected layers, one undo step; the unselected layer is untouched. + let undo = s.history.undo.len(); + s.execute("prop.toggleAnimation", json!({"layer": a, "prop": uid, "selected": true})).unwrap(); + assert!(prop(&s, a, "transform/position").is_animated() && prop(&s, b, "transform/position").is_animated()); + assert!(!prop(&s, c, "transform/position").is_animated()); + assert_eq!(s.history.undo.len(), undo + 1); + s.execute("edit.undo", json!({})).unwrap(); + assert!(!prop(&s, a, "transform/position").is_animated() && !prop(&s, b, "transform/position").is_animated()); + + // A scrub moves both by the same change (merged into one step while it continues). + let (pa, pb) = (pos(&s, a), pos(&s, b)); + for k in 1..=3 { + let v = [pa[0] + 5.0 * k as f64, pa[1]]; + s.execute("prop.set", json!({"layer": a, "prop": uid, "value": v, "selected": true, "offset": true, "merge": "scrub-1"})).unwrap(); + } + assert_eq!(pos(&s, a)[..2], [pa[0] + 15.0, pa[1]]); + assert_eq!(pos(&s, b)[..2], [pb[0] + 15.0, pb[1]]); + assert_eq!(s.history.undo.len(), undo + 1, "one undo step for the scrub"); + // A typed value sets them all. + s.execute("prop.set", json!({"layer": a, "path": "transform/opacity", "value": 40, "selected": true})).unwrap(); + assert_eq!(prop(&s, b, "transform/opacity").value.as_f64(), 40.0); + assert_eq!(prop(&s, c, "transform/opacity").value.as_f64(), 100.0); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(prop(&s, a, "transform/opacity").value.as_f64(), 100.0); + assert_eq!(prop(&s, b, "transform/opacity").value.as_f64(), 100.0); + + // The keyframe button adds a key on each selected layer. + s.execute("prop.toggleKey", json!({"layer": a, "path": "transform/scale", "selected": true})).unwrap(); + assert_eq!(prop(&s, a, "transform/scale").keys.len(), 1); + assert_eq!(prop(&s, b, "transform/scale").keys.len(), 1); + // Without `selected` only the given layer changes. + s.execute("prop.set", json!({"layer": a, "path": "transform/rotation", "value": 30})).unwrap(); + assert_eq!(prop(&s, b, "transform/rotation").value.as_f64(), 0.0); +} diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index 3d5acb2d..133d32b5 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -588,7 +588,8 @@ fn loop_on_two_dimensional_position() { let mut f = fx(); f.layer_mut(f.a).props.prop_mut(POS).unwrap().keys = vec![Keyframe::new(secs(0.0), Value::Vec3([0.0, 0.0, 0.0])), Keyframe::new(secs(1.0), Value::Vec3([100.0, 50.0, 0.0]))]; - assert_eq!(f.pos("loopOut('offset')", 1.5), [150.0, 75.0, 0.0]); + let p = f.pos("loopOut('offset')", 1.5); + assert!(p.iter().zip([150.0, 75.0, 0.0]).all(|(a, b)| (a - b).abs() < 1e-9), "{p:?}"); } #[test] diff --git a/crates/gpu/src/adv3d.rs b/crates/gpu/src/adv3d.rs index f3b9fee2..6ae11445 100644 --- a/crates/gpu/src/adv3d.rs +++ b/crates/gpu/src/adv3d.rs @@ -15,7 +15,6 @@ use effectcraft_render::three_d::Dof; use effectcraft_render::three_d::adv::{Prepared, Rendered, Scene, Target}; use effectcraft_render::three_d::bokeh; -use wgpu::util::DeviceExt; use crate::context::{Enc, GpuContext, GpuImage}; @@ -443,14 +442,11 @@ fn raster_into(e: &mut Enc, s: &Scene) -> Option<(wgpu::Texture, wgpu::Texture)> b }) .collect(); - let vbuf = dev.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("adv3d vertices"), contents: &vb, usage: wgpu::BufferUsages::VERTEX }); + let vbuf = g.buffer_init(Some("adv3d vertices"), &vb, wgpu::BufferUsages::VERTEX); let ib: Vec = s.indices.iter().flat_map(|i| i.to_le_bytes()).collect(); - let ibuf = dev.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("adv3d indices"), contents: &ib, usage: wgpu::BufferUsages::INDEX }); - let ub = dev.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("adv3d globals"), contents: &bufs[0], usage: wgpu::BufferUsages::UNIFORM }); - let sbs: Vec = bufs[1..] - .iter() - .map(|b| dev.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("adv3d data"), contents: b, usage: wgpu::BufferUsages::STORAGE })) - .collect(); + let ibuf = g.buffer_init(Some("adv3d indices"), &ib, wgpu::BufferUsages::INDEX); + let ub = g.buffer_init(Some("adv3d globals"), &bufs[0], wgpu::BufferUsages::UNIFORM); + let sbs: Vec = bufs[1..].iter().map(|b| g.buffer_init(Some("adv3d data"), b, wgpu::BufferUsages::STORAGE)).collect(); let mut entries = vec![wgpu::BindGroupEntry { binding: 0, resource: ub.as_entire_binding() }]; for (i, b) in sbs.iter().enumerate() { entries.push(wgpu::BindGroupEntry { binding: i as u32 + 1, resource: b.as_entire_binding() }); @@ -736,7 +732,7 @@ fn dispatch(e: &mut Enc, kernel: &str, u0: [u32; 4], f0: [f32; 4], b: Binds, gro let g = e.g; let post = &pipes(g).post; let Some((_, pipe)) = post.kernels.iter().find(|(k, _)| *k == kernel) else { return }; - let ub = g.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: None, contents: ¶ms(u0, f0), usage: wgpu::BufferUsages::UNIFORM }); + let ub = g.buffer_init(None, ¶ms(u0, f0), wgpu::BufferUsages::UNIFORM); let mut entries = vec![ wgpu::BindGroupEntry { binding: 0, resource: ub.as_entire_binding() }, wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(b.color.unwrap_or(&post.dummy_color)) }, @@ -859,11 +855,7 @@ fn depth_of_field(e: &mut Enc, acc: &wgpu::Buffer, depth: &wgpu::Buffer, (w, h): for v in [f32::INFINITY.to_bits(), 0, 0, 0] { init.extend_from_slice(&v.to_le_bytes()); } - let range = g.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("adv3d radius range"), - contents: &init, - usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC, - }); + let range = g.buffer_init(Some("adv3d radius range"), &init, wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC); let f0 = [dof.focus as f32, dof.aperture as f32, dof.blur_level as f32, scale as f32]; let b = Binds { bufs: [None, Some(depth), None, None, Some(&radius), Some(&range), None], ..Default::default() }; dispatch(e, "radius_of", [w, h, 0, 0], f0, b, pixel_groups(w, h)); diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index e994dd1b..788757bf 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -5,7 +5,6 @@ use std::sync::{Arc, Mutex, Weak}; use effectcraft_effects::Buf; use effectcraft_raster::Image; -use wgpu::util::DeviceExt; /// Compute entry points in `kernels.wgsl` (one pipeline each). const ENTRIES: &[&str] = &[ @@ -18,14 +17,6 @@ const ENTRIES: &[&str] = &[ "quantize", "convert", "half", - "box_h", - "box_v", - "directional", - "glow_bright", - "glow_combine", - "shadow_make", - "shadow_combine", - "pointwise", "adjust_mix", "bokeh_boost", "bokeh_prefix", @@ -33,6 +24,11 @@ const ENTRIES: &[&str] = &[ "fill", ]; +/// Effect entry points in `kernels.wgsl`. Like the `fx_*` kernels, one that fails to build +/// (a driver's shader compiler rejecting it, #613) leaves its effects to the CPU instead of +/// turning the whole compositor off. +const EFFECT_ENTRIES: &[&str] = &["box_h", "box_v", "directional", "glow_bright", "glow_combine", "shadow_make", "shadow_combine", "pointwise"]; + /// Entry points that also bind group 1 (four read-only storage buffers; see /// [`Enc::dispatch_ext`]). const EXT_ENTRIES: &[&str] = &["classic3d"]; @@ -244,6 +240,8 @@ pub struct GpuContext { bgl: wgpu::BindGroupLayout, bgl_ext: wgpu::BindGroupLayout, pipelines: HashMap<&'static str, wgpu::ComputePipeline>, + /// Effect kernels that failed to build, and why (their effects render on the CPU). + unavailable: Vec<(&'static str, String)>, display_bgl: wgpu::BindGroupLayout, display: wgpu::ComputePipeline, /// The display texture's next mip level from the one above (`display.wgsl` `mip`). @@ -326,7 +324,34 @@ fn init_resource(_device: &wgpu::Device, _label: &str, create: impl FnOnce() Ok(create()) } +/// A buffer holding `contents` (padded to 4 bytes), written through the queue. Unlike +/// `DeviceExt::create_buffer_init`, which maps the buffer at creation and panics when the +/// allocation failed (a driver reports it as an internal error, not out-of-memory: Adreno's +/// `ERROR_INITIALIZATION_FAILED`, #591), a failed allocation only yields an invalid buffer and +/// an error for the caller's error scope. +pub(crate) fn buffer_init(device: &wgpu::Device, queue: &wgpu::Queue, label: Option<&str>, contents: &[u8], usage: wgpu::BufferUsages) -> wgpu::Buffer { + let align = wgpu::COPY_BUFFER_ALIGNMENT as usize; + let padded = contents.len().div_ceil(align).saturating_mul(align); + let buffer = + device.create_buffer(&wgpu::BufferDescriptor { label, size: padded as u64, usage: usage | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false }); + if padded > 0 { + if padded == contents.len() { + queue.write_buffer(&buffer, 0, contents); + } else { + let mut data = contents.to_vec(); + data.resize(padded, 0); + queue.write_buffer(&buffer, 0, &data); + } + } + buffer +} + impl GpuContext { + /// [`buffer_init`] on this context's device. + pub(crate) fn buffer_init(&self, label: Option<&str>, contents: &[u8], usage: wgpu::BufferUsages) -> wgpu::Buffer { + buffer_init(&self.device, &self.queue, label, contents, usage) + } + /// Build on an existing device (the desktop app shares egui-wgpu's). `Err` when the /// adapter cannot run the compositor (no compute shaders, e.g. WebGL2; no float storage /// textures) or the device was created with limits below what its pipelines bind. @@ -450,7 +475,29 @@ impl GpuContext { immediate_size: 0, }) })?; - let pipelines = ENTRIES + let build = |e: &'static str, layout: &wgpu::PipelineLayout| { + init_resource(&device, e, || { + device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some(e), + layout: Some(layout), + module: &module, + entry_point: Some(e), + compilation_options: Default::default(), + cache: None, + }) + }) + }; + // The compositor's own kernels are required; an effect kernel that doesn't build is + // left out, and its effects render on the CPU (see `Enc::take_missing`). + let mut pipelines = ENTRIES + .iter() + .chain(crate::adv3d::SKY_KERNELS) + .map(|e| (e, &layout)) + .chain(EXT_ENTRIES.iter().map(|e| (e, &layout_ext))) + .map(|(e, layout)| Ok((*e, build(e, layout)?))) + .collect::, String>>()?; + let mut unavailable = vec![]; + for e in EFFECT_ENTRIES .iter() .chain(crate::fx_color::KERNELS) .chain(crate::fx_distort::KERNELS) @@ -472,23 +519,17 @@ impl GpuContext { .chain(crate::fx_time::KERNELS) .chain(crate::fx_pixel2::KERNELS) .chain(crate::fx_gen2::KERNELS) - .chain(crate::adv3d::SKY_KERNELS) - .map(|e| (e, &layout)) - .chain(EXT_ENTRIES.iter().map(|e| (e, &layout_ext))) - .map(|(e, layout)| { - let p = init_resource(&device, e, || { - device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { - label: Some(e), - layout: Some(layout), - module: &module, - entry_point: Some(e), - compilation_options: Default::default(), - cache: None, - }) - })?; - Ok((*e, p)) - }) - .collect::, String>>()?; + { + match build(e, &layout) { + Ok(p) => { + pipelines.insert(*e, p); + } + Err(why) => { + log::warn!("gpu {name}: kernel {e} unavailable, its effects render on the CPU: {why}"); + unavailable.push((*e, why)); + } + } + } let dmodule = init_resource(&device, "display shader module", || { device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("effectcraft display"), @@ -534,9 +575,8 @@ impl GpuContext { view_formats: &[], }) })?; - let dummy_buf = init_resource(&device, "dummy storage buffer", || { - device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("dummy"), contents: &[0u8; 16], usage: wgpu::BufferUsages::STORAGE }) - })?; + let dummy_buf = + init_resource(&device, "dummy storage buffer", || buffer_init(&device, &queue, Some("dummy"), &[0u8; 16], wgpu::BufferUsages::STORAGE))?; let dummy_tex = init_resource(&device, "dummy texture view", || dummy.create_view(&Default::default()))?; let max_dim = device.limits().max_texture_dimension_2d; Ok(GpuContext { @@ -547,6 +587,7 @@ impl GpuContext { bgl, bgl_ext, pipelines, + unavailable, display_bgl, display, display_mip, @@ -721,6 +762,18 @@ impl GpuContext { /// Readbacks (GPU → CPU) are possible: waited for natively, or deferred (a browser /// worker, [`crate::deferred`]). Impossible on the browser's main thread. + /// Effect kernels the device couldn't build, with why: their effects render on the CPU. + pub fn unavailable_kernels(&self) -> &[(&'static str, String)] { + &self.unavailable + } + + /// Leave kernel `entry` out, as if it had failed to build (tests of the CPU fallback). + #[cfg(test)] + pub(crate) fn drop_kernel(&mut self, entry: &'static str) { + self.pipelines.remove(entry); + self.unavailable.push((entry, "dropped by a test".into())); + } + pub fn can_readback(&self) -> bool { self.readbacks.check().is_ok() && (self.can_wait() || self.deferred.is_some()) } @@ -859,6 +912,8 @@ pub(crate) struct Enc<'g> { /// The open command encoder and its id in the texture pool. enc: Option<(wgpu::CommandEncoder, u64)>, pending: usize, + /// A dispatch named a kernel the device doesn't have (see [`Enc::take_missing`]). + missing: bool, } impl Drop for Enc<'_> { @@ -874,7 +929,13 @@ impl Drop for Enc<'_> { impl<'g> Enc<'g> { pub fn new(g: &'g GpuContext) -> Enc<'g> { - Enc { g, enc: None, pending: 0 } + Enc { g, enc: None, pending: 0, missing: false } + } + + /// Whether a dispatch since the last call named a kernel the device doesn't have (one + /// that failed to build): its output is wrong, and the effect must render on the CPU. + pub(crate) fn take_missing(&mut self) -> bool { + std::mem::take(&mut self.missing) } pub(crate) fn encoder(&mut self) -> &mut wgpu::CommandEncoder { @@ -954,10 +1015,13 @@ impl<'g> Enc<'g> { ) { let g = self.g; let Some(pipe) = g.pipelines.get(entry) else { - log::error!("gpu: no kernel {entry}"); + if !g.unavailable.iter().any(|(e, _)| *e == entry) { + log::error!("gpu: no kernel {entry}"); + } + self.missing = true; return; }; - let ub = g.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: None, contents: &p.bytes(), usage: wgpu::BufferUsages::UNIFORM }); + let ub = g.buffer_init(None, &p.bytes(), wgpu::BufferUsages::UNIFORM); let sv = src.texture.create_view(&Default::default()); let av = aux.map(|a| a.texture.create_view(&Default::default())); let ov = out.texture.create_view(&Default::default()); @@ -1190,7 +1254,7 @@ impl<'g> Enc<'g> { while bytes.len() < 16 || !bytes.len().is_multiple_of(4) { bytes.push(0); } - self.g.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("data"), contents: &bytes, usage: wgpu::BufferUsages::STORAGE }) + self.g.buffer_init(Some("data"), &bytes, wgpu::BufferUsages::STORAGE) } /// Convert to the viewer's premultiplied RGBA8 texture (egui-wgpu's native texture format), diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index 00cc4fd9..426931d7 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -66,8 +66,12 @@ pub(crate) fn run_chain(e: &mut Enc, chain: &[FxStep], buf: &Buf, levels: Option } let misses = deferred.as_ref().map(|d| d.misses()); let mut b = GBuf { img: e.g.upload_image(&buf.img)?, offset: buf.offset, scale: buf.scale }; + e.take_missing(); for step in chain { b = apply(e, step.spec.id, &step.ctx, b)?; + if e.take_missing() { + return None; + } if let Some(l) = levels { b.img = ops::quantize(e, &b.img, l); } @@ -119,8 +123,13 @@ impl effectcraft_render::FxTarget for GpuFx<'_, '_> { fn gpu(&mut self, steps: &[FxStep]) -> bool { let Some(b) = &self.b else { return false }; let mut cur = GBuf { img: b.img.clone(), offset: b.offset, scale: b.scale }; + self.e.take_missing(); for step in steps { let Some(next) = apply(self.e, step.spec.id, &step.ctx, cur) else { return false }; + // A kernel the device couldn't build: the effects render on the CPU (#613). + if self.e.take_missing() { + return false; + } cur = next; if let Some(l) = self.levels { cur.img = ops::quantize(self.e, &cur.img, l); diff --git a/crates/gpu/src/fx_pixel2.rs b/crates/gpu/src/fx_pixel2.rs index 1b1b42be..9de26f01 100644 --- a/crates/gpu/src/fx_pixel2.rs +++ b/crates/gpu/src/fx_pixel2.rs @@ -476,6 +476,10 @@ fn wire_removal(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { if half <= 0.0 { return Some(b); } + // Frame Offset reads another frame from the effect host: that renders on the CPU. + if ctx.params.e("removalStyle") == 1 && ctx.params.f("frameOffset").round() != 0.0 && ctx.env.host.is_some() { + return None; + } let slope = (ctx.params.f("slope") / 100.0).clamp(0.0, 1.0) as f32; let mirror = (ctx.params.f("mirrorBlend") / 100.0).clamp(0.0, 1.0) as f32; let a = b.to_px(ctx.params.v2("pointA")); diff --git a/crates/gpu/src/fx_stylize.rs b/crates/gpu/src/fx_stylize.rs index c39b8d16..f5b7708d 100644 --- a/crates/gpu/src/fx_stylize.rs +++ b/crates/gpu/src/fx_stylize.rs @@ -299,10 +299,13 @@ fn cc_slant(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { ) } -fn cc_smear(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { +fn cc_smear(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let reach = ctx.params.f("reach") / 100.0; + if !ctx.adjustment { + b.pad(e, effectcraft_effects::cc_smear_pad(ctx, [b.img.width, b.img.height], b.offset, b.scale))?; + } let from = b.to_px(ctx.params.v2("from")); let to = b.to_px(ctx.params.v2("to")); - let reach = ctx.params.f("reach") / 100.0; let radius = (ctx.params.f("radius") * b.scale).max(0.5); let (dx, dy) = (to.0 - from.0, to.1 - from.1); let l2 = dx * dx + dy * dy; @@ -486,6 +489,9 @@ fn ripple_pulse(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { } fn power_pin(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + if ctx.params.b("unstretch") { + return None; // Unstretch (reverse pinning) renders on the CPU (catalog CPU_ONLY_CONTROLS). + } let persp = (ctx.params.f("perspective") / 100.0).clamp(0.0, 1.0); let (lw, lh) = (ctx.layer_size[0].max(1.0), ctx.layer_size[1].max(1.0)); let ex = [ctx.params.f("expandTop"), ctx.params.f("expandLeft"), ctx.params.f("expandRight"), ctx.params.f("expandBottom")]; @@ -582,8 +588,11 @@ fn offset(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { table(e, b, &data, false, blend.max(0.0)) } -fn cc_tiler(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { +fn cc_tiler(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { let s = (ctx.params.f("scale") / 100.0).max(0.001); + if !ctx.adjustment { + b.pad(e, effectcraft_effects::util::layer_cover_pad(ctx, [b.img.width, b.img.height], b.offset, b.scale))?; + } let c = b.to_px(ctx.params.v2("center")); let blend = (ctx.params.f("blendWithOriginal") / 100.0) as f32; let (x0, y0, w, h) = layer_rect(ctx, &b); diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 8879ab86..252145c1 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -102,6 +102,8 @@ pub struct Gpu { ctx: Arc, /// Backend::Auto's per-comp CPU / GPU timings. auto: Arc, + /// (comp, reason) pairs already warned about when the GPU declined a frame. + declined: Arc>>, } /// A viewer frame left on the GPU: premultiplied RGBA8 (`wgpu::TextureFormat::Rgba8Unorm`) with @@ -133,7 +135,7 @@ impl Gpu { } pub fn from_context(ctx: GpuContext) -> Gpu { - Gpu { ctx: Arc::new(ctx), auto: Default::default() } + Gpu { ctx: Arc::new(ctx), auto: Default::default(), declined: Default::default() } } /// A device of its own without blocking, with deferred readbacks (a browser worker, see @@ -262,17 +264,22 @@ impl Gpu { } } - /// Run work in a native thread-local out-of-memory scope. A failed result is discarded - /// and cached allocations are released; callers can retry with a fresh CPU renderer. + /// Run work in native thread-local out-of-memory and internal-error scopes. A failed + /// result is discarded and cached allocations are released; callers can retry with a fresh + /// CPU renderer. Some drivers report a failed allocation as an internal error rather than + /// out-of-memory (Adreno's `ERROR_INITIALIZATION_FAILED`, #591), so both count. /// Browser callers use the host/owned-device failure bridge and bounded readbacks instead /// of blocking their event loop on an error-scope future. pub fn within_memory(&self, f: impl FnOnce() -> T) -> Result { self.ctx.check_health()?; #[cfg(not(target_arch = "wasm32"))] { - let scope = self.ctx.device.push_error_scope(wgpu::ErrorFilter::OutOfMemory); + let oom = self.ctx.device.push_error_scope(wgpu::ErrorFilter::OutOfMemory); + let internal = self.ctx.device.push_error_scope(wgpu::ErrorFilter::Internal); let out = f(); - match pollster::block_on(scope.pop()) { + let internal = pollster::block_on(internal.pop()); + let oom = pollster::block_on(oom.pop()); + match internal.or(oom) { Some(e) => { self.ctx.clear_uploads(); Err(e.to_string()) @@ -287,6 +294,18 @@ impl Gpu { } } + /// Why a frame was not rendered (best effort: device health, else size / support). + fn decline_reason(&self) -> String { + match self.ctx.check_health() { + Err(e) => format!("device failure / out of memory: {e}"), + Ok(()) if !self.ctx.can_readback() => "no GPU readback is available here".into(), + Ok(()) => format!( + "the GPU compositor did not handle the frame (a region of interest, unsupported content, a frame over the {} px texture limit, or an allocation failed)", + self.ctx.max_dim + ), + } + } + /// Wait for submitted GPU work with the readback deadline (benchmarks). pub fn wait(&self) { let _ = self.ctx.poll_readbacks(wgpu::PollType::Wait { submission_index: None, timeout: Some(crate::readback::TIMEOUT) }); @@ -299,7 +318,21 @@ impl Accelerator for Gpu { } fn comp_frame(&self, r: &Renderer, comp: ItemId, t: Tick) -> Option { - self.render(r, comp, t) + let img = self.render(r, comp, t); + if img.is_none() { + // The caller renders the frame on the CPU; say so and why, or an export that falls + // back frame after frame is only mysteriously slow. Once per comp and reason as a + // warning (the log keeps only the last few hundred, for the System Report), then at + // debug level. + let reason = self.decline_reason(); + let first = self.declined.lock().map(|mut seen| seen.len() < 256 && seen.insert((comp.0, reason.clone()))).unwrap_or(false); + if first { + log::warn!(target: "effectcraft_gpu", "GPU declined the frame at {:.3} s, the CPU renders it: {reason}", t.seconds()); + } else { + log::debug!(target: "effectcraft_gpu", "GPU declined the frame at {:.3} s: {reason}", t.seconds()); + } + } + img } fn supports_effect(&self, id: &str) -> bool { diff --git a/crates/gpu/src/particles.rs b/crates/gpu/src/particles.rs index 015edc82..7934dee0 100644 --- a/crates/gpu/src/particles.rs +++ b/crates/gpu/src/particles.rs @@ -6,7 +6,6 @@ use std::sync::{Mutex, OnceLock}; use effectcraft_effects::psim::{ParticleSystem, SimParticle, SimRequest}; -use wgpu::util::DeviceExt; use crate::context::GpuContext; @@ -152,7 +151,7 @@ pub(crate) fn simulate(g: &GpuContext, req: &SimRequest) -> Option = req.births.iter().flat_map(|b| b.to_le_bytes()).collect(); - let births = d.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("births"), contents: &births, usage: wgpu::BufferUsages::STORAGE }); + let births = g.buffer_init(Some("births"), &births, wgpu::BufferUsages::STORAGE); let prev = prev.unwrap_or_else(|| { d.create_buffer(&wgpu::BufferDescriptor { label: Some("particles"), size: STATE_BYTES, usage: wgpu::BufferUsages::STORAGE, mapped_at_creation: false }) }); @@ -162,11 +161,7 @@ pub(crate) fn simulate(g: &GpuContext, req: &SimRequest) -> Option f32 { if (cs <= 0.0) { return 0.0; } - return max(1.0 - (1.0 - cb) / cs, 0.0); + return 1.0 - (1.0 - cb) / cs; } case 7u: { return max(cb + cs - 1.0, 0.0); } case 9u, 14u: { return cb + cs; } @@ -343,7 +343,7 @@ fn non_separable(mode: u32, cb: vec3, cs: vec3) -> vec3 { fn supports_hdr(mode: u32) -> bool { switch mode { - case 0u, 1u, 2u, 3u, 4u, 7u, 8u, 9u, 10u, 11u, 14u, 15u, 23u, 24u, 26u, 27u, 32u, 33u, 34u, 35u, 36u, 37u: { return true; } + case 0u, 1u, 2u, 3u, 4u, 5u, 6u, 7u, 8u, 9u, 10u, 11u, 14u, 15u, 16u, 23u, 24u, 25u, 26u, 27u, 31u, 32u, 33u, 34u, 35u, 36u, 37u: { return true; } default: { return false; } } } @@ -388,8 +388,18 @@ fn blend_pixel(mode: u32, dst: vec4, src: vec4, noise: f32) -> vec4(0.0), vec3(1.0)); cb = clamp(cb, vec3(0.0), vec3(1.0)); } - let b = non_separable(mode, cb, cs); let ao = sa + da - sa * da; + // Classic Color Dodge / Difference (neutral 0) and Classic Color Burn (neutral 1): the + // source fades toward the neutral colour by its coverage, then the formula applies. + if (mode == 13u || mode == 24u || mode == 6u) { + var neutral = 0.0; + if (mode == 6u) { + neutral = 1.0; + } + let bc = non_separable(mode, cb, neutral + sa * (cs - neutral)); + return vec4((1.0 - da) * src.xyz + da * bc, ao); + } + let b = non_separable(mode, cb, cs); return vec4((1.0 - da) * src.xyz + (1.0 - sa) * dst.xyz + sa * da * b, ao); } diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 39be29a9..150f29ad 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -745,3 +745,97 @@ fn within_memory_returns_the_work_when_there_is_room() { let img = g.within_memory(|| g.ctx.image(64, 32)).expect("room for a 64×32 texture"); assert_eq!((img.width, img.height), (64, 32)); } + +/// A buffer whose allocation fails (here: over the device's buffer size limit; on Adreno an +/// internal `ERROR_INITIALIZATION_FAILED`) is reported to the caller's error scope instead of +/// panicking in `create_buffer_init`'s mapped range on a frame thread (#591). +#[test] +fn a_failed_buffer_allocation_is_an_error_not_a_panic() { + hold_gpu_lock(); + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else { return }; + let required_limits = + wgpu::Limits { max_buffer_size: 4096, max_storage_buffer_binding_size: 4096, max_uniform_buffer_binding_size: 4096, ..Default::default() }; + let desc = wgpu::DeviceDescriptor { required_limits, ..Default::default() }; + let (device, queue) = pollster::block_on(adapter.request_device(&desc)).expect("a device with a small buffer limit"); + let scope = device.push_error_scope(wgpu::ErrorFilter::Validation); + let made = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + crate::context::buffer_init(&device, &queue, Some("too big"), &[0u8; 8192], wgpu::BufferUsages::STORAGE); + crate::context::buffer_init(&device, &queue, Some("unaligned"), &[1u8; 7], wgpu::BufferUsages::UNIFORM); + })); + let error = pollster::block_on(scope.pop()); + assert!(made.is_ok(), "a failed allocation must not panic"); + assert!(error.is_some(), "the failed allocation reaches the error scope"); +} + +/// Precomp layers (not collapsed) render their nested comp resident on the GPU +/// (`walk::draw_precomp`): match the CPU (nested blend modes, effects inside and outside, +/// transforms, opacity, a blend mode on the precomp layer, half resolution, +/// two instances at different times of the same nested comp). +#[test] +fn nested_precomps_render_resident() { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + let mut s = Scene::new(depth); + let inner = Comp::new(60, 40, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let inner_id = s.p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.clone().into())); + { + let mut ins = Scene { p: std::mem::take(&mut s.p), cid: inner_id, comp: inner }; + let mut a = ins.footage(50, 30); + ins.effect(&mut a, "ec.blur.gaussian", &[("blurriness", n(3.0))]); + ins.push(a); + let mut b = ins.solid([0.2, 0.7, 0.4], 30, 20); + b.blend_mode = BlendMode::Difference; + set(&mut b, "transform/rotation", Value::Scalar(15.0)); + ins.push(b); + s.p = ins.p; + } + let bg = s.footage(97, 61); + s.push(bg); + let mut pre = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (60, 40), None); + set(&mut pre, "transform/rotation", Value::Scalar(25.0)); + set(&mut pre, "transform/opacity", Value::Scalar(80.0)); + s.effect(&mut pre, "ec.color.levels", &[("gamma", n(1.3))]); + pre.blend_mode = BlendMode::Screen; + s.push(pre); + // A second instance, later in the nested comp's time. + let mut pre2 = build::layer(&mut s.p, &s.comp, "Inner 2", LayerSource::Comp { item: inner_id }, (60, 40), None); + set(&mut pre2, "transform/position", v3(70.0, 40.0)); + pre2.start_time = Tick::from_seconds_f64(-0.5); + s.push(pre2); + check(&format!("nested precomps {depth:?}"), compare_at(&s, opts(), Tick::from_seconds_f64(0.2)), 0.0); + let half = RenderOpts { scale: 0.5, ..opts() }; + check(&format!("nested precomps half {depth:?}"), compare_at(&s, half, Tick::from_seconds_f64(0.2)), 0.0); + } +} + +/// #613: an effect kernel the device's shader compiler rejects (FXC on DirectX 12 aborting on +/// `pointwise`) no longer turns the whole GPU compositor off: the context is built without it, +/// lists it, and the effects that need it render on the CPU, matching the CPU render. +#[test] +fn effects_whose_kernels_failed_render_on_the_cpu() { + if gpu().is_none() { + return; + } + let Ok(mut ctx) = pollster::block_on(crate::context::GpuContext::request()) else { return }; + ctx.drop_kernel("pointwise"); + ctx.drop_kernel("box_h"); + let names: Vec<&str> = ctx.unavailable_kernels().iter().map(|(e, _)| *e).collect(); + assert_eq!(names, ["pointwise", "box_h"]); + let g = Gpu::from_context(ctx); + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + let mut s = Scene::new(depth); + let mut l = s.footage(80, 50); + s.effect(&mut l, "ec.color.exposure", &[("master/exposure", n(0.7))]); + s.effect(&mut l, "ec.color.tint", &[("black", c(0.1, 0.0, 0.3)), ("amount", n(80.0))]); + s.effect(&mut l, "ec.blur.gaussian", &[("blurriness", n(5.0))]); + s.effect(&mut l, "ec.channel.invert", &[("blend", n(50.0))]); + s.push(l); + let cpu = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Cpu, ..opts() }).comp_frame_cpu(s.cid, Tick::ZERO); + let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() }); + r.accel = Some(&g); + let img = g.render(&r, s.cid, Tick::ZERO).expect("the GPU renders the frame"); + let mut d = diff(&cpu, &img, tolerance(depth)); + d.quantized = depth != BitDepth::Bpc32; + check(&format!("missing kernels {depth:?}"), Some(d), 0.0); + } +} diff --git a/crates/gpu/src/tests_fx_pixel2.rs b/crates/gpu/src/tests_fx_pixel2.rs index abd0c655..899955b8 100644 --- a/crates/gpu/src/tests_fx_pixel2.rs +++ b/crates/gpu/src/tests_fx_pixel2.rs @@ -243,6 +243,7 @@ fn keys_and_3d() { effect_case("ec.key.ccsimplewireremoval", &[]); effect_case("ec.key.ccsimplewireremoval", &[("removalStyle", e(0)), ("thickness", n(8.0)), ("slope", n(50.0))]); effect_case("ec.key.ccsimplewireremoval", &[("removalStyle", e(1)), ("mirrorBlend", n(60.0)), ("pointB", pt(60.0, 10.0))]); + effect_case("ec.key.ccsimplewireremoval", &[("removalStyle", e(1)), ("frameOffset", n(-2.0)), ("thickness", n(6.0))]); effect_case("ec.key.ccsimplewireremoval", &[("removalStyle", e(3)), ("mirrorBlend", n(30.0)), ("slope", n(20.0))]); effect_case("ec.obsolete.basic3d", &[("swivel", n(25.0))]); effect_case("ec.obsolete.basic3d", &[("tilt", n(-30.0)), ("swivel", n(10.0)), ("specularHighlight", on())]); diff --git a/crates/gpu/src/walk.rs b/crates/gpu/src/walk.rs index b5bd166c..83cb8550 100644 --- a/crates/gpu/src/walk.rs +++ b/crates/gpu/src/walk.rs @@ -214,6 +214,9 @@ fn draw_layer(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: & if opacity <= 0.0 { return Some(()); } + if let Some(done) = draw_precomp(e, r, ctx, layer, canvas, opacity) { + return done; + } let Some(st) = r.styled_layer(ctx, layer) else { return Some(()) }; if st.plain { return composite_layer(e, r, ctx, layer, &st.body, canvas, opacity); @@ -243,6 +246,55 @@ fn draw_layer(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: & Some(()) } +/// A 2D precomp layer, its nested comp rendered resident on the GPU (`walk::render` one level +/// down) instead of by the CPU compositor and uploaded every frame (`Renderer::layer_source` +/// renders nested comps on the CPU: depth > 0). Then the layer's effects (resident), its +/// placement, track matte / Preserve Transparency and blend, as for any layer. `None`: not this +/// kind of layer, or the nested comp needs the CPU path (masks, layer styles, proxies, +/// overrides, frame blending, nested motion-blur sampling; see `Renderer::precomp_source`); +/// `Some(None)`: the GPU failed. +fn draw_precomp(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: &mut GpuImage, opacity: f32) -> Option> { + if layer.masks().is_some_and(|m| !m.children.is_empty()) || styles::active(ctx, layer) { + return None; + } + let (sub, item, lt, bs) = r.precomp_source(ctx, layer)?; + // A nested comp the GPU can't take (nested too deep, over the texture limit, content it + // declines) goes back to the CPU path for this layer only; only a device failure fails the + // frame. + let Some(mut src) = render(e, &sub, item, lt) else { + return if e.g.check_health().is_ok() { None } else { Some(None) }; + }; + let pipe = r.pipe(); + // `masked_source` quantises a non-solid source to the project's depth. + if let Some(l) = pipe.levels { + src = ops::quantize(e, &src, l); + } + let (mut img, offset, scale) = if layer.switches.effects && layer.effects().is_some_and(|f| !f.children.is_empty()) { + // Effects run in effect space (0.7.0): from the layer bounds' corner. + let o = ctx.effect_bounds(layer).1; + let mut fx = crate::effects::GpuFx::new(e, src, [o[0] * bs, o[1] * bs], bs, pipe.levels); + r.run_layer_effects_on(ctx, layer, &mut fx); + let Some((img, off, sc)) = fx.finish() else { return Some(None) }; + (img, [off[0] - o[0] * sc, off[1] - o[1] * sc], sc) + } else { + (src, [0.0; 2], bs) + }; + if img.width == 0 || img.height == 0 { + return Some(Some(())); + } + if let Some(c) = pipe.to_blend() { + img = ops::convert(e, &img, &c); + } + let shape = Buf { img: Image { width: img.width, height: img.height, data: Vec::new() }, offset, scale }; + if r.track_matte(ctx, layer).is_some() || layer.preserve_transparency { + let Some(iso) = place_src(e, r, ctx, layer, &shape, img, &e.zeros(canvas.width, canvas.height), BlendMode::Normal, 1.0, None) else { + return Some(None); + }; + return Some(composite_iso(e, r, ctx, layer, iso, canvas, opacity)); + } + Some(place_src(e, r, ctx, layer, &shape, img, canvas, layer.blend_mode, opacity, pipe.levels).map(|c| *canvas = c)) +} + /// Adjustment layer (`Renderer::draw_adjustment`): the effect stack runs on the comp below, /// resident on the GPU (non-GPU effects read back, run on the CPU and upload), and the result /// replaces the canvas inside the layer's footprint × opacity. `None` = draw it on the CPU. @@ -342,6 +394,23 @@ fn place( return Some(target.clone()); } let src = e.g.upload_buf(buf)?; + place_src(e, r, ctx, layer, buf, src, target, mode, opacity, levels) +} + +/// [`place`] of a GPU image `src` shaped like `buf`. +#[allow(clippy::too_many_arguments)] +fn place_src( + e: &mut Enc, + r: &Renderer, + ctx: &EvalCtx, + layer: &Layer, + buf: &Buf, + src: GpuImage, + target: &GpuImage, + mode: BlendMode, + opacity: f32, + levels: Option, +) -> Option { let pl = r.placement(ctx, layer, buf); if let [m] = pl.matrices.as_slice() { return Some(ops::warp_q(e, target, &src, m, pl.sampling, mode, opacity, pl.seed, None, levels)); diff --git a/crates/host/Cargo.toml b/crates/host/Cargo.toml index 13fcb87a..344d5433 100644 --- a/crates/host/Cargo.toml +++ b/crates/host/Cargo.toml @@ -31,5 +31,6 @@ effectcraft-raster = { workspace = true } effectcraft-time = { workspace = true } effectcraft-interchange = { workspace = true } image = { workspace = true } +gif = { workspace = true } exr = { workspace = true } rayon = { workspace = true } diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index 7f2d1a5c..cf5661a1 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -257,6 +257,38 @@ mod tests { assert!(red * 20 > img.data.len(), "red cube pixels: {red} of {}", img.data.len()); } + /// #651: an animated GIF imported from disk plays: the layer shows each frame at its time, + /// in the viewer's renders and in exports alike. + #[test] + fn an_imported_animated_gif_plays() { + let dir = std::env::temp_dir().join(format!("ec-gif-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("anim.gif"); + let mut bytes = Vec::new(); + { + let mut enc = gif::Encoder::new(&mut bytes, 4, 4, &[]).unwrap(); + for rgb in [[255u8, 0, 0], [0, 255, 0], [0, 0, 255]] { + let mut f = gif::Frame::from_rgb(4, 4, &rgb.repeat(16)); + f.delay = 10; + enc.write_frame(&f).unwrap(); + } + } + std::fs::write(&path, bytes).unwrap(); + let mut s = super::session(); + s.execute("comp.new", json!({"name": "G", "width": 4, "height": 4, "frameRate": 30, "duration": 1})).unwrap(); + s.execute("file.import", json!({"paths": [path.to_string_lossy()], "addToComp": true})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let rgb = |t: f64| { + let img = s.render(cid, effectcraft_time::Tick::from_seconds_f64(t), effectcraft_engine::render::RenderOpts::default()); + let p = img.data[5]; + [p[0].round(), p[1].round(), p[2].round()] + }; + assert_eq!(rgb(0.05), [1.0, 0.0, 0.0]); + assert_eq!(rgb(0.15), [0.0, 1.0, 0.0]); + assert_eq!(rgb(0.25), [0.0, 0.0, 1.0]); + let _ = std::fs::remove_dir_all(dir); + } + /// The web app's path: outputs go to a sink (downloads), never to the file system. #[test] fn render_queue_exports_to_a_sink() { diff --git a/crates/keyframe/src/lib.rs b/crates/keyframe/src/lib.rs index 13814c53..787a0643 100644 --- a/crates/keyframe/src/lib.rs +++ b/crates/keyframe/src/lib.rs @@ -224,6 +224,20 @@ pub fn ease_progress(out_speed_n: f64, out_inf: f64, in_speed_n: f64, in_inf: f6 bez(0.0, out_speed_n * i0, 1.0 - in_speed_n * i1, 1.0, u) } +/// Whether control point `c` lies on the segment `a`–`b` (within rounding), so a cubic with +/// such handles traces the straight chord. +fn on_chord(a: [f64; 3], c: [f64; 3], b: [f64; 3]) -> bool { + let d = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; + let v = [c[0] - a[0], c[1] - a[1], c[2] - a[2]]; + let dd = d[0] * d[0] + d[1] * d[1] + d[2] * d[2]; + if dd <= 1e-24 { + return false; + } + let t = (v[0] * d[0] + v[1] * d[1] + v[2] * d[2]) / dd; + let off = [v[0] - d[0] * t, v[1] - d[1] * t, v[2] - d[2] * t]; + (0.0..=1.0).contains(&t) && off[0] * off[0] + off[1] * off[1] + off[2] * off[2] <= dd * 1e-18 +} + /// Spatial cubic segment with an arc-length table. struct SpatialSeg { p: [[f64; 3]; 4], @@ -233,26 +247,44 @@ struct SpatialSeg { const ARC_N: usize = 96; +/// 5-point Gauss-Legendre nodes and weights on -1..1. +const GAUSS: [(f64, f64); 5] = [ + (0.0, 0.568_888_888_888_888_9), + (-0.538_469_310_105_683_1, 0.478_628_670_499_366_5), + (0.538_469_310_105_683_1, 0.478_628_670_499_366_5), + (-0.906_179_845_938_664, 0.236_926_885_056_189_1), + (0.906_179_845_938_664, 0.236_926_885_056_189_1), +]; + impl SpatialSeg { fn new(p: [[f64; 3]; 4]) -> SpatialSeg { let mut table = Vec::with_capacity(ARC_N + 1); table.push(0.0); - let mut prev = Self::point(&p, 0.0); let mut acc = 0.0; for i in 1..=ARC_N { - let q = Self::point(&p, i as f64 / ARC_N as f64); - acc += ((q[0] - prev[0]).powi(2) + (q[1] - prev[1]).powi(2) + (q[2] - prev[2]).powi(2)).sqrt(); + acc += Self::arc(&p, (i - 1) as f64 / ARC_N as f64, i as f64 / ARC_N as f64); table.push(acc); - prev = q; } SpatialSeg { p, table } } fn point(p: &[[f64; 3]; 4], u: f64) -> [f64; 3] { [bez(p[0][0], p[1][0], p[2][0], p[3][0], u), bez(p[0][1], p[1][1], p[2][1], p[3][1], u), bez(p[0][2], p[1][2], p[2][2], p[3][2], u)] } + /// |dB/du|. + fn tangent_len(p: &[[f64; 3]; 4], u: f64) -> f64 { + (0..3).map(|k| bez_d(p[0][k], p[1][k], p[2][k], p[3][k], u).powi(2)).sum::().sqrt() + } + /// Arc length between `a` and `b` (Gauss-Legendre; exact enough on one table cell). + fn arc(p: &[[f64; 3]; 4], a: f64, b: f64) -> f64 { + let (m, h) = ((a + b) * 0.5, (b - a) * 0.5); + GAUSS.iter().map(|(x, w)| w * Self::tangent_len(p, m + h * x)).sum::() * h + } fn length(&self) -> f64 { *self.table.last().unwrap_or(&0.0) } + /// The point at arc length `s`. The table cell's linear guess is refined with Newton steps + /// on the true arc length, so the position (and the speed graph, its derivative) is smooth + /// along curved paths instead of piecewise linear per cell (#456). fn at_length(&self, s: f64) -> [f64; 3] { let len = self.length(); if len <= 0.0 { @@ -260,9 +292,21 @@ impl SpatialSeg { } let s = s.clamp(0.0, len); let i = self.table.partition_point(|&v| v < s).clamp(1, ARC_N); - let (a, b) = (self.table[i - 1], self.table[i]); - let f = if b > a { (s - a) / (b - a) } else { 0.0 }; - Self::point(&self.p, (i as f64 - 1.0 + f) / ARC_N as f64) + let (Some(&a), Some(&b)) = (self.table.get(i - 1), self.table.get(i)) else { return self.p[0] }; + let (u0, u1) = ((i - 1) as f64 / ARC_N as f64, i as f64 / ARC_N as f64); + let mut u = u0 + if b > a { (s - a) / (b - a) } else { 0.0 } * (u1 - u0); + for _ in 0..4 { + let d = Self::tangent_len(&self.p, u); + if d <= 1e-12 { + break; + } + let e = a + Self::arc(&self.p, u0, u) - s; + u = (u - e / d).clamp(u0, u1); + if e.abs() <= 1e-12 * len.max(1.0) { + break; + } + } + Self::point(&self.p, u) } } @@ -270,25 +314,18 @@ fn v3(v: &Value) -> [f64; 3] { v.as_vec3() } -/// Auto-Bezier spatial tangents (Catmull-Rom style) for key `i`. +/// Auto-Bezier spatial tangents for key `i`, as After Effects computes them (Catmull-Rom): the out +/// tangent is a sixth of the way from the previous key to the next, the in tangent its mirror, and +/// key times play no part. An end key uses its one neighbour, so the path leaves the first key +/// and enters the last one toward the neighbouring key instead of with no tangent. fn auto_spatial(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) { - if i == 0 || i + 1 >= keys.len() { + if i >= keys.len() || keys.len() < 2 { return ([0.0; 3], [0.0; 3]); } - let a = v3(&keys[i - 1].value); - let b = v3(&keys[i].value); - let c = v3(&keys[i + 1].value); - let d = [c[0] - a[0], c[1] - a[1], c[2] - a[2]]; - let dl = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt(); - if dl < 1e-12 { - return ([0.0; 3], [0.0; 3]); - } - let lin = ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2) + (b[2] - a[2]).powi(2)).sqrt(); - let lout = ((c[0] - b[0]).powi(2) + (c[1] - b[1]).powi(2) + (c[2] - b[2]).powi(2)).sqrt(); - let n = [d[0] / dl, d[1] / dl, d[2] / dl]; - let tin = [-n[0] * lin / 3.0, -n[1] * lin / 3.0, -n[2] * lin / 3.0]; - let tout = [n[0] * lout / 3.0, n[1] * lout / 3.0, n[2] * lout / 3.0]; - (tin, tout) + let a = v3(&keys[i.saturating_sub(1)].value); + let c = v3(&keys[(i + 1).min(keys.len() - 1)].value); + let tout = [(c[0] - a[0]) / 6.0, (c[1] - a[1]) / 6.0, (c[2] - a[2]) / 6.0]; + ([-tout[0], -tout[1], -tout[2]], tout) } /// Effective spatial tangents of key `i` (auto or stored). @@ -298,7 +335,7 @@ pub fn spatial_tangents(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) { } /// Spatial tangents of key `i` as motion-path handles: [`spatial_tangents`], with a side that -/// has none but a neighbour (the ends of an auto-Bezier path, a straight two-key path) pointing +/// has none but a neighbour (a key whose stored tangents are zero, as on a straight path) pointing /// where the path leaves the key, toward the segment's next control point, a third of the way to /// the neighbouring key. After Effects shows Bezier handles there to drag the path into a curve. pub fn spatial_handles(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) { @@ -426,7 +463,13 @@ pub fn retime_roving(keys: &mut [Keyframe], spatial: bool) -> bool { for (o, i) in (a + 1..b).enumerate() { let f = if total > 1e-9 { acc[o + 1] / total } else { (o + 1) as f64 / (b - a) as f64 }; let span = i128::from(t1.0) - i128::from(t0.0); - let retimed = i128::from(t0.0) + (span as f64 * f).round() as i128; + let mut retimed = i128::from(t0.0) + (span as f64 * f).round() as i128; + // Keep every roving key strictly between its neighbours: a zero-length segment would otherwise + // put it on a fixed key's time, and two keys can't share a time. + let (lo, hi) = (i128::from(keys[i - 1].time.0) + 1, i128::from(t1.0) - (b - i) as i128); + if lo <= hi { + retimed = retimed.clamp(lo, hi); + } let nt = Tick(retimed.clamp(i128::from(i64::MIN), i128::from(i64::MAX)) as i64); if nt != keys[i].time { keys[i].time = nt; @@ -500,7 +543,13 @@ pub fn segment_value(keys: &[Keyframe], i: usize, t: Tick, spatial: bool) -> Val let ei = if lin_in { Ease { speed: len / dur, influence: 1.0 / 3.0 } } else { effective_ease(keys, i + 1, 0, true, false) }; ease_progress(eo.speed * dur / len, eo.influence, ei.speed * dur / len, ei.influence, x) }; - let p = seg.at_length(f * len); + // A straight path (both handles on the chord, as auto-Bezier keys on a line have) is the + // chord itself: interpolate it exactly instead of through the arc-length table. + let p = if on_chord(p0, seg.p[1], p3) && on_chord(p0, seg.p[2], p3) { + [p0[0] + (p3[0] - p0[0]) * f, p0[1] + (p3[1] - p0[1]) * f, p0[2] + (p3[2] - p0[2]) * f] + } else { + seg.at_length(f * len) + }; return a.value.with_components(&p); } if lin_out && lin_in { @@ -758,6 +807,24 @@ mod tests { assert!((p[0] - 150.0).abs() < 0.5 && (p[1] - 200.0).abs() < 0.5, "{p:?}"); } + /// #456: along a curved spatial path with linear (constant-speed) keys the speed graph is + /// flat, not a sawtooth from a piecewise-linear arc-length table. + #[test] + fn spatial_curved_path_has_smooth_constant_speed() { + let mut a = Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0])); + let mut b = Keyframe::new(s(2.0), Value::Vec2([400.0, 0.0])); + a.spatial_auto = false; + b.spatial_auto = false; + a.spatial_out = [50.0, -300.0, 0.0]; + b.spatial_in = [80.0, 250.0, 0.0]; + let keys = vec![a, b]; + let len = spatial_segment_length(&keys, 0); + let speeds: Vec = (1..400).map(|i| speed(&keys, s(i as f64 * 0.005), true)).collect(); + for v in &speeds { + assert!((v - len / 2.0).abs() < 1e-3 * len, "speed {v} vs {}", len / 2.0); + } + } + #[test] fn spatial_auto_bezier_passes_through_keys() { let keys = vec![ @@ -772,6 +839,37 @@ mod tests { assert!((m[0] - m[1]).abs() > 1.0, "{m:?}"); } + #[test] + fn straight_auto_bezier_path_is_exact() { + // Auto-Bezier ends now have handles along the line; the chord is still hit exactly. + let keys = vec![Keyframe::new(s(0.0), Value::Vec2([40.0, 50.0])), Keyframe::new(s(2.0), Value::Vec2([120.0, 50.0]))]; + assert_eq!(segment_value(&keys, 0, s(1.0), true), Value::Vec2([80.0, 50.0])); + } + + #[test] + fn spatial_auto_bezier_tangents_follow_after_effects() { + // Measured on After Effects 26.3 with original paths (docs/fidelity/README.md): each + // auto-Bezier key's out tangent is (next - previous) / 6 and its in tangent the mirror, + // whatever the key times. End keys use their one neighbour: (neighbour - key) / 6. + let p = [[40.0, 300.0, 0.0], [260.0, 80.0, 20.0], [700.0, 420.0, -40.0], [1100.0, 150.0, 0.0]]; + let mut keys: Vec = [0.0, 0.2, 1.5, 1.8].iter().zip(p).map(|(&t, v)| Keyframe::new(s(t), Value::Vec3(v))).collect(); + let sixth = |a: [f64; 3], b: [f64; 3]| [(b[0] - a[0]) / 6.0, (b[1] - a[1]) / 6.0, (b[2] - a[2]) / 6.0]; + let neg = |v: [f64; 3]| [-v[0], -v[1], -v[2]]; + let close = |a: [f64; 3], b: [f64; 3]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-9); + let want = [sixth(p[0], p[1]), sixth(p[0], p[2]), sixth(p[1], p[3]), sixth(p[2], p[3])]; + for (i, out) in want.into_iter().enumerate() { + let (tin, tout) = spatial_tangents(&keys, i); + assert!(close(tout, out) && close(tin, neg(out)), "key {i}: {tin:?} {tout:?}"); + } + // Moving a key in time leaves every tangent as it was. + keys[1].time = s(1.2); + assert!(close(spatial_tangents(&keys, 1).1, sixth(p[0], p[2]))); + // A key between two coincident neighbours, and a lone key, have none. + let still = [[5.0, 5.0], [9.0, 1.0], [5.0, 5.0]].iter().enumerate().map(|(i, &v)| Keyframe::new(s(i as f64), Value::Vec2(v))).collect::>(); + assert_eq!(spatial_tangents(&still, 1), ([0.0; 3], [0.0; 3])); + assert_eq!(spatial_tangents(&still[..1], 0), ([0.0; 3], [0.0; 3])); + } + #[test] fn set_key_replaces_same_time() { let mut keys = vec![]; @@ -817,6 +915,21 @@ mod tests { assert!(!keys[0].roving); } + #[test] + fn roving_keys_never_share_a_time_with_a_fixed_key() { + // Zero-length last segment (issue #532) and zero-length first segment. + for xs in [[0.0, 40.0, 120.0, 120.0], [0.0, 0.0, 40.0, 120.0]] { + let mut keys: Vec = xs.iter().enumerate().map(|(i, x)| Keyframe::new(s(i as f64), Value::Vec2([*x, 64.0]))).collect(); + for k in &mut keys { + k.spatial_auto = false; + } + keys[1].roving = true; + keys[2].roving = true; + retime_roving(&mut keys, true); + assert!(keys.windows(2).all(|w| w[0].time < w[1].time), "{xs:?}: {:?}", keys.iter().map(|k| k.time).collect::>()); + } + } + #[test] fn side_ease_reports_linear_and_eased_sides() { let keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(2.0), Value::Scalar(100.0)).eased()]; diff --git a/crates/media/src/exr_channels.rs b/crates/media/src/exr_channels.rs index 06bda925..6e90a6dd 100644 --- a/crates/media/src/exr_channels.rs +++ b/crates/media/src/exr_channels.rs @@ -37,6 +37,18 @@ pub fn parse_manifest(json: &str) -> Vec<(String, u32)> { v } +/// Why an OpenEXR file's pixels can't be decoded when its headers name a compression this build +/// can't decompress (HTJ2K, OpenEXR 3.4's High-Throughput JPEG 2000): the decoders only said +/// "no non-deep rgb channels" or showed a transparent frame (#482). `None` for other files. +pub fn unsupported_compression(bytes: &[u8]) -> Option<&'static str> { + use exr::compression::Compression; + let meta = exr::meta::MetaData::read_from_buffered(std::io::Cursor::new(bytes), false).ok()?; + meta.headers + .iter() + .any(|h| matches!(h.compression, Compression::HTJ2K32 | Compression::HTJ2K256)) + .then_some("OpenEXR HTJ2K compression isn't supported yet: re-save the file with ZIP, PIZ or DWAA compression") +} + /// Read all channels of an EXR file's bytes. `None` when it is not a readable EXR. pub fn read_exr_channels(bytes: &[u8]) -> Option { use exr::prelude::*; @@ -149,9 +161,20 @@ fn picture_layer<'a>(aux: &AuxChannels, layers: &[&'a str]) -> Option<&'a str> { /// 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 { - let aux = read_exr_channels(bytes)?; + picture_of(&read_exr_channels(bytes)?) +} + +/// [`layered_image`] of the EXR at `path` from the channel cache, so the picture and EXtractoR / +/// Cryptomatte share one decode of the file (#474). +pub(crate) fn cached_layered_image(path: &str, bytes: Arc<[u8]>) -> Option { + let aux = cached(path, || Some(bytes))?; + picture_of(&aux) +} + +/// The picture of decoded channels (see [`layered_image`]). +fn picture_of(aux: &AuxChannels) -> Option { let layers = aux.layers(); - let colour = picture_layer(&aux, &layers)?; + let colour = picture_layer(aux, &layers)?; let names = aux.layer_rgba(colour); // A layer without R, G and B channels shows its first channel as grey. let first = aux.names().into_iter().find(|n| split_channel(n).0 == colour).unwrap_or_default(); @@ -242,6 +265,27 @@ mod tests { /// #295: a multi-layer EXR whose colour is only in named layers (`diffuse.R`, as Blender and /// Nuke write them) failed to import ("does not contain non-deep rgb channels"); it now /// shows its colour layer and keeps every channel for EXtractoR. + /// #474: the picture and EXtractoR / Cryptomatte share one decode of a multi-layer file: + /// after the frame is decoded, the aux channels come from the cache without reading the file. + #[test] + fn picture_and_aux_channels_share_one_decode() { + use exr::prelude::*; + let (w, h) = (2usize, 2usize); + let ch = |n: &str, v: f32| AnyChannel::new(n, FlatSamples::F32(vec![v; w * h])); + let channels = AnyChannels::sort(vec![ch("Image.R", 0.5), ch("Image.G", 0.5), ch("Image.B", 0.5), ch("GlossDir.R", 0.25)].into()); + let image = Image::from_layer(Layer::new((w, h), LayerAttributes::default(), Encoding::FAST_LOSSLESS, channels)); + let mut bytes = std::io::Cursor::new(Vec::new()); + image.write().to_buffered(&mut bytes).unwrap(); + let b: Arc<[u8]> = bytes.into_inner().into(); + let path = "/474-shared-decode.exr"; + let pool = crate::MediaPool::new(); + pool.add_bytes(path, b.clone()); + let f = crate::probe_bytes(path, b).unwrap(); + pool.frame_at(&f, effectcraft_time::Tick::ZERO).unwrap(); + let aux = cached(path, || panic!("the frame's decode is reused, the file is not read again")).unwrap(); + assert_eq!(aux.get("GlossDir.R").and_then(|p| p.first()).copied(), Some(0.25)); + } + #[test] fn layered_exr_without_an_unnamed_rgb_layer_imports() { use exr::prelude::*; diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 5ce5730f..ce980c56 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -53,8 +53,10 @@ struct Entry { } /// Conformed audio files written by builds before this version are ignored (and written again): -/// version 1 files of sources longer than about 12:36 at 48 kHz were silent from there on (#172). -const CONFORM_VERSION: u32 = 2; +/// version 1 files of sources longer than about 12:36 at 48 kHz were silent from there on (#172); +/// version 2 files were written silent where the source failed to decode, and kept the footage +/// silent from then on (#324). +const CONFORM_VERSION: u32 = 3; /// Footage longer than this (24 hours) is read from its decoder rather than conformed: its /// duration comes from the file's header, and a damaged one claiming days of audio had the @@ -200,6 +202,10 @@ struct Inner { /// Conformed files being written right now. #[cfg_attr(target_arch = "wasm32", allow(dead_code))] conforming: Mutex>, + /// Conformed files that failed to write (the source didn't decode) this session: their + /// audio is read from the decoder instead of trying again on every read. + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + conform_failed: Mutex>, } /// Decodes footage for the renderer: stills, image sequences and movies (FilmCraft codecs), plus @@ -266,6 +272,7 @@ impl MediaPool { opened: AtomicU64::new(0), conform: Mutex::default(), conforming: Mutex::default(), + conform_failed: Mutex::default(), }), } } @@ -397,18 +404,23 @@ impl MediaPool { /// thread (once). #[cfg(not(target_arch = "wasm32"))] fn conform(&self, footage: &Footage, file: std::path::PathBuf, frames: usize, rate: u32) { - if !lock(&self.inner.conforming).insert(file.clone()) { + if lock(&self.inner.conform_failed).contains(&file) || !lock(&self.inner.conforming).insert(file.clone()) { return; } let (pool, f) = (self.clone(), footage.clone()); let spawned = std::thread::Builder::new().name("ec-conform-audio".into()).spawn(move || { let tmp = file.with_extension("tmp"); - let ok = file.parent().is_some_and(|d| std::fs::create_dir_all(d).is_ok()) - && pool.write_conformed(&f, &tmp, frames, rate).is_ok() - && std::fs::rename(&tmp, &file).is_ok(); - if !ok { + let written = match file.parent().map(std::fs::create_dir_all) { + Some(Ok(())) => pool.write_conformed(&f, &tmp, frames, rate).and_then(|()| std::fs::rename(&tmp, &file)), + Some(Err(e)) => Err(e), + None => Err(std::io::Error::other("no folder")), + }; + // A failed decode leaves no file: one written silent would keep the footage silent + // in every later session (#324). + if let Err(e) = written { let _ = std::fs::remove_file(&tmp); - log::warn!("media: could not write conformed audio {}", file.display()); + log::warn!("media: could not write conformed audio {} of {}: {e}", file.display(), f.path); + lock(&pool.inner.conform_failed).insert(file.clone()); } lock(&pool.inner.conforming).remove(&file); }); @@ -419,6 +431,7 @@ impl MediaPool { /// Write `frames` sample frames of `footage`'s audio at `rate` to `path` (raw interleaved /// stereo `f32`) a chunk at a time: the whole track was held in memory before it was written. + /// Fails when any chunk doesn't decode. #[cfg(not(target_arch = "wasm32"))] fn write_conformed(&self, footage: &Footage, path: &std::path::Path, frames: usize, rate: u32) -> std::io::Result<()> { use std::io::Write; @@ -426,7 +439,7 @@ impl MediaPool { let mut at = 0usize; while at < frames { let n = (frames - at).min(1 << 16); - for v in self.decode_audio(footage, sample_time(at, rate), n, rate) { + for v in self.try_decode_audio(footage, sample_time(at, rate), n, rate).map_err(std::io::Error::other)? { out.write_all(&v.to_le_bytes())?; } at += n; @@ -470,19 +483,27 @@ impl MediaPool { /// applied the edit list's priming offset twice and ramped the start of every read: AAC /// footage not at the mix rate stuttered in previews and exports (#274). fn decode_audio(&self, footage: &Footage, start: Tick, frames: usize, rate: u32) -> Vec { + self.try_decode_audio(footage, start, frames, rate).unwrap_or_else(|e| { + log::warn!("media: audio of {}: {e}", footage.path); + vec![0.0; frames * 2] + }) + } + + /// [`MediaPool::decode_audio`], failing when the source can't be opened or read. + fn try_decode_audio(&self, footage: &Footage, start: Tick, frames: usize, rate: u32) -> std::result::Result, String> { let path: Arc = footage.path.as_str().into(); - let Some(src) = self.inner.source(&path) else { return vec![0.0; frames * 2] }; + let src = self.inner.source(&path).ok_or("the file can't be opened")?; let s0 = start.to_units_floor(rate as i64); let native = src.info().audio.as_ref().map_or(rate, |a| a.sample_rate); if native == 0 || rate == 0 || native == rate { - return read_stereo(&src, &path, s0, frames, rate); + return read_stereo(&src, s0, frames, rate); } // Output sample `n` sits at source position n × native / rate. let (native_i, rate_i) = (i128::from(native), i128::from(rate)); let first = (i128::from(s0) * native_i).div_euclid(rate_i); let last = ((i128::from(s0) + frames as i128) * native_i).div_euclid(rate_i) + 1; - let (Ok(first64), Ok(count)) = (i64::try_from(first), usize::try_from(last - first + 1)) else { return vec![0.0; frames * 2] }; - let buf = read_stereo(&src, &path, first64, count, native); + let (Ok(first64), Ok(count)) = (i64::try_from(first), usize::try_from(last - first + 1)) else { return Ok(vec![0.0; frames * 2]) }; + let buf = read_stereo(&src, first64, count, native)?; let mut out = Vec::with_capacity(frames * 2); for k in 0..frames as i128 { let pos = (i128::from(s0) + k) * native_i; @@ -494,7 +515,7 @@ impl MediaPool { out.push(a + (b - a) * f); } } - out + Ok(out) } /// A thumbnail of `footage` (its first frame) fitting in `max_side` × `max_side`, aspect kept. @@ -710,10 +731,17 @@ impl Inner { if let Some(img) = crate::layered::decode(path, &bytes, footage, op)? { return Ok(img); } + // (an OpenEXR compression the decoder lacks came out transparent or as a missing + // channel error) + if path.to_ascii_lowercase().ends_with(".exr") + && let Some(why) = crate::exr_channels::unsupported_compression(&bytes) + { + return Err(MediaError::Decode(format!("{path}: {why}"))); + } let img = match image::load_from_memory(&bytes) { Ok(img) => img, // A multi-layer OpenEXR file without an unnamed RGB layer: its colour layer. - Err(e) => crate::exr_channels::layered_image(&bytes).ok_or_else(|| MediaError::Decode(format!("{path}: {e}")))?, + Err(e) => crate::exr_channels::cached_layered_image(path, bytes).ok_or_else(|| MediaError::Decode(format!("{path}: {e}")))?, }; Ok(dynamic_to_image(&img, op)) } @@ -800,27 +828,21 @@ impl FootageSource for MediaPool { /// `frames` stereo sample frames (interleaved) of `src` from sample `s0` at `rate` Hz, which /// FilmCraft serves as decoded when it is the source's own rate. Mono is duplicated to both -/// channels; silence before the start and where decoding fails. -fn read_stereo(src: &SharedSource, path: &str, s0: i64, frames: usize, rate: u32) -> Vec { +/// channels; silence before the start. An error where decoding fails. +fn read_stereo(src: &SharedSource, s0: i64, frames: usize, rate: u32) -> std::result::Result, String> { let mut out = vec![0.0; frames * 2]; let skip = usize::try_from(s0.saturating_neg()).unwrap_or(0); if skip >= frames { - return out; + return Ok(out); } - let buf = match src.audio(s0.max(0), frames - skip, rate) { - Ok(b) => b, - Err(e) => { - log::warn!("media: audio of {path}: {e}"); - return out; - } - }; - let (Some(l), Some(r)) = (buf.channels.first(), buf.channels.get(1).or(buf.channels.first())) else { return out }; - let Some(dst) = skip.checked_mul(2).and_then(|i| out.get_mut(i..)) else { return out }; + let buf = src.audio(s0.max(0), frames - skip, rate).map_err(|e| e.to_string())?; + let (Some(l), Some(r)) = (buf.channels.first(), buf.channels.get(1).or(buf.channels.first())) else { return Ok(out) }; + let Some(dst) = skip.checked_mul(2).and_then(|i| out.get_mut(i..)) else { return Ok(out) }; for ([o0, o1], (a, b)) in dst.as_chunks_mut::<2>().0.iter_mut().zip(l.iter().zip(r)) { *o0 = *a; *o1 = *b; } - out + Ok(out) } fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, T> { @@ -880,6 +902,43 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// #324: audio that fails to decode isn't conformed. A conformed file of silence was written + /// instead, and as it is found by the file's path, size and date the footage stayed silent + /// in every later session (a renamed copy played). + #[test] + fn audio_that_fails_to_decode_is_not_conformed() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-conform-fail"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("song.mp3"); + std::fs::write(&path, b"ID3, not really").unwrap(); + let footage = Footage { + path: path.to_string_lossy().into(), + kind: FootageKind::Audio, + duration: Tick::from_seconds_f64(2.0), + has_audio: true, + ..Default::default() + }; + let pool = MediaPool::new(); + let conformed = dir.join("conformed"); + pool.set_conform_folder(Some(conformed.clone())); + let file = pool.conformed_path(&footage.path, 48_000).unwrap(); + assert!(pool.audio_samples(&footage, Tick::ZERO, 1024, 48_000).iter().all(|v| *v == 0.0)); + for _ in 0..500 { + if lock(&pool.inner.conforming).is_empty() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!(lock(&pool.inner.conforming).is_empty(), "the conform thread finished"); + assert!(!file.exists() && !file.with_extension("tmp").exists(), "no conformed file was left"); + assert!(lock(&pool.inner.conform_failed).contains(&file)); + // Later reads go to the decoder without trying again. + pool.audio_samples(&footage, Tick::ZERO, 1024, 48_000); + assert!(lock(&pool.inner.conforming).is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn frame_index_loops_and_clamps() { let r = FrameRate::FPS_30; @@ -911,6 +970,32 @@ mod tests { assert!(c.take_spare(17).is_empty()); } + /// #482: an OpenEXR file with HTJ2K compression (which the decoder lacks) says so on import + /// and when its frame is read, instead of "no non-deep rgb channels" or a transparent frame. + #[test] + fn htj2k_exr_is_reported_as_unsupported() { + use exr::prelude::*; + let channels = + AnyChannels::sort(vec![AnyChannel::new("R", FlatSamples::F32(vec![0.5; 4])), AnyChannel::new("G", FlatSamples::F32(vec![0.5; 4]))].into()); + let image = exr::image::Image::from_layer(Layer::new((2, 2), LayerAttributes::default(), Encoding::UNCOMPRESSED, channels)); + let mut bytes = std::io::Cursor::new(Vec::new()); + image.write().to_buffered(&mut bytes).unwrap(); + let mut bytes = bytes.into_inner(); + assert_eq!(crate::exr_channels::unsupported_compression(&bytes), None); + // The header's compression attribute (name, type, size 1, value) says HTJ2K32 (11). + let tag = b"compression\0compression\0\x01\0\0\0"; + let at = bytes.windows(tag.len()).position(|w| w == tag).unwrap() + tag.len(); + bytes[at] = 11; + let b: Arc<[u8]> = bytes.into(); + let e = crate::probe_bytes("/shot.exr", b.clone()).unwrap_err().to_string(); + assert!(e.contains("/shot.exr: OpenEXR HTJ2K compression isn't supported yet"), "{e}"); + let pool = MediaPool::new(); + pool.add_bytes("/shot.exr", b); + let f = Footage { path: "/shot.exr".into(), kind: FootageKind::Still, width: 2, height: 2, has_video: true, ..Default::default() }; + let e = pool.frame_at(&f, Tick::ZERO).unwrap_err().to_string(); + assert!(e.contains("HTJ2K compression isn't supported yet"), "{e}"); + } + /// #411: Interpret Footage ▸ Preserve RGB reads a float OpenEXR's values as the file stores /// them (no automatic linear → sRGB encode), so an OCIO effect is the only conversion; the /// default interpretation still encodes. Both interpretations of one file decode separately. diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index 4e986151..6e8718fd 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -158,6 +158,11 @@ pub(crate) fn still_footage(path: &str, w: u32, h: u32, fmt: image::ImageFormat, fn probe_still_bytes(path: &str, bytes: &[u8], fmt: image::ImageFormat) -> Result { use image::ImageDecoder; + if fmt == image::ImageFormat::OpenExr + && let Some(why) = crate::exr_channels::unsupported_compression(bytes) + { + return Err(MediaError::Decode(format!("{path}: {why}"))); + } let reader = image::ImageReader::with_format(std::io::Cursor::new(bytes), fmt); let (w, h, has_alpha) = match reader.into_decoder() { Ok(dec) => (dec.dimensions().0, dec.dimensions().1, dec.color_type().has_alpha()), diff --git a/crates/pdf/src/color.rs b/crates/pdf/src/color.rs index 019121e0..6943476b 100644 --- a/crates/pdf/src/color.rs +++ b/crates/pdf/src/color.rs @@ -221,11 +221,9 @@ fn ps_parse(lx: &mut crate::object::Lexer, depth: usize) -> Vec { match o { Obj::Num(n) => out.push(PsOp::Num(n)), Obj::Bool(b) => out.push(PsOp::Num(if b { 1.0 } else { 0.0 })), - Obj::Op(op) if op == "{" => { - if depth < 32 { - out.push(PsOp::Proc(ps_parse(lx, depth + 1))); - } - } + Obj::Op(op) if op == "{" && depth < 32 => out.push(PsOp::Proc(ps_parse(lx, depth + 1))), + // Nested too deep: skip the procedure's opening brace (never push it as an operator). + Obj::Op(op) if op == "{" => {} Obj::Op(op) if op == "}" => return out, Obj::Op(op) => out.push(PsOp::Op(op)), _ => {} diff --git a/crates/pdf/src/page.rs b/crates/pdf/src/page.rs index 94f62933..13e98b3c 100644 --- a/crates/pdf/src/page.rs +++ b/crates/pdf/src/page.rs @@ -313,11 +313,9 @@ impl<'a> Interp<'a> { self.path.curve_to(Point::new(n(0), n(1)), p, p); self.cur = Some(p); } - "h" => { - if self.cur.is_some() { - self.path.close_path(); - self.cur = self.start; - } + "h" if self.cur.is_some() => { + self.path.close_path(); + self.cur = self.start; } "re" => { let (x, y, w, h) = (n(0), n(1), n(2), n(3)); diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 064ac2e0..c1f110e0 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -219,6 +219,23 @@ pub struct ProjectSettings { /// sRGB; Rec. 2100 PQ / HLG give HDR output. #[serde(default)] pub output_space: Option, + /// Working Gamma, 2.2 or 2.4 (`app.project.workingGamma`): with no working space, the curve + /// Interpret As Linear Light footage is encoded with and Blend Colors Using 1.0 Gamma + /// linearizes with: the sRGB curve at 2.2, a pure 2.4 power curve at 2.4. Ignored with a + /// working space (as in After Effects). + #[serde(default = "gamma_22")] + pub working_gamma: f64, +} + +fn gamma_22() -> f64 { + 2.2 +} + +impl ProjectSettings { + /// Working Gamma is 2.4 (anything else reads as 2.2). + pub fn working_gamma_24(&self) -> bool { + (self.working_gamma - 2.4).abs() < 0.05 + } } impl Default for ProjectSettings { @@ -236,6 +253,7 @@ impl Default for ProjectSettings { color_engine: ColorEngine::Adobe, hdr: HdrMode::Clip, output_space: None, + working_gamma: 2.2, } } } diff --git a/crates/project/src/props.rs b/crates/project/src/props.rs index cdacb3f1..f1d5149d 100644 --- a/crates/project/src/props.rs +++ b/crates/project/src/props.rs @@ -37,6 +37,11 @@ pub enum ParamUi { Mask, Path, Text, + /// A file path (a string value) chosen with a "Choose…" button, offering files with these + /// extensions: Apply Color LUT's LUT, an OCIO config. + File { + filters: Vec, + }, Gradient, /// Not shown in Effect Controls / the timeline (internal data). Hidden, diff --git a/crates/psd/src/lib.rs b/crates/psd/src/lib.rs index 422c3153..1c0fd3d4 100644 --- a/crates/psd/src/lib.rs +++ b/crates/psd/src/lib.rs @@ -656,10 +656,8 @@ impl Psd { let gamma = r.f32()? as f64; l.adjustment = Some(Adjustment::Exposure { exposure, offset, gamma }); } - b"curv" | b"blnc" | b"post" | b"thrs" | b"selc" | b"mixr" | b"phfl" | b"vibA" | b"blwh" | b"grdm" | b"clrL" => { - if l.adjustment.is_none() { - l.adjustment = Some(Adjustment::Other(String::from_utf8_lossy(key).into_owned())); - } + b"curv" | b"blnc" | b"post" | b"thrs" | b"selc" | b"mixr" | b"phfl" | b"vibA" | b"blwh" | b"grdm" | b"clrL" if l.adjustment.is_none() => { + l.adjustment = Some(Adjustment::Other(String::from_utf8_lossy(key).into_owned())); } b"SoLd" | b"SoLE" => { let _id = r.tag()?; diff --git a/crates/render/src/color.rs b/crates/render/src/color.rs index 19785722..a420256e 100644 --- a/crates/render/src/color.rs +++ b/crates/render/src/color.rs @@ -14,7 +14,8 @@ //! * **Blend Colors Using 1.0 Gamma**: only blending is linear. Each layer's finished pixels //! are linearised just before they are transformed and blended, the comp accumulates in linear //! and is encoded back to the working space when the comp is done (so precomps hand encoded -//! pixels to their parent). Without a working space the sRGB curve is used. +//! pixels to their parent). Without a working space the Working Gamma's curve is used (sRGB +//! at 2.2, a pure 2.4 power curve at 2.4); so is it for Interpret As Linear Light footage. use effectcraft_color::{ColorSpace, Conversion}; use effectcraft_project::{Footage, HdrMode, ProjectSettings}; @@ -36,6 +37,8 @@ pub struct Pipe { pub out: ColorSpace, /// HDR handling for standard-dynamic-range output. pub hdr: HdrMode, + /// Unmanaged with Working Gamma 2.4: the pure 2.4 power curve instead of sRGB's. + pub gamma24: bool, } impl Pipe { @@ -49,12 +52,20 @@ impl Pipe { linear_blend: s.blend_linear && !linear, out: s.output_space.unwrap_or(ColorSpace::Srgb), hdr: s.hdr, + gamma24: s.working_space.is_none() && s.working_gamma_24(), } } - /// The space whose curve encodes working-space pixels (sRGB when unmanaged). + /// The space whose curve encodes working-space pixels (unmanaged: the Working Gamma's, sRGB + /// at 2.2 and Rec. 709's pure 2.4 power curve at 2.4). fn curve(&self) -> ColorSpace { - self.space.unwrap_or(ColorSpace::Srgb) + self.space.unwrap_or(if self.gamma24 { ColorSpace::Rec709 } else { ColorSpace::Srgb }) + } + + /// The space Interpret As Linear Light encodes footage `f` in: its colour profile, else sRGB + /// (managed; the working space converts from there) or the Working Gamma's curve (unmanaged). + pub fn linear_footage_space(&self, f: &Footage) -> ColorSpace { + f.color_profile.unwrap_or(if self.space.is_some() { ColorSpace::Srgb } else { self.curve() }) } /// Footage `f` from its colour profile (`None` = sRGB) → working space; nothing for @@ -149,6 +160,7 @@ impl Pipe { k = k.wrapping_mul(31).wrapping_add(self.space.map_or(7, |s| s as u64 + 11)); k = k.wrapping_mul(31).wrapping_add(self.linear as u64 * 2 + self.linear_blend as u64); k = k.wrapping_mul(31).wrapping_add(self.out as u64 * 4 + self.hdr as u64); + k = k.wrapping_mul(31).wrapping_add(self.gamma24 as u64); k } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 46e10866..f4a5a98a 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -623,6 +623,30 @@ impl<'a> Renderer<'a> { } } + /// A precomp layer's nested comp for an accelerator to render resident (no CPU render and + /// upload of the nested frame): the renderer one level down, the comp, its time and the + /// buffer scale, exactly as [`Self::layer_source`]'s comp branch would render it. `None` + /// when that branch does more (composition proxy, Essential Properties overrides, frame + /// blending, recursion) or draws nothing (outside the nested comp's span): use the CPU path. + pub fn precomp_source(&self, ctx: &EvalCtx, layer: &Layer) -> Option<(Renderer<'a>, ItemId, Tick, f64)> { + let LayerSource::Comp { item } = layer.source else { return None }; + if layer.switches.adjustment || self.project.comp_contains(item, ctx.comp_id) { + return None; + } + let nc = self.project.comp(item)?; + let lt = ctx.nested_time(layer); + if self.content_times(ctx, layer).is_some() { + return None; + } + if !nc.covers(lt) || self.proxy_for(item).is_some() || !essential_overrides(ctx, layer).is_empty() || frame_blend_mode(ctx, layer) != FrameBlend::Off { + return None; + } + let s = self.opts.scale; + let bs = if s < 1.0 && nc.preserve_resolution { 1.0 } else { s }; + let base = self.nested_through(layer); + Some((Renderer { opts: RenderOpts { scale: bs, ..base.opts }, ..base }, item, lt, bs)) + } + /// The nested comp of a precomp layer whose transformations collapse into this comp: the /// Collapse Transformations switch is on and the layer has no masks, effects or layer /// styles (those force a flattened render of the precomp, as in After Effects). @@ -1200,12 +1224,12 @@ impl<'a> Renderer<'a> { let dominant_upper = f.fields == effectcraft_project::FieldOrder::UpperFirst; // Upper field = even lines (0, 2, …). let parity = if (i % 2 == 0) == dominant_upper { 0 } else { 1 }; - return Some(Arc::new(interpret_pixels(&field_frame(&img, parity), f))); + return Some(Arc::new(interpret_pixels(&field_frame(&img, parity), f, self.pipe.linear_footage_space(f)))); } // Interpretation is per pixel but not linear: it runs on the frame before resampling. let size = size.filter(|_| !(f.invert_alpha || f.linear_light_applies())); let img = self.footage_frame_raw(ctx, layer, item, f, t, size)?; - if f.invert_alpha || f.linear_light_applies() { Some(Arc::new(interpret_pixels(&img, f))) } else { Some(img) } + if f.invert_alpha || f.linear_light_applies() { Some(Arc::new(interpret_pixels(&img, f, self.pipe.linear_footage_space(f)))) } else { Some(img) } } fn footage_frame_raw(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick, size: Option<(u32, u32)>) -> Option> { @@ -1988,6 +2012,15 @@ impl<'a> Renderer<'a> { self.run_effect_stack(ctx, layer, adjustment, usize::MAX, None, [0.0; 2], target); } + /// [`Renderer::run_effects_on`] for a (non-adjustment) layer's own pixels: `target` holds + /// them in effect space (as [`Renderer::layer_input`] hands them out) and the result stays + /// there. Returns the effect-space origin in layer coordinates (rebase by its negation). + pub fn run_layer_effects_on(&self, ctx: &EvalCtx, layer: &Layer, target: &mut dyn FxTarget) -> [f64; 2] { + let origin = ctx.effect_bounds(layer).1; + self.run_effect_stack(ctx, layer, false, usize::MAX, None, origin, target); + origin + } + /// An adjustment layer's footprint (its source with masks applied, layer space): where /// its effects show. `None` = the layer changes nothing. pub fn adjustment_footprint(&self, ctx: &EvalCtx, layer: &Layer) -> Option> { @@ -2074,13 +2107,12 @@ pub fn field_frame(img: &Image, parity: usize) -> Image { } /// Interpret Footage ▸ Invert Alpha and Interpret As Linear Light on decoded (premultiplied) -/// pixels. -pub fn interpret_pixels(img: &Image, f: &Footage) -> Image { +/// pixels; linear light is encoded in `space` ([`color::Pipe::linear_footage_space`]). +pub fn interpret_pixels(img: &Image, f: &Footage, space: effectcraft_color::ColorSpace) -> Image { let linear = f.linear_light_applies(); if !f.invert_alpha && !linear { return img.clone(); } - let space = f.color_profile.unwrap_or(effectcraft_color::ColorSpace::Srgb); let mut out = img.clone(); for p in out.data.iter_mut() { let a = p[3]; diff --git a/crates/render/src/masks.rs b/crates/render/src/masks.rs index 980e6475..3f8d7542 100644 --- a/crates/render/src/masks.rs +++ b/crates/render/src/masks.rs @@ -114,10 +114,8 @@ pub fn shapes(ctx: &EvalCtx, layer: &Layer) -> Vec = Vec::new(); let mut closed = false; kurbo::flatten(path.iter(), 0.25, |el| match el { - effectcraft_path::PathEl::MoveTo(p) | effectcraft_path::PathEl::LineTo(p) => { - if pts.last() != Some(&[p.x, p.y]) { - pts.push([p.x, p.y]); - } + effectcraft_path::PathEl::MoveTo(p) | effectcraft_path::PathEl::LineTo(p) if pts.last() != Some(&[p.x, p.y]) => { + pts.push([p.x, p.y]); } effectcraft_path::PathEl::ClosePath => closed = true, _ => {} @@ -145,9 +143,16 @@ pub fn apply(ctx: &EvalCtx, layer: &Layer, buf: &mut Buf, blur: MaskBlur) -> boo let n = (buf.img.width * buf.img.height) as usize; let first_sub = matches!(list[0].kind, GroupKind::Mask { mode: MaskMode::Subtract, .. }); let mut acc = vec![if first_sub { 1.0f32 } else { 0.0 }; n]; + // The first mask has nothing to combine with. Intersect and Darken would multiply or + // minimise against that empty coverage and hide the layer; they cover like Add instead. + // Lighten and Difference already do. Filling the accumulator first would make Add show + // the whole layer. + let mut first = true; for g in list { let GroupKind::Mask { mode, inverted, .. } = g.kind else { continue }; let Some(mut cov) = coverage(ctx, layer, g, buf, blur) else { continue }; + let mode = if first && matches!(mode, MaskMode::Intersect | MaskMode::Darken) { MaskMode::Add } else { mode }; + first = false; let op = (ctx.f(layer, g, "opacity", 100.0) / 100.0) as f32; cov.data.par_iter_mut().for_each(|c| { if inverted { diff --git a/crates/render/src/shapes.rs b/crates/render/src/shapes.rs index 6bd8686b..e3cbca4a 100644 --- a/crates/render/src/shapes.rs +++ b/crates/render/src/shapes.rs @@ -517,6 +517,20 @@ fn draw_bounds(d: &Draw, paths: &[BezPath]) -> Option { Some(b.inflate(grow, grow)) } +/// Local-space visual bounds of a draw, as After Effects' layer bounds (`sourceRectAtTime` with +/// extents): the path bounds, plus half the stroke width for strokes. No anti-aliasing or miter +/// padding, so snapping and Align put edges exactly together (#352). +fn visual_bounds(d: &Draw, paths: &[BezPath]) -> Option { + let b = effectcraft_path::bounds(paths)?; + let stroke = match &d.paint { + Paint::Stroke { style, .. } => Some(style), + Paint::Gradient { stroke: Some(style), .. } => Some(style), + _ => None, + }; + let grow = stroke.map_or(0.0, |style| style.width * 0.5 * d.xf.mean_scale()); + Some(if grow.is_finite() && grow > 0.0 { b.inflate(grow, grow) } else { b }) +} + /// Maximum number of coverage pixels rasterised concurrently (bounds peak memory). const BATCH_PIXELS: usize = 8 << 20; @@ -639,16 +653,16 @@ pub fn stroke_region(paths: &[BezPath], style: &StrokeStyle) -> Option (!r.elements().is_empty()).then_some(r) } -/// Layer-space bounds of a shape layer's painted contents (strokes included), without -/// rasterising. +/// Layer-space visual bounds of a shape layer's painted contents (half the stroke width +/// included, no anti-aliasing padding), without rasterising. pub fn content_bounds(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> Option { let mut arena = Vec::new(); let (draws, _) = collect(ctx, layer, contents, &mut arena); draws .iter() .filter_map(|d| { - let paths: Vec = d.slots.iter().map(|&i| arena[i].clone()).filter(|p| !p.elements().is_empty()).collect(); - draw_bounds(d, &paths) + let paths: Vec = d.slots.iter().filter_map(|&i| arena.get(i).cloned()).filter(|p| !p.elements().is_empty()).collect(); + visual_bounds(d, &paths) }) .reduce(|a, b| a.union(b)) } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index c22df8ab..7e065391 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -80,6 +80,45 @@ fn mask_cuts_layer() { assert!(img.get(150, 50)[3] < 0.01); } +#[test] +fn first_intersect_and_darken_keep_their_rectangle() { + let render = |modes: &[MaskMode], centers: &[[f64; 2]]| { + let (mut p, cid, comp) = setup(); + let mut l = solid(&mut p, &comp, [1.0, 1.0, 1.0], 200, 100); + for (i, (mode, center)) in modes.iter().zip(centers).enumerate() { + let mut next = p.next_id; + let m = build::mask(&mut Ids(&mut next), &format!("Mask {}", i + 1), ShapePath::rect(*center, 40.0, 40.0), *mode, [255, 255, 0]); + p.next_id = next; + l.props.sub_mut("masks").unwrap().children.push(m.into()); + } + p.comp_mut(cid).unwrap().layers.push(l); + render_frame(&p, cid, Tick::ZERO, 1.0) + }; + // A first Intersect or Darken has nothing to combine with, so it covers like Add. + // Lighten and Difference already do; Subtract still starts from the whole layer. + for mode in [MaskMode::Intersect, MaskMode::Darken, MaskMode::Add, MaskMode::Lighten, MaskMode::Difference] { + let img = render(&[mode], &[[50.0, 50.0]]); + assert!(img.get(50, 50)[3] > 0.99, "{mode:?} keeps the rectangle"); + assert!(img.get(150, 50)[3] < 0.01, "{mode:?} stays outside it"); + } + let hole = render(&[MaskMode::Subtract], &[[50.0, 50.0]]); + assert!(hole.get(50, 50)[3] < 0.01, "subtract still cuts its rectangle"); + assert!(hole.get(150, 50)[3] > 0.99, "subtract leaves the rest of the layer"); + + // Add, then Intersect with a shifted rectangle: only the overlap remains. + let overlap = render(&[MaskMode::Add, MaskMode::Intersect], &[[50.0, 50.0], [70.0, 50.0]]); + assert!(overlap.get(50, 50)[3] > 0.99, "overlap stays"); + assert!(overlap.get(35, 50)[3] < 0.01, "the first rectangle's exclusive side is cut"); + assert!(overlap.get(85, 50)[3] < 0.01, "the second rectangle's exclusive side is cut"); + let darkened = render(&[MaskMode::Add, MaskMode::Darken], &[[50.0, 50.0], [70.0, 50.0]]); + assert!(darkened.get(50, 50)[3] > 0.99 && darkened.get(35, 50)[3] < 0.01 && darkened.get(85, 50)[3] < 0.01); + + // A later disjoint Add still contributes after a first Intersect. + let both = render(&[MaskMode::Intersect, MaskMode::Add], &[[50.0, 50.0], [150.0, 50.0]]); + assert!(both.get(50, 50)[3] > 0.99 && both.get(150, 50)[3] > 0.99); + assert!(both.get(100, 50)[3] < 0.01); +} + #[test] fn track_matte_alpha() { let (mut p, cid, comp) = setup(); @@ -769,3 +808,26 @@ fn precomp_dependencies_hash_nested_sources_and_evaluated_animation() { assert_same(&render_cached(&p, cid, Tick::ZERO, Some(&cache)), &fresh, "precomp source edit"); assert_ne!(before.get(50, 50), fresh.get(50, 50)); } + +/// #352: a shape layer's bounds (snapping, Align) are its visual edges: a 100×100 rectangle is +/// exactly ±50, and a 10 px stroke adds half its width, with no anti-aliasing padding. +#[test] +fn shape_content_bounds_are_the_visual_edges() { + for (stroke, half) in [(None, 50.0), (Some(10.0), 55.0)] { + let (mut p, cid, comp) = setup(); + let mut l = build::layer(&mut p, &comp, "Shape Layer 1", LayerSource::Shape, (200, 100), None); + let mut next = p.next_id; + let mut ids = Ids(&mut next); + let mut items = vec![build::shape_rect(&mut ids, [100.0, 100.0], [0.0, 0.0], 0.0), build::shape_fill(&mut ids, [1.0, 0.0, 0.0, 1.0])]; + if let Some(w) = stroke { + items.push(build::shape_stroke(&mut ids, [1.0, 1.0, 1.0, 1.0], w)); + } + let g = build::shape_group(&mut ids, "Rectangle 1", items); + p.next_id = next; + l.props.sub_mut("contents").unwrap().children.push(g.into()); + p.comp_mut(cid).unwrap().layers.push(l.clone()); + let ctx = crate::EvalCtx::new(&p, cid, p.comp(cid).unwrap(), Tick::ZERO); + let b = crate::content_bounds(&ctx, &l).unwrap(); + assert!(b.iter().zip([-half, -half, half, half]).all(|(a, e)| (a - e).abs() < 1e-6), "{stroke:?}: {b:?}"); + } +} diff --git a/crates/render/src/tests_color.rs b/crates/render/src/tests_color.rs index a45fb69b..c9b8e56f 100644 --- a/crates/render/src/tests_color.rs +++ b/crates/render/src/tests_color.rs @@ -254,3 +254,43 @@ fn layer_cache_is_keyed_by_colour_settings() { assert!((frame(&p) - a).abs() < 1e-9); assert_eq!(cache.stats().hits, hits + 1); } + +/// #495: with no working space, Interpret As Linear Light footage is encoded with the project's +/// Working Gamma: the sRGB curve at 2.2, a pure 2.4 power curve at 2.4 (as After Effects does); +/// Blend Colors Using 1.0 Gamma linearizes with the same curve. A working space ignores it. +#[test] +fn working_gamma_24_encodes_linear_footage_with_a_power_curve() { + let render = |gamma: f64, ws: Option| { + let (mut p, cid, comp) = setup(BitDepth::Bpc32); + p.settings.working_gamma = gamma; + p.settings.working_space = ws; + let fid = p.add_item("F", Label::Aqua, None, ItemKind::Footage(Footage { linear_light: true, ..footage(None) })); + let l = build::layer(&mut p, &comp, "F", LayerSource::Footage { item: fid }, (40, 20), None); + p.comp_mut(cid).unwrap().layers.push(l); + let src = Flat([0.051, 0.18, 0.5]); + Renderer::new(&p, &src, RenderOpts::default()).comp_frame(cid, Tick::ZERO).get(5, 5) + }; + let near = |o: [f32; 4], want: [f32; 3]| (0..3).all(|i| (o[i] - want[i]).abs() < 2e-3); + let o = render(2.4, None); + assert!(near(o, [0.290, 0.490, 0.749]), "2.4: {o:?}"); + let o = render(2.2, None); + assert!(near(o, [0.250, 0.461, 0.735]), "2.2: {o:?}"); + // A working space ignores Working Gamma. + assert_eq!(render(2.4, Some(ColorSpace::Srgb)), render(2.2, Some(ColorSpace::Srgb))); + // Blending with 1.0 gamma: 50% white over black is 0.5 linear, encoded with the curve. + let blend = |gamma: f64| { + let (mut p, cid, _) = setup(BitDepth::Bpc32); + p.settings.working_gamma = gamma; + p.settings.blend_linear = true; + add_solid(&mut p, cid, [0.0, 0.0, 0.0], BlendMode::Normal, 100.0); + add_solid(&mut p, cid, [1.0, 1.0, 1.0], BlendMode::Normal, 50.0); + px(&p, cid)[0] + }; + assert!((blend(2.4) - 0.5f32.powf(1.0 / 2.4)).abs() < 2e-3, "{}", blend(2.4)); + assert!((blend(2.2) - 0.7354).abs() < 2e-3, "{}", blend(2.2)); + assert_ne!(crate::color::Pipe::of(&Project::default().settings).key(), { + let mut s = Project::default().settings; + s.working_gamma = 2.4; + crate::color::Pipe::of(&s).key() + }); +} diff --git a/crates/script/src/model.rs b/crates/script/src/model.rs index e0f17fef..4445fbbf 100644 --- a/crates/script/src/model.rs +++ b/crates/script/src/model.rs @@ -82,6 +82,7 @@ fn project(s: &Session) -> J { "linearBlending": p.settings.blend_linear, "linearizeWorkingSpace": p.settings.linearize, "workingSpace": p.settings.working_space.map(|w| format!("{w:?}")).unwrap_or_default(), + "workingGamma": if p.settings.working_gamma_24() { 2.4 } else { 2.2 }, "timeDisplayFrames": p.settings.time_display == effectcraft_project::TimeDisplayStyle::Frames, "renderQueue": p.render_queue.len(), "revision": s.revision, diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index 2b8085d3..f50a968c 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -1696,6 +1696,8 @@ Project.prototype = { set linearizeWorkingSpace(v) { __call("file.projectSettings", { linearize: !!v }); }, get workingSpace() { return this.__info().workingSpace; }, set workingSpace(v) { __call("file.projectSettings", { workingSpace: String(v) || "none" }); }, + get workingGamma() { return this.__info().workingGamma; }, + set workingGamma(v) { __call("file.projectSettings", { workingGamma: __num(v) }); }, get timeDisplayType() { return this.__info().timeDisplayFrames ? TimeDisplayType.FRAMES : TimeDisplayType.TIMECODE; }, set timeDisplayType(v) { __call("file.projectSettings", { timeDisplay: v === TimeDisplayType.FRAMES ? "frames" : "timecode" }); }, get expressionEngine() { return "javascript-1.0"; }, diff --git a/crates/script/src/runtime.rs b/crates/script/src/runtime.rs index 62479c14..712999ee 100644 --- a/crates/script/src/runtime.rs +++ b/crates/script/src/runtime.rs @@ -152,7 +152,7 @@ pub(crate) fn exec(s: &mut Session, id: &str, params: J) -> Result { if CREATES_LAYER.contains(&id) { s.state.selected_layers.clear(); } - let r = s.execute(id, params); + let r = s.execute_for_script(id, params); if let Some(c) = target && prev != Some(c) && s.state.active_comp == Some(c) diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 946db49d..b50a7bfb 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -362,6 +362,47 @@ fn security_gate_for_files() { let _ = std::fs::remove_dir_all(&dir); } +/// #590: a script can't give itself file and network access (or turn off the warning before +/// executing files) by changing Settings ▸ Scripting & Expressions; the user still can. +#[test] +fn scripts_cant_change_what_scripts_may_do() { + let mut s = session(); + let attempts = [ + "app.run('prefs.set', {key: 'scripting.allowScriptsWriteFiles', value: true})", + "app.run('prefs.set', {values: {'scripting.warnExecutingFiles': false}})", + "app.run('prefs.set', {key: 'scripting', value: {allowScriptsWriteFiles: true}})", + "app.run('engine.batch', {steps: [{command: 'prefs.set', params: {key: 'scripting.enableJsDebugger', value: true}}]})", + ]; + for code in attempts { + let o = run_code(&mut s, code, "grant.jsx"); + let e = o.error.unwrap_or_else(|| panic!("`{code}` was allowed")); + assert!(e.message.contains("Scripts can't change what scripts are allowed to do"), "{code}: {e:?}"); + let sc = &s.prefs.scripting; + assert!(!sc.allow_scripts_write_files && sc.warn_executing_files && !sc.enable_js_debugger, "{code}"); + } + assert!(run_code(&mut s, "new Socket()", "net.jsx").error.is_some()); + // Other settings, and the expression editor's, are still the script's to change. + ok(&mut s, "app.run('prefs.set', {values: {'general.undoLevels': 40, 'scripting.editorFontSize': 16}})"); + assert_eq!((s.prefs.general.undo_levels, s.prefs.scripting.editor_font_size), (40, 16)); + // The user grants access; a script's Reset Settings doesn't take it back or change it. + s.execute("prefs.set", json!({"key": "scripting.allowScriptsWriteFiles", "value": true})).unwrap(); + ok(&mut s, "app.run('prefs.reset', {})"); + assert!(s.prefs.scripting.allow_scripts_write_files); + assert_eq!(s.prefs.general.undo_levels, effectcraft_engine::prefs::Prefs::default().general.undo_levels); + // A command script file is a script too. + let dir = std::env::temp_dir().join(format!("ec-script-grant-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let f = dir.join("grant.json"); + std::fs::write(&f, r#"[{"command": "prefs.set", "params": {"key": "scripting.allowScriptsWriteFiles", "value": false}}]"#).unwrap(); + let e = s.execute("file.runScript", json!({"path": f.to_string_lossy()})).unwrap_err().to_string(); + assert!(e.contains("Scripts can't change"), "{e}"); + assert!(s.prefs.scripting.allow_scripts_write_files); + // The user (UI, CLI, control channel) can take it back. + s.execute("prefs.set", json!({"key": "scripting.allowScriptsWriteFiles", "value": false})).unwrap(); + assert!(!s.prefs.scripting.allow_scripts_write_files); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn engine_entry_points() { let mut s = session(); diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 2a2c08e8..2f7311c5 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -94,10 +94,17 @@ impl DirectorySource { dirs.push(h.join(".fonts")); } } + dirs.extend(extra_dirs(std::env::var_os("EFFECTCRAFT_FONT_DIRS").as_deref())); Self { dirs } } } +/// Extra font folders from `EFFECTCRAFT_FONT_DIRS` (a path list, `:`-separated, `;` on Windows), so a +/// tool can hand EffectCraft its own font library without installing the fonts system-wide. +fn extra_dirs(var: Option<&std::ffi::OsStr>) -> Vec { + var.map(|v| std::env::split_paths(v).filter(|p| !p.as_os_str().is_empty()).collect()).unwrap_or_default() +} + fn walk(dir: &std::path::Path, depth: u32, out: &mut Vec) { let Ok(rd) = std::fs::read_dir(dir) else { return }; for e in rd.flatten() { @@ -119,6 +126,7 @@ impl FontSource for DirectorySource { walk(d, 0, &mut files); } files.sort(); + files.dedup(); let mut out = Vec::new(); for f in files { let Ok(mut file) = std::fs::File::open(&f) else { continue }; @@ -824,6 +832,14 @@ fn system_fallback(c: char, style: &str) -> Option { mod tests { use super::*; + #[test] + fn extra_font_dirs_come_from_a_path_list() { + assert!(extra_dirs(None).is_empty()); + let v = std::env::join_paths(["/a/fonts", "/b/more"]).unwrap_or_default(); + assert_eq!(extra_dirs(Some(&v)), vec![PathBuf::from("/a/fonts"), PathBuf::from("/b/more")]); + assert!(extra_dirs(Some(std::ffi::OsStr::new(""))).is_empty()); + } + #[test] fn bundled_families_and_resolution() { let fams = families(); diff --git a/crates/text/src/path_text.rs b/crates/text/src/path_text.rs index 5c2b4338..85cab071 100644 --- a/crates/text/src/path_text.rs +++ b/crates/text/src/path_text.rs @@ -32,10 +32,8 @@ impl PathMeasure { pts.push(p); } } - PathEl::LineTo(p) => { - if pts.last().is_none_or(|q| q.distance(p) > 1e-9) { - pts.push(p); - } + PathEl::LineTo(p) if pts.last().is_none_or(|q| q.distance(p) > 1e-9) => { + pts.push(p); } PathEl::ClosePath => { closed = true; diff --git a/crates/ui-egui/src/color_picker.rs b/crates/ui-egui/src/color_picker.rs new file mode 100644 index 00000000..cd24dbc0 --- /dev/null +++ b/crates/ui-egui/src/color_picker.rs @@ -0,0 +1,411 @@ +//! The colour picker every colour input opens, laid out like After Effects' Color Picker: a +//! square and a slider for the channel chosen with the H / S / B / R / G / B buttons, the new +//! colour over the original, the HSB and RGB values, the hex code, and OK / Cancel. +//! +//! Colours are sRGB components (0–1), as the project stores them. Changes apply as they are made +//! (After Effects' Preview); Cancel or Escape goes back to the colour the picker opened with. + +use egui::{Color32, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; + +use crate::i18n::tr; +use crate::widgets::{open_popup, popup_is_open, pressed_outside, select_all}; + +/// A colour button for egui-laid-out rows and dialogs: a swatch that opens [`color_popup`]. Don't +/// use egui's `color_edit_button_rgb(a)` for project colours: they read linear values, so the +/// swatch shows lighter and a picked colour is stored darker. The value is only rewritten when the +/// user picks a colour. +pub fn srgb_color_button(ui: &mut Ui, c: &mut [f32; 3]) -> Response { + let (rect, mut resp) = ui.allocate_exact_size(ui.spacing().interact_size, Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ColorButton, ui.is_enabled(), srgb_hex(*c))); + let stroke = if resp.hovered() { ui.visuals().widgets.hovered.fg_stroke } else { ui.visuals().widgets.inactive.bg_stroke }; + ui.painter().rect_filled(rect, 2.0, srgb32(*c)); + ui.painter().rect_stroke(rect, 2.0, stroke, StrokeKind::Inside); + if resp.clicked() { + open_popup(ui, resp.id); + } + if color_popup(ui, resp.id, rect.left_bottom(), c) { + resp.mark_changed(); + } + resp +} + +/// The colour picker as a popup at `pos`, bound to `c`, opened with [`open_popup`]. Returns true +/// when the colour changed (Cancel, Escape and the original swatch change it back). +pub fn color_popup(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, c: &mut [f32; 3]) -> bool { + if !popup_is_open(ui, id) { + return false; + } + let orig_id = id.with("original"); + let original = ui.data_mut(|d| *d.get_temp_mut_or_insert_with(orig_id, || *c)); + let mut out = Outcome::default(); + let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos + vec2(0.0, 4.0)).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| out = color_picker(ui, id, c, original)); + }); + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + out.close = Some(false); + } + if pressed_outside(ui.ctx(), &area.response) { + out.close.get_or_insert(true); + } + if let Some(keep) = out.close { + if !keep && *c != original { + *c = original; + out.changed = true; + } + ui.data_mut(|d| { + d.insert_temp(id.with("open"), false); + d.remove::<[f32; 3]>(orig_id); + }); + } + out.changed +} + +/// What the picker did this frame. +#[derive(Default)] +struct Outcome { + changed: bool, + /// Close the picker, keeping the colour (OK) or not (Cancel). + close: Option, +} + +/// The channel the slider sets; the square sets the other two of its model. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Chan { + H, + S, + V, + R, + G, + B, +} + +impl Chan { + /// The square's (across, up) channels while `self` is on the slider (After Effects' layout). + fn square(self) -> (Chan, Chan) { + match self { + Chan::H => (Chan::S, Chan::V), + Chan::S => (Chan::H, Chan::V), + Chan::V => (Chan::H, Chan::S), + Chan::R => (Chan::B, Chan::G), + Chan::G => (Chan::B, Chan::R), + Chan::B => (Chan::R, Chan::G), + } + } + + fn index(self) -> usize { + match self { + Chan::H | Chan::R => 0, + Chan::S | Chan::G => 1, + Chan::V | Chan::B => 2, + } + } + + fn is_hsv(self) -> bool { + matches!(self, Chan::H | Chan::S | Chan::V) + } +} + +/// The colour as both HSB and RGB: HSB keeps the hue (and saturation) of greys and black, where +/// RGB has none, so moving off black returns to the hue that was chosen. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Pick { + hsv: [f32; 3], + rgb: [f32; 3], +} + +impl Pick { + fn get(&self, ch: Chan) -> f32 { + let v = if ch.is_hsv() { self.hsv } else { self.rgb }; + v.get(ch.index()).copied().unwrap_or(0.0) + } + + fn set(mut self, ch: Chan, v: f32) -> Pick { + let v = v.clamp(0.0, 1.0); + if ch.is_hsv() { + if let Some(x) = self.hsv.get_mut(ch.index()) { + *x = v; + } + self.rgb = hsv_to_rgb(self.hsv); + } else { + if let Some(x) = self.rgb.get_mut(ch.index()) { + *x = v; + } + self.hsv = keep_hue(rgb_to_hsv(self.rgb), self.hsv); + } + self + } + + /// The colour at (`u` across, `v` up) on the square, or `t` up the slider. + fn at(&self, ch: Chan, u: f32, v: f32) -> Pick { + let (x, y) = ch.square(); + self.set(x, u).set(y, v) + } +} + +const SQUARE: f32 = 200.0; +const SLIDER_W: f32 = 16.0; + +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)) { + Some((hsv, rgb)) if rgb == *c => Pick { hsv, rgb }, + Some((hsv, _)) => Pick { hsv: keep_hue(rgb_to_hsv(*c), hsv), rgb: *c }, + None => Pick { hsv: rgb_to_hsv(*c), rgb: *c }, + }; + // The slider's channel is remembered between pickers, as After Effects does. + let chan_id = egui::Id::new("color-picker-channel"); + let mut chan = ui.data(|d| d.get_temp::(chan_id)).and_then(|i| CHANS.get(i).map(|c| c.0)).unwrap_or(Chan::H); + let before = pick; + let mut out = Outcome::default(); + + ui.horizontal_top(|ui| { + ui.spacing_mut().item_spacing.x = 10.0; + // The square and slider, with OK and Cancel under the square's left edge. + ui.vertical(|ui| { + ui.horizontal_top(|ui| { + square(ui, chan, &mut pick); + slider(ui, chan, &mut pick); + }); + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + if ui.add_sized(vec2(72.0, 20.0), egui::Button::new(tr("OK"))).clicked() { + out.close = Some(true); + } + if ui.add_sized(vec2(72.0, 20.0), egui::Button::new(tr("Cancel"))).clicked() { + out.close = Some(false); + } + }); + }); + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 4.0; + if swatches(ui, pick.rgb, original) { + pick = Pick { hsv: keep_hue(rgb_to_hsv(original), pick.hsv), rgb: original }; + } + ui.add_space(4.0); + let mut field = |ui: &mut Ui, ch: Chan, name: &str, scale: f32, suffix: &str| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + let radio = ui.radio(chan == ch, format!("{name}:")); + // Line the fields up whatever each label's width. + ui.add_space((40.0 - radio.rect.width()).max(0.0)); + if radio.clicked() { + chan = ch; + } + // Rounded as the hex code rounds, and 360° shows as 0°. + let mut v = (pick.get(ch) * scale).round(); + if ch == Chan::H && v >= 360.0 { + v = 0.0; + } + let drag = egui::DragValue::new(&mut v).range(0.0..=scale).fixed_decimals(0).suffix(suffix).speed(scale / 255.0); + if ui.add_sized(vec2(56.0, 18.0), drag).labelled_by(radio.id).changed() { + pick = pick.set(ch, v.round() / scale); + } + }); + }; + field(ui, Chan::H, "H", 360.0, "°"); + field(ui, Chan::S, "S", 100.0, "%"); + field(ui, Chan::V, "B", 100.0, "%"); + ui.add_space(4.0); + field(ui, Chan::R, "R", 255.0, ""); + field(ui, Chan::G, "G", 255.0, ""); + field(ui, Chan::B, "B", 255.0, ""); + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + let mut rgb = pick.rgb; + if hex_field(ui, id.with("hex"), &mut rgb) { + pick = Pick { hsv: keep_hue(rgb_to_hsv(rgb), pick.hsv), rgb }; + } + }); + }); + }); + + if let Some(i) = CHANS.iter().position(|(c, _)| *c == chan) { + ui.data_mut(|d| d.insert_temp(chan_id, i)); + } + if pick != before && pick.rgb != *c { + *c = pick.rgb; + out.changed = true; + } + ui.data_mut(|d| d.insert_temp(state_id, (pick.hsv, pick.rgb))); + out +} + +/// The channels in the order of their buttons. +const CHANS: [(Chan, &str); 6] = [(Chan::H, "H"), (Chan::S, "S"), (Chan::V, "B"), (Chan::R, "R"), (Chan::G, "G"), (Chan::B, "B")]; + +/// Drag or click on the square to set its two channels. +fn square(ui: &mut Ui, chan: Chan, pick: &mut Pick) { + let (rect, resp) = ui.allocate_exact_size(vec2(SQUARE, SQUARE), Sense::click_and_drag()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Slider, true, "Color field")); + let (x, y) = chan.square(); + if let Some(p) = resp.interact_pointer_pos().filter(|_| resp.is_pointer_button_down_on()) { + *pick = pick.at(chan, (p.x - rect.min.x) / rect.width(), 1.0 - (p.y - rect.min.y) / rect.height()); + } + // Hue changes unevenly across a row, so the HSB squares get a finer grid. + let n: u32 = if chan.is_hsv() { 24 } else { 8 }; + let row = n + 1; + let mut mesh = egui::Mesh::default(); + for yi in 0..=n { + for xi in 0..=n { + let (u, v) = (xi as f32 / n as f32, yi as f32 / n as f32); + mesh.colored_vertex(rect.lerp_inside(vec2(u, v)), srgb32(pick.at(chan, u, 1.0 - v).rgb)); + if xi < n && yi < n { + let k = yi * row + xi; + mesh.add_triangle(k, k + 1, k + row); + mesh.add_triangle(k + 1, k + row + 1, k + row); + } + } + } + ui.painter().add(mesh); + let at = rect.lerp_inside(vec2(pick.get(x), 1.0 - pick.get(y))); + let light = pick.rgb[0] * 0.3 + pick.rgb[1] * 0.59 + pick.rgb[2] * 0.11 > 0.6; + ui.painter().circle_stroke(at, 5.0, Stroke::new(1.5, if light { Color32::BLACK } else { Color32::WHITE })); +} + +/// Drag or click on the slider to set `chan`; arrows on both sides mark its value. +fn slider(ui: &mut Ui, chan: Chan, pick: &mut Pick) { + let (outer, resp) = ui.allocate_exact_size(vec2(SLIDER_W + 12.0, SQUARE), Sense::click_and_drag()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Slider, true, "Color slider")); + let bar = Rect::from_center_size(outer.center(), vec2(SLIDER_W, SQUARE)); + if let Some(p) = resp.interact_pointer_pos().filter(|_| resp.is_pointer_button_down_on()) { + *pick = pick.set(chan, 1.0 - (p.y - bar.min.y) / bar.height()); + } + let n: u32 = if chan == Chan::H { 36 } else { 16 }; + let mut mesh = egui::Mesh::default(); + for i in 0..=n { + let t = i as f32 / n as f32; + // The hue slider shows pure hues; the others vary their channel in the current colour. + let col = if chan == Chan::H { hsv_to_rgb([1.0 - t, 1.0, 1.0]) } else { pick.set(chan, 1.0 - t).rgb }; + let y = bar.min.y + t * bar.height(); + mesh.colored_vertex(pos2(bar.min.x, y), srgb32(col)); + mesh.colored_vertex(pos2(bar.max.x, y), srgb32(col)); + if i < n { + let k = 2 * i; + mesh.add_triangle(k, k + 1, k + 2); + mesh.add_triangle(k + 1, k + 3, k + 2); + } + } + ui.painter().add(mesh); + let y = bar.min.y + (1.0 - pick.get(chan)) * bar.height(); + let fg = ui.visuals().strong_text_color(); + let stroke = Stroke::new(1.2, fg); + for (edge, dir) in [(outer.min.x + 1.0, 1.0), (outer.max.x - 1.0, -1.0)] { + let tip = pos2(edge + dir * 4.0, y); + ui.painter().line_segment([pos2(edge, y - 4.0), tip], stroke); + ui.painter().line_segment([pos2(edge, y + 4.0), tip], stroke); + } +} + +/// The new colour over the original; returns true when the original is clicked (to go back to it). +fn swatches(ui: &mut Ui, new: [f32; 3], original: [f32; 3]) -> bool { + let (rect, _) = ui.allocate_exact_size(vec2(64.0, 64.0), Sense::hover()); + let (top, bottom) = rect.split_top_bottom_at_fraction(0.5); + let new_resp = ui.interact(top, ui.id().with("new-swatch"), Sense::hover()); + new_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, "New color")); + let old = ui.interact(bottom, ui.id().with("original-swatch"), Sense::click()).on_hover_text(tr("Original color: click to go back to it")); + old.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Original color")); + ui.painter().rect_filled(top, 0.0, srgb32(new)); + ui.painter().rect_filled(bottom, 0.0, srgb32(original)); + ui.painter().rect_stroke(rect, 0.0, ui.visuals().widgets.inactive.bg_stroke, StrokeKind::Outside); + old.clicked() +} + +/// `hsv` with `old`'s hue where `hsv` has none (a grey or black), and `old`'s saturation where it +/// has neither (black). +fn keep_hue(mut hsv: [f32; 3], old: [f32; 3]) -> [f32; 3] { + if hsv[1] == 0.0 || hsv[2] == 0.0 { + hsv[0] = old[0]; + } + if hsv[2] == 0.0 { + hsv[1] = old[1]; + } + hsv +} + +/// The hex field. While it has focus it keeps what was typed (`id` holds the text); otherwise it +/// shows the colour. Focusing it selects the code, so typing replaces it; a valid code applies as +/// it is typed. +fn hex_field(ui: &mut Ui, id: egui::Id, c: &mut [f32; 3]) -> bool { + let mut text = ui.data(|d| d.get_temp::(id)).unwrap_or_else(|| srgb_hex(*c).trim_start_matches('#').to_string()); + let mut changed = false; + let label = ui.label("#"); + let resp = ui + .add(egui::TextEdit::singleline(&mut text).id(id.with("edit")).desired_width(62.0).char_limit(7).font(egui::TextStyle::Monospace)) + .labelled_by(label.id); + if resp.gained_focus() { + select_all(ui.ctx(), resp.id, &text); + } + if resp.changed() + && let Some(rgb) = hex_rgb(&text) + { + let v = rgb.map(|b| b as f32 / 255.0); + if v != *c { + *c = v; + changed = true; + } + } + if resp.has_focus() { + ui.data_mut(|d| d.insert_temp(id, text.clone())); + } else { + ui.data_mut(|d| d.remove::(id)); + } + changed +} + +/// An sRGB colour (components 0–1) as an egui colour. +pub fn srgb32(c: [f32; 3]) -> Color32 { + let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + Color32::from_rgb(b(c[0]), b(c[1]), b(c[2])) +} + +/// An sRGB colour (components 0–1) as `#RRGGBB`. +pub fn srgb_hex(c: [f32; 3]) -> String { + let col = srgb32(c); + format!("#{:02X}{:02X}{:02X}", col.r(), col.g(), col.b()) +} + +/// Parse a hex colour: `#RRGGBB`, `RRGGBB`, `#RGB` or `RGB` (any case, surrounding space ignored). +pub fn hex_rgb(s: &str) -> Option<[u8; 3]> { + let h = s.trim().trim_start_matches('#'); + if !h.is_ascii() { + return None; + } + let digit = |i: usize| h.get(i..i + 1).and_then(|d| u8::from_str_radix(d, 16).ok()); + let pair = |i: usize| h.get(i..i + 2).and_then(|d| u8::from_str_radix(d, 16).ok()); + match h.len() { + 3 => Some([digit(0)? * 17, digit(1)? * 17, digit(2)? * 17]), + 6 => Some([pair(0)?, pair(2)?, pair(4)?]), + _ => None, + } +} + +/// sRGB (components 0–1) to hue, saturation and value (0–1). +pub fn rgb_to_hsv([r, g, b]: [f32; 3]) -> [f32; 3] { + let (r, g, b) = (r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0)); + let max = r.max(g).max(b); + let d = max - r.min(g).min(b); + if d <= 0.0 { + return [0.0, 0.0, max]; + } + let h = if max == r { + ((g - b) / d).rem_euclid(6.0) + } else if max == g { + (b - r) / d + 2.0 + } else { + (r - g) / d + 4.0 + }; + [h / 6.0, d / max, max] +} + +/// Hue, saturation and value (0–1) to sRGB (components 0–1). +pub fn hsv_to_rgb([h, s, v]: [f32; 3]) -> [f32; 3] { + let h = h.clamp(0.0, 1.0) * 6.0; + let (s, v) = (s.clamp(0.0, 1.0), v.clamp(0.0, 1.0)); + let f = |n: f32| { + let k = (n + h) % 6.0; + v - v * s * k.min(4.0 - k).clamp(0.0, 1.0) + }; + [f(5.0), f(3.0), f(1.0)] +} diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index f2756938..3e802487 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -180,15 +180,66 @@ impl SplitSize { } } -/// One panel in a [`DockNode::Stack`]. -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +/// One frame in a [`DockNode::Stack`]: a header with one tab per panel (a panel dropped onto a +/// stacked panel joins its frame as a tab, as in After Effects). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(from = "StackEntryRepr")] pub struct StackEntry { - pub panel: PanelKind, + pub panels: Vec, + /// The shown tab. + pub active: usize, pub open: bool, /// Content height when open (set by dragging the gaps); `None` = share the remaining height. pub height: Option, } +/// A saved [`StackEntry`]: workspaces saved before stacked frames held tabs name one `panel`. +#[derive(Deserialize)] +struct StackEntryRepr { + #[serde(default)] + panel: Option, + #[serde(default)] + panels: Vec, + #[serde(default)] + active: usize, + open: bool, + #[serde(default)] + height: Option, +} + +impl From for StackEntry { + fn from(r: StackEntryRepr) -> StackEntry { + let mut panels = r.panels; + if let Some(p) = r.panel + && !panels.contains(&p) + { + panels.insert(0, p); + } + let active = r.active.min(panels.len().saturating_sub(1)); + StackEntry { panels, active, open: r.open, height: r.height } + } +} + +impl StackEntry { + /// A frame showing just `panel`. + pub fn new(panel: PanelKind, open: bool, height: Option) -> StackEntry { + StackEntry { panels: vec![panel], active: 0, open, height } + } + pub fn contains(&self, p: PanelKind) -> bool { + self.panels.contains(&p) + } + /// The frame's shown panel. + pub fn shown(&self) -> Option { + self.panels.get(self.active).or(self.panels.first()).copied() + } + /// Show `p` (one of its tabs). + fn show(&mut self, p: PanelKind) { + if let Some(i) = self.panels.iter().position(|x| *x == p) { + self.active = i; + } + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum DockNode { /// `vertical`: children stacked top/bottom; else side by side. @@ -214,7 +265,7 @@ fn tabs(p: &[PanelKind], active: usize) -> DockNode { DockNode::Tabs { panels: p.to_vec(), active } } fn stack(e: &[(PanelKind, bool, Option)]) -> DockNode { - DockNode::Stack { entries: e.iter().map(|&(panel, open, height)| StackEntry { panel, open, height }).collect() } + DockNode::Stack { entries: e.iter().map(|&(panel, open, height)| StackEntry::new(panel, open, height)).collect() } } fn hsplit(size: SplitSize, a: DockNode, b: DockNode) -> DockNode { DockNode::Split { vertical: false, size, a: Box::new(a), b: Box::new(b) } @@ -413,7 +464,7 @@ impl DockNode { b.panels(out); } DockNode::Tabs { panels, .. } => out.extend(panels.iter().copied()), - DockNode::Stack { entries } => out.extend(entries.iter().map(|e| e.panel)), + DockNode::Stack { entries } => out.extend(entries.iter().flat_map(|e| e.panels.iter().copied())), } } pub fn contains(&self, p: PanelKind) -> bool { @@ -423,49 +474,139 @@ impl DockNode { } /// Make `p` the active tab of its group. Returns false if not present. pub fn activate(&mut self, p: PanelKind) -> bool { - crate::dock_shared::apply_tree(self, craft_ui::docking::Action::Activate { panel: p }) - } - /// Expand or collapse a stacked panel. Returns false if `p` is not in a stack. - pub fn toggle_stacked(&mut self, p: PanelKind) -> bool { - if crate::dock_shared::encode(self, &[]).is_err() { + if !crate::dock_shared::valid_tree(self) { return false; } - let open = !self.is_visible(p); - crate::dock_shared::apply_tree(self, craft_ui::docking::Action::SetStackOpen { panel: p, open }) + match self { + DockNode::Split { a, b, .. } => a.activate(p) || b.activate(p), + DockNode::Tabs { panels, active } => { + if let Some(i) = panels.iter().position(|x| *x == p) { + *active = i; + true + } else { + false + } + } + DockNode::Stack { entries } => match entries.iter_mut().find(|e| e.contains(p)) { + Some(e) => { + e.show(p); + e.open = true; + true + } + None => false, + }, + } + } + /// Expand or collapse a stacked panel's frame (showing `p`). Returns false if `p` is not in + /// a stack. + pub fn toggle_stacked(&mut self, p: PanelKind) -> bool { + if !crate::dock_shared::valid_tree(self) { + return false; + } + match self { + DockNode::Split { a, b, .. } => a.toggle_stacked(p) || b.toggle_stacked(p), + DockNode::Tabs { .. } => false, + DockNode::Stack { entries } => match entries.iter_mut().find(|e| e.contains(p)) { + Some(e) => { + e.show(p); + e.open = !e.open; + true + } + None => false, + }, + } } pub fn is_visible(&self, p: PanelKind) -> bool { match self { DockNode::Split { a, b, .. } => a.is_visible(p) || b.is_visible(p), DockNode::Tabs { panels, active } => panels.get(*active) == Some(&p), - DockNode::Stack { entries } => entries.iter().any(|e| e.panel == p && e.open), + DockNode::Stack { entries } => entries.iter().any(|e| e.open && e.shown() == Some(p)), } } /// Close a panel (remove its tab; empty groups collapse their split). pub fn close(&mut self, p: PanelKind) { - crate::dock_shared::close_tree(self, p); + if !crate::dock_shared::valid_for_close(self) { + return; + } + if let DockNode::Split { a, b, .. } = self { + a.close(p); + b.close(p); + let empty = |n: &DockNode| match n { + DockNode::Tabs { panels, .. } => panels.is_empty(), + DockNode::Stack { entries } => entries.is_empty(), + DockNode::Split { .. } => false, + }; + if empty(a) { + *self = (**b).clone(); + } else if empty(b) { + *self = (**a).clone(); + } + } else if let DockNode::Tabs { panels, active } = self { + let shown = panels.get(*active).copied(); + panels.retain(|x| *x != p); + *active = shown.and_then(|shown| panels.iter().position(|x| *x == shown)).unwrap_or((*active).min(panels.len().saturating_sub(1))); + } else if let DockNode::Stack { entries } = self { + for e in entries.iter_mut() { + let shown = e.shown(); + e.panels.retain(|x| *x != p); + e.active = shown.and_then(|shown| e.panels.iter().position(|x| *x == shown)).unwrap_or(e.active.min(e.panels.len().saturating_sub(1))); + } + entries.retain(|e| !e.panels.is_empty()); + } } /// Add a panel as a tab next to `near` (or into the first group). pub fn open_near(&mut self, p: PanelKind, near: PanelKind) { - if crate::dock_shared::encode(self, &[]).is_err() { + if !crate::dock_shared::valid_tree(self) { return; } - let anchor = [near, PanelKind::EffectsPresets, PanelKind::Project].into_iter().find(|panel| self.contains(*panel)); - fn in_stack(node: &DockNode, panel: PanelKind) -> bool { - match node { - DockNode::Split { a, b, .. } => in_stack(a, panel) || in_stack(b, panel), - DockNode::Stack { entries } => entries.iter().any(|entry| entry.panel == panel), - DockNode::Tabs { .. } => false, + if self.contains(p) { + self.activate(p); + return; + } + fn add(n: &mut DockNode, p: PanelKind, near: PanelKind) -> bool { + match n { + DockNode::Split { a, b, .. } => add(a, p, near) || add(b, p, near), + DockNode::Tabs { panels, active } => { + if panels.contains(&near) { + panels.push(p); + *active = panels.len() - 1; + true + } else { + false + } + } + DockNode::Stack { entries } => match entries.iter().position(|e| e.contains(near)) { + Some(i) => { + entries.insert(i + 1, StackEntry::new(p, true, None)); + true + } + None => false, + }, } } - // Accordion entries open immediately after their anchor; ordinary tab groups append. - let action = match anchor { - Some(anchor) if !self.contains(p) && in_stack(self, anchor) => { - craft_ui::docking::Action::OpenAt { panel: p, anchor, placement: craft_ui::docking::Placement::Split(craft_ui::docking::Zone::Center) } + fn add_first(n: &mut DockNode, p: PanelKind) -> bool { + match n { + DockNode::Split { a, b, .. } => add_first(a, p) || add_first(b, p), + DockNode::Tabs { panels, active } if !panels.is_empty() => { + panels.push(p); + *active = panels.len() - 1; + true + } + DockNode::Stack { entries } if !entries.is_empty() => { + entries.push(StackEntry::new(p, true, None)); + true + } + _ => false, } - _ => craft_ui::docking::Action::Open { panel: p, anchor }, - }; - crate::dock_shared::apply_tree(self, action); + } + if self.panel_count() == 0 { + // Every panel was closed: there is no anchor, so the panel becomes the whole dock. + *self = tabs(&[p], 0); + } else if !add(self, p, near) && !add(self, p, PanelKind::EffectsPresets) && !add(self, p, PanelKind::Project) { + // None of the anchors is docked (e.g. Minimal with Timeline closed): use the first group. + add_first(self, p); + } } } @@ -531,16 +672,99 @@ pub struct Floating { impl DockNode { /// Insert panel `p` relative to the group that holds `anchor`: as a tab next to it - /// (Center) or in a new group split off to one side. `p` is removed from its old place - /// first. Returns false (tree unchanged) if `anchor` isn't docked or is `p` itself alone. + /// (Center) or in a new group split off to one side. In a stack, the group is `anchor`'s + /// frame: Center adds a tab to that frame and Top / Bottom add a frame above or below it + /// (#302); Left / Right split the column. `p` is removed from its old place first. Returns + /// false (tree unchanged) if `anchor` isn't docked or is `p` itself alone. pub fn dock_panel(&mut self, p: PanelKind, anchor: PanelKind, zone: Zone) -> bool { - crate::dock_shared::move_in_tree(self, p, anchor, craft_ui::docking::Placement::Split(zone.shared())) + if !crate::dock_shared::valid_tree(self) || p == anchor || !self.contains(anchor) { + return false; + } + let before = self.clone(); + self.close(p); + fn rec(n: &mut DockNode, p: PanelKind, anchor: PanelKind, zone: Zone) -> bool { + let here = match n { + DockNode::Split { a, b, .. } => return rec(a, p, anchor, zone) || rec(b, p, anchor, zone), + DockNode::Tabs { panels, .. } => panels.contains(&anchor), + DockNode::Stack { entries } => entries.iter().any(|e| e.contains(anchor)), + }; + if !here { + return false; + } + match (zone, &mut *n) { + (Zone::Center, DockNode::Tabs { panels, active }) => { + let i = panels.iter().position(|x| *x == anchor).map_or(panels.len(), |i| i + 1); + panels.insert(i, p); + *active = i; + } + (Zone::Center, DockNode::Stack { entries }) => { + let Some(e) = entries.iter_mut().find(|e| e.contains(anchor)) else { return false }; + let i = e.panels.iter().position(|x| *x == anchor).map_or(e.panels.len(), |i| i + 1); + e.panels.insert(i, p); + e.active = i; + e.open = true; + } + (Zone::Top | Zone::Bottom, DockNode::Stack { entries }) => { + let Some(i) = entries.iter().position(|e| e.contains(anchor)) else { return false }; + let at = if zone == Zone::Top { i } else { i + 1 }; + entries.insert(at, StackEntry::new(p, true, None)); + } + (z, node) => { + let new = tabs(&[p], 0); + let old = std::mem::replace(node, tabs(&[], 0)); + let vertical = matches!(z, Zone::Top | Zone::Bottom); + let (a, b) = if matches!(z, Zone::Left | Zone::Top) { (new, old) } else { (old, new) }; + *node = DockNode::Split { vertical, size: SplitSize::Ratio(0.5), a: Box::new(a), b: Box::new(b) }; + } + } + true + } + if rec(self, p, anchor, zone) { + true + } else { + *self = before; + false + } } /// Move panel `p` into the group that holds `anchor` as a tab before `before` (one of that /// group's panels) or last, and show it. False (tree unchanged) if `anchor` isn't docked. pub fn insert_tab(&mut self, p: PanelKind, anchor: PanelKind, before: Option) -> bool { - crate::dock_shared::move_in_tree(self, p, anchor, craft_ui::docking::Placement::Tab { before }) + if !crate::dock_shared::valid_tree(self) { + return false; + } + if p == anchor || !self.contains(anchor) { + return false; + } + let saved = self.clone(); + self.close(p); + fn rec(n: &mut DockNode, p: PanelKind, anchor: PanelKind, before: Option) -> bool { + match n { + DockNode::Split { a, b, .. } => rec(a, p, anchor, before) || rec(b, p, anchor, before), + DockNode::Tabs { panels, active } if panels.contains(&anchor) => { + let i = before.and_then(|b| panels.iter().position(|x| *x == b)).unwrap_or(panels.len()); + panels.insert(i, p); + *active = i; + true + } + DockNode::Stack { entries } => { + // A tab of the anchor's frame. + let Some(e) = entries.iter_mut().find(|e| e.contains(anchor)) else { return false }; + let i = before.and_then(|b| e.panels.iter().position(|x| *x == b)).unwrap_or(e.panels.len()); + e.panels.insert(i, p); + e.active = i; + e.open = true; + true + } + _ => false, + } + } + if rec(self, p, anchor, before) { + true + } else { + *self = saved; + false + } } /// Path of the group containing `p` (as in [`Group::path`]; stacks add `sN`). @@ -549,7 +773,7 @@ impl DockNode { match n { DockNode::Split { a, b, .. } => rec(a, p, &format!("{path}a")).or_else(|| rec(b, p, &format!("{path}b"))), DockNode::Tabs { panels, .. } => panels.contains(&p).then(|| path.to_string()), - DockNode::Stack { entries } => entries.iter().position(|e| e.panel == p).map(|i| format!("{path}s{i}")), + DockNode::Stack { entries } => entries.iter().position(|e| e.contains(p)).map(|i| format!("{path}s{i}")), } } rec(self, p, "") @@ -598,11 +822,17 @@ impl Layout { /// Move or reorder a docked/floating tab through the shared transaction. pub fn insert_tab(&mut self, p: PanelKind, anchor: PanelKind, before: Option) -> bool { + if !crate::dock_shared::valid_tree(&self.root) { + return false; + } self.apply(craft_ui::docking::Action::Move { panel: p, anchor, placement: craft_ui::docking::Placement::Tab { before } }).is_ok() } /// Dock a panel beside an anchor; floating destinations accept tabs only. pub fn dock(&mut self, p: PanelKind, anchor: PanelKind, zone: Zone) -> bool { + if !crate::dock_shared::valid_tree(&self.root) { + return false; + } self.apply(craft_ui::docking::Action::Move { panel: p, anchor, placement: craft_ui::docking::Placement::Split(zone.shared()) }).is_ok() } @@ -741,15 +971,17 @@ fn layout_inner( drag = Some((above, below, d)); } } - out.push(Group { path: group, rect: r, content, panels: vec![e.panel], active: 0, stacked: Some(e.open) }); + out.push(Group { path: group, rect: r, content, panels: e.panels.clone(), active: e.active, stacked: Some(e.open) }); y = r.max.y + g; } if let Some((above, below, d)) = drag { let mut proposed = entries.clone(); resize_stacked(&mut proposed, &bodies, above, below, d); for (before, after) in entries.iter().zip(proposed) { - if before.height != after.height { - actions.push(craft_ui::docking::Action::ResizeStack { panel: after.panel, height: after.height }); + if before.height != after.height + && let Some(panel) = after.shown() + { + actions.push(craft_ui::docking::Action::ResizeStack { panel, height: after.height }); } } } @@ -830,6 +1062,29 @@ fn stack_bodies(entries: &[StackEntry], avail: f32) -> Vec { .collect() } +/// `v` held to `lo..=hi`, never panicking: when the room is too small for both minimums +/// (`hi < lo`), `lo` wins; a non-finite `v` gives `lo`. +#[cfg(test)] +fn clamp_ordered(v: f32, lo: f32, hi: f32) -> f32 { + let lo = if lo.is_finite() { lo } else { 0.0 }; + let hi = if hi.is_finite() { hi.max(lo) } else { lo }; + if v.is_finite() { v.max(lo).min(hi) } else { lo } +} + +/// The size of a split's first side in `avail` points (both sides keep 20 pt when there is room). +/// `f32::clamp` panics when its bounds cross, which a split shorter than 40 pt did (#306). +#[cfg(test)] +fn split_first(size: SplitSize, avail: f32) -> f32 { + let avail = if avail.is_finite() { avail.max(0.0) } else { 0.0 }; + let first = match size { + SplitSize::Ratio(r) => avail * r, + SplitSize::FixedA(px) => px.min(avail - 20.0), + SplitSize::FixedB(px) => avail - px.min(avail - 20.0), + }; + let lo = 20.0_f32.min(avail); + clamp_ordered(first, lo, avail - 20.0).min(avail) +} + /// Drag the gap between the open stacked panels `above` and `below` (laid out at `bodies`) by /// `d` points: one grows as much as the other shrinks, neither below [`STACK_MIN`]. The new sizes /// become the panels' heights, except that the last panel without one keeps sharing the rest @@ -1020,7 +1275,9 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, t: &Tokens, reg: &mut cra } crate::widgets::focus_outline(ui, &resp, t); if resp.clicked() { - if g.stacked.is_some() { + // A stacked frame's shown tab expands / collapses it; another tab shows that + // panel (expanding the frame). + if g.stacked.is_some() && (k == ai || entries.len() == 1) { out.actions.push(DockAction::ToggleStacked(*p)); } else { out.actions.push(activate(e)); @@ -1032,6 +1289,21 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, t: &Tokens, reg: &mut cra } x += w + 8.0; } + // A stacked panel's whole header bar expands/collapses it, not just its name (as in After + // Effects): the frame with its shown tab. + if g.stacked.is_some() + && let Some(e) = entries.get(ai).or(entries.first()) + { + let rest = Rect::from_min_max(pos2((x - 8.0).max(strip.min.x), strip.min.y), strip.max); + if rest.width() > 0.0 { + let resp = ui.interact(rest, egui::Id::new(("tab-rest", g.path.clone())), Sense::click()); + reg.add(&format!("panel.header.{}", e.panel.id()), rest, &e.label); + if resp.clicked() { + out.actions.push(DockAction::ToggleStacked(e.panel)); + out.actions.push(DockAction::Focus(e.panel)); + } + } + } } // clicking anywhere in the panel focuses it if let Some(p) = active_panel @@ -1078,6 +1350,24 @@ mod tests { } } + #[test] + fn open_near_in_an_empty_dock_makes_the_panel_the_dock() { + for p in [PanelKind::Composition, PanelKind::Timeline, PanelKind::Project] { + let mut d = tabs(&[], 0); + d.open_near(p, PanelKind::EffectsPresets); + assert!(d.is_visible(p)); + assert_eq!(d.panel_count(), 1); + } + } + + #[test] + fn open_near_without_any_anchor_uses_the_first_group() { + let mut d = tabs(&[PanelKind::Composition], 0); + d.open_near(PanelKind::Timeline, PanelKind::EffectsPresets); + assert!(d.is_visible(PanelKind::Timeline)); + assert_eq!(d.panel_count(), 2); + } + #[test] fn stacked_panels_toggle_open_close() { use PanelKind::*; @@ -1098,7 +1388,7 @@ mod tests { } fn entry(panel: PanelKind, open: bool, height: Option) -> StackEntry { - StackEntry { panel, open, height } + StackEntry::new(panel, open, height) } fn close_to(a: &[f32], b: &[f32]) -> bool { @@ -1225,6 +1515,38 @@ mod tests { } } + #[test] + fn short_splits_lay_out_without_panicking() { + // #306: a split 0 < avail < 40 pt tall made `f32::clamp` panic (its bounds crossed). + for avail in [0.0, 5.0, 19.0, 20.0, 30.0, 39.0, 40.0, 41.0, -10.0, f32::NAN, f32::INFINITY] { + for size in [SplitSize::Ratio(0.5), SplitSize::Ratio(f32::NAN), SplitSize::FixedA(300.0), SplitSize::FixedB(300.0), SplitSize::FixedA(-5.0)] { + let f = split_first(size, avail); + assert!(f.is_finite() && f >= 0.0, "{size:?} {avail}: {f}"); + if avail.is_finite() && avail > 0.0 { + assert!(f <= avail, "{size:?} {avail}: {f}"); + } + } + } + assert_eq!(split_first(SplitSize::FixedA(300.0), 30.0), 20.0); + assert_eq!(split_first(SplitSize::Ratio(0.5), 200.0), 100.0); + assert_eq!(clamp_ordered(5.0, 40.0, -4.0), 40.0); + // Every workspace, laid out in rects too short and too narrow for its minimums. + let ctx = egui::Context::default(); + let t = Tokens::for_kind(crate::theme::ThemeKind::Dark); + for w in WORKSPACES { + for (wd, ht) in [(30.0, 30.0), (800.0, 36.0), (800.0, 45.0), (36.0, 600.0), (1.0, 1.0), (0.0, 0.0)] { + let mut node = workspace(w); + let mut frame = ctx.run_ui(egui::RawInput::default(), |ui| { + let mut out = vec![]; + let mut reg = crate::automation::Registry::default(); + layout(ui, &mut node, Rect::from_min_size(pos2(0.0, 0.0), vec2(wd, ht)), &t, "", &mut out, &mut reg); + assert!(!out.is_empty(), "{w}"); + }); + frame.textures_delta.clear(); + } + } + } + #[test] fn drop_zone_math() { let r = Rect::from_min_size(pos2(100.0, 100.0), vec2(400.0, 300.0)); @@ -1268,16 +1590,42 @@ mod tests { assert!(!d.dock_panel(Audio, Audio, Zone::Left)); assert!(!d.dock_panel(Audio, Tracker, Zone::Left)); assert_eq!(d, before); - // Into a stack. + // Into a stack: a tab of the anchor's frame (#302). let mut s = workspace("Default"); + let props = s.path_of(Properties).unwrap(); assert!(s.dock_panel(Tracker, Properties, Zone::Center)); - assert!(s.path_of(Tracker).unwrap().contains('s')); + assert_eq!(s.path_of(Tracker), Some(props.clone())); + assert!(s.is_visible(Tracker) && !s.is_visible(Properties)); + assert!(s.activate(Properties) && s.is_visible(Properties)); + // Top / Bottom: a frame of its own above / below the anchor's, not a split column. + assert!(s.dock_panel(Info, Properties, Zone::Bottom)); + let below = s.path_of(Info).unwrap(); + assert_ne!(below, props); + assert_eq!(s.path_of(Properties), Some(props.clone())); + assert!(s.dock_panel(Info, Properties, Zone::Top)); + assert_eq!(s.path_of(Info), Some(props.clone()), "above Properties: its frame's index"); + // Closing a tab keeps the frame; closing the frame's last tab removes it. + s.close(Tracker); + assert!(s.contains(Properties) && !s.contains(Tracker)); + // A tab dragged onto a stacked frame's tab strip joins that frame. + let mut t = workspace("Default"); + assert!(t.insert_tab(Info, Preview, None)); + assert_eq!(t.path_of(Info), t.path_of(Preview)); // Moving the only panel of a group collapses its split. let mut m = hsplit(SplitSize::Ratio(0.5), tabs(&[Project], 0), tabs(&[Composition], 0)); assert!(m.dock_panel(Project, Composition, Zone::Center)); assert_eq!(m, tabs(&[Composition, Project], 1)); } + /// #302: workspaces saved when each stacked frame held one `panel` still load. + #[test] + fn old_stack_entries_load() { + let e: StackEntry = serde_json::from_str(r#"{"panel":"Preview","open":true,"height":46.0}"#).unwrap(); + assert_eq!(e, StackEntry::new(PanelKind::Preview, true, Some(46.0))); + let s = serde_json::to_string(&workspace("Default")).unwrap(); + assert_eq!(serde_json::from_str::(&s).unwrap(), workspace("Default")); + } + #[test] fn floating_layout_round_trips() { use PanelKind::*; @@ -1316,6 +1664,27 @@ mod tests { assert_eq!(PanelKind::from_name("effects & presets"), Some(PanelKind::EffectsPresets)); } + /// Ported from #630 (Ni-zav): the reporter's reproduction on the Minimal workspace. + #[test] + fn window_panels_reopen_after_last_panel_is_closed() { + use PanelKind::*; + for reopen in [Composition, Timeline, Project] { + let mut d = workspace("Minimal"); + d.close(Composition); + d.close(Timeline); + assert_eq!(d.panel_count(), 0); + d.open_near(reopen, EffectsPresets); + assert_eq!(d.panel_count(), 1); + assert!(d.is_visible(reopen), "{reopen:?} must be visible"); + } + let mut d = tabs(&[Audio], 0); + d.open_near(History, Project); + assert_eq!(d, tabs(&[Audio, History], 1)); + let mut d = workspace("Minimal"); + d.open_near(EffectsPresets, Timeline); + assert_eq!(d.path_of(EffectsPresets), d.path_of(Timeline)); + } + #[test] fn closing_earlier_inactive_tab_keeps_the_shown_panel() { use PanelKind::*; diff --git a/crates/ui-egui/src/dock_shared.rs b/crates/ui-egui/src/dock_shared.rs index 4370bd61..4f090e21 100644 --- a/crates/ui-egui/src/dock_shared.rs +++ b/crates/ui-egui/src/dock_shared.rs @@ -25,7 +25,15 @@ fn encode_node(node: &DockNode, depth: usize, count: &mut usize) -> Result MAX_PANELS { return Err("too many workspace stack entries".into()); } - Node::Stack { entries: entries.iter().map(|entry| SharedStackEntry { panel: entry.panel, open: entry.open, height: entry.height }).collect() } + if entries.iter().any(|entry| entry.panels.is_empty() || entry.active >= entry.panels.len() || entry.panels.len() > MAX_PANELS) { + return Err("invalid stacked tab selection".into()); + } + Node::Stack { + entries: entries + .iter() + .flat_map(|entry| entry.panels.iter().map(move |panel| SharedStackEntry { panel: *panel, open: entry.open, height: entry.height })) + .collect(), + } } DockNode::Split { vertical, size, a, b } => Node::Split { axis: if *vertical { SplitAxis::Vertical } else { SplitAxis::Horizontal }, @@ -44,7 +52,7 @@ fn decode_node(node: Node) -> DockNode { match node { Node::Tabs { panels, active } => DockNode::Tabs { panels, active }, Node::Stack { entries } => { - DockNode::Stack { entries: entries.into_iter().map(|entry| StackEntry { panel: entry.panel, open: entry.open, height: entry.height }).collect() } + DockNode::Stack { entries: entries.into_iter().map(|entry| StackEntry::new(entry.panel, entry.open, entry.height)).collect() } } Node::Split { axis, size, first, second } => DockNode::Split { vertical: axis == SplitAxis::Vertical, @@ -77,75 +85,174 @@ pub(crate) fn decode(layout: SharedLayout) -> Layout { } } +/// Restore app-owned accordion tab frames after shared validation/transactions. +/// The shared model represents individual stack panels; grouping stays in the persisted app schema. +fn restore_frames(node: &mut DockNode, original: &DockNode) { + fn collect(node: &DockNode, out: &mut Vec) { + match node { + DockNode::Split { a, b, .. } => { + collect(a, out); + collect(b, out); + } + DockNode::Stack { entries } => out.extend(entries.iter().cloned()), + DockNode::Tabs { .. } => {} + } + } + fn restore(node: &mut DockNode, frames: &[StackEntry]) { + match node { + DockNode::Split { a, b, .. } => { + restore(a, frames); + restore(b, frames); + } + DockNode::Stack { entries } => { + let mut result: Vec = Vec::new(); + for entry in std::mem::take(entries) { + let Some(panel) = entry.shown() else { continue }; + let old = frames.iter().find(|frame| frame.contains(panel)); + if let Some(last) = result.last_mut() + && old.is_some_and(|frame| last.panels.iter().all(|p| frame.contains(*p))) + { + last.panels.push(panel); + if old.and_then(StackEntry::shown) == Some(panel) { + last.active = last.panels.len().saturating_sub(1); + } + } else { + result.push(entry); + } + } + for entry in &mut result { + if let Some(old) = frames.iter().find(|frame| entry.panels.iter().all(|panel| frame.contains(*panel))) { + entry.active = old + .shown() + .and_then(|shown| entry.panels.iter().position(|panel| *panel == shown)) + .unwrap_or(old.active.min(entry.panels.len().saturating_sub(1))); + } + } + *entries = result; + } + DockNode::Tabs { .. } => {} + } + } + let mut frames = Vec::new(); + collect(original, &mut frames); + restore(node, &frames); +} + pub(crate) fn apply(layout: &mut Layout, action: Action) -> Result<(), String> { let mut shared = encode(&layout.root, &layout.floating)?; - shared.apply(action).map_err(|error| error.to_string())?; - let proposed = decode(shared); - encode(&proposed.root, &proposed.floating)?; + // Validate the same shared command first. Native stack insertion then retains tabs inside + // their accordion frame, including the existing before/after insertion policy. + shared.apply(action.clone()).map_err(|error| error.to_string())?; + let mut proposed = decode(shared); + restore_frames(&mut proposed.root, &layout.root); + match action { + Action::Move { panel, anchor, placement } | Action::OpenAt { panel, anchor, placement } if layout.root.contains(anchor) => { + fn stacked(node: &DockNode, p: PanelKind) -> bool { + match node { + DockNode::Split { a, b, .. } => stacked(a, p) || stacked(b, p), + DockNode::Stack { entries } => entries.iter().any(|e| e.contains(p)), + _ => false, + } + } + if stacked(&layout.root, anchor) { + let mut native = layout.clone(); + native.unfloat(panel); + let applied = match placement { + Placement::Tab { before } => native.root.insert_tab(panel, anchor, before), + Placement::Split(zone) => native.root.dock_panel( + panel, + anchor, + match zone { + craft_ui::docking::Zone::Left => crate::dock::Zone::Left, + craft_ui::docking::Zone::Right => crate::dock::Zone::Right, + craft_ui::docking::Zone::Top => crate::dock::Zone::Top, + craft_ui::docking::Zone::Bottom => crate::dock::Zone::Bottom, + craft_ui::docking::Zone::Center => crate::dock::Zone::Center, + }, + ), + }; + if !applied { + return Err("stacked panel move failed".into()); + } + proposed = native; + } + } + Action::Activate { panel } => { + proposed.root.activate(panel); + } + Action::SetStackOpen { panel, open } => { + fn set(node: &mut DockNode, p: PanelKind, open: bool) { + match node { + DockNode::Split { a, b, .. } => { + set(a, p, open); + set(b, p, open); + } + DockNode::Stack { entries } => { + if let Some(e) = entries.iter_mut().find(|e| e.contains(p)) { + e.open = open; + } + } + _ => {} + } + } + set(&mut proposed.root, panel, open); + } + Action::ResizeStack { panel, height } => { + fn set(node: &mut DockNode, p: PanelKind, h: Option) { + match node { + DockNode::Split { a, b, .. } => { + set(a, p, h); + set(b, p, h); + } + DockNode::Stack { entries } => { + if let Some(e) = entries.iter_mut().find(|e| e.contains(p)) { + e.height = h; + } + } + _ => {} + } + } + set(&mut proposed.root, panel, height); + } + _ => {} + } + encode(&proposed.root, &proposed.floating)?.validate().map_err(|error| error.to_string())?; *layout = proposed; Ok(()) } pub(crate) fn apply_tree(root: &mut DockNode, action: Action) -> bool { - let Ok(mut shared) = encode(root, &[]) else { return false }; - if shared.apply(action).is_err() { + if !valid_tree(root) { return false; } - let proposed = decode(shared).root; - if encode(&proposed, &[]).is_err() { + let mut layout = Layout { root: root.clone(), floating: vec![] }; + if apply(&mut layout, action).is_err() { return false; } - *root = proposed; + *root = layout.root; true } -pub(crate) fn move_in_tree(root: &mut DockNode, panel: PanelKind, anchor: PanelKind, placement: Placement) -> bool { - let Ok(mut shared) = encode(root, &[]) else { return false }; - if panel == anchor || !root.contains(anchor) { - return false; - } - // This legacy entry point also opens new viewer/ScriptUI panels. Keep that explicit, - // separate from Move, and commit only once both shared transactions have succeeded. - if !root.contains(panel) && shared.apply(Action::Open { panel, anchor: Some(anchor) }).is_err() { - return false; - } - if shared.apply(Action::Move { panel, anchor, placement }).is_err() { - return false; - } - let proposed = decode(shared).root; - if encode(&proposed, &[]).is_err() { - return false; - } - *root = proposed; - true +pub(crate) fn valid_tree(root: &DockNode) -> bool { + encode(root, &[]).is_ok_and(|shared| shared.validate().is_ok()) } -// Legacy close methods repair selection indices. Do that on a bounded encoded copy, -// then retain shared validation for geometry, duplicates and all other invariants. -fn normalize_selection(node: &mut Node) { - match node { - Node::Tabs { panels, active } => *active = (*active).min(panels.len().saturating_sub(1)), - Node::Split { first, second, .. } => { - normalize_selection(first); - normalize_selection(second); +pub(crate) fn valid_for_close(root: &DockNode) -> bool { + fn normalize(node: &mut Node) { + match node { + Node::Tabs { panels, active } => *active = (*active).min(panels.len().saturating_sub(1)), + Node::Split { first, second, .. } => { + normalize(first); + normalize(second); + } + Node::Stack { .. } => {} } - Node::Stack { .. } => {} } -} - -pub(crate) fn close_tree(root: &mut DockNode, panel: PanelKind) { - let Ok(mut shared) = encode(root, &[]) else { return }; - if let Some(node) = &mut shared.root { - normalize_selection(node); + let Ok(mut shared) = encode(root, &[]) else { return false }; + if let Some(root) = &mut shared.root { + normalize(root); } - if shared.validate().is_err() { - return; - } - // An absent close still repairs an invalid selection, as the legacy method did. - if root.contains(panel) && shared.apply(Action::Close { panel }).is_err() { - return; - } - *root = decode(shared).root; + shared.validate().is_ok() } pub(crate) fn unfloat(layout: &mut Layout, panel: PanelKind) -> bool { @@ -160,6 +267,46 @@ pub(crate) fn unfloat(layout: &mut Layout, panel: PanelKind) -> bool { if !found || shared.apply(Action::Close { panel }).is_err() { return false; } - *layout = decode(shared); + let mut proposed = decode(shared); + restore_frames(&mut proposed.root, &layout.root); + *layout = proposed; true } + +#[cfg(test)] +mod stacked_frame_tests { + use super::*; + use crate::dock::Zone; + #[test] + fn stacked_tabs_survive_shared_resize_activate_float_and_close() { + let mut root = crate::dock::workspace("Default"); + assert!(root.dock_panel(PanelKind::Info, PanelKind::Properties, Zone::Center)); + fn group(node: &DockNode) -> Option<&StackEntry> { + match node { + DockNode::Split { a, b, .. } => group(a).or_else(|| group(b)), + DockNode::Stack { entries } => entries.iter().find(|e| e.contains(PanelKind::Properties)), + _ => None, + } + } + let before = group(&root).unwrap().clone(); + assert!(before.contains(PanelKind::Info)); + let mut layout = Layout { root, floating: vec![] }; + layout.apply(Action::ResizeSplit { path: vec![], size: SharedSplitSize::Ratio(0.6) }).unwrap(); + assert_eq!(group(&layout.root).unwrap(), &before); + layout.apply(Action::Activate { panel: PanelKind::Properties }).unwrap(); + assert_eq!(group(&layout.root).unwrap().shown(), Some(PanelKind::Properties)); + let original_frame = group(&layout.root).unwrap().clone(); + layout.apply(Action::OpenAt { panel: PanelKind::History, anchor: PanelKind::Properties, placement: Placement::Tab { before: None } }).unwrap(); + let opened = group(&layout.root).unwrap(); + assert!(opened.contains(PanelKind::History)); + assert!(opened.contains(PanelKind::Info)); + assert_eq!(opened.shown(), Some(PanelKind::History)); + assert_eq!(opened.height, original_frame.height); + layout.apply(Action::Close { panel: PanelKind::History }).unwrap(); + layout.apply(Action::Float { panel: PanelKind::Info, rect: [20.0, 20.0, 300.0, 300.0] }).unwrap(); + assert_eq!(group(&layout.root).unwrap().panels, vec![PanelKind::Properties]); + layout.apply(Action::Close { panel: PanelKind::Info }).unwrap(); + assert!(layout.floating.is_empty()); + assert_eq!(group(&layout.root).unwrap().panels, vec![PanelKind::Properties]); + } +} diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index 83098d06..9c28f353 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -118,7 +118,7 @@ impl EffectcraftApp { if shared.validate().is_err() { return false; } - let mut l = crate::dock_shared::decode(shared); + let mut l = Layout { root: self.ui.dock.clone(), floating: self.ui.floating.clone() }; if !f(&mut l) { return false; } diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 55c75f0b..ccffeb9f 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -280,8 +280,11 @@ fn comp_content_with(project: &Project, comp: ItemId, addr: impl Fn(ItemId, &Arc h.finish() } -/// Hash of render options (part of [`FrameKey`] and of the disk key). +/// Hash of render options (part of [`FrameKey`] and of the disk key). The scale counts as the +/// key's thousandths ([`FrameKey::scale`]): a series rebuilt from the key (the cache bars) has +/// the same options as the frames rendered at 1/3 = 0.333… (#595). pub fn opts_hash(o: &RenderOpts) -> u64 { + let o = RenderOpts { scale: (o.scale * 1000.0).round() / 1000.0, ..*o }; let mut h = disk_cache::Hash128::default(); h.write(format!("{o:?}").as_bytes()); h.finish() as u64 @@ -765,10 +768,8 @@ impl Frames { #[cfg(not(target_arch = "wasm32"))] if self.remote.is_none() { let w = self.worker(); - // One spawn per job; each spawn renders whichever queued job matters most right now. - let job = move || { - w.run_next(); - }; + // One spawn per job; each spawn renders whichever queued jobs matter most right now. + let job = move || w.drain(); match &self.pool { Some(pool) => pool.spawn(job), None => rayon::spawn(job), @@ -921,7 +922,42 @@ struct Worker { gpu_retired: Arc, } +#[cfg(not(target_arch = "wasm32"))] +thread_local! { + /// This pool thread is inside [`Worker::drain`] (rendering a frame). + static DRAINING: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// Clears [`DRAINING`] when the drain ends, even by a panic. +#[cfg(not(target_arch = "wasm32"))] +struct Draining; + +#[cfg(not(target_arch = "wasm32"))] +impl Drop for Draining { + fn drop(&mut self) { + DRAINING.set(false); + } +} + impl Worker { + /// Render queued jobs, most important first, until the queue is empty. + /// + /// A pool thread waiting inside a render (on a parallel loop's join) runs other pool work + /// meanwhile, including the spawns of newer requests: each would start a whole frame render + /// on top of the one waiting, with all of its buffers. Dragging a layer asks for a new frame + /// every UI frame, so renders piled up on every thread until memory ran out (#647). A spawn + /// that lands inside a render returns at once instead; the render below it takes the queued + /// jobs when it is done, so each thread renders one frame at a time. + #[cfg(not(target_arch = "wasm32"))] + fn drain(&self) { + if DRAINING.get() { + return; + } + DRAINING.set(true); + let _reset = Draining; + while self.run_next() {} + } + /// Render the queued job that matters most right now. `false` when the queue was empty. fn run_next(&self) -> bool { let Worker { queue, content_keys, .. } = self; @@ -1231,6 +1267,80 @@ mod tests { assert!(!f.is_cached(&key)); } + /// #647: a pool thread waiting inside a render (on a parallel loop) never starts another + /// frame render on top of it. Dragging a layer queues a frame every UI frame; renders used to + /// nest on every thread, each holding its buffers, until memory ran out. + #[test] + fn a_waiting_render_never_starts_another_on_its_thread() { + use effectcraft_engine::Session; + use effectcraft_engine::project::{Footage, FootageKind}; + use effectcraft_engine::render::Image; + use effectcraft_engine::time::FrameRate; + use serde_json::json; + use std::cell::Cell; + use std::sync::atomic::AtomicUsize; + thread_local! { + static DEPTH: Cell = const { Cell::new(0) }; + } + /// A decoder that works in parallel, as the renderer does: its thread joins the loop and + /// may wait for the parts other threads took. + #[derive(Default)] + struct Parallel { + calls: AtomicUsize, + deepest: AtomicUsize, + } + impl FootageSource for Parallel { + fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + let d = DEPTH.get() + 1; + DEPTH.set(d); + self.deepest.fetch_max(d, Ordering::SeqCst); + self.calls.fetch_add(1, Ordering::SeqCst); + (0..64).into_par_iter().for_each(|_| std::thread::sleep(std::time::Duration::from_millis(1))); + DEPTH.set(d - 1); + Some(Arc::new(Image::new(4, 4))) + } + } + let mut s = Session::default(); + let cid = ItemId(s.execute("comp.new", json!({"name": "A", "width": 8, "height": 8, "duration": 10})).unwrap()["comp"].as_u64().unwrap()); + let clip = Footage { + kind: FootageKind::Video, + width: 4, + height: 4, + has_video: true, + frame_rate: FrameRate::FPS_30, + duration: Tick::from_seconds_f64(10.0), + ..Default::default() + }; + let item = Arc::make_mut(&mut s.project).add_item("clip.mov", Default::default(), None, ItemKind::Footage(clip)); + s.execute("layer.addItem", json!({"item": item.0})).unwrap(); + let footage = Arc::new(Parallel::default()); + let src = RenderSource { + project: s.project.clone(), + footage: footage.clone(), + expr: None, + layer_cache: Arc::default(), + gpu: None, + gpu_display: false, + disk: None, + }; + let f = Frames::default(); + let content = f.content_of(&s.project, s.revision, cid); + let frames = 60; + for frame in 0..frames { + let key = FrameKey { revision: s.revision, content, comp: cid.0, frame, scale: 1000, view: 0, opts: 0 }; + f.request(&src, key, cid, FrameRate::FPS_30.tick_of(frame), RenderOpts::default()); + } + for _ in 0..4000 { + if f.inflight() == 0 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert_eq!(f.inflight(), 0, "every queued frame rendered"); + assert!(footage.calls.load(Ordering::SeqCst) >= frames as usize); + assert_eq!(footage.deepest.load(Ordering::SeqCst), 1, "renders nested on a waiting thread"); + } + /// #284: scrubbing asks for a viewer frame at every step; a newer one drops the comp's viewer /// frames still waiting (they would all render first), but not prefetch frames or another /// comp's viewer frame. diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 5b8cc762..1260c9c9 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -166,6 +166,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if app.ui.tool.puppet_kind().is_some() { x = puppet_options(app, ui, &p, x, cy); } + if app.ui.tool.roto_kind(false).is_some() { + x = roto_options(app, ui, &p, x, cy); + } let snap = Rect::from_min_size(pos2(x, cy - 10.0), vec2(20.0, 20.0)); // The engine owns snapping (View ▸ Snapping); the checkbox mirrors it. app.ui.snapping = app.session.state.snapping; @@ -179,12 +182,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Right side: community buttons, workspaces. let mut rx = rect.max.x - 10.0; - let discord = Rect::from_min_max(pos2(rx - 104.0, cy - 13.0), pos2(rx, cy + 13.0)); + // The pill fits its label (icon, gap, text) with even padding: it was a fixed 104 px with the + // text at its left, which left a wide gap on the right (#581). + let label = p.layout_no_wrap(tr("Discord").to_string(), Tokens::semibold(12.0), Color32::WHITE); + let discord = Rect::from_min_max(pos2(rx - (10.0 + 14.0 + 6.0 + label.size().x + 12.0), cy - 13.0), pos2(rx, cy + 13.0)); let dresp = ui.interact(discord, egui::Id::new("hdr-discord"), Sense::click()); let dc = Color32::from_rgb(0x58, 0x65, 0xf2); p.rect_filled(discord, 13.0, if dresp.hovered() { dc.gamma_multiply(1.2) } else { dc }); - icons::paint(&p, Rect::from_center_size(pos2(discord.min.x + 16.0, cy), vec2(14.0, 14.0)), Icon::Chat, Color32::WHITE); - p.text(pos2(discord.min.x + 28.0, cy), Align2::LEFT_CENTER, tr("Discord"), Tokens::semibold(12.0), Color32::WHITE); + icons::paint(&p, Rect::from_center_size(pos2(discord.min.x + 17.0, cy), vec2(14.0, 14.0)), Icon::Chat, Color32::WHITE); + p.galley(pos2(discord.min.x + 30.0, cy - label.size().y / 2.0), label, Color32::WHITE); app.auto.add("header.discord", discord, "Join the ArtCraft Discord"); if dresp.on_hover_text(tr("Join the ArtCraft community on Discord")).clicked() { let _ = app.session.execute("help.discord", json!({})); @@ -366,7 +372,7 @@ fn fill_stroke_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Pa } } let mut c = tp.color; - if color_popup(ui, picker_id(key), sr.left_bottom(), &mut c) { + if widgets::color_popup(ui, picker_id(key), sr.left_bottom(), &mut c) { tool_options(app, json!({key: c})); paint(app, key, json!(c)); } @@ -447,12 +453,6 @@ fn paint_options_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, key: &str, t } } -/// sRGB components (0–1) as a colour. -fn srgb32(c: [f32; 3]) -> Color32 { - let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; - Color32::from_rgb(b(c[0]), b(c[1]), b(c[2])) -} - /// A Fill or Stroke swatch: its colour, a gradient (white to black), or None's red slash. fn paint_swatch(p: &egui::Painter, r: Rect, paint: &ToolPaint, hovered: bool, t: &Tokens) { let clip = p.with_clip_rect(r); @@ -462,7 +462,7 @@ fn paint_swatch(p: &egui::Painter, r: Rect, paint: &ToolPaint, hovered: bool, t: clip.line_segment([r.left_bottom(), r.right_top()], Stroke::new(1.5, Color32::from_rgb(0xe0, 0x30, 0x30))); } PaintKind::Solid => { - clip.rect_filled(r, 2.0, srgb32(paint.color)); + clip.rect_filled(r, 2.0, widgets::srgb32(paint.color)); } PaintKind::Linear => { let mut m = egui::Mesh::default(); @@ -486,6 +486,26 @@ fn paint_swatch(p: &egui::Painter, r: Rect, paint: &ToolPaint, hovered: bool, t: p.rect_stroke(r, 2.0, Stroke::new(1.0, if hovered { t.text } else { t.field_border }), StrokeKind::Inside); } +/// Roto Brush / Refine Edge tool options: the brush Diameter (⌘/Ctrl-drag in the Layer panel +/// sets it too, as in After Effects). Returns the next x. +fn roto_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, mut x: f32, cy: f32) -> f32 { + let t = app.tokens; + let refine = app.ui.tool.roto_kind(false).is_some_and(|k| k.starts_with("refine")); + let o = &app.session.state.roto; + let (key, v) = if refine { ("refineDiameter", o.refine_diameter) } else { ("diameter", o.diameter) }; + let label = tr("Diameter:"); + let lr = p.text(pos2(x, cy), Align2::LEFT_CENTER, label, Tokens::ui(12.0), t.text_dim); + x = lr.max.x + 6.0; + let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::new(("roto-opt", key)), v, 0.5, (1.0, 5000.0), 0, " px", &t); + app.auto.add("header.roto.diameter", r, label); + if let Some(nv) = nv + && let Err(e) = app.session.execute("roto.options", json!({key: nv})) + { + app.ui.status = e.to_string(); + } + r.max.x + 12.0 +} + /// Puppet tool options: Mesh: Show, Expansion, Density (for new meshes and the selected /// layer's meshes). Returns the next x. fn puppet_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, mut x: f32, cy: f32) -> f32 { @@ -557,28 +577,6 @@ fn ws_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui) { } } -/// A small colour picker popup bound to an RGB value. -pub fn color_popup(ui: &mut egui::Ui, id: egui::Id, pos: egui::Pos2, c: &mut [f32; 3]) -> bool { - let open_id = id.with("open"); - if !ui.data(|d| d.get_temp::(open_id).unwrap_or(false)) { - return false; - } - let mut changed = false; - let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos + vec2(0.0, 4.0)).show(ui.ctx(), |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| { - let mut col = srgb32(*c); - if egui::color_picker::color_picker_color32(ui, &mut col, egui::color_picker::Alpha::Opaque) { - *c = [col.r() as f32 / 255.0, col.g() as f32 / 255.0, col.b() as f32 / 255.0]; - changed = true; - } - }); - }); - if widgets::pressed_outside(ui.ctx(), &area.response) || ui.input(|i| i.key_pressed(egui::Key::Escape)) { - ui.data_mut(|d| d.insert_temp(open_id, false)); - } - changed -} - /// The ArtCraft mark as supplied in `docs/brand` (the README's and getartcraft.com's logo; used /// unmodified, see `docs/brand/LICENSE-brand.txt`). static ARTCRAFT_MARK: &[u8] = include_bytes!("../../../docs/brand/artcraft-mark.png"); diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index fdcaa27e..f7c77905 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -286,6 +286,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Maximize Panel", "パネルを最大化"), ("Media Replacement", "メディアの置き換え"), ("Mesh:", "メッシュ:"), + ("Diameter:", "直径:"), ("Method Used: {} Average Error: {} pixels", "使用した手法:{} 平均誤差:{}ピクセル"), ("Models folder: {}", "モデルフォルダー:{}"), ("Modifiers:", "修飾キー:"), @@ -320,6 +321,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("No device list from this host; the system default output is used.", "このホストからはデバイス一覧を取得できないため、システムの既定の出力を使用します。"), ("No layer selected", "レイヤーが選択されていません"), ("No matching effects or presets", "一致するエフェクトまたはプリセットはありません"), + ("No matching fonts", "一致するフォントはありません"), ("No model credits were built into this copy.", "このビルドにはモデルのクレジットが含まれていません。"), ("No nested compositions", "ネストされたコンポジションはありません"), ("No shortcut", "ショートカットなし"), @@ -403,6 +405,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Select a command to change its shortcut.", "ショートカットを変更するコマンドを選択してください。"), ("Select a layer to see its effects", "レイヤーを選択するとエフェクトが表示されます"), ("Select a layer to see its properties", "レイヤーを選択するとプロパティが表示されます"), + ("({}) Selected Objects", "({}) 個の選択されたオブジェクト"), ( "Select a layer with a mask or shape path (or the path property), or Puppet pins.", "マスクまたはシェイプパスのあるレイヤー(またはパスプロパティ)か、パペットピンを選択してください。", @@ -720,6 +723,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Maximize Panel", "最大化面板"), ("Media Replacement", "媒体替换"), ("Mesh:", "网格:"), + ("Diameter:", "直径:"), ("Method Used: {} Average Error: {} pixels", "使用方法:{} 平均误差:{} 像素"), ("Models folder: {}", "模型文件夹:{}"), ("Modifiers:", "修饰键:"), @@ -751,6 +755,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("No device list from this host; the system default output is used.", "此主机无法获取设备列表;将使用系统默认输出。"), ("No layer selected", "未选择图层"), ("No matching effects or presets", "没有匹配的效果或预设"), + ("No matching fonts", "没有匹配的字体"), ("No model credits were built into this copy.", "此副本未内置模型致谢。"), ("No nested compositions", "没有嵌套合成"), ("No shortcut", "无快捷键"), @@ -836,6 +841,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Select a command to change its shortcut.", "选择一条命令以更改其快捷键。"), ("Select a layer to see its effects", "选择图层以查看其效果"), ("Select a layer to see its properties", "选择图层以查看其属性"), + ("({}) Selected Objects", "({}) 个选定对象"), ( "Select a layer with a mask or shape path (or the path property), or Puppet pins.", "请选择带蒙版或形状路径(或路径属性)的图层,或带有人偶定位点的图层。", @@ -996,6 +1002,37 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Use In-Window Menu Bar on macOS", "在 macOS 上使用窗口内菜单栏"), ("Wireframe", "线框"), ("sRGB IEC61966-2.1 (default)", "sRGB IEC61966-2.1(默认)"), + // The Home screen (#574). + ("New Project", "新建项目"), + ("Open Project…", "打开项目…"), + ("Templates", "模板"), + ("EffectCraft home page", "EffectCraft 主页"), + ("EffectCraft on GitHub", "GitHub 上的 EffectCraft"), + ("Motion graphics and visual effects · version {}", "动态图形和视觉效果 · 版本 {}"), + ("New Composition", "新建合成"), + ("Open Demo Project", "打开示例项目"), + ("Import Footage…", "导入素材…"), + ("Filter recent files", "筛选最近文件"), + ("NAME", "名称"), + ("OPENED", "打开时间"), + ("SIZE", "大小"), + ("KIND", "类型"), + ("Your recent files will show here", "你最近使用的文件会显示在这里"), + ("Create a new project, or open the demo project to look around.", "新建一个项目,或打开示例项目先熟悉一下界面。"), + ("No recent files match the filter", "没有与筛选条件匹配的最近文件"), + ("Try another name or folder.", "请尝试其他名称或文件夹。"), + ("Just now", "刚刚"), + ("Yesterday", "昨天"), + ("{} minute ago", "{} 分钟前"), + ("{} minutes ago", "{} 分钟前"), + ("{} hour ago", "{} 小时前"), + ("{} hours ago", "{} 小时前"), + ("{} day ago", "{} 天前"), + ("{} days ago", "{} 天前"), + ("EffectCraft project", "EffectCraft 项目"), + ("After Effects project", "After Effects 项目"), + ("Lottie animation", "Lottie 动画"), + ("Project", "项目"), ]; const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ @@ -1211,6 +1248,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Maximize Panel", "最大化面板"), ("Media Replacement", "媒體替換"), ("Mesh:", "格點:"), + ("Diameter:", "直徑:"), ("Method Used: {} Average Error: {} pixels", "使用方法:{} 平均誤差:{} 像素"), ("Models folder: {}", "模型資料夾:{}"), ("Modifiers:", "修飾鍵:"), @@ -1242,6 +1280,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("No device list from this host; the system default output is used.", "此主機無法獲取設備清單;將使用系統預設輸出。"), ("No layer selected", "未選取圖層"), ("No matching effects or presets", "沒有匹配的效果或預設集"), + ("No matching fonts", "沒有符合的字體"), ("No model credits were built into this copy.", "此副本未內置模型致謝。"), ("No nested compositions", "沒有嵌套合成"), ("No shortcut", "無快速鍵"), @@ -1327,6 +1366,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Select a command to change its shortcut.", "選取一條命令以更改其快速鍵。"), ("Select a layer to see its effects", "選取圖層以查看其效果"), ("Select a layer to see its properties", "選取圖層以查看其屬性"), + ("({}) Selected Objects", "({}) 個選取的物件"), ( "Select a layer with a mask or shape path (or the path property), or Puppet pins.", "請選取帶遮色片或形狀路徑(或路徑屬性)的圖層,或帶有人偶釘選點的圖層。", @@ -1469,7 +1509,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Import PDF / Illustrator File", "Import PDF / Illustrator File"), ("Import Photoshop File", "Import Photoshop File"), ("Interpret Unlabeled Alpha As", "Interpret Unlabeled Alpha As"), - ("Join the ArtCraft Discord", "Join the ArtCraft Discord"), + ("Join the ArtCraft Discord", "加入 ArtCraft Discord"), ("Mercury GPU Acceleration ({})", "Mercury GPU Acceleration ({})"), ("Mercury Software Only", "Mercury Software Only"), ("OCIO (built-in ACES config)", "OCIO (built-in ACES config)"), @@ -1482,6 +1522,37 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Use In-Window Menu Bar on macOS", "Use In-Window Menu Bar on macOS"), ("Wireframe", "Wireframe"), ("sRGB IEC61966-2.1 (default)", "sRGB IEC61966-2.1 (default)"), + // The Home screen (#574). + ("New Project", "新增專案"), + ("Open Project…", "開啟專案…"), + ("Templates", "範本"), + ("EffectCraft home page", "EffectCraft 首頁"), + ("EffectCraft on GitHub", "GitHub 上的 EffectCraft"), + ("Motion graphics and visual effects · version {}", "動態圖形與視覺效果 · 版本 {}"), + ("New Composition", "新增合成"), + ("Open Demo Project", "開啟範例專案"), + ("Import Footage…", "匯入素材…"), + ("Filter recent files", "篩選最近的檔案"), + ("NAME", "名稱"), + ("OPENED", "開啟時間"), + ("SIZE", "大小"), + ("KIND", "類型"), + ("Your recent files will show here", "你最近使用的檔案會顯示在這裡"), + ("Create a new project, or open the demo project to look around.", "新增一個專案,或開啟範例專案先熟悉一下介面。"), + ("No recent files match the filter", "沒有符合篩選條件的最近檔案"), + ("Try another name or folder.", "請嘗試其他名稱或資料夾。"), + ("Just now", "剛剛"), + ("Yesterday", "昨天"), + ("{} minute ago", "{} 分鐘前"), + ("{} minutes ago", "{} 分鐘前"), + ("{} hour ago", "{} 小時前"), + ("{} hours ago", "{} 小時前"), + ("{} day ago", "{} 天前"), + ("{} days ago", "{} 天前"), + ("EffectCraft project", "EffectCraft 專案"), + ("After Effects project", "After Effects 專案"), + ("Lottie animation", "Lottie 動畫"), + ("Project", "專案"), ]; /// Ukrainian (uk): original translations of the same interface rows as the Chinese catalogs. @@ -1692,6 +1763,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Maximize Panel", "Розгорнути панель"), ("Media Replacement", "Заміна матеріалу"), ("Mesh:", "Сітка:"), + ("Diameter:", "Діаметр:"), ("Method Used: {} Average Error: {} pixels", "Використаний метод: {} Середня похибка: {} пікселів"), ("Models folder: {}", "Тека моделей: {}"), ("Modifiers:", "Модифікатори:"), @@ -1729,6 +1801,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ), ("No layer selected", "Шар не виділено"), ("No matching effects or presets", "Не знайдено відповідних ефектів або наборів"), + ("No matching fonts", "Не знайдено відповідних шрифтів"), ("No model credits were built into this copy.", "Ця копія не містить відомостей про моделі."), ("No nested compositions", "Немає вкладених композицій"), ("No shortcut", "Немає клавіатурного скорочення"), @@ -1812,6 +1885,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Select a command to change its shortcut.", "Виберіть команду, щоб змінити її клавіатурне скорочення."), ("Select a layer to see its effects", "Виділіть шар, щоб переглянути його ефекти"), ("Select a layer to see its properties", "Виділіть шар, щоб переглянути його властивості"), + ("({}) Selected Objects", "Вибрано об’єктів: ({})"), ( "Select a layer with a mask or shape path (or the path property), or Puppet pins.", "Виділіть шар із маскою або контуром фігури (або властивість контуру) чи опорні точки деформації.", @@ -1923,9 +1997,11 @@ mod tests { /// The interface text that has moved into this catalog. Add a panel here when you convert it; /// the tests below then keep it translated. const CONVERTED: &[(&str, &str)] = &[ + ("color_picker.rs", include_str!("../color_picker.rs")), ("credits.rs", include_str!("../credits.rs")), ("header.rs", include_str!("../header.rs")), ("menus.rs", include_str!("../menus.rs")), + ("widgets.rs", include_str!("../widgets.rs")), ("panels/anim_tools.rs", include_str!("../panels/anim_tools.rs")), ("panels/camera_tracker_ui.rs", include_str!("../panels/camera_tracker_ui.rs")), ("panels/comp_settings.rs", include_str!("../panels/comp_settings.rs")), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f7a06d66..a0e05e00 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -10,6 +10,7 @@ pub mod audio; pub mod automation; pub mod bench; +pub mod color_picker; pub mod control; pub mod credits; pub mod dock; @@ -24,6 +25,7 @@ pub mod menu_keys; pub mod menus; pub mod native_menu; pub mod panels; +mod pointer_wrap; pub mod prefs_live; pub mod state; pub mod theme; @@ -98,6 +100,39 @@ pub enum Dialog { /// winit no theme). `None` = the host does not know. pub type SystemThemeFn = Box Option>; +/// A file dialog a host shows without blocking the frame ([`Hooks::file_dialog_async`]). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum FileDialog { + /// Files to open or import with these extensions; several when `many`. + Open { exts: Vec, many: bool }, + /// An EffectCraft project to open. + OpenProject, + /// A folder. + Folder, + /// Where to save `default` (a file name or path) with extension `ext` (empty: no filter); + /// `project`: the project save dialog, given the file name. + Save { default: String, ext: String, project: bool }, +} + +impl FileDialog { + /// The save dialog for `default`, as [`Hooks::save_dialog`] picks it. + pub fn save(default: &str) -> Self { + let ext = std::path::Path::new(default).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default(); + Self::Save { default: default.to_string(), project: ext.eq_ignore_ascii_case("ecproj"), ext } + } +} + +/// Called with the paths a [`FileDialog`] picked (empty: cancelled), from any thread. +pub type DialogDone = Box) + Send>; + +/// A non-blocking file dialog's answer for the command that asked (`menus::dialog_answered`). +pub(crate) struct DialogAnswer { + pub command: String, + pub key: &'static str, + pub params: serde_json::Value, + pub paths: Vec, +} + /// Host hooks provided by the native app (file pickers etc.). #[derive(Default)] pub struct Hooks { @@ -121,6 +156,14 @@ pub struct Hooks { /// Application actions the OS performs (`app.hide`, `app.hideOthers`, `app.showAll` on /// macOS). Returns false when the host doesn't handle the id. pub app_action: Option bool>>, + /// Shows a command's file dialog without blocking the frame and calls `done` with the + /// answer (the host then asks for a repaint). Used in place of the blocking pickers above for + /// the commands' dialogs (File ▸ Import, Open, Save As…) where a modal dialog's nested event + /// loop inside a frame is unsafe: on macOS a drag over the window then aborted the app (#511). + pub file_dialog_async: Option>, + /// Tells the user the graphics device was lost and the window is closing, once. `saved` is + /// the auto-save of unsaved changes (`None`: nothing was unsaved), its path or why it failed. + pub device_lost: Option>)>>, } impl Hooks { @@ -262,6 +305,11 @@ pub struct EffectcraftApp { gpu_checked: bool, gpu_failures: Option, gpu_failure_applied: Option, + /// The graphics device was lost and the window is closing (see `close_after_device_loss`); + /// `true` keeps crash recovery for the next launch. + device_lost_close: Option, + /// Answers of file dialogs shown without blocking ([`Hooks::file_dialog_async`]). + pub(crate) dialog_answers: (Sender, Receiver), /// Commands from the native menu bar. pub command_inbox: Option>, /// Pointer position in comp pixels (Info panel). @@ -298,6 +346,17 @@ pub struct EffectcraftApp { pub(crate) template_list: Option<(Vec, Vec)>, } +/// The saved workspaces in the settings store ([`effectcraft_engine::config::ConfigStore`]). +pub const WORKSPACES_FILE: &str = "workspaces.json"; + +#[derive(serde::Serialize, serde::Deserialize)] +struct SavedWorkspaces { + #[serde(default)] + workspaces: std::collections::BTreeMap, + #[serde(default)] + floating: std::collections::BTreeMap>, +} + impl EffectcraftApp { pub fn new(session: Session) -> Self { let mut ui = UiState::default(); @@ -340,6 +399,8 @@ impl EffectcraftApp { gpu_checked: false, gpu_failures: None, gpu_failure_applied: None, + device_lost_close: None, + dialog_answers: std::sync::mpsc::channel(), command_inbox: None, pointer_comp: None, dialog_state: Default::default(), @@ -361,6 +422,12 @@ impl EffectcraftApp { .with_ui_commands() } + /// Keep the saved workspaces for the next session (see [`EffectcraftApp::new`]): After + /// Effects' saved workspaces last until they are deleted (#320). + pub(crate) fn store_saved_workspaces(&mut self) { + self.save_workspace_state(); + } + /// Offer the frontend's commands (tools, timeline navigation…) for keyboard shortcuts. fn with_ui_commands(mut self) -> Self { let cmds = menus::UI_COMMANDS @@ -392,6 +459,7 @@ impl EffectcraftApp { self.ui.theme = theme::ThemeKind::from_name(p.appearance.resolved_theme(system)).unwrap_or_default(); self.tokens = Tokens::from_prefs(p, self.ui.theme); theme::apply_visuals(ctx, &self.tokens); + ctx.set_theme(theme::theme_preference(&p.appearance.appearance_mode, self.ui.theme.is_light())); let tips = p.general.show_tool_tips; ctx.all_styles_mut(|s| s.interaction.tooltip_delay = if tips { 0.5 } else { f32::INFINITY }); // Settings ▸ Appearance ▸ UI Scale (#284). egui's own Ctrl+= / Ctrl+- / Ctrl+0 zoom stays @@ -607,6 +675,40 @@ impl EffectcraftApp { ctx.request_repaint(); } + /// After the graphics device is lost, eframe's renderer cannot draw again: any mesh makes + /// egui-wgpu panic on the main thread when it cannot stage its index buffer (#591). Draw + /// nothing from then on, auto-save a project with unsaved changes (keeping the crash + /// recovery marker, so the next launch offers it) and close the window. + fn close_after_device_loss(&mut self, ctx: &egui::Context) { + if self.device_lost_close.is_some() { + return; + } + let keep_recovery = self.session.is_dirty(); + let saved = keep_recovery.then(|| match self.session.autosave_now().and_then(|path| self.session.autosave_wait().map(|()| path)) { + Ok(path) => { + log::warn!("graphics device lost: auto-saved {path}; the next launch offers to recover it"); + Ok(path) + } + Err(e) => { + log::error!("graphics device lost: {e}"); + Err(e.to_string()) + } + }); + self.device_lost_close = Some(keep_recovery); + log::warn!("graphics device lost: closing the window"); + if let Some(tell) = &self.hooks.device_lost { + tell(saved); + } + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + + /// Run the commands whose non-blocking file dialogs have been answered. + pub(crate) fn run_dialog_answers(&mut self) { + while let Ok(answer) = self.dialog_answers.1.try_recv() { + menus::dialog_answered(self, answer); + } + } + /// Set up the GPU compositor on egui-wgpu's device (once). Without an adapter that runs /// compute shaders (WebGL2, software GL) everything stays on the CPU. pub(crate) fn init_gpu(&mut self, frame: &eframe::Frame) { @@ -618,6 +720,15 @@ impl EffectcraftApp { return; } let Some(rs) = frame.wgpu_render_state() else { return }; + // Help ▸ System Compatibility Report names the adapter and backend the window got. + let info = rs.adapter.get_info(); + self.session.window_adapter = Some(format!( + "{} ({:?}, {:?}{})", + info.name, + info.backend, + info.device_type, + if info.driver.is_empty() { String::new() } else { format!(", {} {}", info.driver, info.driver_info) } + )); // Backend validation can panic on shader compilation even when device creation // succeeded. Keep the document and the egui renderer, and use CPU compositing. let gpu = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| effectcraft_gpu::Gpu::new(&rs.adapter, rs.device.clone(), rs.queue.clone()))); @@ -1438,6 +1549,7 @@ impl EffectcraftApp { fn frame(&mut self, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); self.apply_gpu_failure(&ctx); + self.run_dialog_answers(); self.auto.begin_frame(); self.frames.set_context(&ctx); if self.session.render_job.is_some() { @@ -1650,12 +1762,16 @@ impl EffectcraftApp { impl eframe::App for EffectcraftApp { fn on_exit(&mut self) { self.save_workspace_state(); - // Clean exit: no crash recovery next launch. - self.session.end_recovery(); + // Clean exit: no crash recovery next launch (unless the graphics device was lost with + // unsaved changes, which are in the auto-save it offers). + if self.device_lost_close != Some(true) { + self.session.end_recovery(); + } } - fn raw_input_hook(&mut self, _ctx: &egui::Context, raw_input: &mut egui::RawInput) { - if !self.synthetic.is_empty() { + fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { + let injected = !self.synthetic.is_empty(); + if injected { // Pointer events go one per frame so egui sees press → moves → release as a real drag. let pointer = |e: &egui::Event| matches!(e, egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } | egui::Event::MouseWheel { .. }); let n = if pointer(&self.synthetic[0]) { @@ -1668,6 +1784,9 @@ impl eframe::App for EffectcraftApp { }; raw_input.events.extend(self.synthetic.drain(..n)); } + if pointer_wrap::prepare_input(ctx, raw_input, injected) { + self.session.history.merge_key = None; + } } fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { @@ -1706,6 +1825,10 @@ impl eframe::App for EffectcraftApp { } fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { + if self.gpu_failure_applied == Some(true) { + self.close_after_device_loss(ui.ctx()); + return; + } self.init_gpu(frame); if !self.fonts_ready { ui.ctx().request_repaint(); @@ -1713,6 +1836,8 @@ impl eframe::App for EffectcraftApp { } self.frame(ui); let ctx = ui.ctx().clone(); + #[cfg(not(target_arch = "wasm32"))] + pointer_wrap::finish_native(&ctx, frame); // wasm32: no frame threads; render queued frames now, between UI frames. if cfg!(target_arch = "wasm32") && self.frames.pump(std::time::Duration::from_millis(if self.playback.playing { 24 } else { 40 })) { ctx.request_repaint(); @@ -1864,6 +1989,35 @@ mod appearance_tests { assert_eq!(app.ui.theme, ThemeKind::Dark); } + /// Sync with System leaves egui's theme preference at System, so egui never gives the native + /// window an appearance of its own (on macOS that stops system appearance changes reaching + /// the app, #642); egui's light and dark styles both carry the resolved theme. Fixed modes + /// pin the window to their family. + #[test] + fn auto_mode_leaves_the_native_window_following_the_system() { + let ctx = egui::Context::default(); + let mut app = app(); + app.hooks.system_theme = Some(Box::new(|_| Some(egui::Theme::Light))); + app.set_pref("appearance.appearanceMode", json!("auto")).unwrap(); + app.apply_prefs(&ctx); + assert_eq!(app.ui.theme, ThemeKind::Light); + assert_eq!(ctx.options(|o| o.theme_preference), egui::ThemePreference::System); + for theme in [egui::Theme::Light, egui::Theme::Dark] { + assert_eq!(ctx.style_of(theme).visuals.panel_fill, app.tokens.panel_bg, "{theme:?}"); + } + // egui asks the window to follow the system, never to take Light or Dark. + let mut out = ctx.run_ui(egui::RawInput::default(), |_| {}); + out.textures_delta.clear(); + let cmds = out.viewport_output.values().flat_map(|v| v.commands.iter()).collect::>(); + assert!(!cmds.iter().any(|c| matches!(c, egui::ViewportCommand::SetTheme(t) if t != &egui::SystemTheme::SystemDefault)), "{cmds:?}"); + for (mode, pref) in [("light", egui::ThemePreference::Light), ("dark", egui::ThemePreference::Dark)] { + app.set_pref("appearance.appearanceMode", json!(mode)).unwrap(); + app.apply_prefs(&ctx); + assert_eq!(ctx.options(|o| o.theme_preference), pref, "{mode}"); + assert_eq!(ctx.global_style().visuals.panel_fill, app.tokens.panel_bg, "{mode}"); + } + } + /// Picking a theme (`view.theme.*`, `ui.set {theme}`) fixes the mode to its family. #[test] fn picking_a_theme_fixes_its_mode() { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 819f1c70..5c61b497 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -123,6 +123,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("app.home", "Home", [], None), uic!("markers.dialog", "Marker Settings...", [], None), uic!("window.maximizePanel", "Maximize Panel Under Pointer", [], Some("`")), + uic!("window.maximizeApp", "Maximize or Restore Application Window", [], Some("Cmd+\\")), uic!("window.dockPanel", "Dock Panel", [], None), uic!("window.floatPanel", "Undock Panel", [], None), uic!("window.closePanel", "Close Panel", [], None), @@ -441,6 +442,10 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V } return Ok(Value::Null); } + // A UI-only command (not in the engine's menus), so `frontend` gets it from here. + if id == "window.maximizeApp" { + return frontend(app, ctx, id, params); + } // Docking: {panel, anchor, zone: center|left|right|top|bottom}, {panel, rect?}, {panel?}. if let Some(op) = id.strip_prefix("window.").filter(|o| matches!(*o, "maximizePanel" | "dockPanel" | "floatPanel" | "closePanel")) { let panel_of = |k: &str| params.get(k).and_then(Value::as_str).map(|s| PanelKind::from_name(s).ok_or(format!("unknown panel `{s}`"))); @@ -659,8 +664,9 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V // up Effect Controls on that layer, as in After Effects. if matches!(id, "effect.apply" | "effect.applyLast") { let r = run_engine(app, ctx, id, params.clone()); - if r.is_ok() { + if let Ok(applied) = &r { crate::panels::effect_controls::reveal_applied(app, ¶ms); + crate::panels::effect_controls::choose_files_after_apply(app, applied); } return r; } @@ -933,6 +939,12 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu }; Value::Null } + // Ctrl+\ / Cmd+\, as in After Effects (#336): `{maximized}`, the window's new state. + "window.maximizeApp" => { + let maximized = !ctx.input(|i| i.viewport().maximized.unwrap_or(false)); + ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(maximized)); + json!({"maximized": maximized}) + } "view.fullScreen" => { let full = ctx.input(|i| i.viewport().fullscreen.unwrap_or(false)); ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(!full)); @@ -967,6 +979,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu let name = app.ui.workspace.clone(); app.ui.saved_workspaces.insert(name.clone(), app.ui.dock.clone()); app.ui.saved_floating.insert(name.clone(), app.ui.floating.clone()); + app.store_saved_workspaces(); json!({"workspace": name}) } "window.saveWorkspaceAs" => { @@ -983,6 +996,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu app.ui.saved_workspaces.insert(name.clone(), app.ui.dock.clone()); app.ui.saved_floating.insert(name.clone(), app.ui.floating.clone()); app.ui.workspace = name.clone(); + app.store_saved_workspaces(); json!({"workspace": name}) } "window.editWorkspaces" => { @@ -1007,6 +1021,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu if app.ui.saved_workspaces.remove(&name).is_none() && !builtin { return Err(format!("no workspace `{name}`")); } + app.store_saved_workspaces(); if app.ui.workspace == name { app.set_workspace("Default"); } @@ -1024,6 +1039,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu if app.ui.workspace == name { app.ui.workspace = new.to_string(); } + app.store_saved_workspaces(); return Ok(json!({"renamed": new})); } json!({"workspaces": app.workspace_names()}) @@ -1208,7 +1224,31 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option crate::FileDialog::Folder, + Ask::Import => crate::FileDialog::Open { exts: MEDIA.iter().map(|e| e.to_string()).collect(), many: key == "paths" }, + Ask::OpenProject => crate::FileDialog::OpenProject, + Ask::Open(exts) => crate::FileDialog::Open { exts: exts.iter().map(|e| e.to_string()).collect(), many: false }, + Ask::Save(default) => crate::FileDialog::save(default), + }; + let tx = app.dialog_answers.0.clone(); + let answer = crate::DialogAnswer { command: id.to_string(), key, params: params.clone(), paths: Vec::new() }; + spawn( + request, + Box::new(move |paths| { + let _ = tx.send(crate::DialogAnswer { paths, ..answer }); + }), + ); + return Some(Ok(json!({"dialog": id}))); + } let picked: Option = match ask { Ask::Folder => { let Some(f) = app.hooks.pick_folder.as_ref() else { return Some(Err("no folder dialog available (pass `folder`)".into())) }; @@ -1216,15 +1256,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option { let Some(f) = app.hooks.pick_files.as_ref() else { return Some(Err("no file dialog available (pass `paths`)".into())) }; - let paths = f(&[ - "mp4", "mov", "m4v", "mkv", "webm", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "wav", "aif", "aiff", "mp3", "flac", - "ogg", "opus", "svg", "pdf", "ai", "eps", "psd", "psb", "gltf", "glb", "obj", "json", "csv", "tsv", - ]); - match (paths.is_empty(), key) { - (true, _) => None, - (false, "paths") => Some(json!(paths)), - (false, _) => paths.first().map(|s| json!(s)), - } + picked_value(key, f(MEDIA)) } Ask::OpenProject => { let Some(f) = app.hooks.pick_open_project.as_ref() else { return Some(Err("no file dialog available (pass `path`)".into())) }; @@ -1240,6 +1272,30 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option) -> Option { + match (paths.is_empty(), key) { + (true, _) => None, + (false, "paths") => Some(json!(paths)), + (false, _) => paths.into_iter().next().map(Value::from), + } +} + +/// A file dialog shown without blocking ([`crate::Hooks::file_dialog_async`]) answered: run its +/// command with the picked path (nothing when it was cancelled). +pub(crate) fn dialog_answered(app: &mut EffectcraftApp, answer: crate::DialogAnswer) { + let Some(v) = picked_value(answer.key, answer.paths) else { return }; + if let Err(e) = run_with_picked(app, &answer.command, answer.key, &answer.params, v) { + app.ui.status = e; + } +} + +/// Run command `id` with the path picked for it in parameter `key`. +fn run_with_picked(app: &mut EffectcraftApp, id: &str, key: &str, params: &Value, v: Value) -> Result { + let mut p = params.as_object().cloned().unwrap_or_default(); p.insert(key.to_string(), v); // Picked files import in the background, with the Importing card showing progress (#270). if matches!(id, "file.import" | "file.importMultiple") { @@ -1251,7 +1307,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option Option String { pub fn parse_shortcut(s: &str) -> Option<(egui::Modifiers, egui::Key)> { let mut m = egui::Modifiers::NONE; let mut key = None; - let parts: Vec<&str> = if s == "+" { vec!["+"] } else { s.split('+').collect() }; + // The plus key itself is written `+` or `Cmd++`. + let (head, plus) = match s.strip_suffix("++") { + Some(h) => (h, true), + None if s == "+" => ("", true), + None => (s, false), + }; + let mut parts: Vec<&str> = if head.is_empty() { vec![] } else { head.split('+').collect() }; + if plus { + parts.push("+"); + } for p in parts { match p { "Cmd" => { @@ -1921,6 +1986,34 @@ mod tests { } } + /// #336: Ctrl+\ (Cmd+\ on macOS) maximizes the application window, and again restores + /// it, as in After Effects. + #[test] + fn ctrl_backslash_maximizes_or_restores_the_window() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + let (m, k) = parse_shortcut("Cmd+\\").unwrap(); + assert!(m.command && k == egui::Key::Backslash, "{m:?} {k:?}"); + assert!(bindings(&app.session).iter().any(|b| (b.0, b.1) == (m, k) && b.2 == "window.maximizeApp")); + let ctx = egui::Context::default(); + let run = |app: &mut EffectcraftApp, maximized: bool| { + let input = egui::RawInput { + viewports: std::iter::once((egui::ViewportId::ROOT, egui::ViewportInfo { maximized: Some(maximized), ..Default::default() })).collect(), + ..Default::default() + }; + let mut r = Value::Null; + let mut out = ctx.run_ui(input, |ui| r = invoke(app, ui.ctx(), "window.maximizeApp", json!({})).unwrap()); + out.textures_delta.clear(); + let cmds = out.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.commands.clone()).unwrap_or_default(); + (r, cmds) + }; + let (r, cmds) = run(&mut app, false); + assert_eq!(r, json!({"maximized": true})); + assert!(cmds.contains(&egui::ViewportCommand::Maximized(true)), "{cmds:?}"); + let (r, cmds) = run(&mut app, true); + assert_eq!(r, json!({"maximized": false})); + assert!(cmds.contains(&egui::ViewportCommand::Maximized(false)), "{cmds:?}"); + } + #[test] fn dispatch_uses_the_active_custom_preset() { let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); @@ -1992,6 +2085,41 @@ mod tests { assert_eq!(*asked.borrow(), ["project Frame.png"]); } + /// #511: with a host's non-blocking dialogs, a command's file dialog opens without running + /// a nested event loop inside the frame (a drag over the window then aborted the app on + /// macOS); the command runs on a later frame with the answer, and a cancelled one not at all. + #[test] + fn file_dialogs_can_answer_on_a_later_frame() { + use std::sync::{Arc, Mutex}; + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.session.execute("comp.new", json!({"name": "A", "width": 64, "height": 36, "frameRate": 30, "duration": 1})).unwrap(); + let ctx = egui::Context::default(); + app.hooks.pick_save = Some(Box::new(|_: &str| panic!("the blocking dialog is not used"))); + let pending: Arc>> = Arc::default(); + let queue = pending.clone(); + app.hooks.file_dialog_async = Some(Box::new(move |dialog, done| queue.lock().unwrap().push((dialog, done)))); + assert_eq!(invoke(&mut app, &ctx, "file.saveAs", json!({})).unwrap(), json!({"dialog": "file.saveAs"})); + let (dialog, done) = pending.lock().unwrap().pop().expect("a dialog was shown"); + assert_eq!(dialog, crate::FileDialog::Save { default: "Untitled Project.ecproj".into(), ext: "ecproj".into(), project: true }); + assert!(app.session.path.is_none(), "nothing saved until the dialog answers"); + let dir = std::env::temp_dir().join(format!("ec-dialog-answer-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Answered.ecproj"); + // Answered from another thread, as the host's dialog does. + std::thread::spawn(move || done(vec![path.to_string_lossy().to_string()])).join().unwrap(); + app.run_dialog_answers(); + assert_eq!(app.session.path.as_deref(), Some(dir.join("Answered.ecproj").to_string_lossy().as_ref())); + assert!(dir.join("Answered.ecproj").is_file()); + // Cancelled: nothing runs. + invoke(&mut app, &ctx, "comp.saveFrameAs", json!({})).unwrap(); + let (dialog, done) = pending.lock().unwrap().pop().expect("a dialog was shown"); + assert_eq!(dialog, crate::FileDialog::Save { default: "Frame.png".into(), ext: "png".into(), project: false }); + done(Vec::new()); + app.run_dialog_answers(); + assert!(!dir.join("Frame.png").exists()); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn menu_items_cover_the_tree() { assert_eq!(menus().last(), Some(&"Help")); diff --git a/crates/ui-egui/src/panels/camera_tracker_ui.rs b/crates/ui-egui/src/panels/camera_tracker_ui.rs index 18e24813..71cea155 100644 --- a/crates/ui-egui/src/panels/camera_tracker_ui.rs +++ b/crates/ui-egui/src/panels/camera_tracker_ui.rs @@ -215,8 +215,22 @@ pub fn viewer_hook( return; } // Interaction over the image. - let resp = ui.interact(map.area, egui::Id::new("viewer-camera-tracker"), Sense::click_and_drag()); + // While Space (Hand tool) is held the overlay only hovers, so the viewer underneath gets the pan drag. + let sense = if super::space_hand(&ctx) { Sense::hover() } else { Sense::click_and_drag() }; + let resp = ui.interact(map.area, egui::Id::new("viewer-camera-tracker"), sense); app.auto.add("viewer.cameraTracker", map.area, "3D Camera Tracker points"); + // The overlay is on top of the viewer, so a middle-button drag lands here: pan the viewer like the Hand tool. + if resp.dragged_by(egui::PointerButton::Middle) { + let pid = egui::Id::new("viewer-camera-tracker-pan"); + if resp.drag_started() { + let start_pan = app.ui.viewer.pan; + ui.data_mut(|d| d.insert_temp(pid, start_pan)); + } + if let (Some(start_pan), Some(d)) = (ui.data(|m| m.get_temp::<[f32; 2]>(pid)), resp.total_drag_delta()) { + app.ui.viewer.pan = [start_pan[0] + d.x, start_pan[1] + d.y]; + } + return; + } for (id, sp, r) in hits.iter().take(400) { app.auto.add(&format!("viewer.cameraTracker.point.{id}"), Rect::from_center_size(*sp, vec2(r * 2.0 + 4.0, r * 2.0 + 4.0)), "track point"); } diff --git a/crates/ui-egui/src/panels/comp_settings.rs b/crates/ui-egui/src/panels/comp_settings.rs index b18e4c8d..ca003c3b 100644 --- a/crates/ui-egui/src/panels/comp_settings.rs +++ b/crates/ui-egui/src/panels/comp_settings.rs @@ -212,7 +212,7 @@ pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens, auto: &mut Registr ui.label(tr("Width:")); ui.horizontal(|ui| { let (ow, oh) = (d.width, d.height); - let r = ui.add(egui::DragValue::new(&mut d.width).range(4..=30000).suffix(" px")); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.width).range(4..=30000).suffix(" px"))); auto.add("dialog.comp.width", r.rect, "Width"); let lbl = format!("Lock Aspect Ratio to {}", aspect_label(ow as f64, oh as f64)); let r = ui.checkbox(&mut d.lock_aspect, lbl); @@ -224,7 +224,7 @@ pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens, auto: &mut Registr ui.end_row(); ui.label(tr("Height:")); let (ow, oh) = (d.width, d.height); - let r = ui.add(egui::DragValue::new(&mut d.height).range(4..=30000).suffix(" px")); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.height).range(4..=30000).suffix(" px"))); auto.add("dialog.comp.height", r.rect, "Height"); if d.lock_aspect && oh != d.height && oh > 0 { d.width = ((d.height as f64) * ow as f64 / oh as f64).round().max(4.0) as u32; @@ -254,7 +254,7 @@ pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens, auto: &mut Registr ui.label(tr("Frame Rate:")); ui.horizontal(|ui| { // Any rate can be typed; the list holds the common ones. - let r = ui.add(egui::DragValue::new(&mut d.fps).range(1.0..=999.0).speed(0.01).max_decimals(3)); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.fps).range(1.0..=999.0).speed(0.01).max_decimals(3))); auto.add("dialog.comp.frameRate", r.rect, "Frame Rate"); let r = egui::ComboBox::from_id_salt("comp-fps").width(18.0).selected_text("").show_ui(ui, |ui| { for f in FRAME_RATES { @@ -320,19 +320,19 @@ pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens, auto: &mut Registr ui.label(egui::RichText::new(tr("Motion Blur")).strong()); ui.end_row(); ui.label(tr("Shutter Angle:")); - let r = ui.add(egui::DragValue::new(&mut d.shutter_angle).range(0.0..=720.0).suffix("°")); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.shutter_angle).range(0.0..=720.0).suffix("°"))); auto.add("dialog.comp.shutterAngle", r.rect, "Shutter Angle"); ui.end_row(); ui.label(tr("Shutter Phase:")); - let r = ui.add(egui::DragValue::new(&mut d.shutter_phase).range(-360.0..=360.0).suffix("°")); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.shutter_phase).range(-360.0..=360.0).suffix("°"))); auto.add("dialog.comp.shutterPhase", r.rect, "Shutter Phase"); ui.end_row(); ui.label(tr("Samples Per Frame:")); - let r = ui.add(egui::DragValue::new(&mut d.samples).range(2..=64)); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.samples).range(2..=64))); auto.add("dialog.comp.samples", r.rect, "Samples Per Frame"); ui.end_row(); ui.label(tr("Adaptive Sample Limit:")); - let r = ui.add(egui::DragValue::new(&mut d.adaptive_limit).range(16..=256)); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.adaptive_limit).range(16..=256))); auto.add("dialog.comp.adaptiveLimit", r.rect, "Adaptive Sample Limit"); ui.end_row(); }); diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 08e7c302..81fa6bab 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -361,14 +361,14 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let (ow, oh) = (size[0], size[1]); ui.label(tr("Width:")); ui.horizontal(|ui| { - let r = ui.add(egui::DragValue::new(&mut size[0]).range(1..=30000).suffix(" px")); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut size[0]).range(1..=30000).suffix(" px"))); auto.add("dialog.solid.width", r.rect, "Width"); let r = ui.checkbox(&mut lock, tr_args("Lock Aspect Ratio to {}", &[&(super::comp_settings::aspect_label(ow as f64, oh as f64))])); auto.add("dialog.solid.lockAspect", r.rect, "Lock Aspect Ratio"); }); ui.end_row(); ui.label(tr("Height:")); - let r = ui.add(egui::DragValue::new(&mut size[1]).range(1..=30000).suffix(" px")); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut size[1]).range(1..=30000).suffix(" px"))); auto.add("dialog.solid.height", r.rect, "Height"); ui.end_row(); if lock && ow > 0 && oh > 0 { diff --git a/crates/ui-egui/src/panels/dialogs_3d.rs b/crates/ui-egui/src/panels/dialogs_3d.rs index 485e2e8b..afc0fc8a 100644 --- a/crates/ui-egui/src/panels/dialogs_3d.rs +++ b/crates/ui-egui/src/panels/dialogs_3d.rs @@ -188,13 +188,13 @@ pub fn camera(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.end_row(); ui.label(tr("Zoom")); ui.horizontal(|ui| { - ui.add(egui::DragValue::new(&mut d.zoom).range(1.0..=100000.0).speed(1.0).suffix(" px")); + ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.zoom).range(1.0..=100000.0).speed(1.0).suffix(" px"))); let mut aov = angle_of_view(w, d.zoom); - if ui.add(egui::DragValue::new(&mut aov).range(0.1..=179.0).speed(0.1).suffix("°")).changed() { + if ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut aov).range(0.1..=179.0).speed(0.1).suffix("°"))).changed() { d.zoom = w * 0.5 / (aov.to_radians() * 0.5).tan(); } let mut fl = focal_for_zoom(w, d.zoom); - if ui.add(egui::DragValue::new(&mut fl).range(0.1..=10000.0).speed(0.1).suffix(" mm")).changed() { + if ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut fl).range(0.1..=10000.0).speed(0.1).suffix(" mm"))).changed() { d.zoom = zoom_for_focal(w, fl); } }); @@ -204,15 +204,15 @@ pub fn camera(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.end_row(); ui.label(tr("Focus Distance")); ui.horizontal(|ui| { - ui.add_enabled(!d.lock_to_zoom, egui::DragValue::new(&mut d.focus).range(0.0..=100000.0).suffix(" px")); + ui.add_enabled(!d.lock_to_zoom, crate::widgets::drag_value(egui::DragValue::new(&mut d.focus).range(0.0..=100000.0).suffix(" px"))); ui.checkbox(&mut d.lock_to_zoom, tr("Lock to Zoom")); }); ui.end_row(); ui.label(tr("Aperture")); - ui.add(egui::DragValue::new(&mut d.aperture).range(0.0..=10000.0).speed(0.1).suffix(" px")); + ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.aperture).range(0.0..=10000.0).speed(0.1).suffix(" px"))); ui.end_row(); ui.label(tr("Blur Level")); - ui.add(egui::DragValue::new(&mut d.blur).range(0.0..=10000.0).suffix(" %")); + ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.blur).range(0.0..=10000.0).suffix(" %"))); ui.end_row(); }); ui.add_space(14.0); @@ -268,18 +268,18 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { }); ui.end_row(); ui.label(tr("Color")); - ui.color_edit_button_rgb(&mut d.color); + crate::widgets::srgb_color_button(ui, &mut d.color); ui.end_row(); ui.label(tr("Intensity")); - ui.add(egui::DragValue::new(&mut d.intensity).range(-10000.0..=10000.0).suffix(" %")); + ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.intensity).range(-10000.0..=10000.0).suffix(" %"))); ui.end_row(); let kind = KINDS[d.kind.min(3)]; if kind == LightKind::Spot { ui.label(tr("Cone Angle")); - ui.add(egui::DragValue::new(&mut d.cone_angle).range(0.0..=180.0).suffix("°")); + ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.cone_angle).range(0.0..=180.0).suffix("°"))); ui.end_row(); ui.label(tr("Cone Feather")); - ui.add(egui::DragValue::new(&mut d.cone_feather).range(0.0..=100.0).suffix(" %")); + ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.cone_feather).range(0.0..=100.0).suffix(" %"))); ui.end_row(); } if kind != LightKind::Ambient { @@ -292,20 +292,20 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.end_row(); if d.falloff > 0 { ui.label(tr("Radius")); - ui.add(egui::DragValue::new(&mut d.radius).range(0.0..=100000.0).suffix(" px")); + ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.radius).range(0.0..=100000.0).suffix(" px"))); ui.end_row(); ui.label(tr("Falloff Distance")); - ui.add(egui::DragValue::new(&mut d.falloff_distance).range(0.0..=100000.0).suffix(" px")); + ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.falloff_distance).range(0.0..=100000.0).suffix(" px"))); ui.end_row(); } ui.label(tr("Shadows")); ui.checkbox(&mut d.casts_shadows, tr("Casts Shadows")); ui.end_row(); ui.label(tr("Shadow Darkness")); - ui.add_enabled(d.casts_shadows, egui::DragValue::new(&mut d.shadow_darkness).range(0.0..=100.0).suffix(" %")); + ui.add_enabled(d.casts_shadows, crate::widgets::drag_value(egui::DragValue::new(&mut d.shadow_darkness).range(0.0..=100.0).suffix(" %"))); ui.end_row(); ui.label(tr("Shadow Diffusion")); - ui.add_enabled(d.casts_shadows, egui::DragValue::new(&mut d.shadow_diffusion).range(0.0..=1000.0).suffix(" px")); + ui.add_enabled(d.casts_shadows, crate::widgets::drag_value(egui::DragValue::new(&mut d.shadow_diffusion).range(0.0..=1000.0).suffix(" px"))); ui.end_row(); } }); diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 94d8e5a4..501adf89 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -79,6 +79,46 @@ pub fn reveal_applied(app: &mut EffectcraftApp, params: &serde_json::Value) { app.raise_panel(crate::dock::PanelKind::EffectControls); } +/// After Effects asks for an effect's file as it applies it (Apply Color LUT opens the file +/// dialog for its LUT). For the instances `effect.apply` made (`applied`, its result), open the +/// file dialog for their first empty file parameter and set the file on each; cancelling +/// leaves the effect without one. Does nothing without a file dialog (tests, agents). +pub fn choose_files_after_apply(app: &mut EffectcraftApp, applied: &serde_json::Value) { + let Some(pick) = app.hooks.pick_files.as_ref() else { return }; + let Some(comp) = app.session.active_comp() else { return }; + let uids: Vec = applied.get("effects").and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|v| v.as_u64()).collect()).unwrap_or_default(); + let mut targets = vec![]; + let mut filters: Vec = vec![]; + for uid in uids { + for l in &comp.layers { + let Some(g) = l.props.find_group(uid) else { continue }; + let mut found = None; + g.walk("", &mut |_, pr| { + if found.is_none() + && let ParamUi::File { filters } = &pr.ui + && matches!(&pr.value, Value::Str(s) if s.trim().is_empty()) + { + found = Some((pr.uid, filters.clone())); + } + }); + if let Some((prop, f)) = found { + targets.push((l.id.0, prop)); + filters = f; + } + } + } + if targets.is_empty() { + return; + } + let exts: Vec<&str> = filters.iter().map(String::as_str).collect(); + let Some(path) = pick(&exts).into_iter().next() else { return }; + for (layer, prop) in targets { + if let Err(e) = app.session.execute("prop.set", json!({"layer": layer, "prop": prop, "value": path})) { + app.ui.status = e.to_string(); + } + } +} + /// An undo-merge key for one gesture on `id`: a new key each time a drag starts, so separate /// drags are separate undo steps while one drag is one step. pub(super) fn gesture_key(ui: &egui::Ui, id: egui::Id, started: bool) -> String { @@ -140,14 +180,15 @@ fn key_navigator( match what { "prev" => actions.push(("time.go".into(), json!({"to": "prevKey", "prop": uid}))), "next" => actions.push(("time.go".into(), json!({"to": "nextKey", "prop": uid}))), - _ => actions.push(("prop.toggleKey".into(), json!({"layer": layer.id.0, "prop": uid}))), + _ => actions.push(("prop.toggleKey".into(), json!({"layer": layer.id.0, "prop": uid, "selected": true}))), } } } } -/// Toggle a viewer pick (crosshair / eyedropper) for a parameter. -fn toggle_pick(app: &mut EffectcraftApp, kind: &str, layer: &Layer, prop: &Property) { +/// Toggle a viewer pick (crosshair / eyedropper) for a parameter (also the Timeline's colour +/// eyedroppers). +pub(crate) fn toggle_pick(app: &mut EffectcraftApp, kind: &str, layer: &Layer, prop: &Property) { let pick = FxPick { kind: kind.into(), layer: layer.id.0, prop: prop.uid, name: prop.name.clone() }; if app.ui.fx_pick.as_ref() == Some(&pick) { app.ui.fx_pick = None; @@ -201,7 +242,7 @@ fn prop_row( ); app.auto.add(&format!("effectControls.prop.{uid}.stopwatch"), swr, &prop.name); if resp.clicked() { - actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid}))); + actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid, "selected": true}))); } } if prop.is_animated() { @@ -233,8 +274,11 @@ fn prop_row( ui.interact(name_clip, egui::Id::new(("ec-prop-name", uid)), Sense::click()); app.auto.add(&format!("effectControls.prop.{uid}.name"), name_clip, &prop.name); let merge = format!("ec-{uid}"); - let set = - |actions: &mut Actions, v: serde_json::Value| actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge}))); + // With several layers selected the edit applies to the same effect property of each (#491). + let scrub = ui.ctx().dragged_id().is_some() && ui.input(|i| i.pointer.is_decidedly_dragging()); + let set = |actions: &mut Actions, v: serde_json::Value| { + actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge, "selected": true, "offset": scrub}))) + }; let twirl_open = |app: &mut EffectcraftApp, ui: &mut egui::Ui| { let open = app.ui.fx_slider_open.contains(&uid); let tw = Rect::from_center_size(pos2(swr.min.x - 9.0, cy), vec2(10.0, 10.0)); @@ -353,7 +397,7 @@ fn prop_row( } app.auto.add(&format!("effectControls.prop.{uid}.eyedropper"), er, &prop.name); let mut rgb = [c[0] as f32, c[1] as f32, c[2] as f32]; - if crate::header::color_popup(ui, pop, sr.left_bottom(), &mut rgb) { + if crate::widgets::color_popup(ui, pop, sr.left_bottom(), &mut rgb) { set(actions, json!([rgb[0], rgb[1], rgb[2], 1.0])); } } @@ -417,6 +461,59 @@ fn prop_row( let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((r.max.x - vx - 56.0).clamp(80.0, 200.0), 18.0)); super::fx_editors::channel_popup(app, ui, layer, prop, s, dr, actions); } + // A file parameter (Apply Color LUT's LUT, an OCIO config): its file name and Choose…, + // as in After Effects. + Value::Str(s) if matches!(prop.ui, ParamUi::File { .. }) => { + let bw = 70.0; + let br = Rect::from_min_size(pos2(vx, cy - 9.0), vec2(bw, 18.0)); + if widgets::text_button(ui, br, crate::i18n::tr("Choose..."), false, &t, egui::Id::new(("ec-file", uid))).clicked() { + let filters: Vec<&str> = match &prop.ui { + ParamUi::File { filters } => filters.iter().map(String::as_str).collect(), + _ => vec![], + }; + match app.hooks.pick_files.as_ref() { + Some(pick) => { + if let Some(path) = pick(&filters).into_iter().next() { + actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": path}))); + } + } + None => app.ui.status = "no file dialog available: set the path with prop.set".into(), + } + } + app.auto.add(&format!("effectControls.prop.{uid}.choose"), br, &prop.name); + let shown = if s.trim().is_empty() { + crate::i18n::tr("None").to_string() + } else if s.contains('\n') { + prop.name.clone() + } else { + effectcraft_engine::sequence::file_name(s) + }; + let nr = Rect::from_min_max(pos2(br.max.x + 8.0, r.min.y), pos2(r.max.x - 8.0, r.max.y)); + let np = p.with_clip_rect(nr.intersect(p.clip_rect())); + np.text(pos2(nr.min.x, cy), Align2::LEFT_CENTER, &shown, Tokens::ui(12.0), if s.trim().is_empty() { t.text_dim } else { t.text }); + let hover = ui.interact(nr, egui::Id::new(("ec-file-name", uid)), Sense::hover()); + if !s.trim().is_empty() && !s.contains('\n') { + hover.on_hover_text(s.as_str()); + } + app.auto.add(&format!("effectControls.prop.{uid}.value"), nr, &shown); + } + // A text parameter (Cryptomatte's Selection, Colorama's Output Cycle, config names): + // typed in a field, set when it loses the focus (Esc keeps the old text). + Value::Str(s) if matches!(prop.ui, ParamUi::Text) => { + let fr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((r.max.x - vx - 12.0).clamp(80.0, 320.0), 18.0)); + let bid = egui::Id::new(("ec-text", uid)); + let mut buf: String = ui.data(|d| d.get_temp(bid)).unwrap_or_else(|| s.clone()); + let resp = widgets::text_field(ui, fr, &mut buf, "", &t); + app.auto.add(&format!("effectControls.prop.{uid}.value"), fr, &prop.name); + if resp.lost_focus() { + if buf != *s && !ui.input(|i| i.key_pressed(egui::Key::Escape)) { + actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": buf}))); + } + ui.data_mut(|d| d.remove::(bid)); + } else if resp.has_focus() { + ui.data_mut(|d| d.insert_temp(bid, buf)); + } + } Value::Gradient(g) => { let gr = Rect::from_min_size(pos2(vx, cy - 7.0), vec2((r.max.x - vx - 56.0).clamp(60.0, 180.0), 14.0)); let n = 48; @@ -1008,7 +1105,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let open = !app.ui.fx_closed.contains(&g.uid); let selected = app.session.state.selected_props.iter().any(|(l, u)| *l == layer.id && *u == g.uid); let active = effect_active(g, layer.id, &app.session.state.selected_props); - bp.rect_filled(r, 0.0, if active { Color32::from_rgb(0x2f, 0x3a, 0x52) } else { Color32::from_rgb(0x2a, 0x2a, 0x2a) }); + bp.rect_filled(r, 0.0, if active { t.fx_header_active } else { t.fx_header }); let fxr = Rect::from_center_size(pos2(r.min.x + 14.0, r.center().y), vec2(16.0, 16.0)); if widgets::icon_toggle(ui, fxr, Icon::Fx, g.enabled, &t, egui::Id::new(("ec-fx", g.uid)), Sense::click()).clicked() { actions.push(("effect.toggle".into(), json!({"layer": layer.id.0, "effect": g.uid}))); diff --git a/crates/ui-egui/src/panels/effects_presets.rs b/crates/ui-egui/src/panels/effects_presets.rs index 7b68ba7f..e60354b1 100644 --- a/crates/ui-egui/src/panels/effects_presets.rs +++ b/crates/ui-egui/src/panels/effects_presets.rs @@ -422,11 +422,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { }; match app.session.execute(cmd, params) { Err(e) => app.ui.status = e.to_string(), - Ok(_) => { + Ok(applied) => { if let Apply::Effect(id) = &apply { push_recent(&mut app.ui.effects_recent, id); app.show_panel(crate::dock::PanelKind::EffectControls); app.ui.focused = crate::dock::PanelKind::EffectsPresets; + crate::panels::effect_controls::choose_files_after_apply(app, &applied); } } } diff --git a/crates/ui-egui/src/panels/essential.rs b/crates/ui-egui/src/panels/essential.rs index 26d458d4..048b0b58 100644 --- a/crates/ui-egui/src/panels/essential.rs +++ b/crates/ui-egui/src/panels/essential.rs @@ -344,11 +344,10 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt } ControlType::Color => { let c = v.as_color(); - let mut rgba = egui::Rgba::from_rgba_unmultiplied(c[0], c[1], c[2], c[3]); - let r = egui::widgets::color_picker::color_edit_button_rgba(ui, &mut rgba, egui::widgets::color_picker::Alpha::Opaque); + let mut rgb = [c[0], c[1], c[2]]; + let r = crate::widgets::srgb_color_button(ui, &mut rgb); if r.changed() { - let u = rgba.to_rgba_unmultiplied(); - out = Some(json!([u[0], u[1], u[2], u[3]])); + out = Some(json!([rgb[0], rgb[1], rgb[2], c[3]])); } r.rect } @@ -375,10 +374,41 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt }); r.response.rect } + // Scale keeps its Constrain Proportions link, on by default, as in After Effects (#338). + // The link is the property's own, shared with the Timeline's chain link. + ControlType::Point if p.match_id == "scale" && matches!(p.ui, ParamUi::Percent) => { + let comps = v.components(); + let linked = !app.ui.timeline.unlinked.contains(&p.uid); + let (lr, link) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::click()); + let t = app.tokens; + crate::icons::paint(ui.painter(), lr, crate::icons::Icon::Link, if linked { t.accent } else { t.text_faint }); + let link = link.on_hover_text(tr("Constrain Proportions")); + app.auto.add(&format!("{auto}.link"), lr, "Constrain Proportions"); + if link.clicked() && !app.ui.timeline.unlinked.remove(&p.uid) { + app.ui.timeline.unlinked.insert(p.uid); + } + let mut rect = lr; + for (d, &old) in comps.iter().enumerate() { + let mut x = old; + let r = ui.add(egui::DragValue::new(&mut x).speed(0.5).suffix(if d + 1 == comps.len() { "%" } else { "" }).max_decimals(1)); + app.auto.add(&format!("{auto}.{d}"), r.rect, &p.name); + rect = rect.union(r.rect); + if r.changed() { + // Alt edits one value of a linked pair, as in the Timeline. + let next = if linked && !ui.input(|i| i.modifiers.alt) { + widgets::constrained(&comps, d, x) + } else { + comps.iter().enumerate().map(|(e, &c)| if e == d { x } else { c }).collect() + }; + out = Some(json!(next)); + } + } + rect + } ControlType::Point => { let mut a = v.as_vec2(); - let r1 = ui.add(egui::DragValue::new(&mut a[0]).speed(1.0).max_decimals(1)); - let r2 = ui.add(egui::DragValue::new(&mut a[1]).speed(1.0).max_decimals(1)); + let r1 = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut a[0]).speed(1.0).max_decimals(1))); + let r2 = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut a[1]).speed(1.0).max_decimals(1))); if r1.changed() || r2.changed() { out = Some(json!(a)); } @@ -386,7 +416,7 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt } ControlType::Angle => { let mut a = v.as_f64(); - let r = ui.add(egui::DragValue::new(&mut a).speed(1.0).suffix("°").max_decimals(1)); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut a).speed(1.0).suffix("°").max_decimals(1))); if r.changed() { out = Some(json!(a)); } @@ -415,7 +445,7 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt } } }); - let r3 = ui.add(egui::DragValue::new(&mut d.size).speed(0.5).range(1.0..=2000.0).suffix(" px").max_decimals(1)); + let r3 = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.size).speed(0.5).range(1.0..=2000.0).suffix(" px").max_decimals(1))); app.auto.add(&format!("{auto}.font"), r1.response.rect, "Font"); app.auto.add(&format!("{auto}.style"), r2.response.rect, "Style"); app.auto.add(&format!("{auto}.size"), r3.rect, "Size"); @@ -428,7 +458,7 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt // One percentage for every axis. let comps = v.components(); let mut x = comps.first().copied().unwrap_or(100.0); - let r = ui.add(egui::DragValue::new(&mut x).speed(0.5).suffix("%").max_decimals(1)); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut x).speed(0.5).suffix("%").max_decimals(1))); if r.changed() { out = Some(json!(vec![x; comps.len().max(1)])); } @@ -438,7 +468,7 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt let mut x = v.as_f64(); let r = match p.ui { ParamUi::Slider { slider_min, slider_max, .. } => ui.add(egui::Slider::new(&mut x, slider_min..=slider_max).max_decimals(2)), - _ => ui.add(egui::DragValue::new(&mut x).speed(0.5).max_decimals(2)), + _ => ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut x).speed(0.5).max_decimals(2))), }; if r.changed() { out = Some(json!(x)); diff --git a/crates/ui-egui/src/panels/expr_editor.rs b/crates/ui-egui/src/panels/expr_editor.rs index 1efd6876..ffb61738 100644 --- a/crates/ui-egui/src/panels/expr_editor.rs +++ b/crates/ui-egui/src/panels/expr_editor.rs @@ -9,13 +9,42 @@ use egui::{Color32, FontId, Rect, Stroke, pos2, vec2}; use crate::theme::Tokens; -/// Syntax colours. -const KEYWORD: Color32 = Color32::from_rgb(0xc6, 0x8a, 0xe6); -const NUMBER: Color32 = Color32::from_rgb(0xe0, 0xb4, 0x6c); -const STRING: Color32 = Color32::from_rgb(0x9c, 0xd0, 0x7a); -const COMMENT: Color32 = Color32::from_rgb(0x80, 0x88, 0x90); -const API: Color32 = Color32::from_rgb(0x6c, 0xb8, 0xf0); -const BRACKET_BG: Color32 = Color32::from_rgb(0x4a, 0x55, 0x6a); +/// Syntax colours, and the matched brackets' background. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Syntax { + pub keyword: Color32, + pub number: Color32, + pub string: Color32, + pub comment: Color32, + pub api: Color32, + pub bracket_bg: Color32, +} + +impl Syntax { + /// The colours for the theme: on the Light theme's light expression field, darker ones that + /// stay readable (#644). + pub fn for_tokens(t: &Tokens) -> Syntax { + if t.kind.is_light() { + Syntax { + keyword: Color32::from_rgb(0x7a, 0x2f, 0xa8), + number: Color32::from_rgb(0x8a, 0x4b, 0x00), + string: Color32::from_rgb(0x2c, 0x6a, 0x10), + comment: Color32::from_rgb(0x52, 0x5a, 0x62), + api: Color32::from_rgb(0x0a, 0x57, 0x94), + bracket_bg: Color32::from_rgb(0xd0, 0xdc, 0xf0), + } + } else { + Syntax { + keyword: Color32::from_rgb(0xc6, 0x8a, 0xe6), + number: Color32::from_rgb(0xe0, 0xb4, 0x6c), + string: Color32::from_rgb(0x9c, 0xd0, 0x7a), + comment: Color32::from_rgb(0x80, 0x88, 0x90), + api: Color32::from_rgb(0x6c, 0xb8, 0xf0), + bracket_bg: Color32::from_rgb(0x4a, 0x55, 0x6a), + } + } + } +} const KEYWORDS: &[&str] = &[ "var", @@ -184,7 +213,7 @@ pub fn completions(text: &str, cursor: usize) -> (String, Vec<&'static str>) { } /// Colour runs for JavaScript: (byte start, byte end, colour). -pub fn highlight(text: &str, base: Color32) -> Vec<(usize, usize, Color32)> { +pub fn highlight(text: &str, base: Color32, sx: &Syntax) -> Vec<(usize, usize, Color32)> { let b = text.as_bytes(); let mut out = vec![]; let mut i = 0; @@ -195,35 +224,35 @@ pub fn highlight(text: &str, base: Color32) -> Vec<(usize, usize, Color32)> { while i < b.len() && b[i] != b'\n' { i += 1; } - COMMENT + sx.comment } else if c == b'/' && b.get(i + 1) == Some(&b'*') { i += 2; while i < b.len() && !(b[i] == b'*' && b.get(i + 1) == Some(&b'/')) { i += 1; } i = (i + 2).min(b.len()); - COMMENT + sx.comment } else if c == b'"' || c == b'\'' || c == b'`' { i += 1; while i < b.len() && b[i] != c { i += if b[i] == b'\\' { 2 } else { 1 }; } i = (i + 1).min(b.len()); - STRING + sx.string } else if c.is_ascii_digit() { while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == b'.') { i += 1; } - NUMBER + sx.number } else if c.is_ascii_alphabetic() || c == b'_' || c == b'$' { while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == b'_' || b[i] == b'$') { i += 1; } let w = &text[start..i]; if KEYWORDS.contains(&w) { - KEYWORD + sx.keyword } else if COMPLETIONS.contains(&w) { - API + sx.api } else { base } @@ -246,9 +275,9 @@ fn byte_of(text: &str, ci: usize) -> usize { } /// The layout job of the editor text: syntax colours, the matched brackets' background, wrap. -fn layout(text: &str, font: FontId, base: Color32, p: &Scripting, brackets: Option<(usize, usize)>, wrap: f32) -> LayoutJob { +fn layout(text: &str, font: FontId, base: Color32, sx: &Syntax, p: &Scripting, brackets: Option<(usize, usize)>, wrap: f32) -> LayoutJob { let mut job = LayoutJob::default(); - let runs = if p.syntax_highlighting { highlight(text, base) } else { vec![(0, text.len(), base)] }; + let runs = if p.syntax_highlighting { highlight(text, base, sx) } else { vec![(0, text.len(), base)] }; let marks: Vec = brackets.filter(|_| p.bracket_matching).map(|(a, b)| vec![byte_of(text, a), byte_of(text, b)]).unwrap_or_default(); for (s, e, col) in runs { // Split runs at the marked brackets. @@ -263,7 +292,7 @@ fn layout(text: &str, font: FontId, base: Color32, p: &Scripting, brackets: Opti cuts.sort_unstable(); cuts.dedup(); for w in cuts.windows(2) { - let bg = if marks.contains(&w[0]) { BRACKET_BG } else { Color32::TRANSPARENT }; + let bg = if marks.contains(&w[0]) { sx.bracket_bg } else { Color32::TRANSPARENT }; job.append(&text[w[0]..w[1]], 0.0, TextFormat { font_id: font.clone(), color: col, background: bg, ..Default::default() }); } } @@ -281,8 +310,9 @@ pub fn editor(ui: &mut egui::Ui, id: egui::Id, buf: &mut String, rect: Rect, p: let cursor = egui::TextEdit::load_state(ui.ctx(), id).and_then(|s| s.cursor.char_range()).map(|r| r.primary.index.0); let brackets = cursor.and_then(|c| matching_bracket(buf, c)); let pc = p.clone(); + let sx = Syntax::for_tokens(t); let mut layouter = move |ui: &egui::Ui, text: &dyn egui::TextBuffer, wrap: f32| { - let job = layout(text.as_str(), font.clone(), color, &pc, brackets, wrap); + let job = layout(text.as_str(), font.clone(), color, &sx, &pc, brackets, wrap); ui.fonts_mut(|f| f.layout_job(job)) }; let mut child = ui.new_child(egui::UiBuilder::new().max_rect(er)); @@ -392,3 +422,70 @@ pub fn error_banner(app: &mut crate::EffectcraftApp, ui: &egui::Ui, area: Rect) } } } + +#[cfg(test)] +mod contrast_tests { + use super::*; + use crate::theme::ThemeKind; + + /// WCAG contrast ratio of two opaque sRGB colours. + fn contrast(a: Color32, b: Color32) -> f32 { + let lum = |c: Color32| { + let f = |v: u8| { + let v = v as f32 / 255.0; + if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } + }; + 0.2126 * f(c.r()) + 0.7152 * f(c.g()) + 0.0722 * f(c.b()) + }; + let (x, y) = (lum(a), lum(b)); + (x.max(y) + 0.05) / (x.min(y) + 0.05) + } + + /// #644: on every theme, effect names stay readable on Effect Controls' headers, and the + /// expression field's text (every syntax colour, enabled and disabled expressions, matched + /// brackets, line numbers) on its background; the switch icons show on their wells. + #[test] + fn custom_painted_surfaces_keep_text_readable_on_every_theme() { + for kind in [ThemeKind::Dark, ThemeKind::Darker, ThemeKind::Light] { + let t = Tokens::for_kind(kind); + let sx = Syntax::for_tokens(&t); + for (what, bg) in [("effect header", t.fx_header), ("active effect header", t.fx_header_active)] { + assert!(contrast(t.text, bg) >= 4.5, "{kind:?}: effect name on {what}: {:.2}", contrast(t.text, bg)); + } + assert!(contrast(t.icon, t.switch_well) >= 3.0, "{kind:?}: switch icons on their wells"); + if !kind.is_light() { + continue; + } + let fg = [ + ("keyword", sx.keyword), + ("number", sx.number), + ("string", sx.string), + ("comment", sx.comment), + ("api", sx.api), + ("enabled expression", t.expr_text), + ("disabled expression", t.text_dim), + ("line numbers", t.text_faint), + ]; + for (what, c) in fg { + assert!(contrast(c, t.expr_bg) >= 4.5, "{kind:?}: {what} on the expression field: {:.2}", contrast(c, t.expr_bg)); + assert!(contrast(c, sx.bracket_bg) >= 4.0, "{kind:?}: {what} on a matched bracket: {:.2}", contrast(c, sx.bracket_bg)); + } + // The dark theme's syntax colours would not do on the light field. + let dark = Syntax::for_tokens(&Tokens::for_kind(ThemeKind::Dark)); + assert!([dark.keyword, dark.number, dark.string, dark.api].iter().all(|c| contrast(*c, t.expr_bg) < 4.5)); + } + } + + /// Syntax highlighting uses the theme's colours. + #[test] + fn highlight_uses_the_theme_palette() { + let t = Tokens::for_kind(ThemeKind::Light); + let sx = Syntax::for_tokens(&t); + let text = "var amount = 12; // note"; + let runs = highlight(text, t.expr_text, &sx); + let col = |s: &str| runs.iter().find(|(a, e, _)| text.get(*a..*e) == Some(s)).map(|r| r.2); + assert_eq!(col("var"), Some(sx.keyword)); + assert_eq!(col("12"), Some(sx.number)); + assert_eq!(col("// note"), Some(sx.comment)); + } +} diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index c7f330b0..039ebb69 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -154,8 +154,9 @@ fn is_pdf(p: &Value) -> bool { /// File ▸ Import ▸ File… picked numbered stills that import as image sequences: ask, as After /// Effects' " Sequence" checkbox in its Import dialog does (rfd's native dialog can't /// carry a checkbox), with Force Alphabetical Order and the frame rate. `p`: the import's -/// parameters. Returns whether the dialog opened. Dropped files and agents' `file.import` -/// don't ask (`sequence` defaults to on). +/// 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 +/// ask (files import as stills, folders' runs as sequences). pub fn open_import_sequence(app: &mut EffectcraftApp, p: &Value) -> bool { if has(p, &["sequence", "alphabetical"]) { return false; @@ -187,7 +188,7 @@ fn sequence_fields(app: &EffectcraftApp, runs: &[Vec]) -> Vec { }; vec![ Field::note("note", ¬e), - Field::bool("sequence", &format!("{ext} Sequence"), true), + Field::bool("sequence", &format!("{ext} Sequence"), false), Field::bool("alphabetical", "Force alphabetical order", false), Field::num("frameRate", "Frame rate (fps)", app.session.prefs.import.sequence_fps), Field::note("after", "Change the frame rate, alpha and start frame later with File ▸ Interpret Footage."), @@ -546,6 +547,8 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { ], st.working_space.map_or(0, |c| 1 + effectcraft_engine::project::ColorSpace::WORKING.iter().position(|x| *x == c).unwrap_or(0)), ), + // With Working Space None (After Effects' Working Gamma, 2.2 or 2.4). + Field::choice("workingGamma", "Working gamma", &[("2.2", json!(2.2)), ("2.4", json!(2.4))], usize::from(st.working_gamma_24())), Field::bool("linearize", "Linearize working space", st.linearize), Field::bool("blendLinear", "Blend colors using 1.0 gamma", st.blend_linear), Field::choice( @@ -771,7 +774,7 @@ pub fn show_form(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let r = match &mut fl.kind { FieldKind::Number { value, speed } => { let dec = if *speed < 0.01 { 5 } else { 3 }; - ui.add(egui::DragValue::new(value).speed(*speed).max_decimals(dec)).rect + ui.add(crate::widgets::drag_value(egui::DragValue::new(value).speed(*speed).max_decimals(dec))).rect } FieldKind::Text(s) => ui.add(egui::TextEdit::singleline(s).desired_width(220.0)).rect, FieldKind::Path { value, .. } => { diff --git a/crates/ui-egui/src/panels/fx_editors.rs b/crates/ui-egui/src/panels/fx_editors.rs index 51b146af..a426f5a1 100644 --- a/crates/ui-egui/src/panels/fx_editors.rs +++ b/crates/ui-egui/src/panels/fx_editors.rs @@ -581,7 +581,7 @@ fn colorama_wheel( t.text_dim, ); let mut rgb = [col[0], col[1], col[2]]; - if crate::header::color_popup(ui, pop, sr.left_bottom(), &mut rgb) { + if crate::widgets::color_popup(ui, pop, sr.left_bottom(), &mut rgb) { let mut st = pal.stops.clone(); st[sel].1 = [rgb[0], rgb[1], rgb[2], col[3]]; new_stops = Some(st); diff --git a/crates/ui-egui/src/panels/fx_widgets.rs b/crates/ui-egui/src/panels/fx_widgets.rs index 036ae070..ca49c3fd 100644 --- a/crates/ui-egui/src/panels/fx_widgets.rs +++ b/crates/ui-egui/src/panels/fx_widgets.rs @@ -41,6 +41,17 @@ pub fn parse_angle(s: &str) -> Option { s.parse().ok() } +/// The angle after the degrees field of `v` (which shows `shown`, the remainder's size) went to +/// `new`. A drag moves the angle by the pointer's distance, so it runs through zero into negative +/// angles; a typed number is a size, so the remainder keeps its sign. +fn degrees_edit(v: f64, shown: f64, new: f64, dragging: bool) -> f64 { + if dragging { + return v + (new - shown); + } + let (rev, deg) = split_angle(v); + rev as f64 * 360.0 + if deg < 0.0 || (deg == 0.0 && v < 0.0) { -new } else { new } +} + /// An angle value, `0x+45.0°`, at `at` (top left): the revolutions and the degrees /// are hot numbers of their own (drag to scrub, click to type) and changing one keeps the other. /// Returns the revolutions' rect, the degrees' rect and the new angle when either changed. @@ -49,10 +60,10 @@ pub fn angle_field(ui: &mut egui::Ui, at: Pos2, id: egui::Id, v: f64, decimals: let (rr, new_rev, _) = widgets::hot_int_at(ui, at, id.with("rev"), rev, 0.1, "x", t); let sign = ui.painter().text(pos2(rr.max.x - 2.0, rr.center().y), Align2::LEFT_CENTER, if deg < 0.0 { "-" } else { "+" }, Tokens::ui(12.0), t.hot_text); let (dr, new_deg, _) = widgets::hot_number_at(ui, pos2(sign.max.x - 2.0, at.y), id, deg.abs(), 0.5, (-1e9, 1e9), decimals, "°", t); + let dragging = new_deg.is_some() && ui.ctx().is_being_dragged(id); let out = match (new_rev, new_deg) { (Some(r), _) => Some(r as f64 * 360.0 + deg), - // The degrees field shows the remainder's size; its sign stays. - (None, Some(d)) => Some(rev as f64 * 360.0 + if deg < 0.0 || (deg == 0.0 && v < 0.0) { -d } else { d }), + (None, Some(d)) => Some(degrees_edit(v, deg.abs(), d, dragging)), (None, None) => None, }; (rr, dr, out) @@ -551,6 +562,23 @@ mod tests { assert_eq!(split_angle(-360.0), (-1, 0.0)); } + #[test] + fn dragging_degrees_crosses_zero_into_negative_angles() { + // The field shows the size (0.3), so a 1-degree drag left lands on -0.7, and keeps going. + let mut v = 0.3; + for _ in 0..3 { + let (_, deg) = split_angle(v); + v = degrees_edit(v, deg.abs(), deg.abs() - 1.0, true); + } + assert!((v + 2.7).abs() < 1e-9, "{v}"); + // And back up through zero. + let (_, deg) = split_angle(v); + assert!((degrees_edit(v, deg.abs(), deg.abs() + 3.0, true) - 0.3).abs() < 1e-9); + // Typing keeps the sign shown. + assert_eq!(degrees_edit(-30.0, 30.0, 45.0, false), -45.0); + assert_eq!(degrees_edit(405.0, 45.0, 10.0, false), 370.0); + } + #[test] fn angle_parsing_round_trips() { for v in [0.0, 45.0, 405.0, -30.5, -400.0, 1080.25] { diff --git a/crates/ui-egui/src/panels/graph.rs b/crates/ui-egui/src/panels/graph.rs index 7f19d91b..2dbb9826 100644 --- a/crates/ui-egui/src/panels/graph.rs +++ b/crates/ui-egui/src/panels/graph.rs @@ -14,8 +14,8 @@ use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde_json::json; use super::timeline::TMap; -use crate::EffectcraftApp; use crate::theme::Tokens; +use crate::{EffectcraftApp, widgets}; type Actions = Vec<(String, serde_json::Value)>; @@ -162,10 +162,88 @@ fn curves<'a>(app: &EffectcraftApp, comp: &'a Comp, ectx: &EvalCtx, tm: TMap, pl out } -fn text_button(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, x: &mut f32, y: f32, label: &str, on: bool, id: &str, tip: &str) -> bool { +/// The Graph Editor's button bar, laid out left to right. Buttons that would run past the right +/// edge (a Timeline narrower than the whole row, #457) go to the "»" menu at its end instead of +/// being clipped, so every one stays reachable. +struct ButtonBar { + x: f32, + /// Where the next button must end to stay on the bar (the "»" button's room is kept free). + limit: f32, + /// Buttons moved to the "»" menu: (automation id, label, on, tooltip). + hidden: Vec<(String, String, bool, String)>, + /// The id picked from the "»" menu last frame, run by its button this frame. + chosen: Option, +} + +/// The "»" menu's popup and the id it picked (read by the next frame's buttons). +const MORE_POPUP: &str = "graph-more"; + +impl ButtonBar { + const MORE_W: f32 = 24.0; + + fn new(ctx: &egui::Context, bar: Rect) -> Self { + let chosen = ctx.data_mut(|d| d.remove_temp::(egui::Id::new(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 } + } + + /// The "»" button and its menu, when buttons did not fit. + fn more(&mut self, app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, bar: Rect) { + if self.hidden.is_empty() { + return; + } + let t = app.tokens; + let r = Rect::from_min_size(pos2(bar.max.x - 8.0 - Self::MORE_W, bar.center().y - 9.0), vec2(Self::MORE_W, 18.0)); + let pid = egui::Id::new(MORE_POPUP); + let resp = ui.interact(r, pid.with("button"), Sense::click()).on_hover_text(crate::i18n::tr("More")); + p.rect_filled(r, 3.0, if resp.hovered() { t.hover } else { Color32::from_rgb(0x2c, 0x2c, 0x2c) }); + p.text(r.center(), Align2::CENTER_CENTER, "»", Tokens::ui(12.0), t.text); + app.auto.add("timeline.graph.more", r, "More"); + if resp.clicked() { + let open = widgets::popup_is_open(ui, pid); + ui.data_mut(|d| d.insert_temp(pid.with("open"), !open)); + } + if !widgets::popup_is_open(ui, pid) { + return; + } + let rows = self.hidden.len(); + let pos = r.left_top() - vec2(0.0, rows as f32 * 22.0 + 18.0); + let chosen = widgets::popup_list(ui, pid, pos, r, rows, 0, |ui| { + let mut chosen = None; + for (id, label, on, tip) in &self.hidden { + let item = ui.selectable_label(*on, label.as_str()).on_hover_text(tip.as_str()); + app.auto.add(&format!("timeline.graph.more.{id}"), item.rect, tip); + if item.clicked() { + chosen = Some(id.clone()); + } + } + chosen + }); + if let Some(id) = chosen { + ui.data_mut(|d| d.insert_temp(pid.with("chosen"), id)); + ui.ctx().request_repaint(); + } + } +} + +fn text_button( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + p: &egui::Painter, + bar: &mut ButtonBar, + y: f32, + label: &str, + on: bool, + id: &str, + tip: &str, +) -> bool { let t = app.tokens; let w = p.layout_no_wrap(label.to_string(), Tokens::ui(11.0), t.text).size().x + 14.0; - let r = Rect::from_min_size(pos2(*x, y - 9.0), vec2(w, 18.0)); + // Once one button overflows, the rest follow it into the menu (they keep their order). + if !bar.hidden.is_empty() || bar.x + w > bar.limit { + bar.hidden.push((id.to_string(), label.to_string(), on, tip.to_string())); + return bar.chosen.as_deref() == Some(id); + } + let r = Rect::from_min_size(pos2(bar.x, y - 9.0), vec2(w, 18.0)); let resp = ui.interact(r, egui::Id::new(("graph-btn", id)), Sense::click()).on_hover_text(tip); p.rect_filled( r, @@ -180,7 +258,7 @@ fn text_button(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, x ); p.text(r.center(), Align2::CENTER_CENTER, label, Tokens::ui(11.0), if on { Color32::WHITE } else { t.text }); app.auto.add(&format!("timeline.graph.{id}"), r, tip); - *x += w + 4.0; + bar.x += w + 4.0; resp.clicked() } @@ -446,14 +524,14 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte p.rect_filled(bar, 0.0, t.panel_bg); p.line_segment([bar.left_top(), bar.right_top()], Stroke::new(1.0, t.separator)); let y = bar.center().y; - let mut x = bar.min.x + 8.0; + let mut buttons = ButtonBar::new(&ctx, bar); let tl = &app.ui.timeline; let (auto_type, show_sel, auto_on) = (tl.graph_mode == "auto", tl.graph_show_selected, tl.graph_auto_zoom); if text_button( app, ui, p, - &mut x, + &mut buttons, y, "Auto", auto_type, @@ -463,45 +541,45 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte app.ui.timeline.graph_mode = if auto_type { if speed { "speed" } else { "value" } } else { "auto" }.into(); } // Choosing a graph type turns Auto-Select off, as in After Effects. - if text_button(app, ui, p, &mut x, y, "Value", !speed, "valueGraph", "Edit Value Graph") { + if text_button(app, ui, p, &mut buttons, y, "Value", !speed, "valueGraph", "Edit Value Graph") { app.ui.timeline.graph_mode = "value".into(); } - if text_button(app, ui, p, &mut x, y, "Speed", speed, "speedGraph", "Edit Speed Graph") { + if text_button(app, ui, p, &mut buttons, y, "Speed", speed, "speedGraph", "Edit Speed Graph") { app.ui.timeline.graph_mode = "speed".into(); } - x += 6.0; - if text_button(app, ui, p, &mut x, y, "Selected", show_sel, "showSelected", "Show Selected Properties (else all animated properties)") { + buttons.x += 6.0; + if text_button(app, ui, p, &mut buttons, y, "Selected", show_sel, "showSelected", "Show Selected Properties (else all animated properties)") { app.ui.timeline.graph_show_selected = !show_sel; } - if text_button(app, ui, p, &mut x, y, "Auto-zoom", auto_on, "autoZoom", "Auto-zoom graph height") { + if text_button(app, ui, p, &mut buttons, y, "Auto-zoom", auto_on, "autoZoom", "Auto-zoom graph height") { app.ui.timeline.graph_auto_zoom = !auto_on; } - if text_button(app, ui, p, &mut x, y, "Fit", false, "fit", "Fit all graphs to view") { + if text_button(app, ui, p, &mut buttons, y, "Fit", false, "fit", "Fit all graphs to view") { app.ui.timeline.graph_auto_zoom = true; app.ui.timeline.pps = None; } - if text_button(app, ui, p, &mut x, y, "Fit Selection", false, "fitSelection", "Fit selection to view") { + if text_button(app, ui, p, &mut buttons, y, "Fit Selection", false, "fitSelection", "Fit selection to view") { fit_selection(app, &cs, tm, plot); } - x += 6.0; + buttons.x += 6.0; let (snap, reference, tbox) = (app.ui.timeline.graph_snap, app.ui.timeline.graph_reference, app.ui.timeline.graph_transform_box); - if text_button(app, ui, p, &mut x, y, "Snap", snap, "snap", "Snap (key drags snap to the current time and other keys)") { + if text_button(app, ui, p, &mut buttons, y, "Snap", snap, "snap", "Snap (key drags snap to the current time and other keys)") { app.ui.timeline.graph_snap = !snap; } - if text_button(app, ui, p, &mut x, y, "Reference", reference, "reference", "Show Reference Graph") { + if text_button(app, ui, p, &mut buttons, y, "Reference", reference, "reference", "Show Reference Graph") { app.ui.timeline.graph_reference = !reference; } - if text_button(app, ui, p, &mut x, y, "Transform Box", tbox, "transformBox", "Show Transform Box when multiple keys are selected") { + if text_button(app, ui, p, &mut buttons, y, "Transform Box", tbox, "transformBox", "Show Transform Box when multiple keys are selected") { app.ui.timeline.graph_transform_box = !tbox; } - x += 6.0; + buttons.x += 6.0; let pos_sel = app.session.state.selected_props.iter().find_map(|(l, u)| super::timeline::is_position(comp.layer(*l)?, *u).then_some(l.0)); - if text_button(app, ui, p, &mut x, y, "Separate Dimensions", false, "separateDimensions", "Separate Dimensions (Position)") + if text_button(app, ui, p, &mut buttons, y, "Separate Dimensions", false, "separateDimensions", "Separate Dimensions (Position)") && let Some(l) = pos_sel.or(app.session.state.selected_layers.first().map(|l| l.0)) { actions.push(("prop.separateDimensions".into(), json!({"layer": l}))); } - x += 6.0; + buttons.x += 6.0; for (label, cmd, params, tip) in [ ("Hold", "keys.interpolation", json!({"interpolation": "hold"}), "Convert selected keyframes to Hold"), ("Linear", "keys.interpolation", json!({"interpolation": "linear"}), "Convert selected keyframes to Linear"), @@ -511,10 +589,11 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte ("Ease Out", "keys.easyEaseOut", json!({}), "Easy Ease Out (Ctrl+Shift+F9)"), ] { let id = cmd.trim_start_matches("keys.").to_string() + label.split_whitespace().next().unwrap_or(""); - if text_button(app, ui, p, &mut x, y, label, false, &id, tip) { + if text_button(app, ui, p, &mut buttons, y, label, false, &id, tip) { actions.push((cmd.into(), params)); } } + buttons.more(app, ui, p, bar); } /// Zoom time and value to the selected keys (and their handles). diff --git a/crates/ui-egui/src/panels/home.rs b/crates/ui-egui/src/panels/home.rs index 861d7d49..46bbb44f 100644 --- a/crates/ui-egui/src/panels/home.rs +++ b/crates/ui-egui/src/panels/home.rs @@ -15,6 +15,7 @@ use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde::Serialize; use serde_json::json; +use crate::i18n::{tr, tr_args}; use crate::icons::{self, Icon}; use crate::theme::Tokens; use crate::{EffectcraftApp, widgets}; @@ -87,13 +88,13 @@ pub fn format_utc(secs: i64) -> String { /// date after a week). pub fn format_ago(now: i64, then: i64) -> String { let d = now.saturating_sub(then); - let plural = |n: i64, unit: &str| format!("{n} {unit}{} ago", if n == 1 { "" } else { "s" }); + let plural = |n: i64, one: &str, many: &str| tr_args(if n == 1 { one } else { many }, &[&n]); match d { - i64::MIN..60 => "Just now".to_string(), - 60..3_600 => plural(d / 60, "minute"), - 3_600..86_400 => plural(d / 3_600, "hour"), - 86_400..172_800 => "Yesterday".to_string(), - 172_800..604_800 => plural(d / 86_400, "day"), + i64::MIN..60 => tr("Just now").to_string(), + 60..3_600 => plural(d / 60, "{} minute ago", "{} minutes ago"), + 3_600..86_400 => plural(d / 3_600, "{} hour ago", "{} hours ago"), + 86_400..172_800 => tr("Yesterday").to_string(), + 172_800..604_800 => plural(d / 86_400, "{} day ago", "{} days ago"), _ => format_utc(then).get(..10).unwrap_or_default().to_string(), } } @@ -112,14 +113,14 @@ pub fn format_size(bytes: u64) -> String { } } -/// The Kind column: what a recent file is, by its extension. +/// The Kind column: what a recent file is, by its extension, in the interface language. fn kind_of(path: &str) -> &'static str { - match std::path::Path::new(path).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref() { + tr(match std::path::Path::new(path).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref() { Some("ecproj") => "EffectCraft project", Some("aep" | "aepx") => "After Effects project", Some("json" | "lottie") => "Lottie animation", _ => "Project", - } + }) } /// The config-store name of a project's thumbnail. @@ -237,7 +238,7 @@ fn rail_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, rail: let mut y = rail.min.y + 20.0; for (label, id, primary) in [("New Project", "file.newProject", true), ("Open Project…", "file.open", false)] { let r = Rect::from_min_size(pos2(x0, y), vec2(w, 34.0)); - if widgets::text_button(ui, r, label, primary, &t, egui::Id::new(("home", id))).clicked() { + if widgets::text_button(ui, r, tr(label), primary, &t, egui::Id::new(("home", id))).clicked() { actions.push((id, json!({}))); } app.auto.add(&format!("home.{id}"), r, label); @@ -261,7 +262,7 @@ fn rail_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, rail: } let col = if on { t.tab_text_active } else { t.text_dim }; icons::paint(p, Rect::from_center_size(pos2(r.min.x + 20.0, r.center().y), vec2(16.0, 16.0)), icon, col); - p.text(pos2(r.min.x + 40.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::medium(13.0), col); + p.text(pos2(r.min.x + 40.0, r.center().y), Align2::LEFT_CENTER, tr(label), Tokens::medium(13.0), col); app.auto.add(&format!("home.tab.{}", label.to_ascii_lowercase()), r, label); if resp.clicked() { app.ui.home_learn = k == 2; @@ -281,7 +282,7 @@ fn rail_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, rail: let foot_h = 22.0 + links.len() as f32 * 30.0 + 22.0 + sib_rows * 26.0; let mut cy = (rail.max.y - 16.0 - foot_h).max(y + 16.0); p.line_segment([pos2(x0, cy - 10.0), pos2(x0 + w, cy - 10.0)], Stroke::new(1.0, t.separator)); - p.text(pos2(x0, cy + 6.0), Align2::LEFT_CENTER, crate::i18n::tr("Community"), Tokens::semibold(12.0), t.text_dim); + p.text(pos2(x0, cy + 6.0), Align2::LEFT_CENTER, tr("Community"), Tokens::semibold(12.0), t.text_dim); cy += 22.0; for (icon, label, cmd) in links { let r = Rect::from_min_size(pos2(x0, cy), vec2(w, 26.0)); @@ -294,14 +295,14 @@ fn rail_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, rail: } let col = if discord { Color32::WHITE } else { t.text }; icons::paint(p, Rect::from_center_size(pos2(r.min.x + 16.0, r.center().y), vec2(13.0, 13.0)), icon, col); - p.text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::medium(12.0), col); + p.text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, tr(label), Tokens::medium(12.0), col); app.auto.add(&format!("home.{cmd}"), r, label); if resp.clicked() { let _ = app.session.execute(cmd, json!({})); } cy += 30.0; } - p.text(pos2(x0, cy + 8.0), Align2::LEFT_CENTER, crate::i18n::tr("More ArtCraft apps"), Tokens::ui(11.0), t.text_faint); + p.text(pos2(x0, cy + 8.0), Align2::LEFT_CENTER, tr("More ArtCraft apps"), Tokens::ui(11.0), t.text_faint); cy += 20.0; let sw = (w - 6.0) / 2.0; for (i, (name, slug)) in sib.iter().enumerate() { @@ -328,12 +329,12 @@ fn home_page( let t = app.tokens; let (x0, w) = (area.min.x, area.width()); let mut y = area.min.y; - p.text(pos2(x0, y + 14.0), Align2::LEFT_CENTER, crate::i18n::tr("Welcome to EffectCraft"), Tokens::semibold(26.0), t.tab_text_active); + p.text(pos2(x0, y + 14.0), Align2::LEFT_CENTER, tr("Welcome to EffectCraft"), Tokens::semibold(26.0), t.tab_text_active); y += 40.0; p.text( pos2(x0, y + 8.0), Align2::LEFT_CENTER, - format!("Motion graphics and visual effects · version {}", env!("CARGO_PKG_VERSION")), + tr_args("Motion graphics and visual effects · version {}", &[&env!("CARGO_PKG_VERSION")]), Tokens::ui(12.5), t.text_dim, ); @@ -355,7 +356,7 @@ fn home_page( let ir = Rect::from_center_size(pos2(r.min.x + 30.0, r.center().y), vec2(32.0, 32.0)); p.rect_filled(ir, 7.0, t.accent.gamma_multiply(0.18)); icons::paint(p, ir.shrink(8.0), icon, t.accent); - p.with_clip_rect(r.shrink(2.0)).text(pos2(ir.max.x + 12.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::medium(12.5), t.text); + p.with_clip_rect(r.shrink(2.0)).text(pos2(ir.max.x + 12.0, r.center().y), Align2::LEFT_CENTER, tr(label), Tokens::medium(12.5), t.text); app.auto.add(&format!("home.{id}"), r, label); if resp.clicked() { if id == "templates" { @@ -369,18 +370,18 @@ fn home_page( y += 64.0 + 36.0; // Recent: heading, filter, clear. - p.text(pos2(x0, y + 12.0), Align2::LEFT_CENTER, crate::i18n::tr("Recent"), Tokens::semibold(17.0), t.tab_text_active); + p.text(pos2(x0, y + 12.0), Align2::LEFT_CENTER, tr("Recent"), Tokens::semibold(17.0), t.tab_text_active); let entries = recent_entries(&app.session.prefs); let fid = egui::Id::new("home-filter"); let mut filter: String = ctx.data(|d| d.get_temp(fid)).unwrap_or_default(); let fr = Rect::from_min_size(pos2(x0 + w - 240.0, y), vec2(240.0, 26.0)); - widgets::search_field(ui, fr, &mut filter, "Filter recent files", &t); + widgets::search_field(ui, fr, &mut filter, tr("Filter recent files"), &t); app.auto.add("home.filter", fr, "Filter recent files"); ctx.data_mut(|d| d.insert_temp(fid, filter.clone())); if !entries.is_empty() { let cr = Rect::from_min_size(pos2(fr.min.x - 96.0, y + 3.0), vec2(84.0, 20.0)); let resp = ui.interact(cr, egui::Id::new("home-clear-recent"), Sense::click()); - p.text(cr.right_center(), Align2::RIGHT_CENTER, crate::i18n::tr("Clear list"), Tokens::ui(11.5), if resp.hovered() { t.text } else { t.text_faint }); + p.text(cr.right_center(), Align2::RIGHT_CENTER, tr("Clear list"), Tokens::ui(11.5), if resp.hovered() { t.text } else { t.text_faint }); app.auto.add("home.clearRecent", cr, "Clear Recent Projects"); if resp.clicked() { actions.push(("file.clearRecent", json!({}))); @@ -390,7 +391,7 @@ fn home_page( // Column headings (Name / Opened / Size / Kind, as in After Effects). let (opened_x, size_x, kind_x) = (x0 + w * 0.52, x0 + w * 0.68, x0 + w * 0.80); for (label, x) in [("NAME", x0 + 76.0), ("OPENED", opened_x), ("SIZE", size_x), ("KIND", kind_x)] { - p.text(pos2(x, y), Align2::LEFT_CENTER, label, Tokens::medium(10.5), t.text_faint); + p.text(pos2(x, y), Align2::LEFT_CENTER, tr(label), Tokens::medium(10.5), t.text_faint); } y += 12.0; p.line_segment([pos2(x0, y), pos2(x0 + w, y)], Stroke::new(1.0, t.separator)); @@ -406,8 +407,8 @@ fn home_page( } else { ("No recent files match the filter", "Try another name or folder.") }; - p.text(c + vec2(0.0, 40.0), Align2::CENTER_CENTER, head, Tokens::semibold(14.0), t.text); - p.text(c + vec2(0.0, 62.0), Align2::CENTER_CENTER, sub, Tokens::ui(12.0), t.text_faint); + p.text(c + vec2(0.0, 40.0), Align2::CENTER_CENTER, tr(head), Tokens::semibold(14.0), t.text); + p.text(c + vec2(0.0, 62.0), Align2::CENTER_CENTER, tr(sub), Tokens::ui(12.0), t.text_faint); } let now = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX)); let row_h = 48.0; @@ -439,7 +440,7 @@ fn home_page( Tokens::medium(13.0), if e.exists { t.tab_text_active } else { t.text_dim }, ); - let sub = if e.exists { e.folder.clone() } else { format!("{} (missing)", e.folder) }; + let sub = if e.exists { e.folder.clone() } else { format!("{} {}", e.folder, crate::i18n::tr("(missing)")) }; name_clip.text(pos2(tr.max.x + 6.0, r.center().y + 9.0), Align2::LEFT_CENTER, sub, Tokens::ui(11.0), t.text_faint); let cell = |x: f32, x1: f32, text: String| { p.with_clip_rect(Rect::from_min_max(pos2(x, r.min.y), pos2(x1 - 8.0, r.max.y))).text( @@ -534,6 +535,21 @@ mod tests { assert_eq!((kind_of("/a/B.ecproj"), kind_of("c.AEP"), kind_of("x")), ("EffectCraft project", "After Effects project", "Project")); } + /// The Home screen's own text goes through the interface catalog (#574): the Opened and Kind + /// columns read in Chinese, and English stays English. + #[test] + fn home_text_follows_the_interface_language() { + let now = 1_790_000_000; + crate::i18n::set_current("zh-hans"); + let zh = (format_ago(now, now - 5), format_ago(now, now - 3 * 3_600), format_ago(now, now - 90_000), kind_of("/a/B.ecproj")); + crate::i18n::set_current("zh-hant"); + let tw = (format_ago(now, now - 60), kind_of("x")); + crate::i18n::set_current("en"); + assert_eq!(zh, ("刚刚".into(), "3 小时前".into(), "昨天".into(), "EffectCraft 项目")); + assert_eq!(tw, ("1 分鐘前".into(), "專案")); + assert_eq!(tr("Filter recent files"), "Filter recent files"); + } + #[test] fn home_screen_registers_its_controls() { let mut s = effectcraft_engine::Session::default(); diff --git a/crates/ui-egui/src/panels/key_dialogs.rs b/crates/ui-egui/src/panels/key_dialogs.rs index 6e1c5d4c..73f76aaa 100644 --- a/crates/ui-egui/src/panels/key_dialogs.rs +++ b/crates/ui-egui/src/panels/key_dialogs.rs @@ -126,7 +126,7 @@ fn buttons(ui: &mut egui::Ui, app: &mut EffectcraftApp, t: &Tokens, prefix: &str } fn drag(ui: &mut egui::Ui, app: &mut EffectcraftApp, id: &str, v: &mut f64, speed: f64, range: std::ops::RangeInclusive, suffix: &str, enabled: bool) { - let r = ui.add_enabled(enabled, egui::DragValue::new(v).speed(speed).range(range).max_decimals(2).suffix(suffix)); + let r = ui.add_enabled(enabled, crate::widgets::drag_value(egui::DragValue::new(v).speed(speed).range(range).max_decimals(2).suffix(suffix))); app.auto.add(id, r.rect, id); } diff --git a/crates/ui-egui/src/panels/layer_styles_dialog.rs b/crates/ui-egui/src/panels/layer_styles_dialog.rs index 7738fbea..1366c2ae 100644 --- a/crates/ui-egui/src/panels/layer_styles_dialog.rs +++ b/crates/ui-egui/src/panels/layer_styles_dialog.rs @@ -103,17 +103,17 @@ fn control(ui: &mut egui::Ui, p: &Property, v: &KV) -> Option { ParamUi::Slider { slider_min, slider_max, decimals, .. } => { ui.add(egui::Slider::new(&mut y, *slider_min..=*slider_max).clamping(egui::SliderClamping::Never).max_decimals(*decimals as usize)) } - ParamUi::Percent => ui.add(egui::DragValue::new(&mut y).speed(0.5).suffix("%")), - ParamUi::Angle => ui.add(egui::DragValue::new(&mut y).speed(0.5).suffix("°")), - ParamUi::Pixels => ui.add(egui::DragValue::new(&mut y).speed(0.25).suffix(" px")), - _ => ui.add(egui::DragValue::new(&mut y).speed(0.1)), + ParamUi::Percent => ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut y).speed(0.5).suffix("%"))), + ParamUi::Angle => ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut y).speed(0.5).suffix("°"))), + ParamUi::Pixels => ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut y).speed(0.25).suffix(" px"))), + _ => ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut y).speed(0.1))), }; (r.changed() && y != *x).then(|| json!(y)) } (KV::Color(c), _) => { - let mut rgba = egui::Rgba::from_rgba_unmultiplied(c[0] as f32, c[1] as f32, c[2] as f32, c[3] as f32); - let changed = egui::color_picker::color_edit_button_rgba(ui, &mut rgba, egui::color_picker::Alpha::Opaque).changed(); - changed.then(|| json!([rgba.r(), rgba.g(), rgba.b()])) + let mut rgb = [c[0] as f32, c[1] as f32, c[2] as f32]; + let changed = crate::widgets::srgb_color_button(ui, &mut rgb).changed(); + changed.then(|| json!(rgb)) } (KV::Gradient(_), _) => { ui.label(egui::RichText::new(tr("Edit in the Timeline")).weak()); diff --git a/crates/ui-egui/src/panels/markers_ui.rs b/crates/ui-egui/src/panels/markers_ui.rs index 89a428a6..c3631604 100644 --- a/crates/ui-egui/src/panels/markers_ui.rs +++ b/crates/ui-egui/src/panels/markers_ui.rs @@ -128,11 +128,11 @@ pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { super::dialogs::modal(ctx, &title, vec2(520.0, 560.0), t, |ui| { egui::Grid::new("marker-grid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { ui.label(tr("Time:")); - let r = ui.add(egui::DragValue::new(&mut d.time).speed(0.01).range(0.0..=1e6).suffix(" s").max_decimals(3)); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.time).speed(0.01).range(0.0..=1e6).suffix(" s").max_decimals(3))); app.auto.add("dialog.marker.time", r.rect, "Time"); ui.end_row(); ui.label(tr("Duration:")); - let r = ui.add(egui::DragValue::new(&mut d.duration).speed(0.01).range(0.0..=1e6).suffix(" s").max_decimals(3)); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.duration).speed(0.01).range(0.0..=1e6).suffix(" s").max_decimals(3))); app.auto.add("dialog.marker.duration", r.rect, "Duration"); ui.end_row(); ui.label(tr("Comment:")); diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 5f198126..2f0ca804 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -78,6 +78,32 @@ pub enum DragPayload { Files(Vec), } +/// Layers carried by a Project item drag. Dragging a selected item uses the +/// complete selection, while dragging an unselected item uses only that item. +/// Folders and data files are not composition layers. +pub(crate) fn project_drop_items(app: &EffectcraftApp, dragged: u64) -> Vec { + use effectcraft_engine::project::{FootageKind, ItemId, ItemKind}; + + let selected = &app.session.state.project_selection; + let candidates: Vec = if selected.iter().any(|id| id.0 == dragged) { + selected.iter().map(|id| id.0).collect() + } else { + // One unselected item goes through as is, so a folder or data file still gets the + // engine's error instead of a silent no-op. + return vec![dragged]; + }; + candidates + .into_iter() + .filter(|id| { + app.session.project.item(ItemId(*id)).is_some_and(|item| match &item.kind { + ItemKind::Comp(_) | ItemKind::Solid(_) => true, + ItemKind::Footage(f) => f.kind != FootageKind::Data, + ItemKind::Folder => false, + }) + }) + .collect() +} + /// The current time formatted per project settings (timecode with `;` for drop-frame, frames /// or Feet + Frames). pub fn timecode(session: &Session, comp: &Comp, t: Tick) -> String { diff --git a/crates/ui-egui/src/panels/paint_panels.rs b/crates/ui-egui/src/panels/paint_panels.rs index 62f27055..675bc1e1 100644 --- a/crates/ui-egui/src/panels/paint_panels.rs +++ b/crates/ui-egui/src/panels/paint_panels.rs @@ -102,11 +102,11 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add("paint.color", fg, "Foreground color"); app.auto.add("paint.background", bg, "Background color"); let mut fc = rgb(o.color); - if crate::header::color_popup(ui, egui::Id::new("paint-fg-pop"), fg.left_bottom(), &mut fc) { + if crate::widgets::color_popup(ui, egui::Id::new("paint-fg-pop"), fg.left_bottom(), &mut fc) { set(app, json!({"color": [fc[0], fc[1], fc[2], 1.0]})); } let mut bc = rgb(o.background); - if crate::header::color_popup(ui, egui::Id::new("paint-bg-pop"), bg.left_bottom(), &mut bc) { + if crate::widgets::color_popup(ui, egui::Id::new("paint-bg-pop"), bg.left_bottom(), &mut bc) { set(app, json!({"background": [bc[0], bc[1], bc[2], 1.0]})); } let swap = Rect::from_min_size(pos2(x0 + 46.0, y), vec2(18.0, 14.0)); diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 8f249b78..21f84297 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -599,12 +599,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(("layer.addItem".into(), json!({"item": id.0}))); ui.close(); } + // File ▸ New Comp from Selection on the selection (the right-clicked row joins it + // above): several items ask how in its dialog, as in After Effects (#360). if ui.button(crate::i18n::tr("New Comp from Selection")).clicked() { - if let Some((w, h)) = it.dimensions() { - let d = it.duration().map(|d| d.seconds()).unwrap_or(10.0); - actions.push(("comp.new".into(), json!({"name": format!("{} Comp", it.name), "width": w, "height": h, "duration": d}))); - actions.push(("layer.addItem".into(), json!({"item": id.0}))); - } + actions.push(("file.newCompFromSelection".into(), json!({}))); ui.close(); } ui.separator(); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 9e78ffbc..e565034e 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -72,6 +72,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut y = top; let mut actions: Actions = vec![]; + // Several layers selected: "(3) Selected Objects", as in After Effects. The rows show the + // first layer; the stopwatch, keyframe button and value edits apply to all of them (#355). + let selected = app.session.state.selected_layers.iter().filter(|id| comp.layer(**id).is_some()).count(); + if selected > 1 { + let n = selected.to_string(); + let label = crate::i18n::tr_args("({}) Selected Objects", &[&n]); + p.text(pos2(x0, y + ROW_H / 2.0), Align2::LEFT_CENTER, &label, Tokens::semibold(12.0), t.text); + app.auto.add("properties.selectedObjects", Rect::from_min_size(pos2(x0, y), vec2(w, ROW_H)), &label); + y += ROW_H + 4.0; + } + // ---------------------------------------------------------------- Layer Transform if let Some(tr) = layer.transform().cloned() { section_header(app, ui, &p, x0, w, y, "Layer Transform", "properties.transform"); @@ -218,7 +229,7 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, match what { "prev" => actions.push(("time.go".into(), json!({"to": "prevKey", "prop": uid}))), "next" => actions.push(("time.go".into(), json!({"to": "nextKey", "prop": uid}))), - _ => actions.push(("prop.toggleKey".into(), json!({"layer": lid, "prop": uid}))), + _ => actions.push(("prop.toggleKey".into(), json!({"layer": lid, "prop": uid, "selected": true}))), } } } @@ -228,13 +239,18 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, icons::paint(p, swr, Icon::Stopwatch, if resp.hovered() { t.text } else { t.text_dim }); app.auto.add(&format!("{base}.stopwatch"), swr, &pr.name); if resp.clicked() { - actions.push(("prop.toggleAnimation".into(), json!({"layer": lid, "prop": uid}))); + actions.push(("prop.toggleAnimation".into(), json!({"layer": lid, "prop": uid, "selected": true}))); } } p.text(pos2(r.min.x + 42.0, cy), Align2::LEFT_CENTER, &pr.name, Tokens::ui(12.0), t.text); let vx = r.min.x + (r.width() * 0.5).max(130.0); let merge = format!("props-{uid}"); - let set = |actions: &mut Actions, v: serde_json::Value| actions.push(("prop.set".into(), json!({"layer": lid, "prop": uid, "value": v, "merge": merge}))); + // With several layers selected the edit applies to all: a scrub by the same change, a typed + // value as it is. + let scrub = ui.ctx().dragged_id().is_some() && ui.input(|i| i.pointer.is_decidedly_dragging()); + let set = |actions: &mut Actions, v: serde_json::Value| { + actions.push(("prop.set".into(), json!({"layer": lid, "prop": uid, "value": v, "merge": merge, "selected": true, "offset": scrub}))) + }; let value = ectx.value(layer, pr); let is_scale = pr.name == "Scale"; let pct = is_scale || pr.name == "Opacity"; @@ -320,13 +336,7 @@ fn text_section( // Font family and style. let fr = Rect::from_min_size(pos2(x0, y), vec2(w, 22.0)); let pop = egui::Id::new("props-font-pop"); - if widgets::dropdown(ui, fr, &doc.font, &t, egui::Id::new("props-font")).clicked() { - widgets::open_popup(ui, pop); - } - app.auto.add("properties.text.font", fr, "Font family"); - // Every installed family (built only while the menu is open: it can be long). - let fams: Vec = if widgets::popup_is_open(ui, pop) { effectcraft_engine::text_families() } else { vec![] }; - if let Some(f) = widgets::popup_menu(ui, pop, fr.left_bottom(), &fams, fams.iter().position(|f| *f == doc.font)).and_then(|i| fams.get(i)) { + if let Some(f) = super::text_panels::font_picker(app, ui, pop, fr, &doc.font, "properties.text.font", true) { set(actions, json!({"font": f})); } y += 28.0; @@ -401,7 +411,7 @@ fn text_section( } app.auto.add(&format!("properties.text.{key}"), sw, label); let mut rgb = [c[0], c[1], c[2]]; - if crate::header::color_popup(ui, pop, sw.left_bottom(), &mut rgb) { + if crate::widgets::color_popup(ui, pop, sw.left_bottom(), &mut rgb) { set(actions, json!({key: [rgb[0], rgb[1], rgb[2]], "merge": format!("props-text-{key}")})); } p.text(pos2(sw.max.x + 8.0, sw.center().y), Align2::LEFT_CENTER, label, Tokens::ui(12.0), t.text); diff --git a/crates/ui-egui/src/panels/roto_tool.rs b/crates/ui-egui/src/panels/roto_tool.rs index f9090981..fbd7119c 100644 --- a/crates/ui-egui/src/panels/roto_tool.rs +++ b/crates/ui-egui/src/panels/roto_tool.rs @@ -168,6 +168,32 @@ pub fn interact( let sign = if kind == "bg" || kind == "refineErase" { "−" } else { "+" }; painter.text(hp, Align2::CENTER_CENTER, sign, Tokens::semibold(12.0), col); } + // ⌘/Ctrl-drag resizes the brush (After Effects): the circle stays at the press point and its + // edge follows the pointer. No stroke is painted. + let rid = egui::Id::new("layer-panel-roto-resize"); + if resp.drag_started() + && mods.command + && let Some(pos) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) + { + ctx.data_mut(|d| d.insert_temp(rid, pos)); + } + if let Some(origin) = ctx.data(|d| d.get_temp::(rid)) { + if let Some(pos) = resp.interact_pointer_pos() + && zoom > 0.0 + { + let dia = (2.0 * f64::from(origin.distance(pos)) / zoom).clamp(1.0, 5000.0).round(); + let key = if refine { "refineDiameter" } else { "diameter" }; + if let Err(e) = app.session.execute("roto.options", json!({key: dia})) { + app.ui.status = e.to_string(); + } + painter.circle_stroke(origin, (dia * 0.5 * zoom) as f32, Stroke::new(1.5, col)); + painter.text(origin + vec2(0.0, -14.0), Align2::CENTER_BOTTOM, format!("{dia:.0} px"), Tokens::ui(11.0), col); + } + if resp.drag_stopped() || !resp.dragged() { + ctx.data_mut(|d| d.remove::(rid)); + } + return true; + } let did = egui::Id::new("layer-panel-roto-drag"); if resp.drag_started() && let Some(pos) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 6a84d761..385febef 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -38,6 +38,12 @@ fn hex(c: [u8; 3]) -> String { format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2]) } +/// sRGB components (0–1) as bytes. +fn srgb_bytes(c: [f32; 3]) -> [u8; 3] { + let col = crate::widgets::srgb32(c); + [col.r(), col.g(), col.b()] +} + fn reg(app: &mut EffectcraftApp, id: &str, r: &egui::Response, label: &str) { app.auto.add(id, r.rect, label); } @@ -112,7 +118,7 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value let r = match kind { Kind::Int(lo, hi, unit) => { let mut x = v.as_i64().unwrap_or(0); - let r = ui.add(egui::DragValue::new(&mut x).range(lo..=hi).speed(0.25)); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut x).range(lo..=hi).speed(0.25))); if !unit.is_empty() { ui.label(RichText::new(unit).color(t.text_dim)); } @@ -123,7 +129,7 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value } Kind::Float(lo, hi, unit) => { let mut x = v.as_f64().unwrap_or(0.0); - let r = ui.add(egui::DragValue::new(&mut x).range(lo..=hi).speed(0.1).max_decimals(3)); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut x).range(lo..=hi).speed(0.1).max_decimals(3))); if !unit.is_empty() { ui.label(RichText::new(unit).color(t.text_dim)); } @@ -178,8 +184,9 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value } Kind::Color => { let c = crate::panels::viewer::hex_rgb(v.as_str().unwrap_or("")).unwrap_or([128, 128, 128]); - let mut rgb = c; - let r = ui.color_edit_button_srgb(&mut rgb); + let mut f = c.map(|v| v as f32 / 255.0); + let r = crate::widgets::srgb_color_button(ui, &mut f); + let rgb = if r.changed() { srgb_bytes(f) } else { c }; ui.label(RichText::new(hex(rgb)).monospace().color(t.text_dim)); if rgb != c { acts.push(Act::Set(key.into(), json!(hex(rgb)))); @@ -559,8 +566,9 @@ fn labels_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, cur: &Value, acts: &mu egui::Grid::new("settings-labels").num_columns(4).spacing([10.0, 4.0]).show(ui, |ui| { for (i, l) in labels.iter().enumerate() { let c = crate::panels::viewer::hex_rgb(l["color"].as_str().unwrap_or("")).unwrap_or([128, 128, 128]); - let mut rgb = c; - let r = ui.color_edit_button_srgb(&mut rgb); + let mut f = c.map(|v| v as f32 / 255.0); + let r = crate::widgets::srgb_color_button(ui, &mut f); + let rgb = if r.changed() { srgb_bytes(f) } else { c }; app.auto.add(&format!("settings.labels.{i}.color"), r.rect, "label colour"); if rgb != c { acts.push(Act::Set(format!("labels.{i}.color"), json!(hex(rgb)))); diff --git a/crates/ui-egui/src/panels/shortcut_editor.rs b/crates/ui-egui/src/panels/shortcut_editor.rs index c0893c5b..021dffe7 100644 --- a/crates/ui-egui/src/panels/shortcut_editor.rs +++ b/crates/ui-egui/src/panels/shortcut_editor.rs @@ -204,6 +204,8 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { _ => None, }) }); + // The shortcut field must not also type the key it just recorded (Alt+R came out as `rAlt+R`). + ctx.input_mut(|i| i.events.retain(|e| !matches!(e, egui::Event::Text(_) | egui::Event::Key { .. }))); if let Some((k, m)) = got { st.recording = false; if k != egui::Key::Escape @@ -586,6 +588,41 @@ mod tests { } assert_eq!(split("Cmd+Shift+K"), ([false, true, false, true], "K".into())); assert_eq!(split("Cmd++"), ([false, true, false, false], "+".into())); + // The plus key, with and without modifiers, parses back (typed by hand or recorded). + for txt in ["+", "Cmd++", "Alt++"] { + assert_eq!(crate::menus::parse_shortcut(txt).map(|(_, k)| k), Some(egui::Key::Plus), "{txt}"); + } + assert_eq!(crate::menus::parse_shortcut("Alt+R").map(|(m, k)| (m.alt, k)), Some((true, egui::Key::R))); assert_eq!(split("F9"), ([false; 4], "F9".into())); } + + fn frame(app: &mut EffectcraftApp, ctx: &egui::Context, events: Vec) { + app.auto.begin_frame(); + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| crate::panels::dialogs::show(app, ui.ctx())); + out.textures_delta.clear(); + } + + #[test] + fn recording_a_shortcut_does_not_also_type_the_key_into_the_field() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &app.tokens, crate::i18n::language(&app)); + open(&mut app); + app.dialog_state.shortcuts.selected = app.session.shortcuts().bindables.first().map(|b| b.key.clone()); + for _ in 0..3 { + frame(&mut app, &ctx, vec![]); + } + let [x, y, w, h] = app.auto.find("shortcuts.record").expect("shortcut field").rect; + let at = pos2(x + w / 2.0, y + h / 2.0); + let click = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(true)]); + frame(&mut app, &ctx, vec![click(false)]); + frame(&mut app, &ctx, vec![]); + assert!(app.dialog_state.shortcuts.recording); + // Alt+R arrives as the key and as the typed text `r`. + let key = egui::Event::Key { key: egui::Key::R, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::ALT }; + frame(&mut app, &ctx, vec![key, egui::Event::Text("r".into())]); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.dialog_state.shortcuts.pending, "Alt+R"); + } } diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index 8e24a083..a32d74b4 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -96,11 +96,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Font family + style. let fr = Rect::from_min_size(pos2(x0, y), vec2(w, 22.0)); let pop = egui::Id::new("char-font-pop"); - if widgets::dropdown(ui, fr, &doc.font, &t, egui::Id::new("char-font")).clicked() && enabled { - widgets::open_popup(ui, pop); - } - app.auto.add("character.font", fr, "Font family"); - if let Some(f) = font_popup(app, ui, pop, fr.left_bottom(), &doc.font) { + if let Some(f) = font_picker(app, ui, pop, fr, &doc.font, "character.font", enabled) { actions.push(json!({"font": f})); } y += 28.0; @@ -202,7 +198,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } app.auto.add(&format!("character.{key}"), fs, label); let mut rgb = [c[0], c[1], c[2]]; - if crate::header::color_popup(ui, pop, fs.left_bottom(), &mut rgb) { + if crate::widgets::color_popup(ui, pop, fs.left_bottom(), &mut rgb) { actions.push(json!({key: [rgb[0], rgb[1], rgb[2]], "merge": format!("char-{key}")})); } p.text(pos2(fs.max.x + 6.0, y + 10.0), Align2::LEFT_CENTER, label, Tokens::ui(11.5), t.text_dim); @@ -551,47 +547,171 @@ pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } -/// The font menu: recent fonts first, names in English or the fonts' own language, and a -/// "Sample" preview in each font (Settings ▸ Type). -fn font_popup(app: &EffectcraftApp, ui: &mut egui::Ui, id: egui::Id, pos: egui::Pos2, current: &str) -> Option { - if !widgets::popup_is_open(ui, id) { +#[derive(Clone, Default)] +struct FontSearch { + query: String, + selected: usize, +} + +/// Shared font menu for Character and Properties: search, recent fonts and previews. +pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui::Id, rect: Rect, current: &str, auto: &str, enabled: bool) -> Option { + let state_id = id.with("search-state"); + app.auto.add(auto, rect, "Font family"); + if !widgets::popup_is_open(ui, id) || !enabled { + ui.data_mut(|d| d.remove::(state_id)); + if widgets::dropdown(ui, rect, current, &app.tokens, id.with("field")).clicked() && enabled { + widgets::open_popup(ui, id); + } return None; } let rows = effectcraft_engine::font_menu(&app.session.prefs); + let saved = ui.data(|d| d.get_temp::(state_id)); + let fresh = saved.is_none(); + let mut state = saved.unwrap_or_default(); + let search_id = id.with("search"); + let (up, down, enter, escape) = ui.input_mut(|i| { + ( + i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp), + i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown), + i.consume_key(egui::Modifiers::NONE, egui::Key::Enter), + i.consume_key(egui::Modifiers::NONE, egui::Key::Escape), + ) + }); let preview = app.session.prefs.type_.font_preview; let t = app.tokens; - let seps = rows.iter().filter(|r| r.family.is_empty()).count(); - widgets::popup_list(ui, id, pos, Rect::NOTHING, rows.len().saturating_sub(seps), seps, |ui| { - ui.set_min_width(if preview { 300.0 } else { 180.0 }); - let mut chosen = None; - for r in &rows { - if r.family.is_empty() { - ui.separator(); - continue; + // The font field itself becomes editable. The popup contains only results. + let arrow = Rect::from_min_max(pos2(rect.max.x - 22.0, rect.min.y), rect.max); + let edit_rect = Rect::from_min_max(rect.min, pos2(arrow.min.x, rect.max.y)); + let search = ui.put(edit_rect, egui::TextEdit::singleline(&mut state.query).id(search_id).hint_text(current).font(Tokens::ui(12.0))); + app.auto.add(&format!("{auto}.search"), search.rect, "Search fonts"); + if fresh { + search.request_focus(); + } + ui.memory_mut(|m| m.set_focus_lock_filter(search_id, egui::EventFilter { vertical_arrows: true, escape: true, ..Default::default() })); + if widgets::icon_button(ui, arrow, crate::icons::Icon::ChevronDown, false, &t, id.with("arrow")).clicked() { + ui.data_mut(|d| { + d.insert_temp(id.with("open"), false); + d.remove::(state_id); + }); + ui.memory_mut(|m| m.surrender_focus(search_id)); + return None; + } + app.auto.add(&format!("{auto}.toggle"), arrow, "Close font list"); + let mut chosen = None; + let screen = ui.ctx().content_rect(); + let below = screen.bottom() - rect.bottom() - 16.0; + let above = below < 100.0 && rect.top() - screen.top() > below; + let max_height = (if above { rect.top() - screen.top() - 16.0 } else { below }).clamp(28.0, 380.0); + let anchor = if above { rect.left_top() } else { rect.left_bottom() }; + let pivot = if above { Align2::LEFT_BOTTOM } else { Align2::LEFT_TOP }; + widgets::note_open_popup_list(ui.ctx()); + let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).pivot(pivot).fixed_pos(anchor).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_width(rect.width().max(180.0)); + let query = state.query.trim().to_lowercase(); + let mut seen = std::collections::HashSet::new(); + let filtered: Vec<_> = rows + .iter() + .filter(|r| { + !r.family.is_empty() + && (query.is_empty() + || ((r.family.to_lowercase().contains(&query) || r.display.to_lowercase().contains(&query)) && seen.insert(&r.family))) + }) + .collect(); + if fresh { + state.selected = filtered.iter().position(|r| r.family == current).unwrap_or(0); } - let resp = ui.selectable_label(r.family == current, &r.display); - if preview && ui.is_rect_visible(resp.rect) { - let key = egui::Id::new(("font-preview", &r.family)); - let lines: std::sync::Arc>> = match ui.data(|d| d.get_temp(key)) { - Some(l) => l, - None => { - let l = std::sync::Arc::new(effectcraft_engine::font_preview(&r.family, 14.0)); - ui.data_mut(|d| d.insert_temp(key, l.clone())); - l - } - }; - let o = pos2(resp.rect.max.x - 120.0, resp.rect.center().y + 5.0); - for poly in lines.iter() { - let pts: Vec = poly.iter().map(|p| o + vec2(p[0], p[1])).collect(); - ui.painter().add(egui::Shape::line(pts, egui::Stroke::new(1.0, t.text_dim))); + if search.changed() { + state.selected = 0; + } + let last = filtered.len().saturating_sub(1); + state.selected = state.selected.min(last); + if down { + state.selected = state.selected.saturating_add(1).min(last); + } + if up { + state.selected = state.selected.saturating_sub(1); + } + if enter { + chosen = filtered.get(state.selected).map(|r| r.family.clone()); + } + // Give the result list its actual height even when the previous query had + // only one row. A cached tiny Area must not collapse a reopened long list. + let height = (filtered.len() as f32 * (24.0 + ui.spacing().item_spacing.y) + 16.0).clamp(28.0, max_height); + ui.set_height(height); + let mut scroll = egui::ScrollArea::vertical().id_salt(id.with("results")).max_height(height).min_scrolled_height(height); + if search.changed() { + scroll = scroll.vertical_scroll_offset(0.0); + } + scroll.show(ui, |ui| { + if filtered.is_empty() { + let r = ui.label(tr("No matching fonts")); + app.auto.add(&format!("{auto}.empty"), r.rect, "No matching fonts"); } - } - if resp.clicked() { - chosen = Some(r.family.clone()); - } - } - chosen - }) + let mut was_recent = false; + for (index, r) in filtered.iter().enumerate() { + if query.is_empty() && was_recent && !r.recent { + ui.separator(); + } + was_recent = r.recent; + let row_width = ui.available_width(); + let label_width = if preview { (row_width - 100.0).max(40.0) } else { row_width }; + let resp = ui.add_sized([row_width, 24.0], egui::Button::selectable(index == state.selected, "")); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, ui.is_enabled(), index == state.selected, &r.display)); + let label_rect = Rect::from_min_size(resp.rect.min + vec2(5.0, 0.0), vec2(label_width - 5.0, resp.rect.height())); + ui.painter().with_clip_rect(label_rect.intersect(ui.clip_rect())).text( + label_rect.left_center(), + Align2::LEFT_CENTER, + &r.display, + Tokens::ui(12.0), + t.text, + ); + if ui.is_rect_visible(resp.rect) { + app.auto.add(&format!("{auto}.option.{}", r.family), resp.rect, &r.display); + } + if index == state.selected && (fresh || up || down) { + resp.scroll_to_me(None); + } + if preview && ui.is_rect_visible(resp.rect) { + let key = egui::Id::new(("font-preview", &r.family)); + let lines: std::sync::Arc>> = match ui.data(|d| d.get_temp(key)) { + Some(l) => l, + None => { + let l = std::sync::Arc::new(effectcraft_engine::font_preview(&r.family, 14.0)); + ui.data_mut(|d| d.insert_temp(key, l.clone())); + l + } + }; + // Scaled and placed to fit the row's preview column, and clipped to the row (#577). + let room = Rect::from_min_max(pos2(resp.rect.max.x - 95.0, resp.rect.min.y + 1.0), pos2(resp.rect.max.x - 6.0, resp.rect.max.y - 1.0)); + if let Some((k, o)) = fit_preview(&lines, room, resp.rect.center().y + 5.0) { + let painter = ui.painter().with_clip_rect(resp.rect.intersect(ui.clip_rect())); + for poly in lines.iter() { + let pts: Vec = + poly.iter().filter(|p| p[0].is_finite() && p[1].is_finite()).map(|p| (o + vec2(p[0], p[1]) * k).to_pos2()).collect(); + painter.add(egui::Shape::line(pts, egui::Stroke::new(1.0, t.text_dim))); + } + } + } + if resp.clicked() { + chosen = Some(r.family.clone()); + } + resp.on_hover_text(&r.display); + } + }); + }); + }); + let outside = widgets::pressed_outside(ui.ctx(), &area.response) && !ui.input(|i| i.pointer.interact_pos().is_some_and(|p| rect.contains(p))); + if chosen.is_some() || outside || escape { + ui.data_mut(|d| { + d.insert_temp(id.with("open"), false); + d.remove::(state_id); + }); + ui.memory_mut(|m| m.surrender_focus(search_id)); + } else { + ui.data_mut(|d| d.insert_temp(state_id, state)); + } + chosen } /// Align panel: Align Layers to Selection / Composition, the six align buttons and the six @@ -722,3 +842,63 @@ fn align_glyph(p: &egui::Painter, r: Rect, op: &str, distribute: bool, t: &Token p.rect_filled(Rect::from_min_size(pos2(c.x + 3.0, off(9.0)), vec2(5.0, 9.0)), 1.0, t.text_dim); } } + +/// Where a font's "Sample" outlines (`lines`, baseline at y = 0) go in `room`: `(scale, origin)`, +/// a point `p` landing at `origin + p * scale`. At their natural size on `baseline` when they fit; +/// fonts with large or odd metrics are scaled down (never up) and moved inside, so no preview +/// spills out of its row (#577). `None`: nothing to draw. +fn fit_preview(lines: &[Vec<[f32; 2]>], room: Rect, baseline: f32) -> Option<(f32, egui::Vec2)> { + let b = lines + .iter() + .flatten() + .filter(|p| p[0].is_finite() && p[1].is_finite()) + .fold(Rect::NOTHING, |b, p| b.union(Rect::from_min_max(pos2(p[0], p[1]), pos2(p[0], p[1])))); + if !(b.min.x <= b.max.x && b.min.y <= b.max.y && room.is_positive()) { + return None; + } + let k = (room.width() / b.width().max(1e-3)).min(room.height() / b.height().max(1e-3)).min(1.0); + let mut o = vec2(room.min.x - b.min.x * k, baseline); + let (top, bottom) = (o.y + b.min.y * k, o.y + b.max.y * k); + if top < room.min.y { + o.y += room.min.y - top; + } else if bottom > room.max.y { + o.y -= bottom - room.max.y; + } + Some((k, o)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// #577: every font's "Sample" preview lands inside its row's preview box: at its natural size + /// when it fits, else scaled down; nothing for no outlines. + #[test] + fn font_previews_fit_their_row() { + let room = Rect::from_min_max(pos2(180.0, 101.0), pos2(294.0, 119.0)); + let baseline = 115.0; + let inside = |lines: &[Vec<[f32; 2]>]| { + let (k, o) = fit_preview(lines, room, baseline).expect("a preview"); + for p in lines.iter().flatten() { + let q = o + vec2(p[0], p[1]) * k; + assert!(room.expand(0.01).contains(pos2(q.x, q.y)), "{p:?} → {q:?} outside {room:?}"); + } + k + }; + // A plain 14 px sample fits as it is, on the baseline. + let plain = vec![vec![[0.0, -10.0], [60.0, -10.0], [60.0, 3.0], [0.0, 3.0]]]; + assert_eq!(inside(&plain), 1.0); + assert_eq!(fit_preview(&plain, room, baseline).map(|(_, o)| o.y), Some(baseline)); + // Huge, wide, tall, offset and negative-bearing outlines are scaled into the box. + for lines in [ + vec![vec![[0.0, -80.0], [400.0, 30.0]]], + vec![vec![[-50.0, -5.0], [500.0, 2.0]]], + vec![vec![[10.0, -200.0], [12.0, 0.0]]], + vec![vec![[0.0, 20.0], [30.0, 40.0]]], + ] { + assert!(inside(&lines) <= 1.0); + } + assert!(fit_preview(&[], room, baseline).is_none()); + assert!(fit_preview(&[vec![[f32::NAN, 1.0]]], room, baseline).is_none()); + } +} diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 7df3439b..61b2d22d 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -58,11 +58,6 @@ fn classic_geometry_row(comp: &Comp, l: &Layer) -> bool { comp.renderer == effectcraft_engine::project::Renderer::Classic3D && l.is_3d() && matches!(l.source, LayerSource::Text | LayerSource::Shape) } -/// Time navigator's visible-span bar and the work area bar. -const NAV_BAR: Color32 = Color32::from_rgb(0x55, 0x55, 0x55); -const WORK_AREA_BAR: Color32 = Color32::from_rgb(0x5c, 0x5c, 0x5c); -/// Fill of the timeline's switch / A/V boxes (dark wells, as in After Effects). -const SWITCH_BOX: Color32 = Color32::from_rgb(0x19, 0x19, 0x19); /// Background of the selected layer's name cell. const SELECTED_NAME_BG: Color32 = Color32::from_rgb(0xa6, 0xa6, 0xa6); @@ -212,6 +207,29 @@ fn edge_scroll_speed(rows: Rect, y: f32, zone: f32) -> f32 { (depth / zone).clamp(-3.0, 3.0) * 400.0 } +/// Autoscroll speed (points per second, left negative) of a ruler scrub held at `x`, past the +/// left (`x0`) or right (`x1`) end of the visible time span: faster the further past. +fn edge_scroll_speed_x(x0: f32, x1: f32, x: f32) -> f32 { + let depth = if x < x0 { + x - x0 + } else if x > x1 { + x - x1 + } else { + return 0.0; + }; + (depth / 40.0).clamp(-3.0, 3.0) * 400.0 + depth.signum() * 100.0 +} + +/// The timeline's first visible time (`start`, showing `span` seconds of a `duration` comp) +/// paged so the CTI at `cti` is in view: unchanged while it is, else the page that starts at +/// the CTI (as After Effects pages the Timeline during a preview). +fn page_to_cti(start: f64, span: f64, duration: f64, cti: f64) -> f64 { + if !(start.is_finite() && span.is_finite() && cti.is_finite()) || (cti >= start && cti <= start + span) { + return start; + } + cti.min(duration - span).max(0.0) +} + /// The text of an expression being edited when its pick whip was pressed, and the selection in /// it (characters): the pick whip's reference goes in there. type WhipInto = (String, [usize; 2]); @@ -1189,6 +1207,34 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let ctx = ui.ctx().clone(); let Some(cid) = app.session.active_comp_id() else { ui.painter().text(rect.center(), Align2::CENTER_CENTER, "(no composition)", Tokens::ui(12.0), t.text_faint); + // Dropping footage into an empty Timeline should create its first composition, + // just like dropping onto the Project panel's New Composition button (#649). + if let Some(crate::panels::DragPayload::Item(dragged)) = egui::DragAndDrop::payload::(&ctx).as_deref() + && ui.rect_contains_pointer(rect) + { + let items: Vec = crate::panels::project_drop_items(app, *dragged) + .into_iter() + .map(ItemId) + .filter(|id| { + app.session.project.item(*id).is_some_and(|item| match &item.kind { + effectcraft_engine::project::ItemKind::Footage(footage) => footage.kind != effectcraft_engine::project::FootageKind::Data, + effectcraft_engine::project::ItemKind::Solid(_) | effectcraft_engine::project::ItemKind::Comp(_) => true, + _ => false, + }) + }) + .collect(); + if !items.is_empty() { + ui.painter().rect_stroke(rect.shrink(2.0), 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside); + app.auto.add("timeline.newCompDrop", rect, "Drop footage to create a composition"); + if ctx.input(|i| i.pointer.any_released()) { + app.session.state.project_selection = items; + if let Err(e) = crate::menus::invoke(app, &ctx, "file.newCompFromSelection", json!({})) { + app.ui.status = e; + } + egui::DragAndDrop::clear_payload(&ctx); + } + } + } return; }; let Some(comp) = app.session.project.comp_arc(cid) else { return }; @@ -1217,6 +1263,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let pps = app.ui.timeline.pps.unwrap_or(fit_pps); if app.ui.timeline.pps.is_none() { app.ui.timeline.start = 0.0; + } else if app.playback.playing { + // During playback the view pages to keep the CTI in sight (After Effects). + let span = ((graph_x1 - graph_x0 - 6.0) as f64 / pps).max(0.0); + app.ui.timeline.start = page_to_cti(app.ui.timeline.start, span, comp.duration.seconds(), app.session.time().seconds()); } let tm = TMap { x0: graph_x0 + 6.0, start: app.ui.timeline.start, pps }; ctx.data_mut(|d| d.insert_temp(tl_map_id(), (tm.x0, tm.start, tm.pps))); @@ -1308,7 +1358,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let vis1 = (tm.t(graph_x1) / comp.duration.seconds()) as f32; let nav_vis = Rect::from_min_max(pos2(nav.min.x + nav.width() * vis0.clamp(0.0, 1.0), nav.min.y), pos2(nav.min.x + nav.width() * vis1.clamp(0.0, 1.0), nav.max.y)); - p.rect_filled(nav_vis, 0.0, NAV_BAR); + p.rect_filled(nav_vis, 0.0, t.tl_nav_bar); for x in [nav_vis.min.x, nav_vis.max.x] { p.rect_filled(Rect::from_min_max(pos2(x - 3.0, nav.min.y - 1.0), pos2(x + 3.0, nav.max.y + 1.0)), 3.0, t.accent); } @@ -1348,7 +1398,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let wa_row = Rect::from_min_max(pos2(graph_x0, top + header_h), pos2(rect.max.x, top + header_h + colhdr_h)); let (wa0, wa1) = (comp.work_area.0.seconds(), comp.work_area.1.seconds()); let wa = Rect::from_min_max(pos2(tm.x(wa0), wa_row.min.y + 3.0), pos2(tm.x(wa1), wa_row.max.y - 6.0)); - p.with_clip_rect(wa_row).rect_filled(wa, 0.0, WORK_AREA_BAR); + p.with_clip_rect(wa_row).rect_filled(wa, 0.0, t.tl_work_area_bar); let shift = ui.input(|i| i.modifiers.shift); let bar = wa.intersect(wa_row); let bresp = ui.interact(bar, egui::Id::new("wa-bar"), Sense::click_and_drag()); @@ -1482,7 +1532,24 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if ((rresp.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_down())) || rresp.clicked()) && let Some(pt) = rresp.interact_pointer_pos() { - let mut secs = tm.t(pt.x).max(0.0); + let mut x = pt.x; + // Zoomed in, a scrub held past either end of the ruler scrolls the timeline that way + // (faster the further), with the CTI at the edge (After Effects). + let mut tm = tm; + if rresp.dragged() && app.ui.timeline.pps.is_some() { + let speed = edge_scroll_speed_x(tm.x0, graph_x1, x); + if speed != 0.0 { + let dt = f64::from(ui.input(|i| i.stable_dt).clamp(0.0, 0.1)); + let span = f64::from(graph_x1 - tm.x0) / pps; + let cur = app.ui.timeline.start; + let hi = (comp.duration.seconds() - span).max(0.0).max(cur); + app.ui.timeline.start = (cur + f64::from(speed) * dt / pps).clamp(0.0, hi); + tm.start = app.ui.timeline.start; + ui.ctx().request_repaint(); + } + x = x.clamp(tm.x0, graph_x1); + } + let mut secs = tm.t(x).max(0.0); // Shift-drag: snap to keyframes, in/out points, markers and the work area (8 px). if ui.input(|i| i.modifiers.shift) { let cands = snap_candidates(&comp, &build_rows(app, &comp)); @@ -1614,9 +1681,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let max_scroll = (total_h - rows_rect.height() + rh).max(0.0); if ui.rect_contains_pointer(rows_rect) { let (dy, dx, zoom) = ui.input(|i| (i.smooth_scroll_delta.y, i.smooth_scroll_delta.x, i.modifiers.alt)); - if zoom && dy.abs() > 0.0 { + // Ctrl/Cmd+wheel and a trackpad pinch reach egui as a zoom factor rather than a scroll + // (#428); like Alt+wheel they zoom time. + let pinch = ui.input(|i| i.zoom_delta()) as f64; + let at_x = ui.input(|i| i.pointer.hover_pos()).map(|p| p.x).unwrap_or(graph_x0); + if (pinch - 1.0).abs() > 1e-4 { + set_zoom(&mut app.ui.timeline, &comp, tm.pps * pinch, fit_pps, tm.t(at_x), (at_x - tm.x0) as f64); + } else if zoom && dy.abs() > 0.0 { // Alt+wheel zooms around the pointer, out until the whole comp shows. - let at_x = ui.input(|i| i.pointer.hover_pos()).map(|p| p.x).unwrap_or(graph_x0); set_zoom(&mut app.ui.timeline, &comp, tm.pps * (dy as f64 / 200.0).exp(), fit_pps, tm.t(at_x), (at_x - tm.x0) as f64); } else { let over_outline = ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| p.x < graph_x0); @@ -1687,7 +1759,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // (outline and time graph alike), not as alternating stripes. let _ = ri; lp.rect_filled(left, 0.0, if is_sel { t.row_selected } else { t.row_alt }); - gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, if is_sel { Color32::from_rgb(0x2a, 0x2a, 0x2a) } else { t.tl_bg }); + gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, if is_sel { t.tl_graph_selected } else { t.tl_bg }); lp.line_segment([pos2(rect.min.x, r.max.y - 0.5), pos2(graph_x0, r.max.y - 0.5)], Stroke::new(1.0, t.app_bg)); gp.line_segment([pos2(graph_x0, r.max.y - 0.5), pos2(r.max.x, r.max.y - 0.5)], Stroke::new(1.0, t.app_bg)); // A/V features. @@ -1703,7 +1775,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if !applicable || !vis.av { continue; } - lp.rect_filled(br.shrink(0.5), 1.0, SWITCH_BOX); + lp.rect_filled(br.shrink(0.5), 1.0, t.switch_well); layer_switch(ui, &t, r, br, icon, on, egui::Id::new(("av", layer.id.0, i)), layer.id.0, name, &mut actions); app.auto.add(&format!("timeline.layer.{}.{name}", layer.id.0), br, name); } @@ -1829,7 +1901,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { continue; } let br = Rect::from_center_size(pos2(cw.switches + SW * i as f32 + SW / 2.0, cy), vec2(16.0, 16.0)); - lp.rect_filled(br.shrink(1.0), 1.0, SWITCH_BOX); + lp.rect_filled(br.shrink(1.0), 1.0, t.switch_well); layer_switch(ui, &t, r, br, icon, on, egui::Id::new(("sw", layer.id.0, i)), layer.id.0, name, &mut actions); app.auto.add(&format!("timeline.layer.{}.switch.{name}", layer.id.0), br, name); } @@ -2184,7 +2256,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let en_r = Rect::from_center_size(pos2(cw.switches + 10.0, cy), vec2(16.0, 16.0)); let en = ui.interact(en_r, egui::Id::new(("expr-en", uid)), Sense::click()).on_hover_text(crate::i18n::tr("Enable Expression")); lp.rect_stroke(en_r.shrink(1.0), 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside); - let expr_col = Color32::from_rgb(0xe8, 0x7c, 0x5c); + let expr_col = t.expr_text; lp.text( en_r.center(), Align2::CENTER_CENTER, @@ -2269,7 +2341,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } // The editor in the time-graph area. ui.set_clip_rect(right_clip); - gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, Color32::from_rgb(0x1a, 0x1a, 0x1a)); + gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, t.expr_bg); let er = Rect::from_min_max(pos2(graph_x0 + 8.0, r.min.y + 3.0), pos2(rect.max.x - 14.0, r.max.y - 3.0)); let mut buf: String = ctx.data(|d| d.get_temp(buf_id)).unwrap_or_else(|| ex.text.clone()); if let Some(after) = ctx.data_mut(|d| d.remove_temp::(expr_refocus_id(*uid))) { @@ -2388,13 +2460,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let is_tr_rot = layer.transform().and_then(|t| t.get("rotation")).is_some_and(|r| r.uid == prop.uid); let base = if is_tr_rot && prop.name == "Rotation" && layer.is_3d() { "Z Rotation".to_string() } else { prop.name.clone() }; let pname = if prop.has_expression() { format!("{base} =") } else { base }; - lp.text( - pos2(name_x, cy), - Align2::LEFT_CENTER, - &pname, - Tokens::ui(12.0), - if prop.has_expression() { Color32::from_rgb(0xe8, 0x7c, 0x5c) } else { t.text }, - ); + lp.text(pos2(name_x, cy), Align2::LEFT_CENTER, &pname, Tokens::ui(12.0), if prop.has_expression() { t.expr_text } else { t.text }); let name_rect = Rect::from_min_max(pos2(name_x, r.min.y), pos2(cw.switches - 2.0, r.max.y)); let name_resp = ui.interact(name_rect, egui::Id::new(("pname", uid)), Sense::click_and_drag()); app.auto.add(&format!("timeline.prop.{uid}.name"), name_rect, &prop.name); @@ -2439,7 +2505,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { Align2::LEFT_CENTER, format!("= {}", e.text.lines().next().unwrap_or("")), Tokens::mono(11.0), - Color32::from_rgb(0xe8, 0x7c, 0x5c), + t.expr_text, ); } // Keyframes. @@ -2959,9 +3025,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { rp.galley(tag.min, g, Color32::WHITE); } if released { - let (index, time) = (json!(index), json!(start.map(Tick::seconds))); + let time = json!(start.map(Tick::seconds)); match payload.as_ref() { - crate::panels::DragPayload::Item(id) => actions.push(("layer.addItem".into(), json!({"item": id, "index": index, "time": time}))), + crate::panels::DragPayload::Item(id) => { + // Insert each selected item at the drop point, in selection order. + for (offset, item) in crate::panels::project_drop_items(app, *id).into_iter().enumerate() { + actions.push(("layer.addItem".into(), json!({"item": item, "index": index.saturating_add(offset), "time": time.clone()}))); + } + } crate::panels::DragPayload::Files(paths) => { actions.push(("mediaBrowser.import".into(), json!({"paths": paths, "addToComp": true, "index": index, "time": time}))) } @@ -3440,26 +3511,6 @@ fn fmt_num(v: f64, d: usize) -> String { format!("{v:.d$}") } -/// Linked values after component `d` of `c` became `v`: the others scale by the same ratio -/// (Constrain Proportions); from 0 the others that are 0 too follow the value. -fn constrained(c: &[f64], d: usize, v: f64) -> Vec { - let Some(&old) = c.get(d) else { return c.to_vec() }; - c.iter() - .enumerate() - .map(|(e, &x)| { - if e == d { - v - } else if old.abs() > 1e-9 { - x * v / old - } else if x.abs() <= 1e-9 { - v - } else { - x - } - }) - .collect() -} - /// Inline value editor for a property row; pushes `prop.set` actions. Returns the rects of a /// multi-dimension value's fields, one per dimension (none for other values). fn value_editor( @@ -3476,8 +3527,11 @@ fn value_editor( let uid = prop.uid; let mut dims = vec![]; let merge = format!("scrub-{uid}"); + // With several layers selected the edit applies to all of them (#491): a scrub by the same + // change, a typed value as it is. + let scrub = ui.ctx().dragged_id().is_some() && ui.input(|i| i.pointer.is_decidedly_dragging()); let set = |actions: &mut Vec<(String, serde_json::Value)>, v: serde_json::Value| { - actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge}))) + actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge, "selected": true, "offset": scrub}))) }; let is_3d = layer.is_3d(); let mut x = at.x; @@ -3535,7 +3589,7 @@ fn value_editor( // Alt edits one value of a linked pair. let shown = if linked && !ui.input(|i| i.modifiers.alt) { n.min(c.len()) } else { 1 }; let from = if shown == 1 { d } else { 0 }; - for (e, v) in constrained(c.get(from..from + shown).unwrap_or_default(), d - from, nv).into_iter().enumerate() { + for (e, v) in widgets::constrained(c.get(from..from + shown).unwrap_or_default(), d - from, nv).into_iter().enumerate() { if let Some(slot) = nc.get_mut(from + e) { *slot = v; } @@ -3557,8 +3611,19 @@ fn value_editor( if resp.clicked() { widgets::open_popup(ui, pop); } + // The eyedropper after the swatch samples the Composition panel (shape Fill and + // Stroke colours, text and effect colours), as in After Effects' Timeline (#633). + let er = Rect::from_center_size(pos2(r.max.x + 12.0, at.y), vec2(16.0, 16.0)); + let active = app.ui.fx_pick.as_ref().is_some_and(|k| k.prop == uid && k.kind == "color"); + if super::fx_widgets::eyedropper_button(ui, er, active, egui::Id::new(("tl-eye", uid)), &t) + .on_hover_text(crate::i18n::tr("Eyedropper: click, then click in the Composition panel")) + .clicked() + { + super::effect_controls::toggle_pick(app, "color", layer, prop); + } + app.auto.add(&format!("timeline.prop.{uid}.eyedropper"), er, &prop.name); let mut rgb = [c[0] as f32, c[1] as f32, c[2] as f32]; - if crate::header::color_popup(ui, pop, r.left_bottom(), &mut rgb) { + if crate::widgets::color_popup(ui, pop, r.left_bottom(), &mut rgb) { set(actions, json!([rgb[0], rgb[1], rgb[2], 1.0])); } } @@ -3619,6 +3684,20 @@ fn value_editor( mod tests { use super::*; + /// #468: playback pages the view to the CTI; a scrub past either end scrolls that way. + #[test] + fn the_view_pages_to_the_cti() { + assert_eq!(page_to_cti(10.0, 5.0, 60.0, 12.0), 10.0, "in view: unchanged"); + assert_eq!(page_to_cti(10.0, 5.0, 60.0, 16.0), 16.0, "past the right end: the page from the CTI"); + assert_eq!(page_to_cti(10.0, 5.0, 60.0, 3.0), 3.0, "before the start"); + assert_eq!(page_to_cti(10.0, 5.0, 60.0, 58.0), 55.0, "not past the comp's end"); + assert_eq!(page_to_cti(10.0, 80.0, 60.0, 95.0), 0.0); + assert_eq!(page_to_cti(10.0, f64::NAN, 60.0, 95.0), 10.0); + assert_eq!(edge_scroll_speed_x(100.0, 500.0, 300.0), 0.0); + assert!(edge_scroll_speed_x(100.0, 500.0, 501.0) > 0.0 && edge_scroll_speed_x(100.0, 500.0, 99.0) < 0.0); + assert!(edge_scroll_speed_x(100.0, 500.0, 600.0) > edge_scroll_speed_x(100.0, 500.0, 520.0)); + } + /// A comp with two solids; "Box" has an animated Position and a Gaussian Blur. fn app() -> EffectcraftApp { let mut s = effectcraft_engine::Session::default(); diff --git a/crates/ui-egui/src/panels/tracker.rs b/crates/ui-egui/src/panels/tracker.rs index 63ab06e8..13a5fa13 100644 --- a/crates/ui-egui/src/panels/tracker.rs +++ b/crates/ui-egui/src/panels/tracker.rs @@ -508,7 +508,8 @@ pub fn options_dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) ui.horizontal(|ui| { let r = ui.checkbox(&mut d.blur_on, crate::i18n::tr("Blur")); app.auto.add("dialog.trackOptions.blurOn", r.rect, "Blur"); - let r = ui.add_enabled(d.blur_on, egui::DragValue::new(&mut d.opts.blur).speed(0.1).range(0.5..=50.0).suffix(" pixels")); + let r = + ui.add_enabled(d.blur_on, crate::widgets::drag_value(egui::DragValue::new(&mut d.opts.blur).speed(0.1).range(0.5..=50.0).suffix(" pixels"))); app.auto.add("dialog.trackOptions.blur", r.rect, "Blur pixels"); let r = ui.checkbox(&mut d.opts.enhance, crate::i18n::tr("Enhance")); app.auto.add("dialog.trackOptions.enhance", r.rect, "Enhance"); @@ -539,7 +540,7 @@ pub fn options_dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) .response; app.auto.add("dialog.trackOptions.action", r.rect, "If Confidence is Below action"); ui.label(crate::i18n::tr("If Confidence is Below")); - let r = ui.add(egui::DragValue::new(&mut d.opts.threshold).speed(0.5).range(0.0..=100.0).suffix(" %")); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.opts.threshold).speed(0.5).range(0.0..=100.0).suffix(" %"))); app.auto.add("dialog.trackOptions.threshold", r.rect, "Confidence threshold"); }); (ok, cancel) = ok_cancel(ui, app, t, "dialog.trackOptions"); diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 7b0c5f75..5fe7697c 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -84,12 +84,17 @@ enum Gesture { /// Layer pixels → comp as the drag began, and its inverse. l2c: Mat3, inv: Mat3, + /// The other selected layers and their scales as the drag began: they scale by the same + /// ratio about their own anchor points, as in After Effects (#491). + others: Vec<(LayerId, [f64; 3])>, }, Rotate { layer: LayerId, center: Pos2, start_angle: f64, start_rot: f64, + /// The other selected layers and their rotations: they turn by the same angle. + others: Vec<(LayerId, f64)>, }, Anchor { layer: LayerId, @@ -258,17 +263,97 @@ pub(crate) fn fx2c(ctx: &EvalCtx, layer: &Layer) -> Mat3 { from_effect_space(ctx, layer, l2c(ctx, layer).0) } -/// Layer → comp matrix and its bounds quad (in comp pixels). +/// The selected, unlocked layers of `comp` other than `layer` (when `layer` is one of them): a +/// handle or Rotation tool drag on one changes them all. +fn selected_others<'a>(app: &EffectcraftApp, comp: &'a Comp, layer: LayerId) -> Vec<&'a Layer> { + let sel = &app.session.state.selected_layers; + if !sel.contains(&layer) { + return vec![]; + } + sel.iter().filter(|id| **id != layer).filter_map(|id| comp.layer(*id)).filter(|l| !l.switches.locked && l.transform().is_some()).collect() +} + +/// The least homogeneous depth (`w`: the distance in front of a perspective camera in pixels, 1 +/// for 2D layers and orthographic views) a viewer outline is drawn at: points at or behind the +/// camera would flip through infinity and draw long lines across the viewer (#572). +const NEAR_W: f64 = 0.5; + +/// `p` through the homography `m` before the perspective divide: (x, y, w). +fn homogeneous(m: &Mat3, p: [f64; 2]) -> [f64; 3] { + let r = |i: usize| m.0.get(i).map_or(0.0, |row| row[0] * p[0] + row[1] * p[1] + row[2]); + [r(0), r(1), r(2)] +} + +/// The closed polygon `poly` (layer pixels) through `m`, clipped to the part in front of the +/// camera's near plane (comp pixels; empty when none of it is). +fn project_clipped(m: &Mat3, poly: &[[f64; 2]]) -> Vec<[f64; 2]> { + let h: Vec<[f64; 3]> = poly.iter().map(|p| homogeneous(m, *p)).collect(); + let mut out = vec![]; + for (i, a) in h.iter().enumerate() { + let Some(b) = h.get((i + 1) % h.len()) else { continue }; + let (ina, inb) = (a[2] >= NEAR_W, b[2] >= NEAR_W); + if ina { + out.push(*a); + } + if ina != inb { + let t = (NEAR_W - a[2]) / (b[2] - a[2]); + out.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, NEAR_W]); + } + } + out.iter().map(|q| [q[0] / q[2], q[1] / q[2]]).filter(|q| q[0].is_finite() && q[1].is_finite()).collect() +} + +/// The segment a → b (world space) through the projection `pc`, clipped at the near plane. +fn project_segment(pc: &effectcraft_engine::geom::Mat4, a: effectcraft_engine::geom::Vec3, b: effectcraft_engine::geom::Vec3) -> Option<[[f64; 2]; 2]> { + let h = |p: effectcraft_engine::geom::Vec3| { + let r = |i: usize| pc.0.get(i).map_or(0.0, |row| row[0] * p.x + row[1] * p.y + row[2] * p.z + row[3]); + [r(0), r(1), r(3)] + }; + let (mut ha, mut hb) = (h(a), h(b)); + if ha[2] < NEAR_W && hb[2] < NEAR_W { + return None; + } + let cut = |p: [f64; 3], q: [f64; 3]| { + let t = (NEAR_W - p[2]) / (q[2] - p[2]); + [p[0] + (q[0] - p[0]) * t, p[1] + (q[1] - p[1]) * t, NEAR_W] + }; + if ha[2] < NEAR_W { + ha = cut(ha, hb); + } else if hb[2] < NEAR_W { + hb = cut(hb, ha); + } + let d = |q: [f64; 3]| [q[0] / q[2], q[1] / q[2]]; + let s = [d(ha), d(hb)]; + s.iter().flatten().all(|v| v.is_finite()).then_some(s) +} + +/// The content box corners (layer pixels) of `layer`. +fn box_corners(b: [f64; 4]) -> [[f64; 2]; 4] { + [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]] +} + +/// Layer → comp matrix and its bounds quad (in comp pixels). None for a 3D layer reaching to or +/// behind the camera (its quad would flip; [`layer_outline`] draws the part in front). pub(crate) fn layer_quad(ctx: &EvalCtx, layer: &Layer) -> Option<(Mat3, [[f64; 2]; 4], [f64; 4])> { let b = effectcraft_engine::render::content_bounds(ctx, layer)?; let (m, _) = l2c(ctx, layer); - let pts = [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]].map(|p| { + let corners = box_corners(b); + if corners.iter().any(|p| homogeneous(&m, *p)[2] < NEAR_W) { + return None; + } + let pts = corners.map(|p| { let q = m.apply(gv2(p[0], p[1])); [q.x, q.y] }); Some((m, pts, b)) } +/// The outline of a layer's content box in comp pixels, clipped at the camera's near plane. +pub(crate) fn layer_outline(ctx: &EvalCtx, layer: &Layer) -> Vec<[f64; 2]> { + let Some(b) = effectcraft_engine::render::content_bounds(ctx, layer) else { return vec![] }; + project_clipped(&l2c(ctx, layer).0, &box_corners(b)) +} + /// Which axes a bounding-box handle scales: corners (0–3) both; the top and bottom edges (4, 6) /// y; the right and left edges (5, 7) x. fn handle_axes(handle: usize) -> [bool; 2] { @@ -522,10 +607,12 @@ fn locked_pane(app: &mut EffectcraftApp, ui: &mut egui::Ui, full: Rect, bg: Colo let cam = (lv.view != View3D::ActiveCamera && comp.has_3d()) .then(|| app.session.state.views3d.get(&lv.comp).cloned().unwrap_or_default().cam(lv.view, comp.width as f64, comp.height as f64).state()); let dc = effectcraft_engine::viewer::DisplayColor::of(&app.session); + // The display conversion's inputs, keyed as in the main viewer's texture caches. + let dc_key = format!("{cam:?}{}", super::viewer_tools::display_key(app, dc.is_some())); let key = { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); - (app.session.revision, lv.comp.0, t.0, scale.to_bits(), format!("{cam:?}{}", dc.is_some())).hash(&mut h); + (app.session.revision, lv.comp.0, t.0, scale.to_bits(), dc_key).hash(&mut h); h.finish() }; let id = egui::Id::new("viewer-locked"); @@ -785,6 +872,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut handle_hits: Vec<(LayerId, usize, Pos2, egui::CursorIcon)> = Vec::new(); let mut vertex_hits: Vec = Vec::new(); let mut key_hits: Vec = Vec::new(); + let mut motion_frames: Vec = Vec::new(); let mut paths: Vec = Vec::new(); let sel_vertices = app.session.state.selected_vertices.clone(); // Pen path in progress ends when the tool changes or its mask is gone; Esc / Enter also end @@ -804,7 +892,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { for l in comp.layers.iter().filter(|l| selected.contains(&l.id) && l.is_active_at(time)) { let col = if label_handles { t.label(l.label) } else { t.accent }; // Motion path of animated position (keyframes and spatial tangents are draggable). - ov::motion_path(app, &painter, &map, &ectx, l, col, &mut key_hits); + ov::motion_path(app, &painter, &map, &ectx, l, col, &mut key_hits, &mut motion_frames); // Masks and shape paths. if app.ui.viewer.show_masks { let pen = ui.data(|d| d.get_temp::<(LayerId, u64)>(pen_id())); @@ -834,6 +922,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } let Some((m, q, _)) = layer_quad(&ectx, l) else { + // A 3D layer reaching past the camera: the part of its box in front of it. + let outline = layer_outline(&ectx, l); + if outline.len() > 2 { + painter.add(egui::Shape::closed_line(outline.iter().map(|p| map.to_screen(*p)).collect(), Stroke::new(1.0, col))); + continue; + } // Cameras, lights, empty layers: just the anchor. if let Some(tr) = l.transform() { let pos = ectx.v3(l, tr, "position", [0.0; 3]); @@ -866,15 +960,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let w = ectx.world_matrix(l); let pc = cam_state(&ectx).projection(cw as f64, ch as f64) * w; let a3 = effectcraft_engine::geom::vec3(a[0], a[1], a[2]); - let o = pc.apply(a3); let len = 60.0 / zoom as f64; for (axis, col) in [ (effectcraft_engine::geom::vec3(len, 0.0, 0.0), Color32::from_rgb(0xe0, 0x50, 0x50)), (effectcraft_engine::geom::vec3(0.0, len, 0.0), Color32::from_rgb(0x60, 0xd0, 0x60)), (effectcraft_engine::geom::vec3(0.0, 0.0, len), Color32::from_rgb(0x50, 0x8c, 0xf0)), ] { - let e = pc.apply(a3 + axis); - painter.arrow(map.to_screen([o.x, o.y]), map.to_screen([e.x, e.y]) - map.to_screen([o.x, o.y]), Stroke::new(2.0, col)); + // (clipped at the camera's near plane, #572) + if let Some([o, e]) = project_segment(&pc, a3, a3 + axis) { + painter.arrow(map.to_screen(o), map.to_screen(e) - map.to_screen(o), Stroke::new(2.0, col)); + } } } } @@ -886,7 +981,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let has_3d = comp.layers.iter().any(|l| l.is_3d() && l.is_active_at(time)); if has_3d && app.session.prefs.three_d.extended_viewer { for l in comp.layers.iter().filter(|l| l.is_3d() && l.is_active_at(time) && l.has_video()) { - if let Some((_, q, _)) = layer_quad(&ectx, l) { + let q = layer_outline(&ectx, l); + if q.len() > 2 { let sq: Vec = q.iter().map(|p| map.to_screen(*p)).collect(); let outside = sq.iter().any(|p| !comp_rect.contains(*p)); if outside { @@ -1044,7 +1140,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } let gid = egui::Id::new("viewer-gesture"); - if let Some(hp) = resp.hover_pos() { + // An overlay on top of the viewer (e.g. the camera tracker points) takes the hover, but the pointer is still over the viewer: keep wheel zoom working. + let hover_pos = resp.hover_pos().or_else(|| if resp.contains_pointer() { ui.input(|i| i.pointer.hover_pos()) } else { None }); + if let Some(hp) = hover_pos { app.pointer_comp = Some({ let c = map.to_comp(hp); [c[0] as f32, c[1] as f32] @@ -1056,7 +1154,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { Tool::Orbit => egui::CursorIcon::Alias, Tool::PanCamera => egui::CursorIcon::Move, Tool::Dolly => egui::CursorIcon::ResizeVertical, - t if t.is_shape() || t.is_pen() => egui::CursorIcon::Crosshair, + t if t.is_pen() => { + pen_cursor( + &ctx, + hp, + pen_cursor_icon(t, ov::segment_at(&paths, &map, hp, 5.0).is_some() || ov::motion_frame_at(&motion_frames, &key_hits, hp).is_some()), + ); + egui::CursorIcon::None + } + t if t.is_shape() => egui::CursorIcon::Crosshair, _ if app.ui.viewer.roi_draw => egui::CursorIcon::Crosshair, t if t.puppet_kind().is_some() => match super::puppet_tool::hit(&pin_hits, hp) { Some((_, super::puppet_tool::PinPart::Ring)) => egui::CursorIcon::Alias, @@ -1104,7 +1210,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { && ui.input(|i| i.pointer.primary_pressed()) && let Some(pos) = ui.input(|i| i.pointer.press_origin()) { - pen_press(app, ui, &ectx, &map, &paths, pos, mods); + pen_press(app, ui, &ectx, &map, &paths, ov::motion_frame_at(&motion_frames, &key_hits, pos), pos, mods); } // A pen click (press + release without a drag) ends its tangent gesture on release. if ui.input(|i| i.pointer.primary_released()) @@ -1150,6 +1256,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } Tool::MaskFeather => ov::begin_feather(app, &ectx, &paths, &map, press).map(Gesture::Overlay), Tool::Rotate => pick(app, &ectx, cpt, mods.shift).map(|l| { + let others = selected_others(app, &comp, l).into_iter().filter_map(|o| Some((o.id, ectx.f(o, o.transform()?, "rotation", 0.0)))).collect(); let layer = comp.layer(l).cloned(); let center = layer .as_ref() @@ -1157,7 +1264,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { .map(|p| map.to_screen([p[0], p[1]])) .unwrap_or(pos); let rot = layer.as_ref().and_then(|l| l.transform().map(|tr| ectx.f(l, tr, "rotation", 0.0))).unwrap_or(0.0); - Gesture::Rotate { layer: l, center, start_angle: ((pos.y - center.y) as f64).atan2((pos.x - center.x) as f64).to_degrees(), start_rot: rot } + Gesture::Rotate { + layer: l, + center, + start_angle: ((pos.y - center.y) as f64).atan2((pos.x - center.x) as f64).to_degrees(), + start_rot: rot, + others, + } }), Tool::PanBehind => pick(app, &ectx, cpt, false).and_then(|l| { let layer = comp.layer(l)?; @@ -1267,6 +1380,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { axes: handle_axes(hi), l2c, inv, + others: selected_others(app, &comp, lid) + .into_iter() + .filter_map(|o| Some((o.id, ectx.v3(o, o.transform()?, "scale", [100.0; 3])))) + .collect(), }) }) } else if let Some(l) = pick(app, &ectx, cpt, mods.shift) { @@ -1335,23 +1452,29 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let _ = app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/position", "value": v, "merge": merge})); } } - Gesture::Scale { layer, start_scale, start_local, handle, anchor, axes, l2c, inv } => { + Gesture::Scale { layer, start_scale, start_local, handle, anchor, axes, l2c, inv, others } => { let lp = inv.apply(gv2(cpt[0], cpt[1])); let uniform = mods.shift && axes == [true, true]; let f = drag_ratio(start_local, [lp.x - anchor[0], lp.y - anchor[1]], axes, uniform); // The handle snaps (to the comp's and other layers' edges, corners and centres), // so a layer scales exactly to them, e.g. to the comp's size. let f = snap_handle(f, handle, anchor, &l2c, axes, uniform, |p| vt::snap(app, &ctx, &ectx, &map, &[layer], &[p], mods)); - let v = [start_scale[0] * f[0], start_scale[1] * f[1], start_scale[2]]; - let _ = app.session.execute("prop.set", json!({"layer": layer.0, "path": "transform/scale", "value": v, "merge": merge})); + for (lid, s0) in std::iter::once((layer, start_scale)).chain(others) { + let v = [s0[0] * f[0], s0[1] * f[1], s0[2]]; + let _ = app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/scale", "value": v, "merge": merge})); + } } - Gesture::Rotate { layer, center, start_angle, start_rot } => { + Gesture::Rotate { layer, center, start_angle, start_rot, others } => { let ang = ((pos.y - center.y) as f64).atan2((pos.x - center.x) as f64).to_degrees(); let mut r = start_rot + (ang - start_angle); if mods.shift { r = (r / 45.0).round() * 45.0; } let _ = app.session.execute("prop.set", json!({"layer": layer.0, "path": "transform/rotation", "value": r, "merge": merge})); + for (lid, r0) in others { + let _ = + app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/rotation", "value": r0 + (r - start_rot), "merge": merge})); + } } Gesture::Anchor { layer, start_anchor, start_pos, start, inv, l2p, own, anchor_only } => { // Pan Behind snaps the anchor point (to its own layer's box, other layers' features, @@ -1589,19 +1712,35 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } Tool::Type | Tool::TypeVertical => {} Tool::Pen | Tool::MaskFeather => {} + // On a motion path (After Effects): Add Vertex adds a keyframe where it is clicked, + // Delete Vertex removes the keyframe, Convert Vertex switches it between Linear and + // Auto Bezier spatial interpolation. Tool::PenAdd => { if let Some((l, m, seg, tt)) = ov::segment_at(&paths, &map, pos, 6.0) { let _ = app.session.execute("mask.insertVertex", json!({"layer": l.0, "mask": m, "segment": seg, "t": tt})); + } else if let Some(f) = ov::motion_frame_at(&motion_frames, &key_hits, pos) { + add_motion_key(app, f); } } Tool::PenDelete => { if let Some(h) = vertex_at(pos, false) { let _ = app.session.execute("mask.deleteVertices", json!({"vertices": [{"layer": h.layer.0, "mask": h.mask, "index": h.index}]})); + } else if let Some(k) = ov::motion_key_at(&key_hits, pos) + && select_motion_key(app, &k) + { + let _ = app.session.execute("keys.delete", json!({})); } } Tool::PenConvert => { if let Some(h) = vertex_at(pos, false) { let _ = app.session.execute("mask.convertVertex", json!({"layer": h.layer.0, "mask": h.mask, "index": h.index})); + } else if let Some(k) = ov::motion_key_at(&key_hits, pos) { + // A key showing direction handles becomes Linear; one without, Auto Bezier. + let curved = key_hits.iter().any(|h| h.tangent.is_some() && h.layer == k.layer && h.prop == k.prop && h.time == k.time); + if select_motion_key(app, &k) { + let spatial = if curved { "linear" } else { "autoBezier" }; + let _ = app.session.execute("keys.interpolation", json!({"spatial": spatial})); + } } } Tool::Selection if mods.alt && vertex_at(pos, false).is_some() => { @@ -1640,6 +1779,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { _ => { if pick(app, &ectx, cpt, mods.shift).is_none() { let _ = app.session.execute("edit.deselectAll", json!({})); + } else if !mods.shift && !app.session.state.selected_vertices.is_empty() && ov::segment_at(&paths, &map, pos, 6.0).is_none() { + // A click away from the paths, even on their layer, deselects the mask + // points (the layer stays selected), as in After Effects (#513). + let _ = app.session.execute("mask.selectVertices", json!({"vertices": []})); } } } @@ -1789,7 +1932,60 @@ fn draw_rigs(app: &mut EffectcraftApp, painter: &egui::Painter, map: &ViewerMap, /// footage/solid layer (or shape layer, with Tool Creates Mask), a shape path (Shape n group with /// the Tools bar's Fill and Stroke) on the selected shape layer, or a new shape layer when /// nothing is selected. The placed point snaps. -fn pen_press(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, map: &ViewerMap, paths: &[ov::PathInfo], pos: Pos2, mods: egui::Modifiers) { +/// Add a Position keyframe at a motion path's frame, keeping the path where it is. +fn add_motion_key(app: &mut EffectcraftApp, f: ov::PathFrame) { + if let Err(e) = app.session.execute("prop.addKey", json!({"layer": f.layer.0, "prop": f.prop, "time": f.time.seconds()})) { + app.ui.status = e.to_string(); + } +} + +/// Select only the motion path's keyframe `k` (what the vertex tools act on). +fn select_motion_key(app: &mut EffectcraftApp, k: &ov::KeyHit) -> bool { + app.session.execute("keys.select", json!({"keys": [{"layer": k.layer.0, "prop": k.prop, "time": k.time.seconds()}]})).is_ok() +} + +/// The pointer for a Pen slot tool: the tool's own pen, the Pen showing Add Vertex's over a path +/// it would add a point to (After Effects). +fn pen_cursor_icon(tool: Tool, over_path: bool) -> crate::icons::Icon { + use crate::icons::Icon; + match tool { + Tool::Pen if over_path => Icon::PenAdd, + Tool::PenAdd => Icon::PenAdd, + Tool::PenDelete => Icon::PenDelete, + Tool::PenConvert => Icon::PenConvert, + Tool::MaskFeather => Icon::MaskFeather, + _ => Icon::Pen, + } +} + +/// Paint pen pointer `icon` with its tip at `at`, outlined for any background. +fn pen_cursor(ctx: &egui::Context, at: Pos2, icon: crate::icons::Icon) { + use crate::icons::Icon; + // Where each glyph's tip sits in its 16 × 16 box. + let tip = match icon { + Icon::PenAdd | Icon::PenDelete | Icon::MaskFeather => vec2(6.0, 2.5), + Icon::PenConvert => vec2(8.0, 3.5), + _ => vec2(8.0, 1.8), + }; + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("viewer-pen-cursor"))); + let rect = Rect::from_min_size(at - tip, vec2(16.0, 16.0)); + for d in [vec2(-1.0, 0.0), vec2(1.0, 0.0), vec2(0.0, -1.0), vec2(0.0, 1.0)] { + crate::icons::paint(&painter, rect.translate(d), icon, Color32::BLACK); + } + crate::icons::paint(&painter, rect, icon, Color32::WHITE); +} + +#[allow(clippy::too_many_arguments)] +fn pen_press( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + ectx: &EvalCtx, + map: &ViewerMap, + paths: &[ov::PathInfo], + motion_frame: Option, + pos: Pos2, + mods: egui::Modifiers, +) { use effectcraft_engine::project::LayerSource; let gid = egui::Id::new("viewer-gesture"); let current: Option<(LayerId, u64)> = ui.data(|d| d.get_temp(pen_id())); @@ -1821,7 +2017,11 @@ fn pen_press(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, map: & } return; } - // On a visible path's segment the Pen adds a vertex there. + // On a visible path's segment the Pen adds a vertex there; on a motion path, a keyframe. + if let Some(f) = motion_frame { + add_motion_key(app, f); + return; + } if let Some((l, m, seg, t)) = ov::segment_at(paths, map, pos, 5.0) { let _ = app.session.execute("mask.insertVertex", json!({"layer": l.0, "mask": m, "segment": seg, "t": t})); return; @@ -2147,14 +2347,7 @@ pub(crate) fn minify_factor(smooth: bool, pixels: f64, screen: f64) -> usize { if smooth && k >= 1.0 { k.min(256.0) as usize } else { 1 } } -pub(crate) fn hex_rgb(s: &str) -> Option<[u8; 3]> { - let h = s.trim().trim_start_matches('#'); - if h.len() != 6 || !h.is_ascii() { - return None; - } - let p = |i: usize| u8::from_str_radix(&h[i..i + 2], 16).ok(); - Some([p(0)?, p(2)?, p(4)?]) -} +pub(crate) use crate::widgets::hex_rgb; /// The Extended Viewer's render region for this frame (see [`ViewerState::extended`]): /// Settings ▸ 3D ▸ Extended Viewer on, the comp has 3D layers and the view is a custom 3D view @@ -2227,3 +2420,40 @@ fn show_frame(app: &mut EffectcraftApp, ctx: &egui::Context, key: crate::frames: } } } + +#[cfg(test)] +mod tests { + use super::*; + use effectcraft_engine::geom::{Mat4, vec3}; + + /// A perspective-like homography: w = 100 + y (the plane crosses the camera at y = −100). + fn tilted() -> Mat3 { + Mat3([[100.0, 0.0, 0.0], [0.0, 100.0, 0.0], [0.0, 1.0, 100.0]]) + } + + /// #572: a box reaching behind the camera is clipped at the near plane instead of flipping + /// through infinity into long lines. + #[test] + fn outlines_clip_at_the_near_plane() { + let m = tilted(); + let front = project_clipped(&m, &box_corners([-50.0, -50.0, 50.0, 50.0])); + assert_eq!(front.len(), 4); + let crossing = project_clipped(&m, &box_corners([-50.0, -150.0, 50.0, 50.0])); + assert_eq!(crossing.len(), 4, "{crossing:?}"); + // Nothing flipped through infinity: a corner behind the camera (y = −150, w = −50) would + // land at y = +300 if it were projected as is; clipped at the near plane it runs off far + // above (negative y) instead, and the front corners stay where they were. + assert!(crossing.iter().all(|p| p[0].is_finite() && p[1].is_finite() && p[1] < 34.0), "{crossing:?}"); + assert!(project_clipped(&m, &box_corners([-50.0, -400.0, 50.0, -200.0])).is_empty(), "wholly behind"); + } + + #[test] + fn gizmo_segments_clip_at_the_near_plane() { + // w = 100 + z. + let pc = Mat4([[100.0, 0.0, 0.0, 0.0], [0.0, 100.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 1.0, 100.0]]); + let s = project_segment(&pc, vec3(10.0, 0.0, 0.0), vec3(10.0, 0.0, -300.0)).unwrap(); + assert_eq!(s[0], [10.0, 0.0]); + assert!(s[1][0] > 10.0 && s[1][0].is_finite(), "toward the camera it grows, never flips: {s:?}"); + assert!(project_segment(&pc, vec3(0.0, 0.0, -200.0), vec3(0.0, 0.0, -300.0)).is_none()); + } +} diff --git a/crates/ui-egui/src/panels/viewer_drop.rs b/crates/ui-egui/src/panels/viewer_drop.rs index 31d426b3..2d42eb2c 100644 --- a/crates/ui-egui/src/panels/viewer_drop.rs +++ b/crates/ui-egui/src/panels/viewer_drop.rs @@ -23,32 +23,37 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &egui::Ui, painter: &egui::Pain let Some(ptr) = ctx.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(map.area)) else { return }; let stroke = Stroke::new(2.0, app.tokens.accent); let at = map.to_comp(ptr); - let action: Option<(&str, Value)> = match payload.as_ref() { - DragPayload::Effect(effect) => layer_at(ectx, at).map(|l| { - if let Some((_, q, _)) = layer_quad(ectx, l) { - painter.add(egui::Shape::closed_line(q.iter().map(|c| map.to_screen(*c)).collect(), stroke)); - } - ("effect.apply", json!({"effect": effect, "layers": [l.id.0]})) - }), + let actions: Vec<(&str, Value)> = match payload.as_ref() { + DragPayload::Effect(effect) => layer_at(ectx, at) + .map(|l| { + if let Some((_, q, _)) = layer_quad(ectx, l) { + painter.add(egui::Shape::closed_line(q.iter().map(|c| map.to_screen(*c)).collect(), stroke)); + } + ("effect.apply", json!({"effect": effect, "layers": [l.id.0]})) + }) + .into_iter() + .collect(), DragPayload::Item(item) => { // The layer's frame, centred on the pointer. if let Some([w, h]) = app.session.project.item(ItemId(*item)).and_then(|it| frame_size(&it.kind, ectx.comp.pixel_aspect)) { painter.rect_stroke(Rect::from_center_size(ptr, vec2(w, h) * map.zoom), 0.0, Stroke::new(1.0, app.tokens.accent), StrokeKind::Middle); } - Some(("layer.addItem", json!({"item": item, "position": at}))) + // New layers go above the current selection, so insert in reverse + // to preserve the order of the dragged Project items. + crate::panels::project_drop_items(app, *item).into_iter().rev().map(|item| ("layer.addItem", json!({"item": item, "position": at}))).collect() } - DragPayload::Files(paths) => Some(("mediaBrowser.import", json!({"paths": paths, "addToComp": true, "position": at}))), - DragPayload::Property { .. } => None, + DragPayload::Files(paths) => vec![("mediaBrowser.import", json!({"paths": paths, "addToComp": true, "position": at}))], + DragPayload::Property { .. } => vec![], }; - if action.as_ref().is_some_and(|(id, _)| *id != "effect.apply") { + if actions.first().is_some_and(|(id, _)| *id != "effect.apply") { painter.rect_stroke(map.area, 0.0, stroke, StrokeKind::Inside); } if ctx.input(|i| i.pointer.any_released()) { egui::DragAndDrop::clear_payload(&ctx); - if let Some((id, params)) = action - && let Err(e) = crate::menus::invoke(app, &ctx, id, params) - { - app.ui.status = e; + for (id, params) in actions { + if let Err(e) = crate::menus::invoke(app, &ctx, id, params) { + app.ui.status = e; + } } } } diff --git a/crates/ui-egui/src/panels/viewer_overlays.rs b/crates/ui-egui/src/panels/viewer_overlays.rs index 0038f192..54218e9f 100644 --- a/crates/ui-egui/src/panels/viewer_overlays.rs +++ b/crates/ui-egui/src/panels/viewer_overlays.rs @@ -248,6 +248,32 @@ pub(crate) struct KeyHit { pub p2c: Mat3, } +/// A frame on a motion path (one of its dots): where the Pen and Add Vertex tools add a +/// keyframe. +#[derive(Clone, Copy)] +pub(crate) struct PathFrame { + pub layer: LayerId, + pub prop: u64, + /// Layer time of the frame. + pub time: Tick, + pub pos: Pos2, +} + +/// The keyframe dot on a motion path under `pos`. +pub(crate) fn motion_key_at(hits: &[KeyHit], pos: Pos2) -> Option { + hits.iter().rev().find(|h| h.tangent.is_none() && h.pos.distance(pos) < 6.0).copied() +} + +/// The motion-path frame nearest `pos` (within a few points) where a keyframe can be added: not +/// on a keyframe dot already there. +pub(crate) fn motion_frame_at(frames: &[PathFrame], hits: &[KeyHit], pos: Pos2) -> Option { + if motion_key_at(hits, pos).is_some() { + return None; + } + let near = frames.iter().filter(|f| f.pos.distance(pos) < 5.0).min_by(|a, b| a.pos.distance(pos).total_cmp(&b.pos.distance(pos)))?; + (!hits.iter().any(|h| h.tangent.is_none() && h.layer == near.layer && h.prop == near.prop && h.time == near.time)).then_some(*near) +} + /// Parent space → comp matrix of a 2D layer at the context time. pub(crate) fn parent_to_comp(ectx: &EvalCtx, l: &Layer) -> Mat3 { match l.parent.and_then(|p| ectx.comp.layer(p)) { @@ -261,7 +287,17 @@ pub(crate) fn parent_to_comp(ectx: &EvalCtx, l: &Layer) -> Mat3 { /// Draw the position motion path of a selected 2D layer: frame dots along the path, keyframe /// squares (filled when selected) and spatial Bezier tangent handles. Works through parents. -pub(crate) fn motion_path(app: &mut EffectcraftApp, painter: &egui::Painter, map: &ViewerMap, ectx: &EvalCtx, l: &Layer, col: Color32, hits: &mut Vec) { +#[allow(clippy::too_many_arguments)] +pub(crate) fn motion_path( + app: &mut EffectcraftApp, + painter: &egui::Painter, + map: &ViewerMap, + ectx: &EvalCtx, + l: &Layer, + col: Color32, + hits: &mut Vec, + frames: &mut Vec, +) { let Some(pos) = l.transform().and_then(|tr| tr.get("position")) else { return }; if pos.keys.len() < 2 || l.is_3d() { return; @@ -278,7 +314,9 @@ pub(crate) fn motion_path(app: &mut EffectcraftApp, painter: &egui::Painter, map let mut pts = vec![]; let mut lt = lt0; while lt <= lt1 && pts.len() < 20_000 { - pts.push(to_scr(pos.value_at(lt).as_vec3())); + let q = to_scr(pos.value_at(lt).as_vec3()); + frames.push(PathFrame { layer: l.id, prop: pos.uid, time: lt, pos: q }); + pts.push(q); lt += fd; } pts.push(to_scr(pos.value_at(lt1).as_vec3())); diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs index 94e7fedd..772d0769 100644 --- a/crates/ui-egui/src/panels/viewer_tools.rs +++ b/crates/ui-egui/src/panels/viewer_tools.rs @@ -349,6 +349,14 @@ fn transformed(img: &egui::ColorImage, ch: Channel, colorized: bool, stops: f32, egui::ColorImage::new(img.size, px.into_iter().map(|a| Color32::from_rgba_premultiplied(a[0], a[1], a[2], a[3])).collect()) } +/// The display conversion's inputs, for the texture caches. +pub(crate) fn display_key(app: &EffectcraftApp, has_conversion: bool) -> String { + let opts = &app.session.state.viewer; + let custom = if opts.simulation.profile == vw::SimProfile::MyCustom { format!("{:?}", app.session.prefs.custom_rgb) } else { String::new() }; + format!("{:?}{:?}{}{custom}", has_conversion, opts.simulation, app.session.prefs.previews.display_profile) + + &format!("{:?}{:?}{}", app.session.project.settings.working_space, app.session.project.settings.output_space, opts.display_color_management) +} + /// Draw the frame into `comp_rect`: the rendered frame (covering the region of interest when one /// is set), or the snapshot while Show Snapshot is on / F5 is held, through Show Channel and the /// exposure. Fast Previews ▸ Wireframe draws layer outlines instead. @@ -378,10 +386,7 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter: return; } let dc = vw::DisplayColor::of(&app.session); - // The display conversion's inputs, for the texture caches. - let custom = if opts.simulation.profile == vw::SimProfile::MyCustom { format!("{:?}", app.session.prefs.custom_rgb) } else { String::new() }; - let dc_key = format!("{:?}{:?}{}{custom}", dc.is_some(), opts.simulation, app.session.prefs.previews.display_profile) - + &format!("{:?}{:?}{}", app.session.project.settings.working_space, app.session.project.settings.output_space, opts.display_color_management); + let dc_key = display_key(app, dc.is_some()); let plain = opts.channel == Channel::Rgb && opts.exposure == 0.0 && dc.is_none(); let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); if snap { @@ -704,11 +709,13 @@ fn bottom_bar_contents(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, z let mut ev = vo.exposure as f64; let resp = ui.put( er, - egui::DragValue::new(&mut ev) - .speed(0.05) - .range(-40.0..=40.0) - .fixed_decimals(1) - .custom_formatter(|v, _| if v == 0.0 { "0.0".into() } else { format!("{v:+.1}") }), + crate::widgets::drag_value( + egui::DragValue::new(&mut ev) + .speed(0.05) + .range(-40.0..=40.0) + .fixed_decimals(1) + .custom_formatter(|v, _| if v == 0.0 { "0.0".into() } else { format!("{v:+.1}") }), + ), ); let resp = resp.on_hover_text(crate::i18n::tr("Adjust Exposure (stops)")); app.auto.add_response("viewer.exposure", &resp, "Adjust Exposure"); @@ -744,9 +751,23 @@ fn bottom_bar_contents(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, z x += 4.0; // 3D renderer + view. if comp.has_3d() { + // The comp's 3D renderer, as After Effects' Composition panel shows it: the menu switches + // it through Composition Settings (undoable), and the automation label is the renderer. + use effectcraft_engine::project::Renderer; + let renderers = [(Renderer::Classic3D, "Classic 3D", "classic3D"), (Renderer::Advanced3D, "Advanced 3D", "advanced3D")]; + let current = renderers.iter().find(|(r, ..)| *r == comp.renderer).map_or("Classic 3D", |(_, label, _)| *label); let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(96.0, 20.0)); - let response = widgets::dropdown(ui, r, "Classic 3D", &t, egui::Id::new("vw-3d")); - app.auto.add_response("viewer.renderer3d", &response, "3D Renderer"); + if widgets::dropdown(ui, r, crate::i18n::tr(current), &t, egui::Id::new("vw-3d")).clicked() { + toggle_popup(ui, "vw-3d-pop"); + } + app.auto.add("viewer.renderer3d", r, current); + let items: Vec<(String, bool)> = renderers.iter().map(|(r, label, _)| (label.to_string(), *r == comp.renderer)).collect(); + if let Some((renderer, _, id)) = popup(app, ui, "vw-3d-pop", r, &items, "renderer3dItem").and_then(|i| renderers.get(i)) + && *renderer != comp.renderer + && let Err(e) = app.session.execute("comp.settings", json!({"renderer": id})) + { + app.ui.status = e.to_string(); + } x = r.max.x + 4.0; let cid = app.session.active_comp_id(); let cur = cid.and_then(|c| app.session.state.views3d.get(&c)).map(|v| v.current).unwrap_or_default(); diff --git a/crates/ui-egui/src/panels/viewers.rs b/crates/ui-egui/src/panels/viewers.rs index f23fd511..8d235133 100644 --- a/crates/ui-egui/src/panels/viewers.rs +++ b/crates/ui-egui/src/panels/viewers.rs @@ -102,7 +102,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: u32, rect: Rect) { app.request_frame(cid, frame, scale); let key = app.frame_key(cid, frame, scale); if let Some((key, img)) = app.frames.get_for_viewer(&key, app.passive_tex.get(&id).map(|(k, _)| k)) { - set_texture(app, &ctx, id, key, img); + let dc = effectcraft_engine::viewer::DisplayColor::of(&app.session); + let dkey = display_hash(&super::viewer_tools::display_key(app, dc.is_some())); + set_texture(app, &ctx, id, key, img, dc, dkey); } match app.passive_tex.get(&id).filter(|(k, _)| k.comp == cid.0) { Some((_, tex)) => { @@ -126,16 +128,38 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: u32, rect: Rect) { } /// Keep the frame viewer `id` shows in its texture. -fn set_texture(app: &mut EffectcraftApp, ctx: &egui::Context, id: u32, key: FrameKey, img: FrameImage) { +fn set_texture( + app: &mut EffectcraftApp, + ctx: &egui::Context, + id: u32, + key: FrameKey, + img: FrameImage, + dc: Option, + dkey: u64, +) { + let dc_id = egui::Id::new(("passive-viewer-display", id)); + let same_display = ctx.data(|d| d.get_temp::(dc_id)) == Some(dkey); if let Some((k, _)) = app.passive_tex.get_mut(&id) && *k == key + && same_display { // An undo or an edit elsewhere can reuse this content at a newer revision. Keep that // revision as the lower bound for subsequent intermediate edit frames. k.revision = key.revision; return; } + ctx.data_mut(|d| d.insert_temp(dc_id, dkey)); let opts = super::viewer::zoom_texture_options(app.session.prefs.viewer_zoom_smooth()); + // The monitor conversion the active viewer applies (Use Display Color Management). + let img = match (img, &dc) { + (FrameImage::Cpu(img), Some(dc)) => { + let mut px: Vec<[u8; 4]> = img.pixels.iter().map(|c| c.to_array()).collect(); + dc.apply(&mut px); + let pixels = px.into_iter().map(|a| Color32::from_rgba_premultiplied(a[0], a[1], a[2], a[3])).collect(); + FrameImage::Cpu(Arc::new(egui::ColorImage::new(img.size, pixels))) + } + (img, _) => img, + }; let tex = match (img, app.passive_tex.remove(&id)) { (FrameImage::Cpu(img), Some((_, PassiveTexture::Cpu(mut h)))) if h.size() == crate::frames::fitted_size(img.size, crate::frames::max_texture_side(ctx)) => @@ -165,6 +189,14 @@ fn set_texture(app: &mut EffectcraftApp, ctx: &egui::Context, id: u32, key: Fram app.passive_tex.insert(id, (key, tex)); } +/// A hash of the display conversion's inputs (see `viewer_tools::display_key`). +fn display_hash(key: &str) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + key.hash(&mut h); + h.finish() +} + /// Give a passive viewer's texture back (GPU textures are registered with the renderer). fn free(app: &EffectcraftApp, tex: Option) { if let (Some(PassiveTexture::Gpu(id, _)), Some(rs)) = (tex, &app.wgpu) { diff --git a/crates/ui-egui/src/pointer_wrap.rs b/crates/ui-egui/src/pointer_wrap.rs new file mode 100644 index 00000000..a4aefe33 --- /dev/null +++ b/crates/ui-egui/src/pointer_wrap.rs @@ -0,0 +1,531 @@ +//! Continuous numeric scrubbing across native monitor edges. + +use egui::{Context, Event, Id, PointerButton, Pos2, RawInput, Response, Vec2}; +#[cfg(any(not(target_arch = "wasm32"), test))] +use egui::{Rect, pos2, vec2}; + +#[derive(Clone, Default)] +struct State { + numeric: Option<(Id, u64)>, + offset: Vec2, + pending: Option<(Pos2, Pos2)>, + disabled: bool, +} + +fn state_id() -> Id { + Id::new("numeric-pointer-wrap") +} + +/// Opt in only number scrubs, never object, keyframe, panel or scrollbar drags. +pub(crate) fn track(response: &Response) { + if response.dragged_by(PointerButton::Primary) { + let pass = response.ctx.cumulative_pass_nr(); + response.ctx.data_mut(|d| { + let state = d.get_temp_mut_or_default::(state_id()); + state.numeric = Some((response.id, pass)); + }); + } +} + +/// Keep egui's drag coordinates continuous while the visible native pointer wraps. +/// Rebase only when the OS acknowledges a successful warp: queued events from before +/// it must not be interpreted as another monitor-width movement. A coalesced event +/// may include a little real movement beyond the requested destination. +/// Returns whether a numeric gesture ended, so the app can close its Undo group. `injected`: +/// this frame carries pointer events from the control channel (an agent's `ui.drag`), whose +/// gesture must never move the user's real cursor. +pub(crate) fn prepare_input(ctx: &Context, raw: &mut RawInput, injected: bool) -> bool { + let lost_focus = !raw.focused || raw.events.iter().any(|e| matches!(e, Event::WindowFocused(false))); + let (down, last_pos, modifiers) = ctx.input(|i| (i.pointer.primary_down(), i.pointer.latest_pos(), i.modifiers)); + ctx.data_mut(|d| { + let state = d.get_temp_mut_or_default::(state_id()); + if lost_focus + && down + && state.numeric.is_some() + && let Some(pos) = last_pos + { + // The OS may deliver the eventual button-up to another app. Explicitly + // finish this scrub so focus returning cannot restart it or leave Undo merged. + raw.events.push(Event::PointerButton { pos: pos - state.offset, button: PointerButton::Primary, pressed: false, modifiers }); + } + let touch = injected || raw.events.iter().any(|e| matches!(e, Event::Touch { .. })); + if lost_focus + || touch + || raw.events.iter().any(|e| matches!(e, Event::WindowFocused(false) | Event::PointerButton { button: PointerButton::Primary, .. })) + { + let ended = state.numeric.is_some(); + *state = State { disabled: touch, ..Default::default() }; + return ended; + } + if state.numeric.is_none() { + return false; + } + // Mouse capture can report leaving the client area mid-scrub. Keep the + // drag's previous coordinate until its next captured movement arrives. + raw.events.retain(|e| !matches!(e, Event::PointerGone)); + for event in &mut raw.events { + let p = match event { + Event::PointerMoved(p) | Event::PointerButton { pos: p, .. } => p, + _ => continue, + }; + if let Some((from, to)) = state.pending + && p.distance_sq(to) < p.distance_sq(from) + { + state.offset += from - to; + state.pending = None; + } + *p += state.offset; + } + false + }) +} + +#[cfg(any(not(target_arch = "wasm32"), test))] +fn wrap_target(p: Pos2, bounds: Rect) -> Option { + if !p.is_finite() || !bounds.is_finite() || bounds.width() < 32.0 || bounds.height() < 32.0 { + return None; + } + let axis = |v: f32, lo: f32, hi: f32| { + if v <= lo + 2.0 { + hi - 8.0 + } else if v >= hi - 2.0 { + lo + 8.0 + } else { + v + } + }; + let to = pos2(axis(p.x, bounds.min.x, bounds.max.x), axis(p.y, bounds.min.y, bounds.max.y)); + (to != p).then_some(to) +} + +#[cfg(any(not(target_arch = "wasm32"), test))] +fn finish_frame(ctx: &Context, bounds: Rect, warp: impl FnOnce(Pos2) -> Option) { + let state = ctx.data(|d| d.get_temp::(state_id())).unwrap_or_default(); + let active = state.numeric.is_some_and(|(id, pass)| pass == ctx.cumulative_pass_nr() && ctx.is_being_dragged(id)); + let (down, focused, virtual_pos) = ctx.input(|i| (i.pointer.primary_down(), i.focused, i.pointer.latest_pos())); + if !active || !down || !focused { + ctx.data_mut(|d| d.remove::(state_id())); + return; + } + if state.disabled || state.pending.is_some() { + return; + } + let Some(from) = virtual_pos.map(|p| p - state.offset) else { return }; + let Some(to) = wrap_target(from, bounds) else { return }; + let destination = warp(to); + ctx.data_mut(|d| { + let state = d.get_temp_mut_or_default::(state_id()); + if let Some(to) = destination { + state.pending = Some((from, to)); + } else { + // Unsupported backends keep ordinary dragging; do not retry/log every frame. + state.disabled = true; + } + }); + if destination.is_some() { + ctx.request_repaint(); + } +} + +/// Native monitor coordinates are physical pixels; egui input uses UI points. +/// Use the monitor containing the original press, including negative desktop origins. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn finish_native(ctx: &Context, frame: &eframe::Frame) { + let pass = ctx.cumulative_pass_nr(); + if !ctx.data(|d| d.get_temp::(state_id()).is_some_and(|s| s.numeric.is_some_and(|(_, p)| p == pass))) { + ctx.data_mut(|d| d.remove::(state_id())); + return; + } + let Some(window) = frame.winit_window() else { return }; + let Ok(origin) = window.inner_position() else { return }; + let ppp = ctx.pixels_per_point(); + if !ppp.is_finite() || ppp <= 0.0 { + return; + } + let Some(press) = ctx.input(|i| i.pointer.press_origin()) else { return }; + let screen_press = press * ppp + vec2(origin.x as f32, origin.y as f32); + let monitor = window + .available_monitors() + .find(|m| { + let p = m.position(); + let s = m.size(); + Rect::from_min_size(pos2(p.x as f32, p.y as f32), vec2(s.width as f32, s.height as f32)).contains(screen_press) + }) + .or_else(|| window.current_monitor()); + let Some(monitor) = monitor else { return }; + let p = monitor.position(); + let s = monitor.size(); + let bounds = Rect::from_min_size( + pos2((p.x as f64 - origin.x as f64) as f32, (p.y as f64 - origin.y as f64) as f32) / ppp, + vec2(s.width as f32, s.height as f32) / ppp, + ); + finish_frame(ctx, bounds, |to| { + // Reuse winit's physical position type obtained above, now client-relative. + let mut target = origin; + target.x = (to.x * ppp).round() as i32; + target.y = (to.y * ppp).round() as i32; + match window.set_cursor_position(target) { + // Remember the actual device pixel to avoid fractional-DPI drift on every wrap. + Ok(()) => Some(pos2(target.x as f32 / ppp, target.y as f32 / ppp)), + Err(e) => { + log::warn!("numeric cursor wrap is unavailable: {e}"); + None + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use egui::{Event, Id, Modifiers, PointerButton, pos2, vec2}; + + struct Scrub { + ctx: Context, + value: f64, + bounds: Rect, + time: f64, + accept_warp: bool, + warps: Vec, + standard: bool, + ordinary: bool, + angle: bool, + angle_rects: Option<(Rect, Rect)>, + modifiers: Modifiers, + injected: bool, + } + + impl Scrub { + fn new() -> Self { + Self { + ctx: Context::default(), + value: 0.0, + bounds: Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)), + time: 0.0, + accept_warp: true, + warps: Vec::new(), + standard: false, + ordinary: false, + angle: false, + angle_rects: None, + modifiers: Modifiers::NONE, + injected: false, + } + } + + fn frame(&mut self, events: Vec, focused: bool) { + self.time += 0.02; + let mut raw = RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), + time: Some(self.time), + events, + focused, + ..Default::default() + }; + raw.events.push(Event::ModifiersChanged(self.modifiers)); + prepare_input(&self.ctx, &mut raw, self.injected); + let mut out = self.ctx.run_ui(raw, |ui| { + if self.standard { + ui.put( + Rect::from_min_size(pos2(100.0, 100.0), vec2(60.0, 20.0)), + crate::widgets::drag_value(egui::DragValue::new(&mut self.value).speed(1.0).max_decimals(3)), + ); + } else if self.ordinary { + ui.interact(Rect::from_min_size(pos2(100.0, 100.0), vec2(60.0, 20.0)), Id::new("ordinary"), egui::Sense::drag()); + } else if self.angle { + let (rr, dr, value) = crate::panels::fx_widgets::angle_field( + ui, + pos2(100.0, 100.0), + Id::new("scrub"), + self.value, + 1, + &crate::theme::Tokens::for_kind(crate::theme::ThemeKind::Dark), + ); + self.angle_rects = Some((rr, dr)); + if let Some(v) = value { + self.value = v; + } + } else { + let (_, value, _) = crate::widgets::hot_number_at( + ui, + pos2(100.0, 100.0), + Id::new("scrub"), + self.value, + 1.0, + (-1e9, 1e9), + 1, + "", + &crate::theme::Tokens::for_kind(crate::theme::ThemeKind::Dark), + ); + if let Some(v) = value { + self.value = v; + } + } + finish_frame(ui.ctx(), self.bounds, |p| { + self.warps.push(p); + self.accept_warp.then_some(p) + }); + }); + out.textures_delta.clear(); + } + + fn move_to(&mut self, p: Pos2) { + self.frame(vec![Event::PointerMoved(p)], true); + } + + fn begin(&mut self) { + self.frame(vec![], true); + let p = pos2(105.0, 108.0); + self.frame( + vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }], + true, + ); + self.move_to(p + vec2(10.0, 0.0)); + } + } + + #[test] + fn angle_degrees_continue_across_zero_and_repeated_monitor_wraps() { + for initial in [0.0, 359.5, -359.5, 720.0, -720.0] { + for direction in [-1.0, 1.0] { + let mut s = Scrub::new(); + s.angle = true; + s.value = initial; + s.frame(vec![], true); + let p = s.angle_rects.unwrap().1.center(); + s.frame( + vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }], + true, + ); + let mut last = p; + let mut expected = initial; + // Feed small real movements, including zero/whole-turn crossings, then + // the synthetic OS warp. Every real pixel contributes exactly once. + for step in 0..800 { + let direction = if step < 400 { direction } else { -direction }; + let next = pos2((last.x + direction * 13.0).clamp(1.0, 799.0), last.y); + expected += f64::from(next.x - last.x) * 0.5; + let count = s.warps.len(); + s.move_to(next); + assert!((s.value - expected).abs() < 1e-6, "initial={initial}, direction={direction}, actual={}, expected={expected}", s.value); + last = next; + if s.warps.len() > count { + last = *s.warps.last().unwrap(); + s.move_to(last); + assert!((s.value - expected).abs() < 1e-6, "the teleport must not reset the angle"); + } + } + assert!(s.warps.len() >= 5); + } + } + } + + #[test] + fn numeric_drag_wraps_repeatedly_without_counting_the_teleport() { + let mut s = Scrub::new(); + s.begin(); + for _ in 0..5 { + s.move_to(pos2(799.0, 108.0)); + let target = *s.warps.last().expect("numeric drag must wrap at the monitor edge"); + assert!(target.x < 16.0); + let before = s.value; + s.move_to(target); + assert_eq!(s.value, before, "the warp itself must not change the value"); + s.move_to(target + vec2(20.0, 0.0)); + assert_eq!(s.value, before + 20.0); + } + assert_eq!(s.warps.len(), 5); + s.move_to(pos2(0.0, 108.0)); + let target = *s.warps.last().unwrap(); + assert!(target.x > 780.0); + let before = s.value; + s.move_to(target - vec2(3.0, 0.0)); + assert_eq!(s.value, before - 3.0, "coalesced movement after a left-edge warp is preserved"); + } + + /// An agent's `ui.drag` over the control channel scrubs the value but never moves the user's + /// real cursor, even at the window's (monitor's) edge. + #[test] + fn injected_drags_never_warp_the_real_cursor() { + let mut s = Scrub::new(); + s.injected = true; + s.begin(); + s.move_to(pos2(799.0, 108.0)); + assert!(s.warps.is_empty()); + } + + #[test] + fn normal_pointer_movement_does_not_warp() { + let mut s = Scrub::new(); + s.move_to(pos2(799.0, 108.0)); + assert!(s.warps.is_empty()); + } + + #[test] + fn standard_editor_wraps_at_top_bottom_and_corner_without_a_value_jump() { + let mut s = Scrub::new(); + s.standard = true; + s.begin(); + for edge in [pos2(200.0, 0.0), pos2(200.0, 599.0), pos2(799.0, 599.0)] { + s.move_to(edge); + let target = *s.warps.last().unwrap(); + assert_ne!(target, edge); + let before = s.value; + s.move_to(target); + assert_eq!(s.value, before); + s.move_to(target + vec2(5.0, -5.0)); + assert_eq!(s.value, before + 10.0); + } + } + + #[test] + fn release_and_focus_loss_end_wrapping_and_restore_normal_hit_testing() { + for lose_focus in [false, true] { + let mut s = Scrub::new(); + s.begin(); + s.move_to(pos2(799.0, 108.0)); + let target = *s.warps.last().unwrap(); + s.move_to(target); + let value = s.value; + let events = if lose_focus { + vec![Event::WindowFocused(false)] + } else { + vec![Event::PointerButton { pos: target, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }] + }; + s.frame(events, !lose_focus); + s.move_to(pos2(799.0, 108.0)); + assert_eq!(s.warps.len(), 1); + assert_eq!(s.value, value); + assert_eq!(s.ctx.input(|i| i.pointer.latest_pos()), Some(pos2(799.0, 108.0))); + s.begin(); + s.move_to(pos2(799.0, 108.0)); + assert_eq!(s.warps.len(), 2, "the next independent drag still wraps"); + } + } + + #[test] + fn object_drag_does_not_wrap_and_failed_warp_is_not_retried() { + let mut s = Scrub::new(); + s.ordinary = true; + s.begin(); + s.move_to(pos2(799.0, 108.0)); + assert!(s.warps.is_empty()); + + let mut s = Scrub::new(); + s.accept_warp = false; + s.begin(); + s.move_to(pos2(799.0, 108.0)); + for _ in 0..3 { + s.move_to(pos2(799.0, 108.0)); + } + assert_eq!(s.warps.len(), 1); + let value = s.value; + s.move_to(pos2(779.0, 108.0)); + assert_eq!(s.value, value - 20.0); + } + + #[test] + fn modifiers_keep_their_sensitivity_after_wrapping() { + for (modifiers, multiplier) in [(Modifiers::SHIFT, 10.0), (Modifiers::COMMAND, 0.1)] { + let mut s = Scrub::new(); + s.modifiers = modifiers; + s.begin(); + s.move_to(pos2(799.0, 108.0)); + let target = *s.warps.last().unwrap(); + let value = s.value; + s.move_to(target + vec2(10.0, 0.0)); + assert!((s.value - value - 10.0 * multiplier).abs() < 1e-8); + } + } + + #[test] + fn monitor_rect_can_extend_beyond_the_app_and_have_a_negative_origin() { + let mut s = Scrub::new(); + s.bounds = Rect::from_min_size(pos2(-200.0, -100.0), vec2(1200.0, 800.0)); + s.begin(); + s.move_to(pos2(999.0, 108.0)); + let target = *s.warps.last().unwrap(); + assert_eq!(target, pos2(-192.0, 108.0)); + let value = s.value; + s.frame(vec![Event::PointerGone, Event::PointerMoved(target)], true); + assert_eq!(s.value, value); + s.move_to(target + vec2(10.0, 0.0)); + assert_eq!(s.value, value + 10.0); + } + + #[test] + fn real_properties_and_angle_drags_keep_each_gesture_in_one_undo_step() { + use crate::{EffectcraftApp, dock::PanelKind}; + use effectcraft_engine::project::LayerId; + use eframe::App; + use serde_json::json; + + for angle in [false, true] { + let mut session = effectcraft_host::session(); + session.execute("comp.new", json!({"width": 32, "height": 16})).unwrap(); + let lid = session.execute("layer.newSolid", json!({"width": 8, "height": 8})).unwrap()["layer"].as_u64().unwrap(); + let path = if angle { "transform/rotation" } else { "transform/position" }; + let prop = session.active_comp().unwrap().layer(LayerId(lid)).unwrap().props.prop(path).unwrap(); + let uid = prop.uid; + let original = prop.value.clone(); + let undo = session.history.undo.len(); + let panel = PanelKind::Properties; + let id = if angle { format!("properties.prop.{uid}.value") } else { format!("properties.prop.{uid}.value.0") }; + let mut app = EffectcraftApp::new(session); + app.show_panel(panel); + app.toggle_maximize(panel); + let ctx = Context::default(); + let bounds = Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0)); + let mut frame = eframe::Frame::_new_kittest(); + let mut time = 0.0; + let mut step = |app: &mut EffectcraftApp, events| { + time += 1.0 / 60.0; + let mut raw = RawInput { screen_rect: Some(bounds), time: Some(time), events, ..Default::default() }; + app.raw_input_hook(&ctx, &mut raw); + let mut warp = None; + let mut out = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx(), &mut frame); + app.ui(ui, &mut frame); + finish_frame(ui.ctx(), bounds, |p| { + warp = Some(p); + Some(p) + }); + }); + out.textures_delta.clear(); + warp + }; + for _ in 0..4 { + step(&mut app, vec![]); + } + for gesture in 1..=2 { + let direction = if gesture == 1 { -1.0 } else { 1.0 }; + let r = app.auto.find(&id).unwrap().rect; + let p = pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0); + step( + &mut app, + vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }], + ); + step(&mut app, vec![Event::PointerMoved(p + vec2(direction * 10.0, 0.0))]); + let mut last = p; + for _ in 0..3 { + let edge = if direction < 0.0 { 1.0 } else { 1599.0 }; + let target = step(&mut app, vec![Event::PointerMoved(pos2(edge, p.y))]).expect("real property scrubs wrap"); + let before = app.session.active_comp().unwrap().layer(LayerId(lid)).unwrap().props.find(uid).unwrap().value.clone(); + step(&mut app, vec![Event::PointerMoved(target)]); + assert_eq!(app.session.active_comp().unwrap().layer(LayerId(lid)).unwrap().props.find(uid).unwrap().value, before); + last = target + vec2(direction * 10.0, 0.0); + step(&mut app, vec![Event::PointerMoved(last)]); + if angle { + let value = app.session.active_comp().unwrap().layer(LayerId(lid)).unwrap().props.find(uid).unwrap().value.as_f64(); + assert!((value - before.as_f64() - f64::from(direction) * 5.0).abs() < 1e-6); + } + } + step(&mut app, vec![Event::PointerButton { pos: last, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }]); + assert_eq!(app.session.history.undo.len(), undo + gesture, "each drag is one undo step: {panel:?}"); + } + app.session.execute("edit.undo", json!({})).unwrap(); + app.session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.active_comp().unwrap().layer(LayerId(lid)).unwrap().props.find(uid).unwrap().value, original); + } + } +} diff --git a/crates/ui-egui/src/prefs_live.rs b/crates/ui-egui/src/prefs_live.rs index ed497dc4..b18d3e87 100644 --- a/crates/ui-egui/src/prefs_live.rs +++ b/crates/ui-egui/src/prefs_live.rs @@ -88,7 +88,10 @@ pub fn dropped_files(app: &mut EffectcraftApp, ui: &egui::Ui, bounds: Rect) { None }; - let params = json!({ + // Dropped files import as stills and folders' numbered stills as sequences; Alt-drag + // imports a folder's files one by one (After Effects). + let alt = ctx.input(|i| i.modifiers.alt); + let mut params = json!({ "paths": rest, "drag": true, "importAs": app.session.prefs.drag_import_as(), @@ -96,6 +99,9 @@ pub fn dropped_files(app: &mut EffectcraftApp, ui: &egui::Ui, bounds: Rect) { "position": at, "background": true }); + if alt { + params["sequence"] = json!(false); + } if let Err(e) = crate::menus::invoke(app, &ctx, "file.import", params) { errors.push(crate::i18n::tr_args("Cannot import dropped files: {}", &[&e])); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index a37bead6..953bf66d 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -81,6 +81,20 @@ pub struct Tokens { pub timecode: Color32, pub danger: Color32, pub warning: Color32, + /// Effect Controls effect headers, and the header of the effect being worked on (it or one + /// of its properties selected). + pub fx_header: Color32, + pub fx_header_active: Color32, + /// Timeline: a selected layer's row in the time graph, the boxes behind the A/V and layer + /// switches, the time navigator and the work area bar. + pub tl_graph_selected: Color32, + pub switch_well: Color32, + pub tl_nav_bar: Color32, + pub tl_work_area_bar: Color32, + /// The Timeline's expression field and the colour of enabled expressions (their text and + /// the names of properties that have one). + pub expr_bg: Color32, + pub expr_text: Color32, /// Label colours (`Label::ALL` order), from Settings ▸ Labels. pub labels: [Color32; 17], pub radius: f32, @@ -153,6 +167,14 @@ impl Tokens { timecode: Color32::from_rgb(0x3d, 0x8f, 0xf5), danger: Color32::from_rgb(0xe0, 0x4a, 0x3c), warning: Color32::from_rgb(0xe8, 0x9a, 0x2c), + fx_header: Color32::from_rgb(0x2a, 0x2a, 0x2a), + fx_header_active: Color32::from_rgb(0x2f, 0x3a, 0x52), + tl_graph_selected: Color32::from_rgb(0x2a, 0x2a, 0x2a), + switch_well: Color32::from_rgb(0x19, 0x19, 0x19), + tl_nav_bar: Color32::from_rgb(0x55, 0x55, 0x55), + tl_work_area_bar: Color32::from_rgb(0x5c, 0x5c, 0x5c), + expr_bg: Color32::from_rgb(0x1a, 0x1a, 0x1a), + expr_text: Color32::from_rgb(0xe8, 0x7c, 0x5c), labels: default_labels(), radius: 6.0, radius_sm: 3.0, @@ -202,6 +224,16 @@ impl Tokens { pasteboard: Color32::from_rgb(0xa8, 0xa8, 0xa8), // Keyframes have no outline: dark enough to stand out on the light time graph. keyframe: Color32::from_rgb(0x6c, 0x6c, 0x6c), + // Custom-painted surfaces in the Light palette, so dark text stays readable on + // them (#644). + fx_header: Color32::from_rgb(0xd8, 0xd8, 0xd8), + fx_header_active: Color32::from_rgb(0xb0, 0xcf, 0xec), + tl_graph_selected: Color32::from_rgb(0xc2, 0xc2, 0xc2), + switch_well: Color32::from_rgb(0xf2, 0xf2, 0xf2), + tl_nav_bar: Color32::from_rgb(0x9a, 0x9a, 0x9a), + tl_work_area_bar: Color32::from_rgb(0x94, 0x94, 0x94), + expr_bg: Color32::from_rgb(0xf2, 0xf2, 0xf2), + expr_text: Color32::from_rgb(0xa3, 0x34, 0x17), ..dark }, } @@ -268,6 +300,10 @@ impl Tokens { &mut self.tl_ruler_bg, &mut self.pasteboard, &mut self.work_area, + &mut self.fx_header, + &mut self.tl_graph_selected, + &mut self.switch_well, + &mut self.expr_bg, ] { adj(c); } @@ -339,8 +375,9 @@ const SCRIPT_PROBES: &[(char, &str)] = &[ /// Japanese-locale machine would be drawn with the Japanese face. The interface language is the /// better signal, so the probe for its script comes first — kana for Japanese, a Han character for /// the Chinese catalogs. Both scripts are registered, so file, layer and template names in the -/// other one still render; then [`SCRIPT_PROBES`]. The web build has no system fonts and installs -/// nothing. +/// other one still render; then [`SCRIPT_PROBES`], then Hangul, since the Japanese and Chinese faces +/// may lack it (Hiragino Sans on macOS has none). The web build has no system fonts +/// and installs nothing. fn install_script_fallbacks(family_fonts: &mut FontDefinitions, language: &str) -> Vec { #[cfg(target_arch = "wasm32")] { @@ -354,13 +391,17 @@ fn install_script_fallbacks(family_fonts: &mut FontDefinitions, language: &str) // language's face draws them. const KANA: (char, &str) = ('あ', "japanese-system"); const HAN: (char, &str) = ('文', "chinese-system"); + // Hangul is probed last: with no Korean font installed the lookup can land on a catch-all + // face (Unifont) that also covers Arabic or Indic text but can't shape it, so the + // script-specific faces must be registered ahead of it. A face already seen is skipped. + const HANGUL: (char, &str) = ('한', "korean-system"); // Han text follows the interface language, not the operating system's locale. fonts::set_cjk_locale(cjk_locale(language)); let base = fonts::resolve("Inter", "Regular").face; let cjk = if is_chinese(language) { [HAN, KANA] } else { [KANA, HAN] }; let mut names = Vec::new(); let mut seen = Vec::new(); - for &(probe, name) in cjk.iter().chain(SCRIPT_PROBES) { + for &(probe, name) in cjk.iter().chain(SCRIPT_PROBES).chain(std::iter::once(&HANGUL)) { let id = fonts::fallback_for(probe, base); if seen.contains(&id) { continue; @@ -400,11 +441,20 @@ fn is_chinese(language: &str) -> bool { matches!(language, "zh-hans" | "zh-hant") } +/// egui's theme preference for an appearance mode showing a `light` (or dark) theme. Sync with +/// System leaves it at System: a concrete preference makes egui set the native window's +/// appearance, and on macOS a window with its own appearance no longer hears when the system's +/// changes (#642). A fixed Light or Dark mode pins the window to match. +pub fn theme_preference(appearance_mode: &str, light: bool) -> egui::ThemePreference { + match appearance_mode { + "auto" => egui::ThemePreference::System, + _ if light => egui::ThemePreference::Light, + _ => egui::ThemePreference::Dark, + } +} + pub fn apply_visuals(ctx: &egui::Context, t: &Tokens) { let light = t.kind.is_light(); - // egui keeps one style per light/dark theme and by default picks it by the system appearance; - // pin it to ours so a fixed Dark or Light mode isn't swapped for egui's defaults. - ctx.set_theme(if light { egui::ThemePreference::Light } else { egui::ThemePreference::Dark }); let mut v = if light { Visuals::light() } else { Visuals::dark() }; v.panel_fill = t.panel_bg; v.window_fill = t.panel_bg; @@ -439,8 +489,10 @@ pub fn apply_visuals(ctx: &egui::Context, t: &Tokens) { v.widgets.active.fg_stroke = Stroke::new(1.0, t.tab_text_active); v.widgets.open.bg_fill = t.hover; v.widgets.open.weak_bg_fill = t.hover; - ctx.set_visuals(v); - ctx.global_style_mut(|s| { + // egui keeps one style per light/dark theme and picks it by its theme preference (see + // [`theme_preference`]); both get ours, so whichever it picks, egui's defaults never show. + ctx.all_styles_mut(|s| { + s.visuals = v.clone(); s.spacing.item_spacing = egui::vec2(6.0, 4.0); s.spacing.button_padding = egui::vec2(8.0, 3.0); s.spacing.interact_size.y = 22.0; @@ -597,6 +649,24 @@ mod tests { install_script_fallbacks(&mut english, "en"); } + #[test] + fn hangul_gets_a_face_when_one_is_installed() { + use effectcraft_text::fonts; + let mut fonts_def = FontDefinitions::default(); + install_script_fallbacks(&mut fonts_def, "en"); + let base = fonts::resolve("Inter", "Regular").face; + let id = fonts::fallback_for('한', base); + let face = fonts::face(id); + if !face.has_char('한') { + eprintln!("no Hangul font is installed: nothing to check here"); + return; + } + let Some(font) = face.font() else { return }; + let bytes = font.data().as_bytes(); + let covered = fonts_def.font_data.values().any(|d| d.font.as_ref() == bytes && d.index == face.info.index); + assert!(covered, "a face covering Hangul must be registered with egui"); + } + #[test] fn a_fallback_face_is_registered_at_most_once() { let mut fonts = FontDefinitions::default(); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index e4252aff..18c6aedb 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -104,6 +104,7 @@ pub fn hot_number_at( return (er, out, out.is_some()); } let resp = ui.interact(rect, id, Sense::click_and_drag()); + crate::pointer_wrap::track(&resp); let col = if resp.hovered() || resp.dragged() { t.accent_hover } else { t.hot_text }; ui.painter().galley_with_override_text_color(rect.min + vec2(2.0, 2.0), galley, col); if resp.hovered() || resp.dragged() { @@ -154,13 +155,34 @@ pub fn hot_int_at(ui: &mut Ui, rect_min: egui::Pos2, id: egui::Id, value: i64, s (rect, (step != 0.0).then(|| value.saturating_add(step as i64)), done) } +/// Standard numeric editors use the same continuous native scrubbing as hot text. +pub fn drag_value(value: egui::DragValue<'_>) -> impl egui::Widget { + move |ui: &mut Ui| { + let response = ui.add(value); + crate::pointer_wrap::track(&response); + response + } +} + /// Keep the keyboard on an inline editor that is open until it loses the focus. The frame it -/// opens (it has no focus yet) its whole `text` is selected, so typing replaces the value. -/// (Only then is the focus requested: a request resets what keys the field keeps for itself, -/// such as Tab with [`egui::TextEdit::lock_focus`].) +/// opens its whole `text` is selected, so typing replaces the value; after that the caret is the +/// user's (arrow keys move it), even if the field is without the focus for a frame (#488). +/// (The focus is only requested while the field doesn't have it: a request resets what keys the +/// field keeps for itself, such as Tab with [`egui::TextEdit::lock_focus`].) pub fn keep_focus(ctx: &egui::Context, field: &Response, text: &str) { + // The editor is open in consecutive passes; a gap means it was closed and opened again. + let shown = field.id.with("keep-focus-pass"); + let pass = ctx.cumulative_pass_nr(); + let previous = ctx.data_mut(|d| { + let previous = d.get_temp::(shown); + d.insert_temp(shown, pass); + previous + }); + let opening = previous.is_none_or(|p| p.saturating_add(1) < pass); if !ctx.memory(|m| m.has_focus(field.id)) { - select_all(ctx, field.id, text); + if opening { + select_all(ctx, field.id, text); + } field.request_focus(); } } @@ -182,17 +204,8 @@ pub fn swatch(ui: &mut Ui, rect: Rect, c: [f32; 4], id: egui::Id, t: &Tokens) -> resp } -/// A colour button with egui's picker for a colour stored as sRGB components (0–1), as the -/// project stores colours (`color_edit_button_rgb` reads linear values and shows them lighter). -/// The value is only rewritten when the user picks a colour. -pub fn srgb_color_button(ui: &mut Ui, c: &mut [f32; 3]) -> Response { - let mut u = c.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8); - let r = ui.color_edit_button_srgb(&mut u); - if r.changed() { - *c = u.map(|v| v as f32 / 255.0); - } - r -} +// The colour picker and its helpers (every colour input uses them). +pub use crate::color_picker::{color_popup, hex_rgb, hsv_to_rgb, rgb_to_hsv, srgb_color_button, srgb_hex, srgb32}; /// A twirl-down triangle; returns clicked. pub fn twirl(ui: &mut Ui, rect: Rect, open: bool, id: egui::Id, t: &Tokens) -> Response { @@ -285,6 +298,8 @@ fn field(ui: &mut Ui, rect: Rect, text: &mut String, hint: &str, t: &Tokens, sea .hint_text(hint) .frame(egui::Frame::NONE) .desired_width(inner.width()) + // As tall as the field, so the text and the hint sit on its middle (they sat high, #581). + .min_size(inner.size()) .font(Tokens::ui(12.0)) .vertical_align(egui::Align::Center), ) @@ -528,8 +543,7 @@ pub fn popup_list(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, anchor: Rect, r egui::ScrollArea::vertical().max_height(max_h).min_scrolled_height((est - 16.0).min(max_h)).show(ui, |ui| chosen = body(ui)); }); }); - let pass = ui.ctx().cumulative_pass_nr(); - ui.ctx().data_mut(|d| d.insert_temp(popup_list_pass_id(), pass)); + note_open_popup_list(ui.ctx()); let outside = pressed_outside(ui.ctx(), &area.response) && !ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| anchor.contains(p)); if chosen.is_some() || outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) { ui.data_mut(|d| d.insert_temp(id.with("open"), false)); @@ -537,6 +551,12 @@ pub fn popup_list(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, anchor: Rect, r chosen } +/// Custom popup lists also hold the app's keyboard shortcuts while open. +pub(crate) fn note_open_popup_list(ctx: &egui::Context) { + let pass = ctx.cumulative_pass_nr(); + ctx.data_mut(|d| d.insert_temp(popup_list_pass_id(), pass)); +} + /// The pass a [`popup_list`] was last drawn in. fn popup_list_pass_id() -> egui::Id { egui::Id::new("popup-list-pass") @@ -567,3 +587,88 @@ pub fn popup_is_open(ui: &Ui, id: egui::Id) -> bool { pub fn open_popup(ui: &Ui, id: egui::Id) { ui.data_mut(|d| d.insert_temp(id.with("open"), true)); } + +/// Linked values after component `d` of `c` became `v`: the others scale by the same ratio +/// (Constrain Proportions); from 0 the others that are 0 too follow the value. +pub(crate) fn constrained(c: &[f64], d: usize, v: f64) -> Vec { + let Some(&old) = c.get(d) else { return c.to_vec() }; + c.iter() + .enumerate() + .map(|(e, &x)| { + if e == d { + v + } else if old.abs() > 1e-9 { + x * v / old + } else if x.abs() <= 1e-9 { + v + } else { + x + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// #488: an inline editor selects its whole text only as it opens. Should it be without the + /// keyboard for a frame later (an input method, another handler), it takes the keyboard back + /// with the caret where the user put it instead of selecting everything again, which made + /// the arrows seem dead and Backspace erase the whole name. + #[test] + fn inline_editors_select_all_only_as_they_open() { + let ctx = egui::Context::default(); + let mut text = String::from("Comp 1"); + let mut id = egui::Id::NULL; + let frame = |ctx: &egui::Context, text: &mut String, id: &mut egui::Id| { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + let r = ui.add(egui::TextEdit::singleline(text)); + keep_focus(ui.ctx(), &r, text); + *id = r.id; + }); + out.textures_delta.clear(); + }; + let range = |ctx: &egui::Context, id: egui::Id| egui::TextEdit::load_state(ctx, id).and_then(|s| s.cursor.char_range()); + let ix = |n: usize| egui::text::CCursor::new(n).index; + frame(&ctx, &mut text, &mut id); + frame(&ctx, &mut text, &mut id); + let all = range(&ctx, id).unwrap(); + assert_eq!((all.primary.index.min(all.secondary.index), all.primary.index.max(all.secondary.index)), (ix(0), ix(6)), "opened with all selected"); + // The caret after "Co", then a frame without the keyboard. + let mut state = egui::TextEdit::load_state(&ctx, id).unwrap(); + state.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(2)))); + state.store(&ctx, id); + ctx.memory_mut(|m| m.surrender_focus(id)); + frame(&ctx, &mut text, &mut id); + frame(&ctx, &mut text, &mut id); + assert!(ctx.memory(|m| m.has_focus(id)), "the editor keeps the keyboard"); + let r = range(&ctx, id).unwrap(); + assert_eq!((r.primary.index, r.secondary.index), (ix(2), ix(2)), "the caret stays where it was"); + // Closed for a frame and opened again: all selected again. + ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear(); + frame(&ctx, &mut text, &mut id); + frame(&ctx, &mut text, &mut id); + let r = range(&ctx, id).unwrap(); + assert_eq!((r.primary.index.min(r.secondary.index), r.primary.index.max(r.secondary.index)), (ix(0), ix(6))); + } + + /// #581: a field's text edit fills it, so its text and hint sit on the field's middle line + /// (it was a row at the top of the field, its text high). + #[test] + fn field_text_is_vertically_centred() { + let ctx = egui::Context::default(); + let t = Tokens::for_kind(Default::default()); + for h in [22.0, 26.0, 30.0] { + let field = Rect::from_min_size(pos2(20.0, 40.0), vec2(220.0, h)); + let mut text = String::new(); + let mut got = None; + let mut out = ctx.run_ui(Default::default(), |ui| { + got = Some(search_field(ui, field, &mut text, "Filter recent files", &t).rect); + }); + out.textures_delta.clear(); + let r = got.expect("the field was drawn"); + assert!((r.center().y - field.center().y).abs() < 0.5 && r.height() >= h - 2.5, "{h}: {r:?} in {field:?}"); + } + } +} diff --git a/crates/ui-egui/src/workspace_state.rs b/crates/ui-egui/src/workspace_state.rs index 6e2b9a06..9262266d 100644 --- a/crates/ui-egui/src/workspace_state.rs +++ b/crates/ui-egui/src/workspace_state.rs @@ -120,7 +120,13 @@ impl Snapshot { } DockNode::Stack { entries } => { if entries.len() > MAX_PANELS - || entries.iter().any(|e| !panel_ok(e.panel) || e.height.is_some_and(|v| !v.is_finite() || !(0.0..=MAX_COORD).contains(&v))) + || entries.iter().any(|e| { + e.panels.is_empty() + || e.panels.len() > MAX_PANELS + || e.active >= e.panels.len() + || e.panels.iter().any(|p| !panel_ok(*p)) + || e.height.is_some_and(|v| !v.is_finite() || !(0.0..=MAX_COORD).contains(&v)) + }) { return Err("invalid workspace panel stack".into()); } @@ -168,7 +174,20 @@ impl WorkspaceStore { pub(crate) fn load(config: Option<&dyn ConfigStore>, ui: &mut UiState) -> Self { let loaded = config.map_or(Ok(None), |c| c.read_bounded(FILE, MAX_BYTES)).map_err(|e| e.to_string()).and_then(|text| { text.map(|text| { - let snapshot = serde_json::from_str::(&text).map_err(|e| e.to_string())?; + let value: serde_json::Value = serde_json::from_str(&text).map_err(|e| e.to_string())?; + // Only an explicitly legacy-shaped record can migrate. A versioned future + // snapshot must never deserialize as an empty defaulted legacy record. + let snapshot = if value.get("version").is_none() + && value.as_object().is_some_and(|fields| fields.contains_key("workspaces") || fields.contains_key("floating")) + { + let legacy: crate::SavedWorkspaces = serde_json::from_value(value).map_err(|e| e.to_string())?; + let mut snapshot = Snapshot::capture(ui); + snapshot.saved_workspaces = legacy.workspaces; + snapshot.saved_floating = legacy.floating; + snapshot + } else { + serde_json::from_value::(value).map_err(|e| e.to_string())? + }; snapshot.validate()?; Ok(snapshot) }) @@ -336,7 +355,7 @@ mod tests { let mut snapshot = Snapshot::capture(&UiState::default()); let panel = PanelKind::Viewer(id); let dock = DockNode::Tabs { panels: vec![panel], active: 0 }; - let stack = DockNode::Stack { entries: vec![crate::dock::StackEntry { panel, open: true, height: None }] }; + let stack = DockNode::Stack { entries: vec![crate::dock::StackEntry::new(panel, true, None)] }; let floating = vec![Floating { panels: vec![panel], active: 0, rect: [0.0, 0.0, 200.0, 100.0] }]; match location { 0 => snapshot.focused = panel, diff --git a/crates/ui-egui/tests/shared_docking_ui.rs b/crates/ui-egui/tests/shared_docking_ui.rs index 9f848522..a1bc9280 100644 --- a/crates/ui-egui/tests/shared_docking_ui.rs +++ b/crates/ui-egui/tests/shared_docking_ui.rs @@ -19,9 +19,9 @@ fn shared_move_preserves_accordion_metadata_and_unrelated_active_tabs() { a: Box::new(DockNode::Tabs { panels: vec![PanelKind::Project, PanelKind::Composition, PanelKind::Layer], active: 1 }), b: Box::new(DockNode::Stack { entries: vec![ - StackEntry { panel: PanelKind::Preview, open: false, height: Some(73.0) }, - StackEntry { panel: PanelKind::Properties, open: true, height: Some(211.0) }, - StackEntry { panel: PanelKind::Align, open: false, height: None }, + StackEntry::new(PanelKind::Preview, false, Some(73.0)), + StackEntry::new(PanelKind::Properties, true, Some(211.0)), + StackEntry::new(PanelKind::Align, false, None), ], }), }, @@ -33,11 +33,14 @@ fn shared_move_preserves_accordion_metadata_and_unrelated_active_tabs() { let DockNode::Split { b, size, .. } = &layout.root else { panic!("split was lost") }; assert_eq!(*size, SplitSize::FixedB(300.0)); let DockNode::Stack { entries } = b.as_ref() else { panic!("stack was lost") }; - assert_eq!(entries.iter().find(|entry| entry.panel == PanelKind::Preview).unwrap().height, Some(73.0)); - assert_eq!( - entries.iter().find(|entry| entry.panel == PanelKind::Properties).unwrap(), - &StackEntry { panel: PanelKind::Properties, open: true, height: Some(211.0) } - ); + // Upstream now makes a tab drop join the anchor's accordion frame. Its height belongs + // to the destination frame; the unrelated Properties frame keeps its exact metadata. + let joined = entries.iter().find(|entry| entry.contains(PanelKind::Preview)).unwrap(); + assert_eq!(joined.panels, vec![PanelKind::Align, PanelKind::Preview]); + assert_eq!(joined.shown(), Some(PanelKind::Preview)); + assert!(joined.open); + assert_eq!(joined.height, None); + assert_eq!(entries.iter().find(|entry| entry.contains(PanelKind::Properties)).unwrap(), &StackEntry::new(PanelKind::Properties, true, Some(211.0))); let before = layout.clone(); assert!(layout.apply(Action::Move { panel: PanelKind::Preview, anchor: PanelKind::Audio, placement: Placement::Split(Zone::Left) }).is_err()); assert_eq!(layout, before); @@ -62,7 +65,7 @@ fn every_builtin_workspace_and_dynamic_identity_roundtrips() { }, }, DockNode::Tabs { panels, .. } => Action::Move { panel: panels[0], anchor: panels[1], placement: Placement::Tab { before: Some(panels[0]) } }, - DockNode::Stack { entries } => Action::SetStackOpen { panel: entries[0].panel, open: entries[0].open }, + DockNode::Stack { entries } => Action::SetStackOpen { panel: entries[0].shown().unwrap(), open: entries[0].open }, }; layout.apply(action).unwrap(); assert_eq!(before, serde_json::to_value(&layout).unwrap()); @@ -332,9 +335,9 @@ fn accordion_resize_routes_pointer_and_keyboard_without_changing_closed_entries( h.run_steps(4); h.state_mut().ui.dock = DockNode::Stack { entries: vec![ - StackEntry { panel: PanelKind::Preview, open: true, height: Some(100.0) }, - StackEntry { panel: PanelKind::Properties, open: true, height: None }, - StackEntry { panel: PanelKind::Align, open: false, height: Some(77.0) }, + StackEntry::new(PanelKind::Preview, true, Some(100.0)), + StackEntry::new(PanelKind::Properties, true, None), + StackEntry::new(PanelKind::Align, false, Some(77.0)), ], }; h.run_steps(3); @@ -353,7 +356,7 @@ fn accordion_resize_routes_pointer_and_keyboard_without_changing_closed_entries( let DockNode::Stack { entries } = &h.state().ui.dock else { panic!("stack lost") }; assert_eq!(entries[0].height, Some(124.0)); assert_eq!(entries[1].height, None); - assert_eq!(entries[2], StackEntry { panel: PanelKind::Align, open: false, height: Some(77.0) }); + assert_eq!(entries[2], StackEntry::new(PanelKind::Align, false, Some(77.0))); assert!(std::sync::Arc::ptr_eq(&project, &h.state().session.project)); } diff --git a/crates/ui-egui/tests/ui_3d.rs b/crates/ui-egui/tests/ui_3d.rs index 385bf7ac..0c65084b 100644 --- a/crates/ui-egui/tests/ui_3d.rs +++ b/crates/ui-egui/tests/ui_3d.rs @@ -47,6 +47,37 @@ fn viewer_registers_3d_controls() { assert!(ids.iter().any(|i| i.starts_with("viewer.camera.")), "camera wireframe registered"); } +/// Click the centre of the automation element `id`. +fn click_id(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let [x, y, w, hh] = h.state().auto.find(id).unwrap_or_else(|| panic!("{id} is registered")).rect; + let p = egui::pos2(x + w / 2.0, y + hh / 2.0); + h.input_mut().events.push(egui::Event::PointerMoved(p)); + h.input_mut().events.push(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.step(); + h.input_mut().events.push(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(3); +} + +/// #307: the viewer's 3D Renderer menu shows the comp's renderer (it always read "Classic 3D") +/// and switches it, as After Effects' Composition panel does. +#[test] +fn renderer_menu_shows_and_switches_the_comp_renderer() { + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app()); + h.state_mut().session.execute("comp.settings", json!({"renderer": "classic3D"})).unwrap(); + h.run_steps(3); + let label = |h: &Harness<'_, EffectcraftApp>| h.state().auto.find("viewer.renderer3d").map(|e| e.label.clone()); + assert_eq!(label(&h).as_deref(), Some("Classic 3D")); + click_id(&mut h, "viewer.renderer3d"); + click_id(&mut h, "viewer.renderer3dItem.1"); + let renderer = h.state().session.active_comp().unwrap().renderer; + assert_eq!(renderer, effectcraft_engine::project::Renderer::Advanced3D); + h.run_steps(2); + assert_eq!(label(&h).as_deref(), Some("Advanced 3D")); + // One undoable Composition Settings step. + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(h.state().session.active_comp().unwrap().renderer, effectcraft_engine::project::Renderer::Classic3D); +} + #[test] fn camera_and_light_dialogs_create_layers() { let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app()); diff --git a/crates/ui-egui/tests/ui_camera_tracker.rs b/crates/ui-egui/tests/ui_camera_tracker.rs index 9e0bede6..a90c3fa4 100644 --- a/crates/ui-egui/tests/ui_camera_tracker.rs +++ b/crates/ui-egui/tests/ui_camera_tracker.rs @@ -145,6 +145,60 @@ fn viewer_points_selection_and_effect_controls() { assert!(h.state().session.is_enabled("track.camera")); } +/// Drag with `button` from `from` to `to` in steps, then release. +fn drag_with(h: &mut Harness<'_, EffectcraftApp>, button: egui::PointerButton, from: egui::Pos2, to: egui::Pos2) { + h.input_mut().events.push(egui::Event::PointerMoved(from)); + h.input_mut().events.push(egui::Event::PointerButton { pos: from, button, pressed: true, modifiers: Default::default() }); + h.step(); + for i in 1..=8 { + h.input_mut().events.push(egui::Event::PointerMoved(from + (to - from) * (i as f32 / 8.0))); + h.step(); + } + h.input_mut().events.push(egui::Event::PointerButton { pos: to, button, pressed: false, modifiers: Default::default() }); + h.run_steps(3); +} + +/// #505: with the track points shown, the wheel still zooms the viewer, and a middle-button drag +/// and a Spacebar (Hand tool) drag over the points still pan it. +#[test] +fn viewer_zooms_and_pans_over_the_track_points() { + let (a, ..) = app(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| a); + h.state_mut().show_panel(PanelKind::Composition); + h.run_steps(4); + // Wheel over a point: zooms. + let at = rect(&h, "viewer.cameraTracker.point.7").center(); + let zoom = h.state().ui.viewer.zoom; + h.input_mut().events.push(egui::Event::PointerMoved(at)); + h.input_mut().events.push(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, 120.0), + modifiers: Default::default(), + phase: egui::TouchPhase::Move, + }); + h.run_steps(20); + assert_ne!(h.state().ui.viewer.zoom, zoom, "the wheel zooms over the track points"); + // Middle-button drag from a point: pans, and selects nothing. + let at = rect(&h, "viewer.cameraTracker.point.7").center(); + let before = h.state().ui.viewer.pan; + drag_with(&mut h, egui::PointerButton::Middle, at, at + egui::vec2(120.0, 60.0)); + let after = h.state().ui.viewer.pan; + assert!((after[0] - before[0] - 120.0).abs() < 1.0 && (after[1] - before[1] - 60.0).abs() < 1.0, "{before:?} → {after:?}"); + assert!(h.state().session.state.camera_points.is_empty(), "a pan selects no points"); + // Spacebar held: a primary drag from a point pans instead of drawing a marquee. + let at = rect(&h, "viewer.cameraTracker.point.7").center(); + let space = |h: &mut Harness<'_, EffectcraftApp>, pressed: bool| { + h.input_mut().events.push(egui::Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Default::default() }); + h.step(); + }; + space(&mut h, true); + drag_with(&mut h, egui::PointerButton::Primary, at, at + egui::vec2(80.0, 40.0)); + space(&mut h, false); + let end = h.state().ui.viewer.pan; + assert!((end[0] - after[0] - 80.0).abs() < 1.0 && (end[1] - after[1] - 40.0).abs() < 1.0, "{after:?} → {end:?}"); + assert!(h.state().session.state.camera_points.is_empty(), "a Spacebar pan selects no points"); +} + /// Step the UI until background frame renders have landed. fn settle(h: &mut Harness<'_, EffectcraftApp>) { for _ in 0..600 { diff --git a/crates/ui-egui/tests/ui_color_hex.rs b/crates/ui-egui/tests/ui_color_hex.rs new file mode 100644 index 00000000..90eb19b8 --- /dev/null +++ b/crates/ui-egui/tests/ui_color_hex.rs @@ -0,0 +1,233 @@ +//! The colour picker every colour input opens: its hex field sets an effect's colour (CC Toner), +//! the dialogs' colour button stores sRGB (egui's own `color_edit_button_rgba` read the stored +//! sRGB values as linear, so mid-grey `#808080` was stored as `#373737`), the square, slider and +//! HSB / RGB fields set the colour, and Cancel / Escape / the original swatch restore it. + +use effectcraft_engine::Session; +use effectcraft_engine::project::LayerId; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; +use effectcraft_ui_egui::widgets; +use egui::accesskit::Role; +use egui::{Event, Key, Modifiers, Pos2, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +const GREY: f32 = 128.0 / 255.0; + +fn click(h: &mut Harness<'_, S>, p: Pos2) { + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +/// Click the open popup's Hex field and type `text` over the code it shows. +fn type_hex(h: &mut Harness<'_, S>, text: &str) { + let at = h.get_by_role_and_label(Role::TextInput, "#").rect().center(); + click(h, at); + for pressed in [true, false] { + h.input_mut().events.push(Event::Key { key: Key::A, physical_key: None, pressed, repeat: false, modifiers: Modifiers::COMMAND }); + } + h.step(); + h.input_mut().events.push(Event::Text(text.into())); + h.run_steps(3); +} + +fn close(c: [f32; 3], want: [f32; 3]) -> bool { + c.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-4) +} + +#[test] +fn hex_code_sets_an_effect_colour() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Hex", "width": 320, "height": 180, "duration": 4})).unwrap(); + let layer = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#40b080"})).unwrap()["layer"].as_u64().unwrap(); + let fx = s.execute("effect.apply", json!({"layer": layer, "effect": "CC Toner"})).unwrap()["effects"][0].as_u64().unwrap(); + let uid = s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find_group(fx).unwrap().get("midtones").unwrap().uid; + s.state.selected_layers = vec![LayerId(layer)]; + let mut app = EffectcraftApp::new(s); + app.show_panel(PanelKind::EffectControls); + app.toggle_maximize(PanelKind::EffectControls); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| app); + h.run_steps(4); + let midtones = |h: &Harness<'_, EffectcraftApp>| { + let c = h.state().session.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(uid).unwrap().value.as_color(); + [c[0], c[1], c[2]] + }; + + let [x, y, w, ht] = h.state().auto.find(&format!("effectControls.prop.{uid}.value")).expect("the Midtones swatch").rect; + click(&mut h, pos2(x + w / 2.0, y + ht / 2.0)); + type_hex(&mut h, "#FF8000"); + assert!(close(midtones(&h), [1.0, GREY, 0.0]), "{:?}", midtones(&h)); + // A partial code doesn't apply; the short form does. + type_hex(&mut h, "#12"); + assert!(close(midtones(&h), [1.0, GREY, 0.0]), "{:?}", midtones(&h)); + type_hex(&mut h, "0f8"); + assert!(close(midtones(&h), [0.0, 1.0, 136.0 / 255.0]), "{:?}", midtones(&h)); +} + +/// A colour button in a harness of its own, its picker open. +fn picker(c: [f32; 3]) -> Harness<'static, [f32; 3]> { + let mut h = Harness::builder().with_size(vec2(560.0, 400.0)).build_ui_state( + |ui, c: &mut [f32; 3]| { + widgets::srgb_color_button(ui, c); + }, + c, + ); + h.run_steps(2); + let button = h.get_by_role(Role::ColorWell).rect().center(); + click(&mut h, button); + h +} + +fn press(h: &mut Harness<'_, [f32; 3]>, key: Key, modifiers: Modifiers) { + for pressed in [true, false] { + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }); + } + h.run_steps(2); +} + +fn click_label(h: &mut Harness<'_, [f32; 3]>, label: &str) { + let at = h.get_by_label(label).rect().center(); + click(h, at); +} + +/// Type `text` into the first number field labelled `label` ("R:", "H:" …; "B:" is Brightness +/// before Blue, as in After Effects) and press Enter. +fn type_field(h: &mut Harness<'_, [f32; 3]>, label: &str, text: &str) { + let at = h.get_all_by_role_and_label(Role::SpinButton, label).next().expect("the field").rect().center(); + click(h, at); + press(h, Key::A, Modifiers::COMMAND); + h.input_mut().events.push(Event::Text(text.into())); + h.step(); + press(h, Key::Enter, Modifiers::NONE); +} + +fn near(c: [f32; 3], want: [f32; 3]) -> bool { + c.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.02) +} + +fn is_open(h: &Harness<'_, [f32; 3]>) -> bool { + h.query_by_role_and_label(Role::TextInput, "#").is_some() +} + +#[test] +fn the_dialog_colour_button_stores_srgb() { + let mut h = picker([1.0, 1.0, 1.0]); + type_hex(&mut h, "808080"); + assert!(close(*h.state(), [GREY; 3]), "{:?}", h.state()); + click_label(&mut h, "OK"); + assert!(!is_open(&h)); + assert!(close(*h.state(), [GREY; 3])); +} + +/// Changes apply as they are made; Cancel, Escape and the original swatch go back to the colour +/// the picker opened with, OK keeps the new one. +#[test] +fn cancel_escape_and_the_original_swatch_restore_the_colour() { + let red = [1.0, 0.0, 0.0]; + let mut h = picker(red); + type_hex(&mut h, "00FF00"); + assert!(close(*h.state(), [0.0, 1.0, 0.0])); + click_label(&mut h, "Cancel"); + assert!(!is_open(&h)); + assert!(close(*h.state(), red), "{:?}", h.state()); + + let button = h.get_by_role(Role::ColorWell).rect().center(); + click(&mut h, button); + type_hex(&mut h, "0000FF"); + press(&mut h, Key::Escape, Modifiers::NONE); + assert!(!is_open(&h)); + assert!(close(*h.state(), red), "{:?}", h.state()); + + click(&mut h, button); + type_hex(&mut h, "0000FF"); + click_label(&mut h, "Original color"); + assert!(is_open(&h), "going back to the original keeps the picker open"); + assert!(close(*h.state(), red), "{:?}", h.state()); + type_hex(&mut h, "FFFF00"); + click_label(&mut h, "OK"); + assert!(close(*h.state(), [1.0, 1.0, 0.0]), "{:?}", h.state()); +} + +/// With H on the slider the square is saturation × brightness; the hue is kept while the colour is +/// black, where RGB has none. HSB and RGB values can be typed. +#[test] +fn the_hsb_square_slider_and_fields_set_the_colour() { + let mut h = picker([1.0, 0.0, 0.0]); + let square = h.get_by_label("Color field").rect(); + let slider = h.get_by_label("Color slider").rect(); + + click(&mut h, pos2(square.center().x, square.max.y - 1.0)); + assert!(near(*h.state(), [0.0; 3]), "the bottom edge is black: {:?}", h.state()); + click(&mut h, slider.center()); + assert!(near(*h.state(), [0.0; 3]), "a new hue leaves black black: {:?}", h.state()); + click(&mut h, pos2(square.max.x - 1.0, square.min.y + 1.0)); + assert!(near(*h.state(), [0.0, 1.0, 1.0]), "the hue chosen while black is kept: {:?}", h.state()); + + type_field(&mut h, "R:", "64"); + assert!((h.state()[0] - 64.0 / 255.0).abs() < 1e-4 && near(*h.state(), [64.0 / 255.0, 1.0, 1.0]), "{:?}", h.state()); + type_field(&mut h, "H:", "0"); + type_field(&mut h, "S:", "100"); + type_field(&mut h, "B:", "50"); + assert!(near(*h.state(), [0.5, 0.0, 0.0]), "{:?}", h.state()); +} + +/// With R chosen the slider sets red and the square blue (across) and green (up), as in After +/// Effects; the choice is remembered by the next picker. +#[test] +fn choosing_r_puts_red_on_the_slider_and_blue_green_on_the_square() { + let mut h = picker([0.0, 0.0, 0.0]); + let radio = h.get_by_role_and_label(Role::RadioButton, "R:").rect().center(); + click(&mut h, radio); + let square = h.get_by_label("Color field").rect(); + let slider = h.get_by_label("Color slider").rect(); + click(&mut h, pos2(slider.center().x, slider.min.y + 1.0)); + assert!(near(*h.state(), [1.0, 0.0, 0.0]), "the top of the slider is full red: {:?}", h.state()); + click(&mut h, pos2(square.max.x - 1.0, square.max.y - 1.0)); + assert!(near(*h.state(), [1.0, 0.0, 1.0]), "bottom right adds full blue: {:?}", h.state()); + click(&mut h, pos2(square.min.x + 1.0, square.min.y + 1.0)); + assert!(near(*h.state(), [1.0, 1.0, 0.0]), "top left is full green, no blue: {:?}", h.state()); + click_label(&mut h, "OK"); + + let button = h.get_by_role(Role::ColorWell).rect().center(); + click(&mut h, button); + let slider = h.get_by_label("Color slider").rect(); + click(&mut h, pos2(slider.center().x, slider.max.y - 1.0)); + assert!(near(*h.state(), [0.0, 1.0, 0.0]), "the slider still sets red: {:?}", h.state()); +} + +#[test] +fn hex_codes_parse() { + assert_eq!(widgets::hex_rgb("#ff8000"), Some([255, 128, 0])); + assert_eq!(widgets::hex_rgb(" FF8000 "), Some([255, 128, 0])); + assert_eq!(widgets::hex_rgb("#0f8"), Some([0, 255, 136])); + for bad in ["", "#", "#12", "#12345", "#1234567", "#gg0000", "#ffé000"] { + assert_eq!(widgets::hex_rgb(bad), None, "{bad}"); + } + assert_eq!(widgets::srgb_hex([1.0, GREY, 0.0]), "#FF8000"); +} + +#[test] +fn hsv_round_trips() { + for c in [[1.0, 0.0, 0.0], [0.0, 1.0, 1.0], [GREY, 0.25, 0.75], [0.2, 0.2, 0.2], [0.0, 0.0, 0.0], [1.0, 1.0, 1.0]] { + let back = widgets::hsv_to_rgb(widgets::rgb_to_hsv(c)); + assert!(close(back, c), "{c:?} -> {back:?}"); + } + assert!(close(widgets::hsv_to_rgb([0.5, 1.0, 1.0]), [0.0, 1.0, 1.0])); +} + +/// The fields round as the hex code does (0.7 × 255 = 178.5 shows as 179, `B3`), and a hue just +/// under 360° shows as 0°. +#[test] +fn the_fields_agree_with_the_hex_code() { + let h = picker([1.0, 0.699, 0.7]); + let value = |label: &str, nth: usize| h.get_all_by_role_and_label(Role::SpinButton, label).nth(nth).and_then(|n| n.value()).unwrap_or_default(); + assert_eq!(h.get_by_role_and_label(Role::TextInput, "#").value().as_deref(), Some("FFB2B3")); + assert_eq!(value("G:", 0), "178"); + assert_eq!(value("B:", 1), "179"); + assert_eq!(value("H:", 0), "0°"); +} diff --git a/crates/ui-egui/tests/ui_drag_drop.rs b/crates/ui-egui/tests/ui_drag_drop.rs index 9738fef2..ce09d7fd 100644 --- a/crates/ui-egui/tests/ui_drag_drop.rs +++ b/crates/ui-egui/tests/ui_drag_drop.rs @@ -123,6 +123,46 @@ fn project_items_dropped_on_the_viewer_land_under_the_pointer() { assert!((p[0] - 80.0).abs() <= tol && (p[1] - 45.0).abs() <= tol, "{p:?}"); } +/// #351: dragging one of several selected Project items inserts all the items, +/// in selection order, instead of only the item the drag started on. +#[test] +fn dragging_selected_project_items_adds_every_item_to_the_timeline() { + let (mut h, clip) = harness(); + let main = h.state().session.active_comp_id().unwrap().0; + let other = h.state_mut().session.execute("comp.new", json!({"name": "Other", "width": 80, "height": 60, "frameRate": 30, "duration": 1})).unwrap()["comp"] + .as_u64() + .unwrap(); + h.state_mut().session.execute("comp.open", json!({"comp": main})).unwrap(); + h.state_mut().session.state.project_selection = vec![effectcraft_engine::project::ItemId(clip), effectcraft_engine::project::ItemId(other)]; + h.run_steps(3); + let from = rect(&h, &format!("project.item.{clip}.name")).center(); + let top = rect(&h, &format!("timeline.layer.{}.row", layer_id(&h, "Top"))); + drag(&mut h, from, pos2(top.center().x, top.max.y - 2.0), Modifiers::NONE); + assert_eq!(stack(&h), ["Top", "Clip", "Other", "Middle", "Bottom"]); + assert_eq!(in_point(&h, 1), in_point(&h, 2), "both layers use the drop time"); +} + +#[test] +fn dragging_selected_project_items_adds_every_item_to_the_viewer() { + let (mut h, clip) = harness(); + let main = h.state().session.active_comp_id().unwrap().0; + let other = h.state_mut().session.execute("comp.new", json!({"name": "Other", "width": 80, "height": 60, "frameRate": 30, "duration": 1})).unwrap()["comp"] + .as_u64() + .unwrap(); + h.state_mut().session.execute("comp.open", json!({"comp": main})).unwrap(); + h.state_mut().session.state.project_selection = vec![effectcraft_engine::project::ItemId(clip), effectcraft_engine::project::ItemId(other)]; + h.run_steps(3); + let from = rect(&h, &format!("project.item.{clip}.name")).center(); + let to = comp_to_screen(&h.ctx, [80.0, 45.0]).unwrap(); + drag(&mut h, from, to, Modifiers::NONE); + assert_eq!(stack(&h), ["Clip", "Other", "Top", "Middle", "Bottom"]); + for layer in &h.state().session.active_comp().unwrap().layers[..2] { + let Some(KV::Vec3(p)) = layer.props.prop("transform/position").map(|p| p.value.clone()) else { panic!("no position") }; + let tol = 1.0 / last_fit(&h.ctx) as f64 + 1e-6; + assert!((p[0] - 80.0).abs() <= tol && (p[1] - 45.0).abs() <= tol, "{p:?}"); + } +} + /// #88: an effect dragged from Effects & Presets onto the viewer goes on the layer under the /// pointer, not the selected one. #[test] @@ -170,6 +210,48 @@ fn project_items_dropped_on_new_comp_make_a_composition() { assert_eq!(comps(&h), before + 1, "nothing made yet"); } +/// #649: a Project footage item dropped into an empty Timeline makes a composition +/// with that item's dimensions, frame rate, duration, and a source layer. +#[test] +fn project_footage_dropped_on_empty_timeline_creates_first_comp() { + let mut s = Session::default(); + let footage = + s.execute("file.importPlaceholder", json!({"name": "Landscape.png", "width": 640, "height": 360, "frameRate": 24, "duration": 5})).unwrap()["item"] + .as_u64() + .unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + assert!(h.state().session.active_comp().is_none()); + let from = rect(&h, &format!("project.item.{footage}.name")).center(); + let to = rect(&h, "panel.Timeline").center(); + drag(&mut h, from, to, Modifiers::NONE); + let comp = h.state().session.active_comp().expect("created a composition"); + assert_eq!((comp.width, comp.height), (640, 360)); + assert_eq!(comp.frame_rate, effectcraft_engine::time::FrameRate::from_f64(24.0)); + assert_eq!(comp.duration.seconds(), 5.0); + assert_eq!(comp.layers.len(), 1); + assert_eq!(comp.layers[0].name, "Landscape.png"); + assert!(h.state().dialog.is_none()); +} + +/// Multiple source items use the existing New Comp from Selection dialog, rather than +/// quietly creating only the dragged item or changing the composition settings. +#[test] +fn multiple_selected_footage_dropped_on_empty_timeline_asks_how_to_create_comps() { + let mut s = Session::default(); + let first = s.execute("file.importPlaceholder", json!({"name": "First.png", "width": 160, "height": 90})).unwrap()["item"].as_u64().unwrap(); + let second = s.execute("file.importPlaceholder", json!({"name": "Second.png", "width": 300, "height": 200})).unwrap()["item"].as_u64().unwrap(); + s.state.project_selection = vec![effectcraft_engine::project::ItemId(first), effectcraft_engine::project::ItemId(second)]; + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + let from = rect(&h, &format!("project.item.{first}.name")).center(); + let to = rect(&h, "panel.Timeline").center(); + drag(&mut h, from, to, Modifiers::NONE); + assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Form)); + assert!(h.state().auto.find("form.field.single").is_some()); + assert!(h.state().session.active_comp().is_none(), "no composition until the user chooses"); +} + /// A file dropped on the window (egui hands it over as a dropped file). #[derive(Debug)] struct Dropped(std::path::PathBuf); @@ -368,7 +450,7 @@ fn footage_items(h: &Harness<'_, EffectcraftApp>) -> Vec<(String, usize)> { /// #297: File ▸ Import ▸ File… on one numbered still asks, as After Effects' "PNG Sequence" /// checkbox does, whether to import its whole run as one image sequence (and at what frame -/// rate); unticked, the file imports as a still. +/// rate); unticked (the default, as in After Effects), the file imports as a still. #[test] fn import_dialog_offers_the_image_sequence_option() { let (mut h, _) = harness(); @@ -384,35 +466,41 @@ fn import_dialog_offers_the_image_sequence_option() { assert_eq!(label(&h, "form.field.sequence").as_deref(), Some("PNG Sequence")); assert!(label(&h, "form.field.alphabetical").is_some() && label(&h, "form.field.frameRate").is_some()); assert!(footage_items(&h).is_empty(), "nothing imported before OK"); + // Unticked (the default, #431): the picked file alone, as a still. click_id(&mut h, "form.ok"); step_until(&mut h, |h| !footage_items(h).is_empty()); - assert_eq!(footage_items(&h), [("frame_[0001-0003].png".to_string(), 3)]); + assert_eq!(footage_items(&h), [("frame_0002.png".to_string(), 0)]); - // Unticked: the picked file alone, as a still. + // Ticked: the whole run as one sequence. effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "file.import", json!({})).unwrap(); h.run_steps(2); click_id(&mut h, "form.field.sequence"); click_id(&mut h, "form.ok"); step_until(&mut h, |h| footage_items(h).len() == 2); - assert_eq!(footage_items(&h)[1], ("frame_0002.png".to_string(), 0)); + assert_eq!(footage_items(&h)[1], ("frame_[0001-0003].png".to_string(), 3)); let _ = std::fs::remove_dir_all(&dir); } -/// #297: every frame of a sequence dropped on the window arrives as one image sequence (it used -/// to make one item per frame), and Interpret Footage shows its frames and Start Timecode (missing frames -/// always show colour bars, as in After Effects: there is no Missing Frames choice). +/// #297: a folder of frames dropped on the window arrives as one image sequence, and Interpret +/// Footage shows its frames and Start Timecode (missing frames always show colour bars, as in +/// After Effects: there is no Missing Frames choice). #431: dropped files import as stills, one +/// item each, as in After Effects. #[test] -fn dropped_frames_import_as_one_sequence_with_sequence_interpretation() { +fn a_dropped_folder_imports_as_one_sequence_with_sequence_interpretation() { let (mut h, _) = harness(); let dir = frames("drop", 4); h.state_mut().session.importer = Some(Arc::new(Stills)); - for k in 1..=4 { + for k in 1..=2 { h.input_mut().dropped_files.push(Arc::new(Dropped(dir.join(format!("frame_{k:04}.png"))))); } h.run_steps(2); - step_until(&mut h, |h| !footage_items(h).is_empty()); + step_until(&mut h, |h| footage_items(h).len() == 2); + assert_eq!(footage_items(&h), [("frame_0001.png".to_string(), 0), ("frame_0002.png".to_string(), 0)]); + h.input_mut().dropped_files.push(Arc::new(Dropped(dir.clone()))); h.run_steps(2); - assert_eq!(footage_items(&h), [("frame_[0001-0004].png".to_string(), 4)]); + step_until(&mut h, |h| footage_items(h).len() == 3); + h.run_steps(2); + assert_eq!(footage_items(&h)[2], ("frame_[0001-0004].png".to_string(), 4)); assert!(h.state().dialog.is_none(), "drops don't ask"); // Interpret Footage on it (the import selected it). let ctx = h.ctx.clone(); diff --git a/crates/ui-egui/tests/ui_effect_controls.rs b/crates/ui-egui/tests/ui_effect_controls.rs index f6137f5f..551f8ad4 100644 --- a/crates/ui-egui/tests/ui_effect_controls.rs +++ b/crates/ui-egui/tests/ui_effect_controls.rs @@ -574,3 +574,85 @@ fn custom_effect_editors_activate_their_owner() { click(&mut h, graph); assert!(h.state().session.state.selected_props.contains(&(LayerId(x.small), x.curves))); } + +/// #514: Apply Color LUT asks for its LUT as it is applied (as in After Effects), Effect +/// Controls shows the file with a Choose… button that picks another, and the LUT changes the +/// pixels. #473: a text parameter (Cryptomatte's Selection) has a field that sets it. +#[test] +fn file_and_text_parameters_have_widgets() { + let dir = std::env::temp_dir().join(format!("ec-ui-lut-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + // A 2×2×2 LUT that inverts, and one that does nothing. + let cube = |inv: bool| { + let mut t = String::from("LUT_3D_SIZE 2\n"); + for b in 0..2 { + for g in 0..2 { + for r in 0..2 { + let f = |c: i32| if inv { 1 - c } else { c }; + t += &format!("{} {} {}\n", f(r), f(g), f(b)); + } + } + } + t + }; + let (invert, identity) = (dir.join("invert.cube"), dir.join("identity.cube")); + std::fs::write(&invert, cube(true)).unwrap(); + std::fs::write(&identity, cube(false)).unwrap(); + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "LUT", "width": 64, "height": 32, "frameRate": 30, "duration": 1})).unwrap(); + let l = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#ff0000", "width": 64, "height": 32})).unwrap()["layer"].as_u64().unwrap(); + s.state.selected_layers = vec![LayerId(l)]; + let mut app = EffectcraftApp::new(s); + app.show_panel(PanelKind::EffectControls); + let picked = std::sync::Arc::new(std::sync::Mutex::new(invert.to_string_lossy().to_string())); + let pick = picked.clone(); + app.hooks.pick_files = Some(Box::new(move |exts: &[&str]| { + assert_eq!(exts, ["cube"]); + vec![pick.lock().unwrap().clone()] + })); + let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).build_eframe(|_| app); + settle(&mut h); + let ctx = h.ctx.clone(); + let r = effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "effect.apply", json!({"effect": "Apply Color LUT"})).unwrap(); + let fx = r["effects"][0].as_u64().unwrap(); + let lut = |h: &Harness<'_, EffectcraftApp>| { + let g = layer(&h.state().session, l).effects().and_then(|f| f.groups().find(|g| g.uid == fx).cloned()).unwrap(); + let p = g.get("lut").unwrap().clone(); + ( + p.uid, + match p.value { + KV::Str(s) => s, + _ => String::new(), + }, + ) + }; + assert_eq!(lut(&h).1, invert.to_string_lossy(), "the LUT was asked for on apply"); + let px = |h: &Harness<'_, EffectcraftApp>| { + let s = &h.state().session; + s.render(s.active_comp_id().unwrap(), effectcraft_engine::time::Tick::ZERO, Default::default()).get(10, 10) + }; + assert!(px(&h)[0] < 0.05 && px(&h)[1] > 0.95, "red inverted to cyan: {:?}", px(&h)); + settle(&mut h); + let uid = lut(&h).0; + assert!(h.state().auto.find(&format!("effectControls.prop.{uid}.value")).is_some_and(|e| e.label == "invert.cube")); + *picked.lock().unwrap() = identity.to_string_lossy().to_string(); + let at = rect_of(&h, &format!("effectControls.prop.{uid}.choose")).center(); + click(&mut h, at); + assert_eq!(lut(&h).1, identity.to_string_lossy()); + assert!(px(&h)[0] > 0.95, "{:?}", px(&h)); + + // A text parameter: type into its field; it is set when the field loses the focus. + let crypto = h.state_mut().session.execute("effect.apply", json!({"layers": [l], "effect": "ec.3d.cryptomatte"})).unwrap()["effects"][0].as_u64().unwrap(); + settle(&mut h); + let sel = + layer(&h.state().session, l).effects().and_then(|f| f.groups().find(|g| g.uid == crypto).and_then(|g| g.get("selection")).map(|p| p.uid)).unwrap(); + let at = rect_of(&h, &format!("effectControls.prop.{sel}.value")).center(); + click(&mut h, at); + h.event(egui::Event::Text("left*".into())); + h.step(); + h.key_press(egui::Key::Enter); + h.run_steps(3); + let v = layer(&h.state().session, l).props.find(sel).unwrap().value.clone(); + assert_eq!(v, KV::Str("left*".into())); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/ui-egui/tests/ui_font_search.rs b/crates/ui-egui/tests/ui_font_search.rs new file mode 100644 index 00000000..e3da6ef9 --- /dev/null +++ b/crates/ui-egui/tests/ui_font_search.rs @@ -0,0 +1,107 @@ +//! Real keyboard/pointer coverage for the font menus in both text panels. +use effectcraft_engine::{Session, commands::text_edit::layer_doc, project::LayerId}; +use effectcraft_ui_egui::{EffectcraftApp, dock::PanelKind}; +use egui::{Event, Key, Modifiers, PointerButton}; +use egui_kittest::Harness; +use serde_json::json; + +fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}")).clone(); + let pos = egui::pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0); + h.input_mut().events.push(Event::PointerMoved(pos)); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(2); + } +} + +fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key) { + for pressed in [true, false] { + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }); + } + h.run_steps(3); +} + +fn type_text(h: &mut Harness<'_, EffectcraftApp>, text: &str) { + h.input_mut().events.push(Event::Text(text.into())); + h.run_steps(3); +} + +#[test] +fn font_search_filters_selects_and_undoes_in_both_panels() { + for (panel, prefix) in [(PanelKind::Character, "character.font"), (PanelKind::Properties, "properties.text.font")] { + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 320, "height": 180})).unwrap(); + let lid = s.execute("layer.newText", json!({"text": "Keep this text", "font": "Inter"})).unwrap()["layer"].as_u64().unwrap(); + let builder = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)); + let mut h = if std::env::var_os("EC_SNAPSHOT_DIR").is_some() { + builder.wgpu().build_eframe(move |_| EffectcraftApp::new(s)) + } else { + builder.build_eframe(move |_| EffectcraftApp::new(s)) + }; + h.state_mut().show_panel(panel); + h.run_steps(3); + click(&mut h, prefix); + assert!(h.state().auto.find(&format!("{prefix}.search")).is_some()); + assert!(effectcraft_ui_egui::widgets::any_menu_open(&h.ctx), "font search must hold app shortcuts like other popup lists"); + let field = h.state().auto.find(prefix).unwrap(); + let search = h.state().auto.find(&format!("{prefix}.search")).unwrap(); + assert!( + (field.rect[1] + field.rect[3] / 2.0 - search.rect[1] - search.rect[3] / 2.0).abs() < 1.0, + "font selection and search must occupy the same row" + ); + // Search gets focus on open. Mixed case and surrounding whitespace are accepted. + type_text(&mut h, " jEtBrAiNs "); + let rows = h.state().auto.query(&format!("{prefix}.option.")); + assert!(!rows.is_empty()); + assert!(rows.iter().all(|r| r.label.to_lowercase().contains("jetbrains"))); + if let Ok(dir) = std::env::var("EC_SNAPSHOT_DIR") { + h.render().unwrap().save(format!("{dir}/{prefix}-search.png")).unwrap(); + } + assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "Inter", "typing only filters"); + key(&mut h, Key::Enter); + assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "JetBrains Mono"); + assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().text, "Keep this text"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "Inter"); + click(&mut h, prefix); + type_text(&mut h, "no_such_font_123456789"); + assert!(h.state().auto.query(&format!("{prefix}.option.")).is_empty()); + assert!(h.state().auto.find(&format!("{prefix}.empty")).is_some()); + key(&mut h, Key::Enter); + assert!(h.state().auto.find(&format!("{prefix}.search")).is_some(), "no result keeps menu open"); + key(&mut h, Key::Escape); + assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "Inter"); + click(&mut h, prefix); + // Reopening clears the previous query, and mouse selection still works. + h.run_steps(2); + if let Ok(dir) = std::env::var("EC_SNAPSHOT_DIR") { + h.render().unwrap().save(format!("{dir}/{prefix}-reopened.png")).unwrap(); + } + assert!(h.state().auto.query(&format!("{prefix}.option.")).len() > 1); + type_text(&mut h, "JetBrains"); + click(&mut h, &format!("{prefix}.option.JetBrains Mono")); + assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "JetBrains Mono"); + } +} + +#[test] +fn font_search_preserves_edited_text_and_applies_only_to_selected_characters() { + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 320, "height": 180})).unwrap(); + let lid = s.execute("layer.newText", json!({"text": "First second", "font": "Inter"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("text.edit", json!({"layer": lid})).unwrap(); + s.execute("text.setSelection", json!({"start": 0, "end": 5})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(move |_| EffectcraftApp::new(s)); + h.state_mut().show_panel(PanelKind::Character); + h.run_steps(3); + click(&mut h, "character.font"); + type_text(&mut h, "noto serif"); + key(&mut h, Key::ArrowDown); + key(&mut h, Key::ArrowUp); + key(&mut h, Key::Enter); + let doc = layer_doc(&h.state().session, LayerId(lid)).unwrap(); + assert_eq!(doc.text, "First second"); + assert_eq!(doc.style_at(0).font, "Noto Serif"); + assert_eq!(doc.style_at(8).font, "Inter"); +} diff --git a/crates/ui-egui/tests/ui_gpu.rs b/crates/ui-egui/tests/ui_gpu.rs index 92d18a44..906ac0cd 100644 --- a/crates/ui-egui/tests/ui_gpu.rs +++ b/crates/ui-egui/tests/ui_gpu.rs @@ -86,3 +86,34 @@ fn viewer_draws_gpu_frames_that_match_the_cpu() { cpu_shot.save(format!("{d}/ui_cpu_viewer.png")).unwrap(); } } + +/// Once the graphics device is lost, eframe's renderer cannot stage buffers and egui-wgpu +/// panicked on the main thread drawing the next frame (#591). The app now draws nothing, +/// auto-saves unsaved changes and closes its window instead. +#[test] +fn a_lost_device_draws_nothing_autosaves_and_closes() { + let dir = std::env::temp_dir().join(format!("ec-device-lost-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut s = Session { autosave_folder_override: Some(dir.clone()), ..Default::default() }; + s.execute("comp.new", json!({"name": "Unsaved", "width": 64, "height": 64, "duration": 1})).unwrap(); + assert!(s.is_dirty()); + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_| EffectcraftApp::new(s)); + let told = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let log = told.clone(); + h.state_mut().hooks.device_lost = Some(Box::new(move |saved| log.borrow_mut().push(saved))); + h.run_steps(3); + assert!(!h.output().shapes.is_empty(), "the UI draws before the loss"); + let bridge = effectcraft_ui_egui::gpu_failure::GpuFailureBridge::new(&h.ctx); + h.state_mut().set_gpu_failure_bridge(bridge.clone()); + bridge.report("GPU device lost (Unknown): Device is lost", true); + h.step(); + let closing = h.output().viewport_output.get(&egui::ViewportId::ROOT).is_some_and(|v| v.commands.contains(&egui::ViewportCommand::Close)); + assert!(closing, "the window closes"); + assert!(h.output().shapes.is_empty(), "nothing for the lost renderer to stage"); + h.step(); + assert!(h.output().shapes.is_empty()); + let saved = h.state().session.autosave.last_path.clone().expect("unsaved changes auto-saved"); + assert!(std::path::Path::new(&saved).is_file(), "{saved}"); + assert_eq!(*told.borrow(), vec![Some(Ok(saved))], "the user is told once, with the recovery copy"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/tests/ui_inline_edit.rs b/crates/ui-egui/tests/ui_inline_edit.rs index fc5c522e..4119d79b 100644 --- a/crates/ui-egui/tests/ui_inline_edit.rs +++ b/crates/ui-egui/tests/ui_inline_edit.rs @@ -474,3 +474,44 @@ fn a_layout_switch_keeps_the_name_field_and_arabic_names_are_kept() { assert_eq!(focused.trim(), "File", "Alt focused the menu bar"); } } + +/// #488: once a rename field is open, the arrow keys move its caret (the whole name is selected +/// only as it opens), so part of a name can be edited. +#[test] +fn arrow_keys_move_the_caret_in_rename_fields() { + let (mut h, comp) = harness(); + let name = |h: &Harness<'_, EffectcraftApp>| h.state().session.project.items.values().find(|i| i.id.0 == comp).unwrap().name.clone(); + let p = center(&h, &format!("project.item.{comp}.name")); + click(&mut h, p, 1); + key(&mut h, Key::Enter); + // The arrows keep the field (Up / Down too); in "Main", two to the left of its end. + key(&mut h, Key::ArrowRight); + key(&mut h, Key::ArrowLeft); + key(&mut h, Key::ArrowUp); + key(&mut h, Key::ArrowDown); + key(&mut h, Key::End); + key(&mut h, Key::ArrowLeft); + key(&mut h, Key::ArrowLeft); + type_text(&mut h, "X"); + key(&mut h, Key::Backspace); + key(&mut h, Key::Backspace); + type_text(&mut h, "Y"); + key(&mut h, Key::Enter); + assert_eq!(name(&h), "MYin"); + // The Timeline's layer rename. + let layer = h.state_mut().session.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + h.state_mut().session.execute("layer.select", json!({"layers": [layer]})).unwrap(); + h.run_steps(3); + let p = center(&h, &format!("timeline.layer.{layer}.row")); + click(&mut h, p, 1); + key(&mut h, Key::Enter); + key(&mut h, Key::ArrowRight); + key(&mut h, Key::ArrowUp); + key(&mut h, Key::End); + key(&mut h, Key::ArrowLeft); + key(&mut h, Key::ArrowLeft); + type_text(&mut h, "-"); + key(&mut h, Key::Enter); + let layer_name = h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(layer)).unwrap().name.clone(); + assert_eq!(layer_name, "Pla-te"); +} diff --git a/crates/ui-egui/tests/ui_keyframes.rs b/crates/ui-egui/tests/ui_keyframes.rs index 50ccf1ef..a7d1d1ae 100644 --- a/crates/ui-egui/tests/ui_keyframes.rs +++ b/crates/ui-egui/tests/ui_keyframes.rs @@ -293,3 +293,108 @@ fn a_graph_handle_drag_moves_the_handles_of_every_selected_key() { assert_eq!(h.state().session.history.undo.len(), undo + 1, "one undo step"); assert!((handle(&h, 0).x - k0.x - px_per_s * 0.2).abs() < 2.0, "key 0's handle moved on screen"); } + +/// #457: on a Timeline narrower than the Graph Editor's whole button row, the buttons that don't +/// fit go to a "»" menu at the end of the bar instead of being cut off; each stays reachable and +/// works from there. +#[test] +fn graph_editor_buttons_that_do_not_fit_move_to_the_more_menu() { + let (mut h, _, _) = harness(); + h.set_size(egui::vec2(1280.0, 800.0)); + h.state_mut().ui.timeline.graph_editor = true; + h.run_steps(4); + let ids = [ + "autoSelectGraphType", + "valueGraph", + "speedGraph", + "showSelected", + "autoZoom", + "fit", + "fitSelection", + "snap", + "reference", + "transformBox", + "separateDimensions", + "interpolationHold", + "interpolationLinear", + "interpolationAuto", + "easyEaseEasy", + "easyEaseInEase", + "easyEaseOutEase", + ]; + let graph = h.state().auto.find("timeline.graph").expect("the Graph Editor is shown").rect; + let right = graph[0] + graph[2]; + let shown: Vec<&str> = ids.iter().copied().filter(|id| h.state().auto.find(&format!("timeline.graph.{id}")).is_some()).collect(); + for id in &shown { + let r = h.state().auto.find(&format!("timeline.graph.{id}")).unwrap().rect; + assert!(r[0] + r[2] <= right, "{id} is drawn past the bar's right edge"); + } + let more = h.state().auto.find("timeline.graph.more").expect("the buttons that don't fit are in a menu").rect; + assert!(more[0] + more[2] <= right); + let p = pos2(more[0] + more[2] / 2.0, more[1] + more[3] / 2.0); + let click = |h: &mut Harness<'_, EffectcraftApp>, p: Pos2| { + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); + }; + click(&mut h, p); + let hidden: Vec<&str> = ids.iter().copied().filter(|id| h.state().auto.find(&format!("timeline.graph.more.{id}")).is_some()).collect(); + assert_eq!(shown.len() + hidden.len(), ids.len(), "every button is on the bar or in the menu: {shown:?} + {hidden:?}"); + assert!(hidden.contains(&"transformBox"), "{hidden:?}"); + let before = h.state().ui.timeline.graph_transform_box; + let item = h.state().auto.find("timeline.graph.more.transformBox").unwrap().rect; + click(&mut h, pos2(item[0] + item[2] / 2.0, item[1] + item[3] / 2.0)); + assert_eq!(h.state().ui.timeline.graph_transform_box, !before, "the menu item runs the button"); +} + +/// #355, #491: with three layers selected the Properties panel heads "(3) Selected Objects"; its +/// stopwatch animates Position on all three (one undo step) and scrubbing X moves all three by +/// the same amount. +#[test] +fn properties_panel_edits_every_selected_layer() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Three", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap(); + let mut ids = vec![]; + for (i, c) in ["#e04020", "#20e040", "#2040e0"].iter().enumerate() { + let l = s.execute("layer.newSolid", json!({"color": c, "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": l, "path": "transform/position", "value": [60.0 + 80.0 * i as f64, 90.0]})).unwrap(); + ids.push(l); + } + s.execute("layer.select", json!({"layers": ids})).unwrap(); + let first = s.state.selected_layers[0]; + let uid = s.active_comp().unwrap().layer(first).unwrap().props.prop("transform/position").unwrap().uid; + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.state_mut().show_panel(effectcraft_ui_egui::dock::PanelKind::Properties); + h.run_steps(4); + assert_eq!(h.state().auto.find("properties.selectedObjects").map(|e| e.label.clone()), Some("(3) Selected Objects".into())); + let pos = |h: &Harness<'_, EffectcraftApp>, l: u64| { + h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("transform/position").unwrap().clone() + }; + let at = |h: &Harness<'_, EffectcraftApp>, id: &str| { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")); + pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0) + }; + let sw = at(&h, &format!("properties.prop.{uid}.stopwatch")); + h.input_mut().events.push(Event::PointerMoved(sw)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: sw, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: sw, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); + assert!(ids.iter().all(|l| pos(&h, *l).is_animated()), "every selected layer's stopwatch"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.run_steps(2); + assert!(ids.iter().all(|l| !pos(&h, *l).is_animated()), "one undo step"); + // Scrub X: each layer moves by the same amount. + let before: Vec = ids.iter().map(|l| pos(&h, *l).value.components()[0]).collect(); + let x = at(&h, &format!("properties.prop.{uid}.value.0")); + drag(&mut h, x, x + egui::vec2(30.0, 0.0), Default::default()); + let after: Vec = ids.iter().map(|l| pos(&h, *l).value.components()[0]).collect(); + let d = after[0] - before[0]; + assert!(d.abs() > 1.0, "the scrub moved X: {before:?} → {after:?}"); + for (a, b) in after.iter().zip(&before) { + assert!((a - b - d).abs() < 1e-6, "same change on every layer: {before:?} → {after:?}"); + } +} diff --git a/crates/ui-egui/tests/ui_locked_color.rs b/crates/ui-egui/tests/ui_locked_color.rs new file mode 100644 index 00000000..d942da11 --- /dev/null +++ b/crates/ui-egui/tests/ui_locked_color.rs @@ -0,0 +1,81 @@ +//! The split locked viewer re-converts its pixels when the display profile or the output +//! simulation changes (egui_kittest, texture uploads captured). + +use effectcraft_engine::{Session, render::RenderOpts, viewer::DisplayColor}; +use effectcraft_ui_egui::EffectcraftApp; +use egui_kittest::{Harness, TestRenderer}; +use serde_json::{Value, json}; +use std::{cell::RefCell, collections::HashMap, rc::Rc}; + +type Images = Rc>>; + +struct Capture(Images); + +impl TestRenderer for Capture { + fn handle_delta(&mut self, delta: &mut egui::TexturesDelta) { + for (id, ops) in std::mem::take(&mut delta.set) { + for op in ops { + let egui::ImageData::Color(image) = op.image; + if op.pos.is_none() && image.size[0] <= 256 && image.size[1] <= 256 { + self.0.borrow_mut().insert(id, (*image).clone()); + } + } + } + for id in std::mem::take(&mut delta.free) { + self.0.borrow_mut().remove(&id); + } + } + + fn render(&mut self, _: &egui::Context, _: &egui::FullOutput) -> Result { + Err("Texture inspection only".into()) + } +} + +fn invoke(h: &mut Harness<'_, EffectcraftApp>, command: &str, params: Value) { + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, command, params).unwrap(); + h.run_steps(3); +} + +fn locked_center(h: &Harness<'_, EffectcraftApp>, images: &Images) -> [u8; 4] { + let (_, texture) = h.ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(egui::Id::new("viewer-locked"))).unwrap(); + let images = images.borrow(); + let image = images.get(&texture.id()).unwrap(); + image.pixels[(image.size[1] / 2) * image.size[0] + image.size[0] / 2].to_array() +} + +fn expected_center(h: &mut Harness<'_, EffectcraftApp>) -> [u8; 4] { + let s = &mut h.state_mut().session; + let comp = s.active_comp_id().unwrap(); + let image = s.render(comp, s.time(), RenderOpts::default()); + let ci = effectcraft_ui_egui::frames::to_color_image(&image); + let mut pixels = vec![ci.pixels[(ci.size[1] / 2) * ci.size[0] + ci.size[0] / 2].to_array()]; + DisplayColor::of(s).unwrap().apply(&mut pixels); + pixels[0] +} + +#[test] +fn locked_viewer_follows_display_profile_and_simulation() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Color", "width": 64, "height": 64, "duration": 1})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080", "width": 64, "height": 64})).unwrap(); + s.execute("file.projectSettings", json!({"workingSpace": "srgb", "outputSpace": "rec2020", "renderer": "software"})).unwrap(); + s.execute("prefs.set", json!({"key": "previews.displayProfile", "value": "srgb"})).unwrap(); + s.execute("view.displayColorManagement", json!({"value": true})).unwrap(); + let images = Images::default(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).renderer(Capture(images.clone())).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + invoke(&mut h, "view.splitLockedViewer", json!({})); + let first = expected_center(&mut h); + assert_eq!(locked_center(&h, &images), first); + + invoke(&mut h, "prefs.set", json!({"key": "previews.displayProfile", "value": "p3"})); + let after_display = expected_center(&mut h); + assert_ne!(after_display, first); + assert_eq!(locked_center(&h, &images), after_display); + + invoke(&mut h, "view.simulateOutput", json!({"profile": "rec709", "preserveRgb": true})); + let after_sim = expected_center(&mut h); + assert_ne!(after_sim, after_display); + assert_eq!(locked_center(&h, &images), after_sim); +} diff --git a/crates/ui-egui/tests/ui_panel_focus.rs b/crates/ui-egui/tests/ui_panel_focus.rs index f87c3ddd..5d3302b1 100644 --- a/crates/ui-egui/tests/ui_panel_focus.rs +++ b/crates/ui-egui/tests/ui_panel_focus.rs @@ -47,7 +47,7 @@ fn group_of(n: &DockNode, p: PanelKind) -> Option> { match n { DockNode::Split { a, b, .. } => group_of(a, p).or_else(|| group_of(b, p)), DockNode::Tabs { panels, .. } => panels.contains(&p).then(|| panels.clone()), - DockNode::Stack { entries } => entries.iter().any(|e| e.panel == p).then(|| entries.iter().map(|e| e.panel).collect()), + DockNode::Stack { entries } => entries.iter().find(|e| e.contains(p)).map(|e| e.panels.clone()), } } diff --git a/crates/ui-egui/tests/ui_panels.rs b/crates/ui-egui/tests/ui_panels.rs index d58c2146..a71bebfe 100644 --- a/crates/ui-egui/tests/ui_panels.rs +++ b/crates/ui-egui/tests/ui_panels.rs @@ -35,6 +35,17 @@ fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { h.run_steps(2); } +/// #581: the header's Discord pill fits its label with even padding (it was a fixed 104 px with +/// the text at its left). +#[test] +fn discord_pill_fits_its_label() { + let h = harness(); + let e = h.state().auto.find("header.discord").expect("the Discord pill").clone(); + let text = h.ctx.fonts_mut(|f| f.layout_no_wrap("Discord".into(), effectcraft_ui_egui::theme::Tokens::semibold(12.0), egui::Color32::WHITE).size().x); + // Padding 10, the 14 px icon, a 6 px gap, the text, padding 12. + assert!((e.rect[2] - (text + 42.0)).abs() < 0.5, "{} for a {text} px label", e.rect[2]); +} + #[test] fn window_menu_opens_the_new_panels() { let mut h = harness(); @@ -117,6 +128,42 @@ fn hover(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) { h.run_steps(3); } +/// Issue #320: saved workspaces last between sessions (they were only kept in memory): a new +/// app on the same settings store lists them and brings their layout back; renamed and deleted +/// ones stay that way. +#[test] +fn saved_workspaces_persist_across_sessions() { + use effectcraft_engine::config::{ConfigStore, MemoryConfig}; + use effectcraft_ui_egui::menus::invoke; + let store = std::sync::Arc::new(MemoryConfig::default()); + let launch = || { + let s = Session { config: Some(store.clone()), ..Default::default() }; + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(2); + h + }; + let mut h = launch(); + let ctx = h.ctx.clone(); + invoke(h.state_mut(), &ctx, "window.workspace", json!({"name": "Minimal"})).unwrap(); + invoke(h.state_mut(), &ctx, "window.saveWorkspaceAs", json!({"name": "My Layout"})).unwrap(); + invoke(h.state_mut(), &ctx, "window.saveWorkspaceAs", json!({"name": "Scratch"})).unwrap(); + let saved = h.state().ui.dock.clone(); + drop(h); + let mut h = launch(); + assert_eq!(h.state().saved_workspace_names(), ["My Layout", "Scratch"]); + assert_eq!(h.state().ui.workspace, "Scratch"); + let ctx = h.ctx.clone(); + invoke(h.state_mut(), &ctx, "window.workspace", json!({"name": "My Layout"})).unwrap(); + assert_eq!(h.state().ui.dock, saved); + invoke(h.state_mut(), &ctx, "window.editWorkspaces", json!({"name": "Scratch", "delete": true})).unwrap(); + invoke(h.state_mut(), &ctx, "window.editWorkspaces", json!({"name": "My Layout", "rename": "Edit Bay"})).unwrap(); + drop(h); + assert_eq!(launch().state().saved_workspace_names(), ["Edit Bay"]); + // A damaged file is ignored. + store.write(effectcraft_ui_egui::WORKSPACES_FILE, "{not json").unwrap(); + assert!(launch().state().saved_workspace_names().is_empty()); +} + /// Issue #191: a workspace saved with Save as New Workspace is listed in Window ▸ Workspace /// (below the built-ins, where the submenu used to be cut off) and choosing it there brings its /// layout back. @@ -271,3 +318,47 @@ fn panels_snapshot() { img.save(format!("{dir}/{panel}{scope}.png")).unwrap(); } } + +/// Drag the automation element `id` horizontally by `dx` points. +fn drag_x(h: &mut Harness<'_, EffectcraftApp>, id: &str, dx: f32) { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone(); + let from: Pos2 = pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0); + h.input_mut().events.push(Event::PointerMoved(from)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + for k in 1..=10 { + h.input_mut().events.push(Event::PointerMoved(from + egui::vec2(dx * k as f32 / 10.0, 0.0))); + h.step(); + } + let to = from + egui::vec2(dx, 0.0); + h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); +} + +/// #338: an Essential Graphics Scale control keeps After Effects' Constrain Proportions link, on +/// by default: dragging X scales Y with it; unlinked, X moves alone. +#[test] +fn essential_graphics_scale_keeps_constrain_proportions() { + let mut h = harness(); + let layer = h.state().session.active_comp().unwrap().layers[0].id.0; + h.state_mut().session.execute("prop.set", json!({"layer": layer, "path": "transform/scale", "value": [100.0, 50.0]})).unwrap(); + let c = + h.state_mut().session.execute("essential.addProperty", json!({"layer": layer, "path": "transform/scale"})).unwrap()["controls"][0].as_u64().unwrap(); + open(&mut h, "essentialGraphics"); + let value = format!("essential.control.{c}.value"); + let scale = |h: &Harness<'_, EffectcraftApp>| { + let comp = h.state().session.active_comp().unwrap(); + let l = comp.layers.iter().find(|l| l.id.0 == layer).unwrap(); + l.props.prop("transform/scale").unwrap().value.components() + }; + assert!(h.state().auto.find(&format!("{value}.link")).is_some(), "the chain link is drawn"); + drag_x(&mut h, &format!("{value}.0"), 40.0); + let linked = scale(&h); + assert!(linked[0] > 100.0, "{linked:?}"); + assert!((linked[1] / linked[0] - 0.5).abs() < 1e-6, "Y follows X in proportion: {linked:?}"); + click(&mut h, &format!("{value}.link")); + drag_x(&mut h, &format!("{value}.0"), 40.0); + let unlinked = scale(&h); + assert!(unlinked[0] > linked[0] && unlinked[1] == linked[1], "unlinked, X moves alone: {unlinked:?}"); +} diff --git a/crates/ui-egui/tests/ui_project_rows.rs b/crates/ui-egui/tests/ui_project_rows.rs index 43daf2ed..4c255cad 100644 --- a/crates/ui-egui/tests/ui_project_rows.rs +++ b/crates/ui-egui/tests/ui_project_rows.rs @@ -148,6 +148,32 @@ fn footage_context_menu_interprets_the_footage() { assert_eq!(h.state().dialog, Some(Dialog::Form), "the Interpret Footage dialog opened"); } +/// #360: the row menu's New Comp from Selection works on the whole selection, as File ▸ New +/// Comp from Selection does: with several items selected it asks how in After Effects' New +/// Composition from Selection dialog, and Multiple Compositions makes one per item. +#[test] +fn context_menu_new_comp_from_selection_uses_the_selection() { + let (mut h, _, comp, solid) = harness(); + h.state_mut().session.state.project_selection = vec![comp, solid]; + h.run_steps(2); + let at = center(&h, &format!("project.item.{}.name", solid.0)); + h.input_mut().events.push(Event::PointerMoved(at)); + h.step(); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(2); + assert_eq!(h.state().session.state.project_selection, vec![comp, solid], "the selection stays"); + let item = h.query_by_label_contains("New Comp from Selection").expect("in the menu").rect(); + click_at(&mut h, item.center()); + assert_eq!(h.state().dialog, Some(Dialog::Form)); + assert!(h.state().auto.find("form.field.single").is_some(), "the New Composition from Selection dialog"); + let comps = h.state().session.project.comps().count(); + h.state_mut().session.execute_checked("file.newCompFromSelection", json!({"single": false})).unwrap(); + assert_eq!(h.state().session.project.comps().count(), comps + 2, "one per item"); +} + /// Dragging from the Project panel's empty area draws a selection box that selects the rows it /// touches; a click there deselects (#203). #[test] diff --git a/crates/ui-egui/tests/ui_ram_preview.rs b/crates/ui-egui/tests/ui_ram_preview.rs index 84610b7c..5459be8c 100644 --- a/crates/ui-egui/tests/ui_ram_preview.rs +++ b/crates/ui-egui/tests/ui_ram_preview.rs @@ -125,3 +125,23 @@ fn prefetch_waits_while_edits_keep_coming() { let series = h.state().shown_series(ItemId(c)); assert!(h.state().frames.cached_frames(&series).len() > 1, "prefetch resumed"); } + +/// #595: at Third (and other resolutions that aren't a whole number of thousandths) the cache +/// bars find the rendered frames. The series they count was rebuilt from the frame key's scale +/// (333 → 0.333), whose render options hashed apart from the frames rendered at 0.3333…. +#[test] +fn cached_frames_are_counted_at_one_third_resolution() { + use effectcraft_ui_egui::state::Resolution; + for res in [Resolution::Third, Resolution::Custom(7)] { + let mut s = Session::default(); + let c = s.execute("comp.new", json!({"name": "Third", "width": 64, "height": 36, "duration": 1})).unwrap()["comp"].as_u64().unwrap(); + s.execute("layer.newSolid", json!({"color": "#3080ff"})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.state_mut().ui.viewer.res = res; + settle(&mut h); + let series = h.state().shown_series(ItemId(c)); + assert_eq!(series.scale, (res.scale(1.0, 1.0) * 1000.0).round() as u32, "{res:?}"); + assert!(shown_cached(&h, c), "{res:?}: the shown frame counts as cached"); + assert!(!h.state().frames.cached_frames(&series).is_empty(), "{res:?}"); + } +} diff --git a/crates/ui-egui/tests/ui_tabs.rs b/crates/ui-egui/tests/ui_tabs.rs index d3a71ec3..4d6777b6 100644 --- a/crates/ui-egui/tests/ui_tabs.rs +++ b/crates/ui-egui/tests/ui_tabs.rs @@ -64,7 +64,7 @@ fn group_of(n: &DockNode, p: PanelKind) -> Option> { match n { DockNode::Split { a, b, .. } => group_of(a, p).or_else(|| group_of(b, p)), DockNode::Tabs { panels, .. } => panels.contains(&p).then(|| panels.clone()), - DockNode::Stack { entries } => entries.iter().any(|e| e.panel == p).then(|| entries.iter().map(|e| e.panel).collect()), + DockNode::Stack { entries } => entries.iter().find(|e| e.contains(p)).map(|e| e.panels.clone()), } } @@ -209,7 +209,7 @@ fn stacked_panel_gaps_resize_the_panels() { match n { DockNode::Split { a, b, .. } => height(a, p).or_else(|| height(b, p)), DockNode::Tabs { .. } => None, - DockNode::Stack { entries } => entries.iter().find(|e| e.panel == p).map(|e| e.height), + DockNode::Stack { entries } => entries.iter().find(|e| e.contains(p)).map(|e| e.height), } } let (preview, props) = (rect(&h, "panel.Preview"), rect(&h, "panel.Properties")); @@ -298,3 +298,70 @@ fn project_empty_area_double_click_and_dropped_files_import() { h.run_steps(2); assert_eq!(imports(&h), 2, "the dropped file was imported"); } + +/// Issue #306: dragging the viewer/Timeline divider far up made a nested split shorter than its +/// two 20 pt minimums, and `f32::clamp` panicked with crossed bounds, closing the app. +#[test] +fn dragging_the_viewer_timeline_divider_far_up_does_not_crash() { + let (mut h, _, _) = harness(); + // The horizontal gutter right above the Timeline. + let tl = rect(&h, "panel.Timeline"); + let gutter = |h: &Harness<'_, EffectcraftApp>| { + h.state() + .auto + .previous + .iter() + .filter(|e| e.id.starts_with("dock.gutter.") && e.rect[2] > e.rect[3]) + .map(|e| (e.id.clone(), egui::Rect::from_min_size(pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3])))) + .filter(|(_, g)| g.x_range().contains(tl.center().x) && g.max.y <= tl.min.y + 1.0) + .min_by(|a, b| (tl.min.y - a.1.max.y).total_cmp(&(tl.min.y - b.1.max.y))) + .expect("the gutter above the Timeline") + }; + let (id, g) = gutter(&h); + drag(&mut h, g.center(), g.center() - egui::vec2(0.0, 2000.0)); + let g2 = rect(&h, &id); + assert!(g2.center().y < g.center().y - 100.0, "the divider moved up: {g:?} → {g2:?}"); + // And back down past the bottom. + drag(&mut h, g2.center(), g2.center() + egui::vec2(0.0, 3000.0)); + h.run_steps(2); +} + +/// Issue #301: clicking anywhere on a stacked panel's header bar expands/collapses it, not only +/// its name; the panel menu button keeps its own click. +#[test] +fn a_stacked_panels_whole_header_bar_toggles_it() { + let (mut h, _, _) = harness(); + let open = |h: &Harness<'_, EffectcraftApp>| h.state().ui.dock.is_visible(PanelKind::Preview); + assert!(open(&h)); + let bar = rect(&h, "panel.header.Preview"); + let tab = rect(&h, "panel.tab.Preview"); + assert!(bar.min.x >= tab.max.x - 1.0 && bar.width() > 40.0, "{bar:?} {tab:?}"); + click_n(&mut h, pos2(bar.max.x - 6.0, bar.center().y), 1); + assert!(!open(&h), "a click at the bar's right end collapses Preview"); + let bar = rect(&h, "panel.header.Preview"); + click_n(&mut h, bar.center(), 1); + assert!(open(&h), "and expands it again"); + let menu = rect(&h, "panel.menu.Preview"); + click_n(&mut h, menu.center(), 1); + assert!(open(&h), "the panel menu button doesn't toggle the panel"); +} + +/// #302: a panel dropped onto a stacked panel becomes a tab in that panel's frame (it was +/// inserted as a stacked panel of its own below it); its tabs switch the frame's panel. +#[test] +fn a_tab_dropped_on_a_stacked_panel_joins_its_frame() { + let (mut h, _, _) = harness(); + let dock = |h: &Harness<'_, EffectcraftApp>| h.state().ui.dock.clone(); + let fx = rect(&h, "panel.tab.EffectControls"); + let props = rect(&h, "panel.Properties"); + drag(&mut h, fx.center(), props.center()); + assert_eq!(group_of(&dock(&h), PanelKind::Properties), Some(vec![PanelKind::Properties, PanelKind::EffectControls])); + assert!(dock(&h).is_visible(PanelKind::EffectControls) && !dock(&h).is_visible(PanelKind::Properties)); + // The frame's other tab shows Properties again, the frame stays open. + let tab = rect(&h, "panel.tab.Properties"); + click_n(&mut h, tab.center(), 1); + assert!(dock(&h).is_visible(PanelKind::Properties) && !dock(&h).is_visible(PanelKind::EffectControls)); + // The layout (saved workspaces) round-trips. + let s = serde_json::to_string(&dock(&h)).unwrap(); + assert_eq!(serde_json::from_str::(&s).unwrap(), dock(&h)); +} diff --git a/crates/ui-egui/tests/ui_timeline_shapes.rs b/crates/ui-egui/tests/ui_timeline_shapes.rs index 811b55c7..dd9232d7 100644 --- a/crates/ui-egui/tests/ui_timeline_shapes.rs +++ b/crates/ui-egui/tests/ui_timeline_shapes.rs @@ -197,3 +197,49 @@ fn toolbar_stroke_edits_the_selected_shape_layer() { assert_eq!(width, Some(4.0), "a stroke was added with the toolbar's width"); assert_eq!(h.state().session.state.shape_tool.stroke_width, 4.0); } + +/// Issue #633: a colour property in the Timeline (a shape's Fill Color) has an eyedropper after +/// its swatch, as in After Effects: click it, then click in the Composition panel to sample. +#[test] +fn timeline_colour_eyedropper_samples_the_comp() { + let mut s = session(); + s.execute("layer.newSolid", json!({"name": "Back", "color": "#20c040"})).unwrap(); + let l = s.execute("layer.newShape", json!({"kind": "rect", "fill": [1.0, 0.0, 0.0], "size": [100.0, 60.0]})).unwrap()["layer"].as_u64().unwrap(); + let layer = s.active_comp().unwrap().layer(LayerId(l)).unwrap().clone(); + let mut fill = None; + layer.props.walk("", &mut |_, p| { + if fill.is_none() && matches!(p.value, effectcraft_engine::keyframe::Value::Color(_)) { + fill = Some(p.uid); + } + }); + let fill = fill.expect("a fill colour"); + fn groups(g: &effectcraft_engine::project::PropGroup, out: &mut Vec) { + for c in g.groups() { + out.push(c.uid); + groups(c, out); + } + } + let mut open = vec![]; + groups(&layer.props, &mut open); + let mut h = harness(s); + h.state_mut().ui.timeline.open_groups.extend(open); + open_layer(&mut h, l); + h.run_steps(2); + click(&mut h, &format!("timeline.prop.{fill}.eyedropper")); + assert_eq!(h.state().ui.fx_pick.as_ref().map(|p| (p.prop, p.kind.as_str())), Some((fill, "color")), "the eyedropper waits for a viewer click"); + let pos = effectcraft_ui_egui::panels::viewer::comp_to_screen(&h.ctx, [6.0, 6.0]).unwrap(); + click_at(&mut h, pos); + assert!(h.state().ui.fx_pick.is_none(), "the pick is used up"); + let layer = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().clone(); + let mut got = None; + layer.props.walk("", &mut |_, p| { + if p.uid == fill { + got = Some(p.value.clone()); + } + }); + let Some(effectcraft_engine::keyframe::Value::Color(c)) = got else { panic!("{got:?}") }; + let want = [0x20 as f64 / 255.0, 0xc0 as f64 / 255.0, 0x40 as f64 / 255.0]; + for k in 0..3 { + assert!((c[k] - want[k]).abs() < 0.02, "{c:?} vs {want:?}"); + } +} diff --git a/crates/ui-egui/tests/ui_timeline_zoom.rs b/crates/ui-egui/tests/ui_timeline_zoom.rs index 48b19cb8..2c802600 100644 --- a/crates/ui-egui/tests/ui_timeline_zoom.rs +++ b/crates/ui-egui/tests/ui_timeline_zoom.rs @@ -59,6 +59,34 @@ fn alt_wheel_zooms_out_until_the_whole_comp_shows() { assert!(h.state().ui.timeline.pps.is_some()); } +/// #428: Ctrl/Cmd+wheel and a trackpad pinch zoom time over the layers, as Alt+wheel does (egui +/// hands them over as a zoom factor, which the Timeline ignored). +#[test] +fn ctrl_wheel_and_pinch_zoom_time() { + let (mut h, layer) = harness(); + h.state_mut().ui.timeline.pps = Some(200.0); + h.run_steps(2); + let pps = |h: &Harness<'_, EffectcraftApp>| h.state().ui.timeline.pps.unwrap_or(0.0); + let start = pps(&h); + assert!(start > 0.0); + let at = over_bar(&h, layer); + h.event(Event::PointerMoved(at)); + h.event(Event::Zoom(0.5)); + h.step(); + let pinched = pps(&h); + assert!(pinched > 0.0 && pinched < start * 0.75, "a pinch in zooms out: {start} -> {pinched}"); + let at = over_bar(&h, layer); + h.event(Event::PointerMoved(at)); + h.event(Event::ModifiersChanged(Modifiers::COMMAND)); + h.event(Event::MouseWheel { unit: MouseWheelUnit::Point, delta: vec2(0.0, 240.0), modifiers: Modifiers::COMMAND, phase: egui::TouchPhase::Move }); + h.step(); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.step(); + assert!(pps(&h) > pinched, "Ctrl/Cmd+wheel up zooms in: {pinched} -> {}", pps(&h)); + // Over the layers, so the rows did not scroll. + assert_eq!(h.state().ui.timeline.scroll_y, 0.0); +} + fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> egui::Rect { let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone(); egui::Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])) @@ -311,3 +339,68 @@ fn dragging_out_of_the_marker_bin_adds_a_comp_marker() { h.run_steps(2); assert_eq!(markers(&h), vec![13.0, 20.0]); } + +/// #468: zoomed in, a CTI scrub held past the right end of the ruler scrolls the timeline (the +/// CTI rides the edge), past the left end scrolls it back; during playback the view pages to +/// keep the CTI in sight. +#[test] +fn the_timeline_follows_the_cti_past_its_edges() { + let (mut h, _) = harness(); + // (Opening the comp showed all of it.) + h.state_mut().ui.timeline.pps = Some(200.0); + h.run_steps(2); + let ruler = rect(&h, "timeline.ruler"); + let (s0, e0) = visible(&h); + assert_eq!(s0, 0.0); + let y = ruler.max.y - 4.0; + let from = pos2(ruler.center().x, y); + let past = pos2(ruler.max.x + 60.0, y); + h.event(Event::PointerMoved(from)); + h.step(); + h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=4 { + h.event(Event::PointerMoved(from + (past - from) * (k as f32 / 4.0))); + h.step(); + } + // Held still past the edge: it keeps scrolling (the view is read once the button is up). + h.run_steps(30); + let release = |h: &mut Harness<'_, EffectcraftApp>, at: Pos2| { + h.event(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + }; + release(&mut h, past); + let (s1, e1) = visible(&h); + let cti = h.state().session.time().seconds(); + assert!(s1 > 0.5, "the view scrolled right: {s0}..{e0} → {s1}..{e1}"); + assert!(cti > e0 && cti >= s1 && cti <= e1 + 0.05, "the CTI rides the right edge: {cti} in {s1}..{e1}"); + // Pressed again and held past the left end: back to the left. + let left = pos2(ruler.min.x - 60.0, y); + h.event(Event::PointerMoved(from)); + h.step(); + h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=4 { + h.event(Event::PointerMoved(from + (left - from) * (k as f32 / 4.0))); + h.step(); + } + h.run_steps(5); + release(&mut h, left); + let (s2, _) = visible(&h); + let cti = h.state().session.time().seconds(); + assert!(s2 < s1 && (cti - s2).abs() < 0.05, "the view scrolled back left: {s1} → {s2}, CTI {cti}"); + + // Playback past the visible span pages the view to the CTI. + let (s3, e3) = visible(&h); + let t = e3 + 20.0; + h.state_mut().session.set_time(effectcraft_engine::time::Tick::from_seconds_f64(t)); + let now = h.ctx.input(|i| i.time); + h.state_mut().play(now); + assert!(h.state().playback.playing); + h.step(); + h.state_mut().stop(); + h.step(); + let cti = h.state().session.time().seconds(); + let (s4, e4) = visible(&h); + assert!(s4 > s3 && cti >= s4 && cti <= e4, "paged from {s3}..{e3} to {s4}..{e4} for the CTI at {cti}"); +} diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index 6b535b6c..bdb4189d 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -750,8 +750,28 @@ fn pen_mask_undoes_step_by_step_and_double_click_moves_the_whole_mask() { assert!(back.vertices.iter().zip(&moved.vertices).all(|(v, c)| near(*v, *c)), "{back:?}"); } -/// A straight two-key motion path shows Bezier handles at both keys (it had none), and dragging -/// one pulls the path into a curve (#290). +/// #513: clicking away from a mask's path, even on its own layer, deselects the mask's points; +/// the layer stays selected. +#[test] +fn clicking_off_a_mask_deselects_its_points() { + let mut h = harness(); + let plate = layer_id(&h, "Plate"); + let s = &mut h.state_mut().session; + s.execute("layer.select", json!({"layers": [plate]})).unwrap(); + s.execute("mask.new", json!({"layer": plate, "vertices": [[100.0, 60.0], [500.0, 60.0], [500.0, 300.0], [100.0, 300.0]], "closed": true})).unwrap(); + h.state_mut().ui.tool = Tool::Selection; + h.run_steps(2); + let at = screen(&h, [500.0, 60.0]); + click(&mut h, at); + assert_eq!(h.state().session.state.selected_vertices.len(), 1, "a click on a point selects it"); + let away = screen(&h, [200.0, 250.0]); + click(&mut h, away); + assert!(h.state().session.state.selected_vertices.is_empty(), "a click off the path deselects its points"); + assert_eq!(h.state().session.state.selected_layers, vec![LayerId(plate)], "the layer stays selected"); +} + +/// A straight two-key motion path shows Bezier handles at both keys, and dragging one pulls the +/// path into a curve (#290). #[test] fn a_straight_motion_path_has_handles_to_curve_it() { let mut h = harness(); @@ -765,15 +785,16 @@ fn a_straight_motion_path_has_handles_to_curve_it() { h.run_steps(3); let (out0, in1) = (format!("viewer.motionPath.{}.0.out", box_id.0), format!("viewer.motionPath.{}.1.in", box_id.0)); assert!(h.state().auto.find(&in1).is_some(), "the second key has a handle"); - // The first key's handle lies a third of the way along the path: drag it down 150 px. + // The first key's auto-Bezier handle lies a sixth of the way along the path, as After Effects + // puts it: drag it down 150 px. let from = rect(&h, &out0).center(); - assert!(from.distance(screen(&h, [200.0, 100.0])) < 2.0, "{from:?}"); - let to = screen(&h, [200.0, 250.0]); + assert!(from.distance(screen(&h, [150.0, 100.0])) < 2.0, "{from:?}"); + let to = screen(&h, [150.0, 250.0]); drag(&mut h, from, to); let p = h.state().session.active_comp().unwrap().layer(box_id).unwrap().props.prop("transform/position").unwrap().clone(); assert!(!p.keys[0].spatial_auto); let t = p.keys[0].spatial_out; - assert!((t[0] - 100.0).abs() < 2.0 && (t[1] - 150.0).abs() < 2.0, "{t:?}"); + assert!((t[0] - 50.0).abs() < 2.0 && (t[1] - 150.0).abs() < 2.0, "{t:?}"); let mid = p.value_at(effectcraft_engine::time::Tick::from_seconds_f64(1.0)).as_vec3(); assert!(mid[1] > 130.0, "the path curves: {mid:?}"); } @@ -1687,6 +1708,32 @@ fn handle_drags_scale_about_the_anchor_and_follow_the_pointer() { assert!(close(scale(&h), [162.5, 162.5]), "{:?}", scale(&h)); } +/// #491: with several layers selected, a handle drag scales all of them by the same ratio, each +/// about its own anchor point, in one undo step. +#[test] +fn handle_drags_scale_every_selected_layer() { + let mut h = harness(); + let layers = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect::>(); + let (box_id, plate) = (layers[0], layers[1]); + let scale = |h: &Harness<'_, EffectcraftApp>, id: LayerId| { + let l = h.state().session.active_comp().unwrap().layer(id).unwrap().clone(); + l.props.prop("transform/scale").unwrap().value.as_vec3() + }; + h.state_mut().session.execute("prop.set", json!({"layer": plate.0, "path": "transform/scale", "value": [50, 50, 100]})).unwrap(); + h.state_mut().session.execute("layer.select", json!({"layers": [box_id.0, plate.0]})).unwrap(); + h.state_mut().session.execute("view.snapping", json!({"value": false})).unwrap(); + h.run_steps(2); + let undo0 = h.state().session.history.undo.len(); + // The box's bottom-right corner (360, 220) to (400, 260): twice the box's size. + let corner = rect(&h, &format!("viewer.handle.{}.2", box_id.0)).center(); + let to = screen(&h, [400.0, 260.0]); + drag_path(&mut h, &[corner, to], Default::default()); + let (b, p) = (scale(&h, box_id), scale(&h, plate)); + assert!((b[0] - 200.0).abs() < 1.0 && (b[1] - 200.0).abs() < 1.0, "{b:?}"); + assert!((p[0] - 100.0).abs() < 1.0 && (p[1] - 100.0).abs() < 1.0, "the other layer doubles too: {p:?}"); + assert_eq!(h.state().session.history.undo.len(), undo0 + 1, "one undo step"); +} + /// #252: with snapping on, a dragged handle snaps to the comp's corners and edges (and other /// layers'), so a layer scales exactly to the comp; Shift keeps the proportions and still lands /// on the comp's size. @@ -1817,3 +1864,87 @@ fn full_resolution_frames_wider_than_the_texture_limit_fit() { assert!(full(&mut h), "the full-size frame is shown (and stays readable): {:?}", h.state().ui.viewer.res); assert_eq!(h.state().viewer_texture_size(), Some([800, 134]), "2400×400 averaged by 3"); } + +/// #509: ⌘/Ctrl-drag in the Layer panel resizes the Roto Brush (as in After Effects) without +/// painting, the next stroke uses the new size, and the tool options show the Diameter. +#[test] +fn roto_brush_ctrl_drag_resizes_the_brush() { + let mut h = harness(); + let id = h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == "Box").unwrap().id.0; + h.state_mut().session.execute("layer.select", json!({"layers": [id]})).unwrap(); + h.state_mut().ui.layer_panel = Some(id); + h.state_mut().show_panel(effectcraft_ui_egui::dock::PanelKind::Layer); + h.state_mut().ui.tool = Tool::RotoBrush; + h.run_steps(3); + assert!(h.state().auto.find("header.roto.diameter").is_some(), "the Diameter field"); + let canvas = rect(&h, "layerPanel.canvas"); + let zoom = canvas.width() / 80.0; + let c = canvas.center(); + h.input_mut().events.push(Event::ModifiersChanged(egui::Modifiers::COMMAND)); + drag(&mut h, c, c + vec2(20.0 * zoom, 0.0)); + h.input_mut().events.push(Event::ModifiersChanged(egui::Modifiers::NONE)); + h.run_steps(2); + let dia = h.state().session.state.roto.diameter; + assert!((dia - 40.0).abs() <= 1.0, "a 20 px drag makes a 40 px brush: {dia}"); + let strokes = |h: &Harness<'_, EffectcraftApp>| { + let l = h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().clone(); + let roto = effectcraft_engine::effects::roto::ID; + let g = l + .effects() + .and_then(|fx| fx.groups().find(|g| matches!(&g.kind, effectcraft_engine::project::GroupKind::Effect { effect } if effect == roto)).cloned()); + g.map(|g| effectcraft_engine::effects::roto::data(&effectcraft_engine::effects::flatten_params(&g, &mut |p| p.value.clone())).strokes.clone()) + .unwrap_or_default() + }; + assert!(strokes(&h).is_empty(), "resizing paints nothing"); + drag(&mut h, c - vec2(10.0 * zoom, 0.0), c + vec2(10.0 * zoom, 0.0)); + let s = strokes(&h); + assert_eq!(s.len(), 1); + assert!((s[0].radius - dia / 2.0).abs() < 1e-6, "{} vs {dia}", s[0].radius); +} + +/// #553: the Pen slot's tools work on a motion path as in After Effects: Add Vertex (and the +/// Pen) add a keyframe where the path is clicked, Convert Vertex switches a keyframe between +/// Linear and Auto Bezier, Delete Vertex removes it; the pointer is the tool's pen. +#[test] +fn vertex_tools_edit_motion_path_keyframes() { + let mut h = harness(); + let box_id: LayerId = h.state().session.active_comp().unwrap().layers[0].id; + let s = &mut h.state_mut().session; + s.execute("layer.select", json!({"layers": [box_id.0]})).unwrap(); + s.execute("prop.addKey", json!({"layer": box_id.0, "path": "transform/position", "time": 0.0, "value": [100, 100, 0]})).unwrap(); + s.execute("prop.addKey", json!({"layer": box_id.0, "path": "transform/position", "time": 2.0, "value": [500, 100, 0]})).unwrap(); + s.execute("view.snapping", json!({"value": false})).unwrap(); + s.set_time(effectcraft_engine::time::Tick::from_seconds_f64(3.0)); + let keys = |h: &Harness<'_, EffectcraftApp>| { + h.state().session.active_comp().unwrap().layer(box_id).unwrap().props.prop("transform/position").unwrap().keys.clone() + }; + h.state_mut().ui.tool = Tool::PenAdd; + h.run_steps(3); + let mid = screen(&h, [300.0, 100.0]); + h.input_mut().events.push(Event::PointerMoved(mid)); + h.step(); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "the pen is painted in place of the system pointer"); + click(&mut h, mid); + let k = keys(&h); + assert_eq!(k.len(), 3, "Add Vertex adds a keyframe on the path"); + let v = k[1].value.as_vec3(); + assert!((v[0] - 300.0).abs() < 10.0 && (v[1] - 100.0).abs() < 1.0, "{v:?}"); + assert!((k[1].time.seconds() - 1.0).abs() < 0.1, "{:?}", k[1].time); + // Convert Vertex: the new key's handles go (Linear), then come back (Auto Bezier). + h.state_mut().ui.tool = Tool::PenConvert; + let at = screen(&h, [v[0] as f32, v[1] as f32]); + click(&mut h, at); + let k = keys(&h); + assert!(!k[1].spatial_auto && k[1].spatial_in == [0.0; 3] && k[1].spatial_out == [0.0; 3], "Linear"); + click(&mut h, at); + assert!(keys(&h)[1].spatial_auto, "Auto Bezier"); + // Delete Vertex removes it. + h.state_mut().ui.tool = Tool::PenDelete; + click(&mut h, at); + assert_eq!(keys(&h).len(), 2); + // The Pen adds one too. + h.state_mut().ui.tool = Tool::Pen; + let quarter = screen(&h, [200.0, 100.0]); + click(&mut h, quarter); + assert_eq!(keys(&h).len(), 3); +} diff --git a/crates/ui-egui/tests/ui_workspace_focus.rs b/crates/ui-egui/tests/ui_workspace_focus.rs index c53b3aa4..f9dd3c47 100644 --- a/crates/ui-egui/tests/ui_workspace_focus.rs +++ b/crates/ui-egui/tests/ui_workspace_focus.rs @@ -74,7 +74,9 @@ fn native_config_read_is_byte_bounded_and_reports_errors() { #[test] fn unreadable_native_workspace_is_preserved_after_later_layout_changes() { - for bytes in [vec![0xff, 0xfe], vec![b' '; 1024 * 1024 + 1], b"{".to_vec()] { + for bytes in + [vec![0xff, 0xfe], vec![b' '; 1024 * 1024 + 1], b"{".to_vec(), serde_json::to_vec(&json!({"version":2,"workspaces":{},"floating":{}})).unwrap()] + { let dir = scratch_dir("preserve"); let path = dir.join("workspaces.json"); std::fs::write(&path, &bytes).unwrap(); @@ -83,6 +85,7 @@ fn unreadable_native_workspace_is_preserved_after_later_layout_changes() { assert!(effectcraft_ui_egui::control::inspect(&app, &ctx)["workspaceStorageError"].is_string()); app.set_workspace("Minimal"); app.ui.focused = PanelKind::Project; + effectcraft_ui_egui::menus::invoke(&mut app, &ctx, "window.saveWorkspaceAs", json!({"name":"Must not overwrite"})).unwrap(); eframe::App::on_exit(&mut app); assert_eq!(std::fs::read(&path).unwrap(), bytes); assert!(effectcraft_ui_egui::control::inspect(&app, &ctx)["workspaceStorageError"].is_string()); @@ -90,6 +93,24 @@ fn unreadable_native_workspace_is_preserved_after_later_layout_changes() { } } +#[test] +fn legacy_named_workspaces_migrate_once_and_survive_actual_app_restart() { + let store = Arc::new(MemoryConfig::default()); + let saved = effectcraft_ui_egui::dock::workspace("Minimal"); + store.write("workspaces.json", &json!({"workspaces":{"Legacy":saved},"floating":{}}).to_string()).unwrap(); + let mut app = EffectcraftApp::new(Session { config: Some(store.clone()), ..Default::default() }); + assert_eq!(app.ui.saved_workspaces.get("Legacy"), Some(&saved)); + app.set_workspace("Legacy"); + let ctx = egui::Context::default(); + effectcraft_ui_egui::menus::invoke(&mut app, &ctx, "window.saveWorkspaceAs", json!({"name":"Migrated"})).unwrap(); + let text = store.read("workspaces.json").unwrap(); + assert_eq!(serde_json::from_str::(&text).unwrap()["version"], 1); + let restarted = EffectcraftApp::new(Session { config: Some(store), ..Default::default() }); + assert_eq!(restarted.ui.saved_workspaces.get("Legacy"), Some(&saved)); + assert_eq!(restarted.ui.saved_workspaces.get("Migrated"), Some(&saved)); + assert_eq!(restarted.ui.workspace, "Migrated"); +} + #[test] fn tab_and_shift_tab_traverse_toolbar_and_space_activates_only_focused_tool() { let mut session = Session::default(); diff --git a/crates/ui-egui/tests/viewer_passive_display.rs b/crates/ui-egui/tests/viewer_passive_display.rs new file mode 100644 index 00000000..797689af --- /dev/null +++ b/crates/ui-egui/tests/viewer_passive_display.rs @@ -0,0 +1,58 @@ +//! A passive Composition viewer applies the same monitor conversion as the active one (#547). +use effectcraft_engine::Session; +use effectcraft_ui_egui::{EffectcraftApp, menus, panels::viewers}; +use egui::{Color32, Context, ImageData, RawInput, Rect, pos2, vec2}; +use serde_json::json; +use std::collections::HashMap; + +fn frames(app: &mut EffectcraftApp, ctx: &Context, textures: &mut HashMap>) { + for _ in 0..4 { + let mut output = ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(256.0, 256.0))), ..Default::default() }, |ui| { + viewers::show(app, ui, 0, Rect::from_min_size(pos2(0.0, 0.0), vec2(128.0, 256.0))); + viewers::show(app, ui, 1, Rect::from_min_size(pos2(128.0, 0.0), vec2(128.0, 256.0))); + }); + for (&id, deltas) in &output.textures_delta.set { + let Some(name) = ctx.tex_manager().read().meta(id).map(|m| m.name.clone()) else { continue }; + for delta in deltas { + let ImageData::Color(image) = &delta.image; + if delta.pos.is_none() { + textures.insert(name.clone(), image.pixels.clone()); + } + } + } + output.textures_delta.clear(); + std::thread::sleep(std::time::Duration::from_millis(100)); + } +} + +fn pixel(textures: &HashMap>, name: &str) -> [u8; 4] { + let p = &textures[name]; + p[p.len() / 2].to_array() +} + +#[test] +fn passive_viewer_matches_the_monitor_conversion() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Display test", "width": 16, "height": 16, "duration": 1})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Color", "color": "#406080", "width": 16, "height": 16})).unwrap(); + s.execute("edit.deselectAll", json!({})).unwrap(); + let mut app = EffectcraftApp::new(s); + let ctx = Context::default(); + menus::invoke(&mut app, &ctx, "view.newViewer", json!({})).unwrap(); + let mut textures = HashMap::new(); + app.session.execute("file.projectSettings", json!({"workingSpace": "rec2020"})).unwrap(); + app.session.execute("prefs.set", json!({"key": "previews.displayProfile", "value": "p3"})).unwrap(); + frames(&mut app, &ctx, &mut textures); + assert_eq!(pixel(&textures, "viewer-display"), [37, 89, 122, 255]); + assert_eq!(pixel(&textures, "viewer-0"), [37, 89, 122, 255]); + // Changing which viewer is active keeps the conversion in both. + viewers::activate(&mut app, &ctx, 0); + frames(&mut app, &ctx, &mut textures); + assert_eq!(pixel(&textures, "viewer-display"), [37, 89, 122, 255]); + assert_eq!(pixel(&textures, "viewer-1"), [37, 89, 122, 255]); + // No output-to-monitor conversion needed: both show the raw frame. + app.session.execute("prefs.set", json!({"key": "previews.displayProfile", "value": "srgb"})).unwrap(); + frames(&mut app, &ctx, &mut textures); + assert_eq!(pixel(&textures, "viewer-frame"), [0, 90, 125, 255]); + assert_eq!(pixel(&textures, "viewer-1"), [0, 90, 125, 255]); +} diff --git a/docs/agents.md b/docs/agents.md index a0f4e7d0..6a3b9e85 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -253,7 +253,8 @@ build real projects through these interfaces; they are worked examples of everyt `{"colors":[[0,"#0080ff"],[1,[1,1,0]]],"opacities":[[0,1],[1,0.5]]}`. * **Image sequences**: `file.import {"paths":["/r/shot_0001.png"]}` imports the file's whole numbered run as one item (several picked frames: that range; a folder: each run in it; - `"sequence": false`: one still; `"alphabetical": true`: Force Alphabetical Order, the files + `"sequence": false`: one still; dropped files, `"drag": true`, import as stills unless + `"sequence": true`, as in After Effects; `"alphabetical": true`: Force Alphabetical Order, the files play one after another) at `frameRate` or 30 fps (Settings ▸ Import). Missing frames show colour bars, as in After Effects. `file.interpretFootage {"items":[id], "frameRate":12, "startTimecode":1001}` conforms them and sets Override Start (the item and layers that ran to its end get the new @@ -494,6 +495,8 @@ styles, origin (`bundled`, `system`, `user`) and own-language name (`{"query":"g by name; `{"rescan":true}` first picks up fonts installed since launch). Pass a listed `family` and one of its `styles` to `layer.setText {"font", "style"}`; `text.fontFeatures` says which OpenType options a font draws with its own glyphs. +A tool with its own font library can hand it over without installing it: folders listed in +`EFFECTCRAFT_FONT_DIRS` (a path list, `:`-separated, `;` on Windows) are scanned like the system font folders. In expressions, `text.sourceText.style` / `getStyleAt(i, t)` read styles and the setters (`setFontSize(v, start?, count?)`, `setFillColor`, `setText`, `setJustification`…) return a diff --git a/docs/architecture.md b/docs/architecture.md index b938f4c9..9e6fc0e7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,5 +1,7 @@ # Architecture +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (crate table checked against `crates/`: effect count 306, VP9 inter frames) · **Target:** Adobe After Effects 2026 + EffectCraft is a stack of small Rust crates. The engine knows nothing about the user interface; the egui frontend, the command-line tool and the MCP server all sit on top of the same `Session` and drive it through the same command registry. @@ -16,7 +18,7 @@ cpal or muda. Everything in L0 to L4, the egui UI and the web app also build for | L0 | `time` | `Tick` (254 016 000 000 per second), rational frame rates incl. NTSC, SMPTE and drop-frame timecode | | L0 | `geom` | Vectors, matrices, quaternions, the layer transform (anchor, position, scale, orientation, rotation) | | L0 | `color` | sRGB and linear, HSL/HSV, luminance, the 38 blend modes, label colors | -| L0 | `vp9enc` | VP9 intra-frame encoder (profile 0, 8-bit 4:2:0; lossless at quality 100) for WebM export, from the VP9 bitstream specification | +| L0 | `vp9enc` | VP9 encoder (profile 0, 8-bit 4:2:0, key and inter frames with motion search, loop filter and rate control; lossless at quality 100) for WebM export, from the VP9 bitstream specification | | L0 | `opusenc` | Opus encoder: RFC 6716 SILK (NB/MB/WB), hybrid (SWB/FB) and CELT (FB) modes chosen by bitrate and application (audio / voice), 48 kHz 20 ms packets, mono/stereo, and the RFC 7845 `OpusHead`, for WebM export audio | | L0 | `hevcenc` | HEVC (H.265) encoder from ITU-T H.265: Main / Main 10 4:2:0, IDR + P slices (quarter-pel motion, merge/AMVP), CABAC, deblocking, bitrate or constant-QP rate control, for MP4 (`hvc1`) export | | L0 | `av1enc` | AV1 encoder from the AV1 bitstream specification: Main profile 8/10-bit 4:2:0, key + inter frames (quarter-pel motion), deblocking, for MP4 (`av01`) and WebM export | @@ -27,7 +29,7 @@ cpal or muda. Everything in L0 to L4, the egui UI and the web app also build for | L1 | `path` | Bezier paths, path operators (trim, offset, round corners, zig zag, twist, merge…), stroking, coverage masks | | L2 | `project` | The document: items, compositions, layers, the property tree, render queue model, `.ecproj` serde | | L2 | `text` | Fonts, shaping, layout, per-glyph geometry, text animators and selectors | -| L2 | `effects` | The effect registry (241 effects) and their CPU implementations | +| L2 | `effects` | The effect registry (306 effects) and their CPU implementations | | L2 | `svg` | SVG import (W3C SVG 1.1/2 static subset: shapes, paths, transforms, `use`, CSS, gradients) and rasterisation at any scale | | L2 | `pdf` | PDF, PDF-compatible Illustrator (`.ai`) and EPS (PostScript subset) vector footage into the `svg` render tree: paths, fills, strokes, shadings, tiling patterns, clipping, text (embedded TrueType / CFF / Type 1 / Type 3 fonts; the bundled `text` fonts for the standard 14), images, blend modes, soft masks, optional-content layers | | L2 | `model` | 3D models for Advanced 3D: glTF 2.0 (`.gltf`/`.glb`) and OBJ/MTL import (meshes, PBR metallic-roughness materials and textures, node hierarchy, skins, animations), parametric primitives, extruded/bevelled outline meshes and polygon triangulation | @@ -688,3 +690,9 @@ The before frame times were dominated by an O(layers²) menu build per visible t by every panel deep-cloning the active comp each frame. Left for later: per-layer structural sharing (so a one-layer edit doesn't copy its comp), a binary or compact project format (pretty JSON parsing is now the bulk of open), and rendering 5,000-layer comps faster. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Status line added; crate table checked against `crates/` (every crate listed); effect count and VP9 encoder description corrected | diff --git a/docs/effects-parity.md b/docs/effects-parity.md new file mode 100644 index 00000000..b57b84b5 --- /dev/null +++ b/docs/effects-parity.md @@ -0,0 +1,67 @@ +# Effects parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first effects parity checklist: presence, GPU, fidelity and presets against After Effects 2026 26.5) · **Target:** Adobe After Effects 2026 + +Effects are the deepest single area of After Effects. This page separates the four questions +that a single "306 of 306" hides: does each effect exist, does it run fast, does it look like +After Effects', and does the preset library exist. The per-effect catalogue is the generated +[effects.md](effects.md); the overall numbers are in [target-app-parity.md](target-app-parity.md). + +## Summary + +| Question | Value | Kind | +|---|---|---| +| Effects present | **306** registered, 3,014 parameters; all of After Effects' effects by name (298 at the M9.11 count) plus Obsolete and our own | Measured: registry, [effects.md](effects.md). After Effects 26.5's `Plug-ins/Effects` holds 306 `.plugin` bundles in 231 entries (one bundle can hold several effects, so this is a cross-check, not a match) | +| Parameter names, order, popups, units, defaults | aligned in M9.12 | Agent-checked against After Effects' documentation, not against a live dump | +| On the GPU | **280** (92%) | Measured: registry flags; GPU vs CPU agreement tested per effect | +| 32-bit float, no clamping | 306 | Measured: registry flags | +| **Rendered output matches After Effects** | **unmeasured** | No effect is compared with an After Effects render yet. `effectcraft_effects::fidelity::compare_frames` exists for the corpus ([fidelity/README.md](fidelity/README.md)); simulation effects are pinned by golden hashes of our own output (M13.27) | +| Animation presets | **8** original text-animator presets against After Effects' **621** `.ffx` presets | Measured: `crates/engine/presets/text_animators.json`; the After Effects `Presets` folder | +| 26.x additions | Curl Noise (26.3) present (#463 open); effect colour labels and the rebuilt Effect Controls (26.5) missing | Release notes | + +**Breadth ≈ 85%** (presets weigh in; the effects themselves are ≈ 100%). **Ready ≈ 40%**: until a +corpus compares renders, each effect is assumed to differ somewhere, and users keep confirming it. + +## By category (from [effects.md](effects.md)) + +| Category | Effects | On the GPU | Known differences or bugs | +|---|---:|---:|---| +| 3D Channel | 8 | 8 | Cryptomatte selection can't be set in the UI and the GPU path is slow (#473); multi-layer EXRs decoded twice (#474) | +| Audio | 13 | 0 | CPU by design (applied in the mixdown) | +| Blur & Sharpen | 16 | 15 | Gaussian Blur interaction and output to be measured first ([fidelity/ae-2026-acceptance.md](fidelity/ae-2026-acceptance.md)) | +| Channel | 13 | 13 | — | +| Color Correction | 40 | 40 | Apply Color LUT (#514); 32-bpc blending differences affect looks (#494); ACES 2.0 / OCIO 2.5 (26.5) | +| Distort | 39 | 35 | Mesh Warp can't be dragged in the viewer (#303); CC Power Pin Unstretch (#534, community fix pending); Liquify has no viewer brush | +| Expression Controls | 9 | 9 | — | +| Generate | 26 | 25 | Fractal on the CPU (needs f64), Reset doesn't work (#526); Write-on drops marks when animated (#536, fix pending) | +| Immersive Video | 12 | 12 | — | +| Keying | 12 | 12 | Key Cleaner approximated; wire-removal Frame Offset (#533, fix pending) | +| Matte | 6 | 4 | Mocha shape reads our documented JSON, not Mocha's format | +| Noise & Grain | 13 | 12 | Curl Noise (#463); Match Grain on the CPU | +| Obsolete | 9 | 9 | — | +| Paint | 1 | 0 | Tablet pressure not read ([hardware-parity.md](hardware-parity.md)) | +| Perspective | 10 | 9 | Chromatic Aberrations pan/tilt crash (#580) | +| Simulation | 18 | 18 | Particle Playground lacks Particle Exploder, text particles, ephemeral mapper; CC Particle World camera fixed (#397) | +| Stylize | 25 | 25 | Motion Tile output size (#369) | +| Text | 2 | 2 | — | +| Time | 8 | 8 | Pixel Motion Blur averages frames, ignores Vector Detail (#535, fix pending) | +| Transition | 17 | 17 | — | +| Utility | 9 | 7 | — | + +Third-party bundles in After Effects (Cineware, Mocha AE) are out of scope; Keylight is +implemented under a generic name ("Key Light"). + +## Estimate + +**≈ 70–130 h**: a fidelity corpus case per effect at default and one non-default setting, compared +with After Effects renders, and the fixes they turn up (40–80 h, most of it parallel, but every +reference render needs After Effects on the owner's Mac); an original preset library covering +After Effects' preset categories (Behaviors, Backgrounds, Shapes, Synthetics, Text, Transitions, +Image effects) 20–40 h; the open effect bugs 10–20 h. Moving Fractal and the analysis effects to +the GPU is performance, not parity. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | First effects parity checklist | diff --git a/docs/effects.md b/docs/effects.md index 4073251a..e498cf1a 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -412,7 +412,7 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| -| Apply Color LUT | `ec.utility.applylut` | 0 | GPU | 32 | Implemented | +| Apply Color LUT | `ec.utility.applylut` | 1 | GPU | 32 | Implemented | | CC Overbrights | `ec.utility.ccoverbrights` | 2 | GPU | 32 | Implemented | | Cineon Converter | `ec.utility.cineon` | 7 | GPU | 32 | Implemented | | Color Profile Converter | `ec.utility.colorprofileconverter` | 7 | GPU | 32 | Implemented | diff --git a/docs/fidelity/README.md b/docs/fidelity/README.md index ae88186b..025997d4 100644 --- a/docs/fidelity/README.md +++ b/docs/fidelity/README.md @@ -50,12 +50,30 @@ time curve. Its closed-form inverse and a parameter-based convergence check now mathematical regression test at times within `1e-12` of the midpoint; that test is additional to the 168 live AE samples. +## Auto-Bezier spatial tangents + +Measured on 10 October 2026 with After Effects 2026 (26.3) by running the original +`examples/ae-spatial-tangents.jsx` in both hosts (`AfterFX.exe -r` and `effectcraft-cli script`): +three original Position paths, ten keys, with even and uneven key timing. The script pins spatial +interpolation to auto-Bezier, since an AE preference can default new keys to linear. + +AE gives each auto-Bezier key an out tangent of (next key − previous key) / 6 and the mirrored in +tangent, whatever the key times; an end key uses its one neighbour, (neighbour − key) / 6. That is a +Catmull-Rom spline. EffectCraft gave end keys no tangent and scaled middle keys' tangents by the +lengths of their segments, so all ten tangents differed. Sampled Position along an auto-Bezier path +with Easy Ease was up to 25.1 px off, and a roving path up to 27.7 px. + +After the fix, all ten tangents equal AE's to the printed 0.001 px, and the two path cases are within +0.05 px over 61 samples each; what remains is the arc-length table's sub-pixel speed. The regression +test `spatial_auto_bezier_tangents_follow_after_effects` checks the rule on other original keys, +including a timing change. AE's output stays local; only the script and these scores are tracked. + ## Still unmeasured Rendered frame comparisons now have a deterministic `effectcraft_effects::fidelity::compare_frames` helper. Corpus runners can use it with locally generated host/reference frames and fail on either a mean or maximum channel error without committing proprietary renders. -Mixed interpolation sides, non-unit durations, automatic/continuous Bezier, spatial speeds and paths, +Mixed interpolation sides, non-unit durations, temporal automatic/continuous Bezier, spatial speeds, time remapping, expressions, actual rendered motion and other effects need separate corpus cases. The initial probe makes no claim about those features. diff --git a/docs/fidelity/ae-2026-acceptance.md b/docs/fidelity/ae-2026-acceptance.md index 042d1177..018d1ddf 100644 --- a/docs/fidelity/ae-2026-acceptance.md +++ b/docs/fidelity/ae-2026-acceptance.md @@ -12,7 +12,7 @@ platform, fonts, color settings, source files, and project parameters. | --- | --- | --- | | Work projects | Representative projects recreated or imported, edited, saved, reopened and delivered without lost state | Native AE project import and third-party AE plug-in execution remain gaps | | Workflow | Pointer and keyboard cases for all panels, tools, shortcuts, docking, undo, dialogs and selections | Current pass covers 34 panel layouts and selected interaction regressions; every control is not verified | -| Animation | AE-sampled values for scalar, spatial, multidimensional and expression-driven properties, timing and interpolation | Eight scalar Rotation/ease cases, 168 samples verified against AE 26.3x87; other cases remain open | +| Animation | AE-sampled values for scalar, spatial, multidimensional and expression-driven properties, timing and interpolation | Eight scalar Rotation/ease cases, 168 samples verified against AE 26.3x87; auto-Bezier spatial tangents (ten keys, three paths) match AE 26.3; other cases remain open | | Render fidelity | Original test scenes compared at 8/16/32 bpc, specified color spaces and alpha conventions; edges, impulses, gradients and temporal sequences | Most AE-rendered comparisons remain open; CPU/GPU agreement alone is insufficient | | Media and delivery | Real footage import, seek, frame rates, audio sync, relinking, image sequences, codec/container metadata and exports checked in downstream tools | Actual work fixtures still required | | Performance | Matched AE/EffectCraft input-to-present and export timings at 1080p/4K, warm/cold caches, memory/VRAM and long sessions | No claim of beating AE or measured blur response yet | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md new file mode 100644 index 00000000..66f18b47 --- /dev/null +++ b/docs/file-format-parity.md @@ -0,0 +1,117 @@ +# File format parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first per-format table, measured against the After Effects 2026 26.5 bundle and this repository's import/export registries) · **Target:** Adobe After Effects 2026 + +Every format After Effects reads or writes, with EffectCraft's support. After Effects' list comes +from its installed bundle (26.5.0.89: `Info.plist` `CFBundleDocumentTypes`, `Plug-ins/Format`, +`Resources/usd_plugins`) and its public documentation; ours from `crates/media/src/lib.rs` +(`STILL_EXTENSIONS`, `VIDEO_EXTENSIONS`, `MODEL_EXTENSIONS`, FilmCraft's `AUDIO_EXTENSIONS`), +`crates/engine/src/commands/file.rs` (`DATA_EXTENSIONS`) and `OutputFormat::ALL` in +`crates/project/src/render_queue.rs`. Video codecs come from FilmCraft's pure-Rust crates +([architecture.md](architecture.md)). + +**Overall ≈ 50%** (estimated, weighted by use): modern delivery and interchange formats are +strong, but After Effects' own project, template and preset formats, which decide whether +existing work moves over, are 0%. + +Fidelity key: **exact** (bit-exact or within a stated tolerance of a reference), **good** (round +trips and renders, checked by tests, not against After Effects), **partial** (named limitations), +**none**. + +## After Effects' own formats + +| Format | After Effects | EffectCraft read | Write | Fidelity / notes | Tested by | +|---|---|---|---|---|---| +| Project `.aep` | R/W (native) | none | none | Main file format; needs an owner decision on clean-room scope (#190, #248: py-aep as an external converter). Blocks beta | — | +| XML project `.aepx` | R/W | none | none | Same decision as `.aep` | — | +| Templates `.aet` / `.aetx` | R/W | none | none | Own `.ectemplate` (Home ▸ Templates, Save as Template) | `templates.rs` tests | +| Motion Graphics template `.mogrt` | W (and R in Premiere) | none | none | Own `.ectemplate` Essential Graphics templates; FilmCraft rejects our `.fcgt` export (#611) | `essential.rs` tests | +| Animation preset `.ffx` | R/W, 621 bundled | none | none | Own JSON `.ecpreset`; `.ffx` is out of clean-room scope (AGENTS.md §2) | `animation.rs` tests | +| Render Settings / Output Module templates `.ars` / `.aom` | R/W | none | none | Templates are stored in the project and settings instead | `render_templates.rs` tests | +| Scripts `.jsx` | R (run) | yes | — | After Effects object model and ScriptUI ([scripting-parity.md](scripting-parity.md)) | `crates/script` tests | +| Binary scripts `.jsxbin` | R (run) | none | — | No public specification: out of scope | — | +| Motion graphics JSON `.mgjson` | R (data) | none | none | Media Browser lists it; import accepts only `.json` / `.csv` / `.tsv` data | — | +| Captions `.aecap`, graphics `.aegraphic` | R/W | none | none | — | — | +| Native `.ecproj` | — | yes | yes | Versioned JSON; schema migrations tested; absolute footage paths, no relinking (gap 18) | engine project tests | + +## Footage: stills + +| Format | After Effects | Read | Write | Fidelity / notes | +|---|---|---|---|---| +| PNG | R/W | yes | yes (sequence) | good | +| JPEG | R/W | yes | yes (sequence) | good | +| TIFF | R/W | yes | yes (sequence) | good | +| OpenEXR (multi-layer, Cryptomatte) | R/W | yes | yes (32-bit sequence) | good; HTJ2K compression fails (#482); EXRs without an unnamed RGB layer decode twice (#474) | +| Photoshop PSD / PSB | R (footage, composition) / W (layers, sequence) | yes (layers, groups, masks, styles, smart objects) | Save Frame As ▸ Photoshop Layers; no Photoshop sequence output | good, from Adobe's published spec | +| Illustrator `.ai`, PDF, EPS | R | yes (vector, Continuously Rasterize, Create Shapes) | — | partial: no JPX / JBIG2 images, no Illustrator procset EPS | +| SVG | R | yes | — | good; pasting SVG/AI as shape layers (26.3) missing | +| GIF | R/W | yes | yes (animated) | good | +| BMP | R | yes | — | good | +| WebP | — | yes | — | beyond After Effects | +| Targa `.tga` | R/W | none | none | Media Browser lists it but import refuses (gap 17) | +| DPX / Cineon `.dpx` `.cin` | R/W | none | none | Same; common in film VFX | +| Radiance `.hdr` | R/W | none | none | Same | +| SGI, PICT, ElectricImage, RLA/RPF images | R | none | none | Legacy; RPF camera data import exists (`keys.rpfCameraImport`) | +| Camera Raw (DNG, CR3, NEF…) | R (Camera Raw) | none | none | LightCraft has decoders that could be shared | +| HEIF / AVIF | R (platform) | none | none | — | + +## Footage: video and audio + +| Format | After Effects | Read | Write | Fidelity / notes | +|---|---|---|---|---| +| MP4 / MOV with H.264 | R/W | yes | yes (own encoder, + AAC) | decoder bit-exact on conformance streams (FilmCraft) | +| HEVC (H.265) | R/W | yes | yes (MP4 `hvc1`, Main / Main 10) | encoder bit-exact with ffmpeg's decode; no B-frames / SAO, larger files | +| AV1 | R | yes | yes (MP4 `av01`, WebM) | beyond After Effects' export; no B-frames / CDEF | +| Apple ProRes (422 family, 4444) | R/W | yes | yes | good; 4444 colour tag wrong (#340); no ProRes RAW | +| WebM / Matroska (VP9, AV1, Opus) | R (limited) | yes, incl. VP9 alpha | yes | beyond After Effects | +| MXF (OP1a / OP-Atom) | R/W | none (FilmCraft reads it; not wired) | none | Media Browser lists it | +| AVI | R | none | none | Media Browser lists it | +| DNxHD / DNxHR | R/W | unverified (FilmCraft decodes it; reachable only inside `.mov`) | none | — | +| BRAW, R3D, ARRIRAW | R (plug-ins / SDKs) | none | none | proprietary SDKs | +| Variable-frame-rate phone video | R | unverified | — | needs a real-media corpus | +| WAV, AIFF | R/W | yes | yes (16-bit PCM) | good | +| MP3, AAC / M4A, FLAC, Ogg, Opus | R | yes | AAC and Opus inside video only | MP3 layer can go silent (#324) | + +## 3D models + +| Format | After Effects 26 | Read | Notes | +|---|---|---|---| +| glTF / GLB | R | yes (meshes, PBR, skins, animations) | good | +| OBJ / MTL | R | yes | good | +| FBX | R (`usdFbx`) | none | gap 13 | +| USD / USDZ | R (`usd_plugins`) | none | gap 13 | +| STL | R (`usdStl`) | none | gap 13 | +| Substance 3D materials `.sbsar` | R (26.0) | none | gap 13 | + +## Interchange and data + +| Format | After Effects | Read | Write | Notes | +|---|---|---|---|---| +| Premiere Pro `.prproj` | R (import) | none | none | Out of clean-room scope pending a decision; we interchange through FCP7 XML, FCPXML, OTIO, EDL, AAF and OMF instead (beyond After Effects) | +| Lottie JSON / dotLottie | via third-party plug-ins | yes | yes | beyond After Effects; a warnings list names what Lottie can't express | +| JSON / CSV / TSV data | R | yes (`footage()`, data layers) | — | good | +| LUTs (`.cube`, `.3dl`, `.csp`, `.spi1d/3d`, CDL) | R | yes (Apply Color LUT, OCIO effects) | — | Apply Color LUT bug (#514) | +| OpenColorIO configs | R (OCIO 2.5.1, ACES 2.0 in 26.5) | yes (OCIO v2 subset, Blender configs) | — | partial: no ACES 2.0 output transforms | +| Fonts (TTF, OTF, TTC, variable) | R | yes | — | good | +| Mocha shapes | R (Mocha AE) | own documented JSON | — | Mocha's format is not public | +| Vanishing Point `.vpe` | R | none | — | no public specification; the menu entry explains why | + +## Render queue outputs (`OutputFormat::ALL`, 12) + +H.264 MP4, HEVC MP4, AV1 MP4, ProRes MOV, WebM (VP9 or AV1 + Opus), PNG / JPEG / TIFF / OpenEXR +sequences, animated GIF, WAV, AIFF. Missing against After Effects + Media Encoder: uncompressed +or lossless single-file video (#372), DPX / Targa / Photoshop sequences, MXF, DNxHR, hardware +encoders ([hardware-parity.md](hardware-parity.md)), and rendering safely over an existing file +(#325). + +## Estimate + +**≈ 130–230 h**: `.aep` / `.aepx` import 80–160 h (after the owner decision), wiring TGA / DPX / +HDR / MXF / AVI / DNx import and DPX/TGA output 10–20 h, FBX / USD / STL and Substance 25–45 h, +relinking 6–12 h, `.mogrt`-compatible templates 15–30 h (partly parallel with the rest). + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | First file format parity document, from the After Effects 26.5 bundle and this repository's import/export registries | diff --git a/docs/footage.md b/docs/footage.md index 784a75ad..458c4c99 100644 --- a/docs/footage.md +++ b/docs/footage.md @@ -26,7 +26,7 @@ dialog shows the run it found (and any missing frames) with these options: | Option | What it does | |---|---| -| **PNG Sequence** (EXR, TIFF, JPG… after the file type) | On: the whole run imports as one sequence. Off: just the picked file imports, as a still. | +| **PNG Sequence** (EXR, TIFF, JPG… after the file type) | On: the whole run imports as one sequence. Off (the default, as in After Effects): the picked files import as stills. | | **Force alphabetical order** | Imports every image of that type in the folder in name order, whatever the numbering, and plays the files one after another (gaps in the numbering don't count). Use it for files that aren't numbered consistently. | | **Frame rate (fps)** | The sequence's frame rate. The default comes from Settings ▸ Import ▸ Sequence Footage (30 fps). | @@ -34,10 +34,14 @@ Other ways in: - **Pick part of a run** (Shift-click the first and the last frame in the file dialog) to import just that range as one sequence. -- **Pick or drop every frame** of the run: it arrives as one sequence, not one item per frame. +- **Pick every frame** of the run with the Sequence option on: it arrives as one sequence, not + one item per frame. - **Drop a folder**: its media files import, and each numbered run in it becomes a sequence. -- **In the browser** (the web app) there are no folders to look in, so pick or drop all the - frames of the sequence at once. + Alt-drag the folder to import its files one by one. +- **Dropped files** import as stills, one item each, as in After Effects; drop the folder (or use + File ▸ Import ▸ File… with the Sequence option) for a sequence. +- **In the browser** (the web app) there are no folders to look in, so pick all the frames of + the sequence at once with the Sequence option on. Stills of any format EffectCraft reads can form a sequence: PNG, JPEG, TIFF, OpenEXR, BMP and WebP. Layered and vector documents (PSD, SVG, PDF) import one by one. @@ -48,8 +52,11 @@ Effects does, so every other frame stays at its number. Render or copy the missi folder and choose File ▸ Reload Footage to fill the gaps. Agents: `file.import {"paths": ["/shots/shot_0001.png"], "sequence": true, "frameRate": 24}`. -`sequence` is on by default; `"sequence": false` imports the file as a still, and -`"alphabetical": true` is Force Alphabetical Order. +Without `sequence`, a numbered file brings its whole run and a folder's numbered runs import as +sequences, except dropped files (`"drag": true`), which import as stills as in After Effects. +`"sequence": true` is the Sequence option (one file brings its whole run), `"sequence": false` +imports stills and a folder's files one by one, and `"alphabetical": true` is Force Alphabetical +Order. ## Interpret Footage diff --git a/docs/gaps.md b/docs/gaps.md index e089534e..acb22a63 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,44 +1,87 @@ # Where EffectCraft falls short +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (re-measured against After Effects 2026 26.5; ranked gap list with estimates; numbers reconciled with target-app-parity.md) · **Target:** Adobe After Effects 2026 (26.5.0.89) + An honest assessment of how far EffectCraft is from being a real replacement for After Effects, and the work that closes the gap. This document is meant for contributors and agents choosing -what to work on. The [ROADMAP](../ROADMAP.md) summarises it; [parity.md](parity.md) is the -feature-by-feature checklist it builds on. - -*Assessed 5 October 2026, user issues updated 8 October. Estimates marked "≈" are judgements from the evidence listed, not -measurements. Update this file when the evidence changes.* +what to work on. [target-app-parity.md](target-app-parity.md) holds the numbers and how they were +measured; the [ROADMAP](../ROADMAP.md) summarises both. Estimates marked "≈" are judgements from +the evidence listed, not measurements. Hours are Opus 5.5 agent wall-clock hours, calibrated in +[target-app-parity.md](target-app-parity.md#calibration-of-the-hours). Update this file when the +evidence changes. ## Two different questions -[parity.md](parity.md) reports **≈ 99%**. That number answers one question: *does each After -Effects feature exist?* It scores a 92-item catalogue we wrote ourselves, graded by the same agents -that built the features, with partial features counting half. As a measure of breadth it is fair, -and the breadth is real: every one of After Effects' effects exists by name, along with the -menus, panels, 3D, tracking, expressions, scripting and export. +**Feature breadth ≈ 91%** answers *does each After Effects feature exist?* Every one of After +Effects' effects exists by name, along with the menus, panels, Classic and Advanced 3D, tracking, +expressions, scripting and export. The self-graded 92-item checklist (appendix of +target-app-parity.md) says ≈ 99%; measuring against After Effects 26.5 itself, rather than our +catalogue, finds what that checklist never listed: the 26.2–26.5 features (Object Matte, +Substance materials, ACES 2.0, Proportional Scrubbing, paste SVG/AI as shapes…), the 621 bundled +animation presets, project and template formats, and hardware encoding. -It does not answer the question users care about: *can someone who uses After Effects for a living -do real work in EffectCraft?* Nobody has measured that yet. Our best estimate is **≈ 30–50%**, -limited mainly by four things: projects we can't open, behaviour nobody has checked against After -Effects, reliability on platforms other than macOS, and the third-party plug-ins professional -projects depend on. - -The checklist is also internally inconsistent: parity.md's per-area table still shows Interface -75%, Project 68%, Animation 70% and Paint 0% next to the 99% headline. Some rows are stale; none -of them have been reconciled. +**Ready for real work ≈ 45%** answers *can someone who uses After Effects for a living do real +work in EffectCraft?* It is still estimated, not measured. Four things hold it back: projects we +can't open (`.aep`), behaviour nobody has checked against After Effects (one live comparison so +far: scalar ease, 168 samples), reliability outside macOS, and the third-party plug-ins and +presets professional projects depend on. It sits higher than the 5 October estimate (30–50%) +because 179 issues were closed with regression tests between 5 and 10 October. ## By dimension -| Dimension | Estimate | Evidence | -|---|---|---| -| Breadth of features | ≈ 95%+ | All 306 effects, the After Effects menus and panels, Classic and Advanced 3D, tracking, expressions, scripting, render queue (parity.md) | -| Behaves like After Effects | largely unmeasured, ≈ 60–80% | One outside contributor found four bugs in features marked done within a day (PRs #6–#10): Hold keyframes eased the motion into them, `keyInSpatialTangent` / `keyOutSpatialTangent` had the wrong names, a zero frame rate crashed, Find and Enter Full Screen shared Ctrl+F off macOS. There are about 10 After Effects reference captures in total. The first live G1 comparison now checks eight scalar Rotation/ease cases (168 samples) against AE 26.3x87 via AEsync 2.0.4: all pass after fixing overlapping temporal influences, with maximum error below 3e-11 degrees ([scores and reproduction](fidelity/README.md)). Other animation behavior and rendered frames remain unmeasured. User reports of 6 October found five more in features marked done (the Render Queue's template menu, angle revolutions, switches wiping the RAM preview, audio playback skipping frames, preview crashing when video memory ran out; all fixed 7 October) | -| Opening existing After Effects work | ≈ 0% | `.aep` / `.aepx` projects cannot be opened. Third-party After Effects plug-ins cannot run. Expressions and the scripting object model are strong, so scripts and expressions carry over | -| Stability | improving | 23 never-crash PRs landed on 4 October; community PRs on 7 October fixed a hang on projects whose parent chains loop (#142), a panic on non-ASCII label colours (#135), and contained GPU initialization and device failures (#139); [AGENTS.md](../AGENTS.md) "Never crash" now binds every crate. On 5 October `cargo xtask ci` failed on main under Rust 1.99's clippy (a fix is in progress) | -| Real-user experience | ≈ 70%, uneven by platform | Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the viewer snaps back, panels can't be resized or rearranged, drag-and-drop and double-click import don't work, layer rename gets stuck, the Layer Settings arrow does nothing, the Project panel clips, the Wayland window icon is generic. Issues #63–#68 (macOS, 5 October): scaling a layer by its handles goes wrong and can stick at 0, two 3D compasses, the viewer lags while a layer is dragged (a frame renders in 20 ms but showed after ≈ 100 ms, behind RAM preview prefetch), the Discord and other links do nothing, Delete doesn't delete in the Project panel, hidden layers can be selected in the viewer. All but the last two were fixed or confirmed fixed with a test on 5–6 October (those two have community PRs); file drops still can't work on Wayland (winit has no support). The panning, rename and arrow bugs had been fixed in v0.2.0 already; nobody had told the reporter. A Windows user (6 October, on Discord) found the font menus listed only the three bundled fonts: installed fonts were read only when a project asked for one, on every platform. Fixed with a regression test on 6 October; the menus now list every installed family and its own styles, and agents get `text.fonts` / `list_fonts`. Seven reports of 6 October (Linux .deb and Windows): Project items and files couldn't be dropped on the viewer (#85), nor effects on a layer there (#88); drops in the Timeline ignored where they landed (#89); an angle's revolutions couldn't be edited (#93); toggling Audio, Lock or Shy threw away the RAM preview and playback with audio skipped frames (#103); running out of video memory panicked every frame thread and left frames stuck (#106); choosing a Render Settings template in the Render Queue did nothing (#117: any popup menu moved up to fit the window closed on the press). All seven were fixed with regression tests on 7 October. Reports of 7 October (Linux, macOS, Windows): the app opened the demo project on every launch, so a new comp landed in it (#204); a comp wider than the GPU's texture limit (11000 × 2200) at Full resolution stopped the window drawing (#201); an Intel HD Graphics 5500 couldn't start the app (#198: most likely the window's device asked for limits that GPU doesn't have, and the failure was silent); Audio Spectrum / Waveform stood still in the viewer (#209) and preview sound waited for the whole work area to cache (#208); a layer couldn't be deselected from the time graph, a shape layer's Fill and Stroke showed only with a shape tool and a drawn shape had no stroke to change, Alt+Shift+P didn't reveal Position (#205); a mask selected in the Timeline wasn't selected in the viewer, Mask Feather had no link and took negative values, the Project panel had no selection box (#203); shape layers had no Add menu for Trim Paths and the other operations, and Classic 3D comps hid Geometry Options (#206); dragging Puppet pins re-rendered the layer under the Puppet at every move (#212). All were fixed with regression tests the same day (#219–#222); the #198 fix couldn't be checked on that GPU. Reports of 8 October (browser, Windows, macOS): an empty project at 1280 × 800 with device pixel ratio 2 crashed on its first frame, because the Project panel's scroll bar clamped its thumb to a track shorter than the thumb (#231, any short panel on every platform); Media Browser ▸ Add Files… dropped a file it couldn't read without a word (#226); after the page's WebGPU device was lost, the browser editor froze on its last frame while edits went on (#225); a press in the viewer always took the topmost layer, so a selected layer behind others couldn't be dragged (#230); the language didn't follow the system's (#229); and a list of missing After Effects behaviour (#227): drawing into, copying, pasting and duplicating shape contents, Tool Creates Shape / Mask and Fill / Stroke Options, Turbulent Displace, Wave Warp and Bulge pinning all but a shape layer's bottom-right quadrant, a Spacebar hold that previewed instead of being the Hand tool, the Effect menu in Effect Controls, dropping items on New Composition and dragging over layer switches. All were fixed with regression tests the same day (#235, #237–#241); multi-monitor support and the Project panel's empty-area menu (community PR #210) remain from #227, and effect points on shape and text layers still sit half a comp off. A list of tweaks from an Intel iMac (#290): the layer bar didn't show where extended footage runs past its source, and Enable Time Remapping then ended at the extended out point; footage items had no Interpret Footage in their menu and the work area bar no menu; Label menus named the labels without their colours; the arrow keys didn't nudge layers; a straight motion path had no Bezier handles; Delete on Time Remap deleted the layer; a mask being drawn with the Pen blacked out its layer until it was closed; a double-click on a mask point didn't select the whole mask. All were fixed with regression tests the same day; the rest of #290 had been fixed already (#221, #238, #255) or came with community PRs #210 (the Timeline's empty-area menu) and #213 (tool flyouts on a long press). A report of 9 October (#397): CC Particle World ignored the comp camera and looked flat. It now sees its particles through an active camera layer, as After Effects does, and through its own Extras ▸ Effect Camera (Distance, Rotation X / Y / Z, FOV, added) otherwise; the layer cache also missed camera and light moves for the effects that read them (Card Dance, Shatter, Card Wipe, Caustics). Fixed with regression tests the same day. Most checking happens on macOS. Localisation covers the menus in Japanese, Simplified and Traditional Chinese, and Ukrainian, and since 9 October the cataloged panels, dialogs and tooltips too (Simplified and Traditional Chinese, and Ukrainian; the Japanese rows of the panel catalog are still open); no accessibility work | -| Performance | unknown against After Effects | Internal numbers only (e.g. Advanced 3D 290 ms/frame at 1080p on the GPU, an M4 Pro under load). Nothing benchmarked against After Effects; no large real projects (4K footage, hundreds of layers) tested | -| Media formats | ≈ 80% | H.264, ProRes, HEVC, AV1, image sequences and audio exist. The new HEVC / AV1 encoders have no B-frames, multi-reference or SAO / CDEF, so files are larger than from mature encoders. Camera formats (BRAW, R3D, ProRes RAW, variable-frame-rate phone video) are unverified | -| AI-assisted tools | ≈ 50% | Both tools can use trained models (pure-Rust inference, optional downloads), but nothing compares them with After Effects yet. Roto Brush 2.0 / 3.0 with MobileSAM (M13.35) scores IoU 0.989 on the base frame of our synthetic moving-disc test and ≥ 0.980 over 20 propagated frames (classic: 0.973). Face tracking with MediaPipe Face Landmarker (M13.36) matches Google's own pipeline to 0.85 px on average on a test portrait; on our synthetic clip the eyes and chin stay within 4% of the face height | -| Maturity | early | First commit 1 October 2026. ≈ 285,000 lines of almost entirely agent-written Rust, ≈ 2,050 tests, about 30 external issue reports so far. After Effects has around 30 years of edge cases behind it | +| Dimension | % | Hours | Evidence | +|---|---:|---:|---| +| Breadth of features | ≈ 91% | (below) | All 306 effects, the After Effects menus (587 entries) and panels, Classic and Advanced 3D, tracking, expressions, scripting, render queue. Missing: entries 7, 13, 14, 16 and 21 below | +| Behaves like After Effects | ≈ 50%, mostly unmeasured | 360–660 (features) | The first live G1 comparison checks eight scalar Rotation/ease cases (168 samples) against After Effects 26.3x87 via AEsync 2.0.4: all pass after fixing overlapping temporal influences, maximum error below 3e-11 degrees ([fidelity/README.md](fidelity/README.md)). Nothing else is compared. An outside contributor found four bugs in features marked done within a day (PRs #6–#10); users have found well over a hundred more since, and 134 issues are open | +| Opening existing After Effects work | ≈ 0% | 80–160 | `.aep` / `.aepx` / `.mogrt` / `.ffx` can't be read; After Effects plug-ins can't run. Expressions and the scripting object model are strong, so scripts and expressions carry over | +| UI/UX fidelity | ≈ 65% | 60–120 | [ui-parity.md](ui-parity.md) | +| File formats | ≈ 50% | 130–230 | [file-format-parity.md](file-format-parity.md) | +| Hardware | ≈ 40% | 60–120 | [hardware-parity.md](hardware-parity.md) | +| Localization | ≈ 12% | 60–110 + review | [localization-parity.md](localization-parity.md) | +| Stability | ≈ 45% | 40–80 | 23 never-crash PRs landed on 4 October and the "Never crash" rules bind every crate, but crash reports are open (#580 Chromatic Aberrations pan/tilt, #591 Windows ARM64 device loss, #501 crash on startup, #426 timeline drag while playing, #341 quit on Intel Mac, #306 divider drag, #234 macOS 27 exit, #511 drag over import dialog), and no CI workflow runs `cargo xtask ci` on pull requests | +| Real-user experience | ≈ 60%, uneven by platform | (in G2) | 179 issues closed 5–10 October, nearly all with regression tests ([log below](#real-user-report-log-510-october)); 134 open, many from Linux and Windows. Most checking still happens on macOS | +| Performance | unknown against After Effects, ≈ 35% | 50–100 | Internal numbers only (e.g. Advanced 3D 290 ms/frame at 1080p on the GPU on an M4 Pro). Users report stutter on cached frames (#595, #304), slow scrubbing (#329), late audio (#554). No 4K / hundreds-of-layers project tested | +| Media formats | ≈ 75% | (in file formats) | H.264, ProRes, HEVC, AV1, VP9, image sequences and audio exist. The HEVC / AV1 encoders have no B-frames, multi-reference or SAO / CDEF, so files are larger than from mature encoders. Camera formats (BRAW, R3D, ProRes RAW, variable-frame-rate phone video) are unverified | +| AI-assisted tools | ≈ 35% | 40–80 | Roto Brush 2.0 / 3.0 with MobileSAM (M13.35) scores IoU 0.989 on the base frame of our synthetic moving-disc test and ≥ 0.980 over 20 propagated frames (classic: 0.973). Face tracking with MediaPipe Face Landmarker (M13.36) matches Google's own pipeline to 0.85 px on a test portrait. Neither is compared with After Effects; Object Matte (26.2) is missing | +| Maturity | early | — | First commit 1 October 2026. ≈ 329,000 lines of almost entirely agent-written Rust, 2,704 tests, 313 GitHub issues. After Effects has around 30 years of edge cases behind it | + +## Every known gap, ranked + +**Alpha blockers: none.** Every core workflow passes the [alpha gate](roadmap.md#alpha-gate); the +entries below are what stands between alpha and beta (gap 1 decides beta). The rough edges the +gate lists live in gaps 5, 8, 9, 10, 11 and 17. + +Ranked by user impact × how many users hit it. Each entry: what's missing, evidence, impact, +estimate, the workstream (G1–G9, below) and the parity doc it belongs to. Small single-issue bugs +are grouped by area; the issue tracker has the rest. + +| # | Gap | Evidence | Impact | Estimate | Work · doc | +|---:|---|---|---|---:|---| +| 1 | **`.aep` / `.aepx` projects can't be opened** | No reader; candidate route py-aep (#248, MIT, external converter) awaits an owner decision on clean-room scope; #190 | The biggest barrier to switching: existing work, templates and client projects stay in After Effects | 80–160 h | G4 · [file-format-parity.md](file-format-parity.md) | +| 2 | **Fidelity unmeasured** beyond scalar ease | [fidelity/](fidelity/README.md): 168 samples, one property type; `compare_frames` helper exists, no corpus | Every "done" feature may still differ; users find the differences | 60–100 h harness + P0/P1 corpus; more per feature | G1 · [target-app-parity.md](target-app-parity.md) | +| 3 | **Open crash reports** | #580, #591, #501, #426, #341, #306, #234, #511 | A crash loses work; outranks features ([AGENTS.md](../AGENTS.md)) | 10–25 h | G3 · this file | +| 4 | **No CI gate on pull requests** | `.github/workflows` has release, packaging-lint, FreeBSD and Windows ARM64 jobs only; `cargo xtask ci` runs locally | Regressions reach main unseen; 49 community PRs landed in one batch (#598) | 4–8 h | G3 · this file | +| 5 | **Playback and scrubbing feel** | #595 stutter on cached frames, #304, #329 slow scrubbing, #554 / #504 / #335 audio late or crackling, #586 puppet lag | Preview is where motion designers spend their day | 25–50 h | G5 · [hardware-parity.md](hardware-parity.md) | +| 6 | **Linux and Windows interaction bugs** | #491 multi-layer edits, #488 rename arrows, #493, #492, #467 shortcuts, #268 drag and drop, #320 workspaces not persisting, #302 docking, #613 Direct3D 12 compositor, #596 GPU not detected | Many reports come from Linux; some Windows GPUs fail to start the compositor | 25–45 h | G2 · [ui-parity.md](ui-parity.md) | +| 7 | **Animation presets: 8 against 621** | After Effects ships 621 `.ffx` presets (Behaviors, Backgrounds, Shapes, Synthetics, Text, Transitions…); we ship 8 original text-animator presets and read only our `.ecpreset` | Presets are how many designers start; tutorials assume them | 20–40 h (original presets; `.ffx` reading is out of clean-room scope) | G8 · [effects-parity.md](effects-parity.md) | +| 8 | **Mask and shape bugs** | #510, #513, #527 (first Intersect/Darken mask), #509 Roto Brush size, #303 Mesh Warp, #553 pen tools on motion paths | Masks are core compositing | 10–20 h | G2 · [target-app-parity.md](target-app-parity.md) | +| 9 | **Effect behaviour bugs** | #580, #526 Fractal reset, #514 Apply Color LUT, #463 Curl Noise, #369 Motion Tile output size, #473 Cryptomatte selection, #474 multi-layer EXR decoded twice, #534–#536 community fixes pending | Each one breaks a look someone built | 10–20 h | G1/G2 · [effects-parity.md](effects-parity.md) | +| 10 | **Colour pipeline differences** | #494 32-bpc blend modes clamp and apply opacity differently; #495 no Working Gamma 2.4; #340 ProRes 4444 tagged bt709 but sRGB-encoded; ACES 2.0 / OCIO 2.5 configs (26.5) missing; #546 / #547 viewer colour | Wrong colour in delivery is a client-visible failure | 15–30 h | G1 · [file-format-parity.md](file-format-parity.md) | +| 11 | **Text rendering and layout** | #453 CJK text in comps, #316 paragraph alignment inverted, #483 squeezed at 4:3, #417 soft at Fit, #583 font search, #550 toolbar font options | Text is in almost every motion graphic | 10–20 h | G2 · [ui-parity.md](ui-parity.md) | +| 12 | **3D interaction and depth** | No 3D transform gizmos (#392, #347); Z position (#489); camera aperture (#260); reflections (#261); bevel options (#315); 3D issue reopened (#307) | Advanced 3D is After Effects' growth area since 2023 | 20–35 h | G2 · [ui-parity.md](ui-parity.md) | +| 13 | **3D model formats and materials** | After Effects 26 imports FBX, USD/USDZ, STL besides glTF and OBJ (`usd_plugins`), and Substance 3D materials with mesh displacement (26.0–26.2); we read glTF and OBJ | Product shots and 3D scenes come as FBX/USDZ | 25–45 h | — · [file-format-parity.md](file-format-parity.md) | +| 14 | **Object Matte** (26.2, disk cache in 26.5) | Not present; MobileSAM is already in `effectcraft-segment` and could back it | The new one-click roto; Roto Brush covers part of it | 15–30 h | G7 · [target-app-parity.md](target-app-parity.md) | +| 15 | **Hardware video decode and encode** | No VideoToolbox, NVENC, Quick Sync or VA-API; #498 asks for it; FilmCraft has VideoToolbox / NVENC / VA-API paths to borrow | 4K playback and export speed | 25–45 h | G5/G6 · [hardware-parity.md](hardware-parity.md) | +| 16 | **Localization depth** | Menus in 6 languages; panels in 4; effect names and ~1,650 effect parameter names untranslated; French, German, Korean, Italian, Russian (After Effects ships them) absent; #599 Hangul boxes on macOS | After Effects is used heavily in Japan, China, Korea, Brazil, Europe | 60–110 h + native review | G9 · [localization-parity.md](localization-parity.md) | +| 17 | **Media Browser lists files import refuses** | `media_browser.rs` lists TGA, DPX, HDR, AVI, MXF; `media::is_importable` has none of them; #431 any image imports as a sequence; #324 MP3 goes silent | Confusing failures on common VFX formats (DPX, TGA) | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) | +| 18 | **Relative paths and relinking** | Projects store absolute footage paths; no relink when a project moves | Moving or sharing a project breaks it | 6–12 h | G4 · [file-format-parity.md](file-format-parity.md) | +| 19 | **Plug-in ecosystem** | No After Effects SDK hosting (#600, #327 OpenFX, #334, #376 plug-in manager); our WebAssembly API has no third-party plug-ins | Many pro projects depend on Trapcode, Element, Saber… | 40–80 h for API growth; SDK hosting needs an owner decision | G8 · [scripting-parity.md](scripting-parity.md) | +| 20 | **Multi-monitor and video output** | #486 multi-screen, floating panels on other screens, Mercury Transmit to video hardware (we have a second preview window only) | Two-screen setups are standard for professionals | 10–20 h | G2 · [hardware-parity.md](hardware-parity.md) | +| 21 | **Small 26.x features** | Proportional Scrubbing (26.2), copy frame to clipboard and paste SVG/AI as shapes (26.3), variable-font filter (26.3), percentage guides and effect colour labels (26.5), the rebuilt Effect Controls (26.5) | Users of the current version expect them | 15–25 h | — · [ui-parity.md](ui-parity.md) | +| 22 | **Encoder efficiency** | HEVC / AV1 have no B-frames, multi-reference or SAO / CDEF; Opus no FEC / DTX; #372 uncompressed single-file output; #325 render over existing file | Larger files than After Effects + Media Encoder | 20–40 h | G6 · [file-format-parity.md](file-format-parity.md) | +| 23 | **Essential Graphics interchange** | Templates are `.ectemplate`, not `.mogrt`; #338 Scale exposes X and Y; #611 FilmCraft rejects exported `.fcgt` | Premiere editors can't use our templates | 15–30 h | — · [file-format-parity.md](file-format-parity.md) | +| 24 | **Timeline depth requests** | #601 expand precomps in place, #602 reorderable render stack, #604 guide-only effects, #355 bulk keyframes, #425 keyframe confusion, #428 more shortcuts | Power-user speed | 15–30 h | G2 · [ui-parity.md](ui-parity.md) | +| 25 | **Accessibility** | AccessKit is enabled in eframe and tests read focus labels, but no screen-reader pass, keyboard navigation audit or contrast check | Excludes users; After Effects supports screen readers poorly too | 15–30 h | G9 · [ui-parity.md](ui-parity.md) | ## Where we're going: workstreams in priority order @@ -55,11 +98,11 @@ The most important missing piece: it turns every other estimate here into a meas - Drive After Effects through ExtendScript (see CLAUDE.md) to record property values at sampled times and render reference frames; render the same projects in EffectCraft headless. - Compare values exactly and frames with a perceptual metric; publish a per-feature fidelity - score and a fidelity column in parity.md. + score and a fidelity column in target-app-parity.md. - Clean room: After Effects output stays local in `plan/aftereffects/ref/` (gitignored). Commit only our projects, the harness and the scores, never After Effects frames. - Done when: every P0 and P1 feature has at least one corpus project, scores are reproducible from - one command, and parity.md reports breadth and measured fidelity side by side. + one command, and target-app-parity.md reports breadth and measured fidelity side by side. ### G2. Real-user reliability on every platform @@ -143,6 +186,12 @@ The most important missing piece: it turns every other estimate here into a meas - Done when: no open bug blocks a basic workflow (import, arrange, animate, preview, render) on any of the three desktop platforms, and every bug users report gets triaged within a day. +### Real-user report log (5–10 October) + +Kept from the 5 October assessment's "Real-user experience" row, as evidence of what users hit and what was fixed: + +Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the viewer snaps back, panels can't be resized or rearranged, drag-and-drop and double-click import don't work, layer rename gets stuck, the Layer Settings arrow does nothing, the Project panel clips, the Wayland window icon is generic. Issues #63–#68 (macOS, 5 October): scaling a layer by its handles goes wrong and can stick at 0, two 3D compasses, the viewer lags while a layer is dragged (a frame renders in 20 ms but showed after ≈ 100 ms, behind RAM preview prefetch), the Discord and other links do nothing, Delete doesn't delete in the Project panel, hidden layers can be selected in the viewer. All but the last two were fixed or confirmed fixed with a test on 5–6 October (those two have community PRs); file drops still can't work on Wayland (winit has no support). The panning, rename and arrow bugs had been fixed in v0.2.0 already; nobody had told the reporter. A Windows user (6 October, on Discord) found the font menus listed only the three bundled fonts: installed fonts were read only when a project asked for one, on every platform. Fixed with a regression test on 6 October; the menus now list every installed family and its own styles, and agents get `text.fonts` / `list_fonts`. Seven reports of 6 October (Linux .deb and Windows): Project items and files couldn't be dropped on the viewer (#85), nor effects on a layer there (#88); drops in the Timeline ignored where they landed (#89); an angle's revolutions couldn't be edited (#93); toggling Audio, Lock or Shy threw away the RAM preview and playback with audio skipped frames (#103); running out of video memory panicked every frame thread and left frames stuck (#106); choosing a Render Settings template in the Render Queue did nothing (#117: any popup menu moved up to fit the window closed on the press). All seven were fixed with regression tests on 7 October. Reports of 7 October (Linux, macOS, Windows): the app opened the demo project on every launch, so a new comp landed in it (#204); a comp wider than the GPU's texture limit (11000 × 2200) at Full resolution stopped the window drawing (#201); an Intel HD Graphics 5500 couldn't start the app (#198: most likely the window's device asked for limits that GPU doesn't have, and the failure was silent); Audio Spectrum / Waveform stood still in the viewer (#209) and preview sound waited for the whole work area to cache (#208); a layer couldn't be deselected from the time graph, a shape layer's Fill and Stroke showed only with a shape tool and a drawn shape had no stroke to change, Alt+Shift+P didn't reveal Position (#205); a mask selected in the Timeline wasn't selected in the viewer, Mask Feather had no link and took negative values, the Project panel had no selection box (#203); shape layers had no Add menu for Trim Paths and the other operations, and Classic 3D comps hid Geometry Options (#206); dragging Puppet pins re-rendered the layer under the Puppet at every move (#212). All were fixed with regression tests the same day (#219–#222); the #198 fix couldn't be checked on that GPU. Reports of 8 October (browser, Windows, macOS): an empty project at 1280 × 800 with device pixel ratio 2 crashed on its first frame, because the Project panel's scroll bar clamped its thumb to a track shorter than the thumb (#231, any short panel on every platform); Media Browser ▸ Add Files… dropped a file it couldn't read without a word (#226); after the page's WebGPU device was lost, the browser editor froze on its last frame while edits went on (#225); a press in the viewer always took the topmost layer, so a selected layer behind others couldn't be dragged (#230); the language didn't follow the system's (#229); and a list of missing After Effects behaviour (#227): drawing into, copying, pasting and duplicating shape contents, Tool Creates Shape / Mask and Fill / Stroke Options, Turbulent Displace, Wave Warp and Bulge pinning all but a shape layer's bottom-right quadrant, a Spacebar hold that previewed instead of being the Hand tool, the Effect menu in Effect Controls, dropping items on New Composition and dragging over layer switches. All were fixed with regression tests the same day (#235, #237–#241); multi-monitor support and the Project panel's empty-area menu (community PR #210) remain from #227, and effect points on shape and text layers still sit half a comp off. A list of tweaks from an Intel iMac (#290): the layer bar didn't show where extended footage runs past its source, and Enable Time Remapping then ended at the extended out point; footage items had no Interpret Footage in their menu and the work area bar no menu; Label menus named the labels without their colours; the arrow keys didn't nudge layers; a straight motion path had no Bezier handles; Delete on Time Remap deleted the layer; a mask being drawn with the Pen blacked out its layer until it was closed; a double-click on a mask point didn't select the whole mask. All were fixed with regression tests the same day; the rest of #290 had been fixed already (#221, #238, #255) or came with community PRs #210 (the Timeline's empty-area menu) and #213 (tool flyouts on a long press). A report of 9 October (#397): CC Particle World ignored the comp camera and looked flat. It now sees its particles through an active camera layer, as After Effects does, and through its own Extras ▸ Effect Camera (Distance, Rotation X / Y / Z, FOV, added) otherwise; the layer cache also missed camera and light moves for the effects that read them (Card Dance, Shatter, Card Wipe, Caustics). Fixed with regression tests the same day. Most checking happens on macOS. Localisation covers the menus in Japanese, Simplified and Traditional Chinese, and Ukrainian, and since 9 October the cataloged panels, dialogs and tooltips too (Simplified and Traditional Chinese, and Ukrainian; the Japanese rows of the panel catalog are still open); no accessibility work + ### G3. Stability and a green main - Keep `cargo xtask ci` green on the current stable toolchain. A toolchain release that breaks the @@ -150,7 +199,10 @@ The most important missing piece: it turns every other estimate here into a meas - Fuzz the inputs we don't control: project files, imported media and documents, expressions, scripts, control-channel and MCP requests. Every crash found becomes a regression test (AGENTS.md "Never crash"). -- Done when: the gate is green on stable and the fuzzers run regularly without new crashes. +- Run the gate on every pull request in CI (gap 4): today no workflow runs the tests on PRs. +- Fix the open crash reports first (gap 3). +- Done when: the gate is green on stable, runs on every pull request, and the fuzzers run + regularly without new crashes. ### G4. Open After Effects projects @@ -163,7 +215,7 @@ The most important missing piece: it turns every other estimate here into a meas `.ecproj`, the way ffmpeg is used only from outside), or its format knowledge could be ported. The second means reading another implementation of the format, which AGENTS.md rules out unless the clean-room scope allows it. -- Also in this area: relative footage paths and relinking when a project moves (parity.md, Project). +- Also in this area: relative footage paths and relinking when a project moves (gap 18). - Done when: the decision is recorded and, if approved, a corpus of real-world-shaped projects opens with its comps, layers, keyframes, effects and expressions intact. @@ -211,19 +263,34 @@ The most important missing piece: it turns every other estimate here into a meas ScriptUI panel bundled as an optional extension (`extensions/scriptui-panels/Ease Presets.jsx`, Window ▸ Ease Presets.jsx) on the public scripting API, which needed `app.settings` that last, per-key methods that keep the key selection, and eases reported as they play - ([plugins.md](plugins.md#bundled-extensions), [parity.md](parity.md)). + ([plugins.md](plugins.md#bundled-extensions), [target-app-parity.md](target-app-parity.md)). ### G9. Reach -- Localisation of the interface, accessibility (keyboard navigation, screen readers, contrast), - user documentation and tutorials. +- Localisation of the interface ([localization-parity.md](localization-parity.md)): since 6–10 + October the menus are translated into Japanese, Simplified and Traditional Chinese, Ukrainian, + Spanish and Brazilian Portuguese, and the panel catalog covers the first four. Next: effect and + parameter names, the remaining panel strings, French, German and Korean, and native-speaker + review of all of them. +- Accessibility (keyboard navigation, screen readers, contrast), user documentation and + tutorials. ## For agents choosing work 1. Read `plan/STATUS.md` for owner blockers and running work, then this file. 2. Prefer G1–G3 over new features. A feature that exists but behaves differently from After Effects is not done. -3. When you fix a behaviour bug in a feature parity.md marks as done, add it to the evidence - above. That is how this assessment stays honest. -4. Don't raise parity.md's numbers without evidence: a G1 score, a user-facing check on more than +3. When you fix a behaviour bug in a feature target-app-parity.md marks as done, add it to the evidence + above, and update its row in the ranked list (remove it when the gap is closed). That is how this assessment stays honest. +4. Don't raise target-app-parity.md's numbers without evidence: a G1 score, a user-facing check on more than one platform, or both. + + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Alpha gate checked: no alpha blockers | +| 2026-10-10 | major | Re-measured against After Effects 2026 26.5: two numbers (breadth ≈ 91%, ready ≈ 45%), dimension table with hours, a ranked list of 25 gaps with evidence, impact, estimate and parity doc; the real-user evidence moved into a report log under G2; links follow the rename of `parity.md` to `target-app-parity.md` | +| 2026-10-08 | minor | User issues of 6–8 October and their regression evidence | +| 2026-10-05 | major | First honest assessment: breadth ≈ 99% vs real use ≈ 30–50%; workstreams G1–G9 | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md new file mode 100644 index 00000000..922f75e6 --- /dev/null +++ b/docs/hardware-parity.md @@ -0,0 +1,42 @@ +# Hardware parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first hardware parity document, from the code and open issues against After Effects 2026 26.5) · **Target:** Adobe After Effects 2026 + +Each piece of hardware After Effects uses, per platform, against EffectCraft. Ours comes from the +code at `origin/main` 1446e27 (`crates/gpu`, `crates/render`, `crates/ui-egui`) and the issue +tracker; After Effects' from its documentation (Mercury GPU Acceleration, Multi-Frame Rendering, +hardware-accelerated decode and encode, Mercury Transmit) and its bundle. + +**Overall ≈ 40%** (estimated). The GPU compositor and 280 GPU effects are a real strength and run +in the browser too; hardware video decode and encode, video-out devices, tablets and multiple +monitors are missing, and two GPU start-up failures are open. + +| Feature | After Effects 2026 | EffectCraft macOS | Windows | Linux | Web | Notes / evidence | +|---|---|---|---|---|---|---| +| GPU compositing and effects | Mercury GPU (Metal; CUDA / OpenCL / DirectX on Windows) | yes, Metal via wgpu | Vulkan yes; **Direct3D 12 fails** (FXC can't compile the `pointwise` kernel, #613) | Vulkan | WebGPU, in frame workers | 280 of 306 effects on the GPU, checked against the CPU renderer; Fractal and the analysis/tool effects stay on the CPU ([effects-parity.md](effects-parity.md)) | +| CPU fallback and device-loss recovery | yes | yes | yes; Windows ARM64 Adreno panics after device loss (#591) | yes | yes (#225 fixed) | Out of video memory falls back to the CPU (#106) | +| GPU detection and choice | GPU info, renderer choice | Backend Auto per comp | "GPU isn't being detected" (#596); Intel HD 5500 fix unverified (#198) | yes | yes | No settings page listing adapters yet | +| Multi-core rendering | Multi-Frame Rendering | rayon pools; preview scheduler sized to the render pool | same | same | Web Workers, one engine per worker | No benchmark against Multi-Frame Rendering | +| Hardware video decode | H.264 / HEVC / ProRes (Apple silicon) | none (software, FilmCraft) | none | none | none | FilmCraft has VideoToolbox, Media Foundation/DXVA and VA-API paths to borrow (gap 15) | +| Hardware video encode | H.264 / HEVC via VideoToolbox, NVENC, Quick Sync (render queue and Media Encoder) | none | none | none | none | #498 asks for NVENC; our software encoders are slower and lack B-frames | +| Video output devices | Mercury Transmit to AJA, Blackmagic, second display | second preview window only (Video Preview) | same | same | — | No SDI/HDMI device output | +| HDR display | HDR monitoring on EDR / HDR displays | tone-mapped SDR view; no EDR output | no HDR output | no | no | Colour engine handles HDR maths (Rec. 2100 PQ/HLG output files) | +| Pen tablets (pressure, tilt) | yes, for Paint and Roto | Brushes panel has pressure options, but no tablet pressure is read | same | same | same | Paint strokes store pressure per vertex; input never fills it | +| Multiple monitors | panels and windows across screens | floating panels within one window only | same | same (#486) | — | gap 20 | +| Touch / trackpad gestures | pinch zoom in viewers | egui pointer and scroll; no pinch-to-zoom audited | — | — | — | — | +| Audio hardware | Core Audio / ASIO / WASAPI, device choice, latency | cpal output; preview sound late on Linux (#554) and after a start offset (#504) | cpal | cpal (ALSA) | Web Audio | No input or device latency settings | +| Machine learning acceleration | Apple Neural Engine / GPU for Object Matte, Roto Brush 3 | CPU inference (pure Rust, rayon) | CPU | CPU | CPU | No GPU inference for MobileSAM or MediaPipe | +| Memory management | RAM reserve, disk cache | RAM Reserved, Reduce Cache When Low on Memory, disk cache | same | same | OPFS disk cache | good | + +## Estimate + +**≈ 60–120 h**: Direct3D 12 and adapter start-up fixes 6–12 h; hardware decode on three platforms +20–35 h and encode 15–30 h (borrowing FilmCraft's work); multiple monitors 10–20 h; tablet pressure +4–8 h; HDR output 8–15 h. Needs Windows and Linux GPUs we don't have locally, and a human for +the Adreno and Intel cases. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | First hardware parity document | diff --git a/docs/localization-parity.md b/docs/localization-parity.md new file mode 100644 index 00000000..ff70ca62 --- /dev/null +++ b/docs/localization-parity.md @@ -0,0 +1,89 @@ +# Localization parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first measurement of every catalog against the After Effects 2026 26.5 language set) · **Target:** Adobe After Effects 2026 + +How much of EffectCraft's interface is translated, language by language, compared with After +Effects. [gaps.md](gaps.md) gap 16 and workstream G9 hold the work; +[target-app-parity.md](target-app-parity.md) the overall numbers. + +## What After Effects ships + +The installed After Effects 2026 (26.5.0.89) bundle has 11 `.lproj` localisations: English, +French, German, Italian, Japanese, Korean, Portuguese (Brazil), Russian, Spanish, Simplified +Chinese (`zh_CN`) and Traditional Chinese (`zh_TW`). Each translates the whole interface, +including effect names and parameters. It ships no Hindi, Arabic, Indonesian or Vietnamese +interface; Arabic and Hebrew text *in layers* is supported through its Middle Eastern text engine. + +## How EffectCraft translates + +- **Menus:** `crates/ui-egui/src/i18n.rs`, one catalog per language keyed by + `(command, English label)`. A test (`every_translation_key_exists_in_the_actual_menu_tree`) + fails if any fixed menu entry is untranslated or a row is stale, so every catalog covers all + **618** fixed entries. The generated Effect menu (effect names) is deliberately left in English. +- **Panels, dialogs, buttons, tooltips:** `crates/ui-egui/src/i18n/ui.rs`, keyed by the English + source string, with English fallback. Catalogs exist for Japanese, Simplified and Traditional + Chinese and Ukrainian. +- **Not translated anywhere yet:** effect names and their ≈ 1,650 parameter names + (`crates/effects`), engine-generated text (property names, errors, undo labels), the Home + screen's unwrapped literals (#574), Learn tutorials and documentation. +- **Language choice:** Settings ▸ General ▸ Language, or Match System + (`match_system_uses_the_system_language_where_there_is_a_catalog`). +- **Fonts:** no bundled CJK font; the UI installs system fallbacks probed by script (Kana, Han, + Arabic, Hebrew, Devanagari, Bengali, Gurmukhi, Gujarati, Tamil, Telugu, Kannada, Malayalam, + Thai: `crates/ui-egui/src/theme.rs`). There is no Hangul probe, so Korean renders as boxes on + macOS (#599). The web build has no system fonts. +- **Script support in the interface:** egui draws text without complex shaping or right-to-left + layout, so an Arabic or Hindi interface would render unshaped and left to right. **In text + layers** (`crates/text`) shaping (harfrust), bidi (UAX #9), vertical text and Tate-Chu-Yoko + work; text-layer typing goes through the IME (`viewer_text.rs` handles preedit and commit). + +## Measurement + +Measured 10 October 2026 at `origin/main` 1446e27 by counting catalog rows. The denominator, +**≈ 3,900 user-visible strings**, is estimated: 618 fixed menu entries + ≈ 1,320 capitalised UI +literals in `crates/ui-egui/src` outside tests and catalogs (a regex count, so an upper bound) + +≈ 1,650 distinct effect parameter names + 306 effect names. Engine messages are not counted, so +the true percentages are slightly lower. + +| Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | To `full` | +|---|---|---|---|---|---|---|---| +| English | `en` | ≈ 3,900 (100%, source) | All; docs in English | — | yes | full | — | +| Simplified Chinese | `zh-hans` | 1,081 (≈ 28%): menus 618/618, panels 463 | Panels and dialogs partly (≈ 35% of panel strings); no help, effects untranslated; Home screen literals unwrapped (#574) | System CJK fallback; Han follows the UI language; CJK text in comps renders wrongly (#453); IME in text layers | no | partial | 6–9 h + review | +| Spanish | `es` | 618 (≈ 16%) | Menus only | Latin, fine | no | menus only | 7–10 h + review | +| Hindi | `hi` | 0 | — | UI: Devanagari fallback font but no shaping; text layers shape Devanagari | no | none (After Effects: none) | 9–13 h + shaping in the UI | +| Arabic | `ar` | 0 | — | UI: Arabic fallback font but no shaping and no RTL layout; text layers: bidi + shaping | no | none (After Effects: none) | 9–13 h + 15–25 h RTL UI | +| French | `fr` | 0 | — | Latin, fine | no | none | 8–12 h + review | +| Portuguese (Brazil) | `pt-br` | 618 (≈ 16%) | Menus only | Latin, fine | no | menus only | 7–10 h + review | +| Indonesian | `id` | 0 | — | Latin, fine | no | none (After Effects: none) | 8–12 h + review | +| Japanese | `ja` | 1,015 (≈ 26%): menus 618/618, panels 397 | Panels and dialogs partly (≈ 30% of panel strings) | Japanese system fallback; vertical text and Tate-Chu-Yoko in text layers; IME | no | partial | 6–9 h + review | +| German | `de` | 0 | — | Latin, fine | no | none | 8–12 h + review | +| Korean | `ko` | 0 | — | No Hangul fallback probe: boxes on macOS (#599) | no | none | 9–13 h + review | +| Vietnamese | `vi` | 0 | — | Latin with stacked diacritics; untested | no | none (After Effects: none) | 8–12 h + review | + +Other languages EffectCraft ships (2): + +| Language | Code | UI strings translated | Status | To `full` | +|---|---|---|---|---| +| Traditional Chinese | `zh-hant` | 1,081 (≈ 28%): menus 618/618, panels 463 | partial | 6–9 h + review | +| Ukrainian | `uk` | 1,015 (≈ 26%): menus 618/618, panels 397 | partial | 6–9 h + review | + +After Effects also ships Italian and Russian, which EffectCraft does not (8–12 h each). + +**Overall: ≈ 12%**, the average coverage of After Effects' 10 non-English languages +(Simplified and Traditional Chinese 28%, Japanese 26%, Spanish and Portuguese 16%, French, +German, Korean, Italian and Russian 0%). This is the localization row in +[target-app-parity.md](target-app-parity.md#by-dimension). + +**Effort.** One-time work first: route effect names, parameter names and engine messages through +a catalog, wrap the remaining panel literals, and add a Hangul probe (10–20 h). Then each language +needs ≈ 2,900 more strings (≈ 5–9 h per language at the pace of #398 and #401). Parity with After +Effects' 10 non-English languages: **≈ 60–110 h**; the four extra languages of the standard list +(Hindi, Arabic, Indonesian, Vietnamese) add ≈ 50–80 h, most of it right-to-left layout and +complex shaping in the interface. Every language needs native-speaker review, which only a human +can give; none has had it. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | First localization parity document: catalog rows counted for all six languages, the twelve-language table, After Effects' 11 shipped languages from its bundle | diff --git a/docs/mcp.md b/docs/mcp.md index 0d7f83e9..1104927a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3,6 +3,19 @@ Run `effectcraft-cli mcp` for a headless session, or `effectcraft-cli mcp --bridge 9877` to use the desktop app's control channel. Existing ports are unchanged. +## Confining file access + +`--automation-read-root DIR` and `--automation-write-root DIR` (each repeatable, as in +PhotoCraft) confine what the agent can reach: files are read only under a read or write root and +written only under a write root; with no write root nothing is written. Every engine command +(`execute_command`, `command_run`, each `batch` step) is checked before it runs: its file +parameters must be absolute paths inside the roots (symbolic links resolved, no `..`), and so must +any other absolute path in its parameters. Save, Increment and Save and rendering the Render +Queue are checked against the files they write (the open project's, the queued outputs). Scripts +(`run_script`, `file.runScript`…), Execute File, Reveal in Finder, `$N` references to file paths +in a batch, and the bridge's UI-driving tools (`ui_click`, `ui_key`, `ui_type`, `ui_drag`, +`ui_set`, `script_ui`, `control`) are refused while confined. Without the flags nothing changes. + The server provides the same core conventions as FilmCraft #28: | Tool | Arguments | Result | diff --git a/docs/preferences.md b/docs/preferences.md index 3e3fa878..157eca99 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -38,7 +38,7 @@ Commands (CLI, MCP, control channel): - `general.defaultSpatialLinear`: Default Spatial Interpolation to Linear - `startup.showHomeOnLaunch`: Show Home Screen When Launching - `startup.offerCrashRecovery`: Offer to Open the Latest Auto-Save After a Crash -- `startup.windowGraphics`: Window Graphics (`auto` / `gl`), what the desktop window draws with from the next launch. `auto` lets the platform pick (DirectX 12, Vulkan or Metal); `gl` uses OpenGL, with CPU compositing, for graphics drivers that crash with the others. A launch whose window never drew leaves a `launch-pending` marker in the settings folder, and the next launch switches to `gl` and says so (not on macOS, which has no OpenGL backend). `WGPU_BACKEND` overrides both. +- `startup.windowGraphics`: Window Graphics (`auto` / `gl`), what the desktop window draws with from the next launch. `auto` lets the platform pick (DirectX 12, Vulkan or Metal); `gl` uses OpenGL, with CPU compositing, for graphics drivers that crash with the others. A launch leaves a `launch-pending` marker in the settings folder until its window has been drawing for a few seconds (or the app quits normally before that); a launch that stops earlier (a graphics driver crashing as the window opens or as its surface is re-created) leaves the marker behind, and the next launch switches to `gl` and says so (not on macOS, which has no OpenGL backend). `WGPU_BACKEND` overrides both. - `project.useTemplate`: New Project Loads Template - `project.templatePath`: Template Project - `autoSave.enabled`: Automatically Save Projects @@ -118,7 +118,7 @@ Commands (CLI, MCP, control channel): size and modification time with what they were, and reloads what another app saved: a Photoshop file edited in PhotoCraft or Photoshop shows its new pixels without File ▸ Reload Footage. It is not an undo step and doesn't modify the project; a layer item stays on its layer - (found again by name), and a file still being written is tried again on the next check. + (found again by name), and a file that can't be read yet (still being written) is tried again once it changes again. `footage.reloadChanged` runs the same check once (CLI, agents) - `import.unlabeledAlpha`: alpha of TGA / TIFF / movie footage (Ask opens Interpret Footage; Guess takes premultiplied for movies, straight for stills) diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 00000000..73fdfd23 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,87 @@ +# Roadmap detail + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (new file: milestones moved from ROADMAP.md, current focus and next steps with Opus 5.5 estimates) · **Target:** Adobe After Effects 2026 + +Forward-looking detail behind the [ROADMAP](../ROADMAP.md). The work list itself, one gap at a +time, is [gaps.md](gaps.md); the numbers are in [target-app-parity.md](target-app-parity.md). +There is no `cargo xtask scorecard` or `cargo xtask parity` in this repository yet. + +## Current focus + +In this order (gap numbers from [gaps.md](gaps.md)): + +1. **Crashes and the CI gate** (gaps 3–4, G3): fix the eight open crash reports; run + `cargo xtask ci` on every pull request. 15–30 h. +2. **Fidelity harness** (gap 2, G1): extend the AEsync ease oracle to rendered frames; a corpus + case for every P0 feature, then P1. 60–100 h; needs After Effects running on the owner's Mac. +3. **Real-user reliability** (gaps 5, 6, 8, 9, 11, G2): the open Linux and Windows interaction, + mask, effect and text bugs, each with a scripted test. 60–120 h, parallel. +4. **Owner decision on `.aep`** (gap 1, G4): then the importer. 80–160 h after the decision. +5. **Performance** (gap 5, G5): benchmarks at 1080p and 4K; real-time cached playback. 40–70 h. + +## Alpha gate + +The core workflows of a motion designer in After Effects, checked on EffectCraft's main platform +(macOS) from the scripted UI tests, the live After Effects ease oracle and the open issues +(standard: craftrules `standards/progress-docs.md`, "Stages"). **All six pass, so EffectCraft is +alpha**: each works end to end and the work saves and reopens; what's rough in them is listed, but +none blocks the workflow. + +| Workflow | Works end to end? | Evidence | Hours to clear the rough edges | +|---|---|---|---| +| 1. Set up: new comp, import footage, stills, PSD/AI and audio, arrange and trim layers | yes | `file.import` and image-sequence tests, `ui_drag_drop`, `ui_project_*`; rough: a still imports as a sequence (#431), an MP3 layer can go silent (#324), multi-file drops (#552) | 2–4 | +| 2. Animate: keyframes, easing in the Graph Editor, motion paths, parenting | yes | 168 live samples match After Effects 26.3 ([fidelity/](fidelity/README.md)); `ui_keyframes`; rough: Graph Editor jitter and clipped buttons (#456, #457), negative rotation drag (#569) | 3–6 | +| 3. Motion graphics: text layers with animators, shape layers with operators | yes | `ui_text_edit`, `ui_timeline_shapes`, text and shape engine tests; rough: paragraph alignment inverted (#316), CJK text in comps (#453) | 3–6 | +| 4. Composite: masks, track mattes, blend modes, keying and effects | yes | Compositor and mask tests, 306 effects with CPU/GPU agreement; rough: mask bugs (#510, #513, #527), 32-bpc blend differences (#494), effect bugs (#580 crash, #514) | 6–12 | +| 5. Preview with audio (RAM preview, cache) | yes | `ui_ram_preview`, `ui_live_preview`; rough: stutter on cached frames (#595, #304), late audio (#554, #504) | 10–20 | +| 6. Save, reopen and deliver: `.ecproj`, auto-save, Render Queue to H.264 / ProRes / sequences | yes | Versioned-schema reopen tests, crash recovery, render-queue and encoder tests; rough: the macOS default output folder is `/`, so a render fails until an output path is chosen (#446), ProRes 4444 colour tag (#340), projects don't relink when moved | 1–3 | + +The gate is not beta: beta also needs After Effects' own `.aep` projects to open (gap 1) and +≈ 75% ready for real work. + +## Milestones + +The original build plan; all but M7 and M12 are done. What is left after them is measured +against After Effects in [gaps.md](gaps.md), not as milestones. + +| | Milestone | State | +|---|---|---| +| M0 | Skeleton: crates, compositor, the After Effects style shell, control channel, `cargo xtask` | Done (web build: [web.md](web.md)) | +| M1 | Keyframes: temporal and spatial interpolation, Easy Ease, roving, velocity | Done | +| M2 | Compositing: 38 blend modes, track mattes, parenting, adjustment layers | Done | +| M3 | Project operations: settings dialogs, layer commands, `.ecproj`, undo, After Effects menu bar | Done | +| M4 | Preview: precomps, motion blur, cached playback | Done | +| M5 | Timeline depth: graph editor, keyframe clipboard and dialogs, time remapping, pick-whips, expression editor | Done | +| M6 | Shapes, masks and footage: shape operators, masks and the pen tool, video and image import | Done | +| M7 | 3D: 3D layers, cameras, lights, shadows, depth of field, 3D views and camera tools | In review (3D gizmos, FBX/USD, Substance still missing: gaps 12–13) | +| M8 | Expressions with the After Effects object model | Done | +| M9 | Text and effects: text animators, layer styles, 306 effects incl. time and audio effects | Done | +| M10 | Export: render queue, H.264, ProRes, image sequences, GIF, audio | Done | +| M11 | Animation tools: audio, Lottie, presets, Motion Sketch, Wiggler, Smoother | Done (preset library: gap 7) | +| M12 | Performance: layer cache, parallel and GPU compositing, disk cache, motion tracking; GPU versions of the remaining CPU-only effects | Mostly done (benchmarks and hardware video: gaps 5, 15) | +| M13 | Puppet, paint, Roto Brush, tracking, plug-in API, timeline depth, trained models | Done | +| M14 | Built for agents: MCP server, CLI, control channel, Settings, shortcut editor, auto-save, crash recovery | Done | +| M15 | The web app (WebAssembly, WebGPU) | Done | + +## What's next, with estimates + +| Next | Gaps | Opus 5.5 hours | Parallel? | Needs a human | +|---|---|---:|---|---| +| Crash fixes and a PR CI gate | 3, 4 | 15–30 | yes | GPUs for #591 / #613 | +| G1 fidelity harness and P0/P1 corpus | 2 | 60–100 | after the harness | After Effects runs on the owner's Mac | +| Open bug backlog (Linux, Windows, masks, effects, text, 3D) | 5, 6, 8, 9, 11, 12, 24 | 100–190 | yes | Linux / Windows testers | +| `.aep` / `.aepx` import | 1 | 80–160 | partly | Owner decision on clean-room scope | +| Performance benchmarks and real-time preview | 5, 15 | 50–100 | partly | — | +| Localization depth | 16 | 60–110 | yes | Native-speaker review | +| Preset library, 26.x features, Object Matte | 7, 14, 21 | 50–95 | yes | — | +| Formats: VFX stills, MXF, 3D models, templates | 13, 17, 18, 22, 23 | 80–160 | yes | — | +| Plug-in ecosystem, multi-monitor, accessibility | 19, 20, 25 | 65–130 | yes | Owner decision on SDK hosting | + +To beta (≈ 75% ready and `.aep` opening): **≈ 350–620 h**. To full parity: **≈ 850–1,500 h**. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Alpha gate: six core workflows checked; all pass, stage stays alpha | +| 2026-10-10 | major | New file: milestones from ROADMAP.md, current focus, next steps with estimates | diff --git a/docs/scripting-parity.md b/docs/scripting-parity.md new file mode 100644 index 00000000..fef88efb --- /dev/null +++ b/docs/scripting-parity.md @@ -0,0 +1,43 @@ +# Expressions and scripting parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first expressions and scripting checklist against After Effects 2026 26.5) · **Target:** Adobe After Effects 2026 + +Expressions and ExtendScript are how After Effects work is automated and how much of it is +built: rigs, templates, studio pipelines. They are also what carries over from After Effects +today, while `.aep` projects can't. Architecture: [architecture.md](architecture.md) §5 and §5a; +extension points: [plugins.md](plugins.md); overall numbers: +[target-app-parity.md](target-app-parity.md). + +**Breadth ≈ 93%, ready ≈ 65%** (estimated). The object models are written from Adobe's public +Expression Language Reference and Scripting Guide (behaviour only, AGENTS.md §2) and tested with +our own scripts; no real-world script library has been run against both apps. + +| Feature | After Effects 2026 | EffectCraft | Status | Evidence / gap | +|---|---|---|---|---| +| Expression language | JavaScript engine (default) and Legacy ExtendScript | JavaScript (boa) with legacy array maths (`value + [10, 0]`) | good | `crates/expr` tests; no separate Legacy ExtendScript engine mode | +| Expression object model | `thisComp`, `thisLayer`, `wiggle`, `loopOut`, `valueAtTime`, `sampleImage`, `footage()`, text style API… | Same members, from the reference | good | `crates/expr/src/tests.rs`; behaviour of edge cases unmeasured (G1) | +| Expression editing | Inline field, error bar, pick whip, Expression Language menu, external editors | Inline field that grows, error bar, pick whips (property and value), language menu | partial | External editor support (#364) | +| Expression performance | Cached, multi-frame | Per-thread boa contexts | unknown | No benchmark | +| Scripting object model | `app`, `Project`, items, layers, properties, keys, eases, render queue, `TextDocument`, `Shape`, markers, `File`/`Folder`, `Socket`, `app.settings` | Same classes, edits run through undoable engine commands; match names table | good | `crates/script` tests (≈ 10 test files) | +| ScriptUI | Dialogs, palettes, dockable panels, resource strings, `onDraw` / ScriptUIGraphics | Same, dockable panels reopen at launch | good | `ui_scriptui`, `tests_ui*` | +| Scripts menu, ScriptUI Panels folder | yes | yes | same | [plugins.md](plugins.md) | +| Startup / Shutdown folders | yes | not run | missing | plugins.md | +| `#include`, `#target` directives | yes | a script starting with one doesn't parse | missing | plugins.md; common in real scripts | +| `.jsxbin` | yes | no | out of scope | No public specification | +| Keyboard shortcuts for scripts | yes | no | missing | plugins.md | +| Command-line rendering `aerender` | yes | `effectcraft-cli render`, `script`, `exec` | same purpose | `apps/effectcraft-cli` | +| Essential Graphics scripting hooks | `addToMotionGraphicsTemplate…` | yes (M13.7) | good | — | +| Agent control (MCP, control channel, every command) | none | yes | beyond | [agents.md](agents.md), [control-protocol.md](control-protocol.md); `list_effects` omits parameter types and ranges (#441) | +| Real-world script corpus | — | none run | gap | Run popular free scripts (the 11 sample scripts After Effects ships are a good start, run as behaviour tests, never copied) | + +## Estimate + +**≈ 20–40 h**: `#include` / `#target` and Startup / Shutdown folders 4–8 h, script shortcuts 2–4 h, +a corpus of public scripts and expressions with the fixes it finds 10–20 h, an expression +performance benchmark 4–8 h. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | First expressions and scripting checklist | diff --git a/docs/parity.md b/docs/target-app-parity.md similarity index 76% rename from docs/parity.md rename to docs/target-app-parity.md index cdec9986..12d37245 100644 --- a/docs/parity.md +++ b/docs/target-app-parity.md @@ -1,16 +1,280 @@ # Parity with After Effects -How close EffectCraft is to After Effects 2026, feature by feature, and how much work is left. +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (full re-measure against After Effects 2026 26.5; merged `docs/parity.md` into this file; reconciled the stale per-area rows) · **Target:** Adobe After Effects 2026 (26.5.0.89) -> **Read this as a measure of breadth, not of real-world readiness.** It checks whether each -> feature exists, using our own catalogue, graded by the agents that built it. It does not measure -> whether the feature behaves like After Effects (nothing compares the two yet), whether After -> Effects projects open (they don't), or how the app holds up for real users on every platform. -> Those gaps, and the work that closes them, are in [gaps.md](gaps.md). Don't raise the numbers -> here without the evidence gaps.md asks for. The per-area table further down has stale rows that -> have not been reconciled with the headline. +The authoritative assessment of how close EffectCraft is to After Effects, and how much work is +left. [gaps.md](gaps.md) lists every known shortfall one at a time; the +[ROADMAP](../ROADMAP.md) summarises both. Deep dives: [effects-parity.md](effects-parity.md), +[scripting-parity.md](scripting-parity.md), [file-format-parity.md](file-format-parity.md), +[ui-parity.md](ui-parity.md), [hardware-parity.md](hardware-parity.md), +[localization-parity.md](localization-parity.md), and the live measurements in +[fidelity/](fidelity/README.md). -## Current status (audit at commit `d39c0e8`, 4 October 2026; updated 5 October for M3.9–M4.12) +## Headline + +| Number | Value | Kind | +|---|---|---| +| **Feature breadth** (does each After Effects 26.5 feature exist?) | **≈ 91%** | Estimated, weighted by area (table below). The self-graded 92-item checklist says 89 done / 3 partial (≈ 99%), but it was written before After Effects 26.2 and 26.5 and does not count animation presets, project formats or hardware; see the appendix | +| **Ready for real work** (can an After Effects professional do client work here?) | **≈ 45%** (range 40–50%) | Estimated: 60% feature depth (≈ 50%, table below), 15% opening and delivering work files (≈ 35%), 10% performance (≈ 35%), 10% stability (≈ 45%), 5% platforms (≈ 65%) | +| **Stage** | **alpha** | All six core workflows pass the [alpha gate](roadmap.md#alpha-gate) on macOS and ready (≈ 45%) is above the ≈ 40% bar, but `.aep` projects can't be opened, fidelity is measured for one narrow case, and 134 issues are open ([ROADMAP](../ROADMAP.md#stage)) | +| **Mainstream practitioner** (the typical motion designer's weekly work) | **≈ 47%** | Estimated: everyday depth 72% × interaction 0.90 × stability 0.90 × file exchange 0.80 ([below](#mainstream-practitioner)) | +| **Essentials user** (core features only) | **≈ 52%** | Estimated: core depth 77% × launch/stability 0.88 × clarity 0.90 × opening files 0.85 ([below](#essentials-user)) | +| Remaining to **beta** (≈ 75% ready, `.aep` opens) | **≈ 350–620 Opus 5.5 agent-hours** | Estimated | +| Remaining to **full parity** | **≈ 850–1,500 Opus 5.5 agent-hours** | Estimated | + +### Readiness by audience + +| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | +|---|---:|---:|---| +| Full target (ready for real work) | ≈ 45% | ≈ 850–1,500 h | `.aep` import, a fidelity corpus for every feature, the bug backlog, performance, hardware video, formats, localization, ecosystem | +| Mainstream practitioner | ≈ 47% | ≈ 280–530 h | `.aep` exchange (80–160 h), stability and crash fixes (40–80 h), fidelity of the everyday features (40–70 h), everyday-area bugs and depth (60–110 h), interaction feel (30–60 h), preview speed (25–50 h) | +| Essentials user | ≈ 52% | ≈ 150–300 h | Opening the `.aep` files tutorials ship, basic subset (60–120 h), start-up and stability (20–40 h), core-feature bugs and clarity (30–60 h), preview with sound (20–40 h), fidelity of the core features (20–40 h) | + +Each audience's hours are a subset of the next (essentials ≤ mainstream ≤ full). They are +calibrated like the rest of this file ([calibration](#calibration-of-the-hours)): a behaviour bug +with its regression test ≈ 0.1–0.3 h, a mid-size feature 1–4 h. About 60–70% parallelises across +5–8 agents. The serial parts: the `.aep` owner decision before the importer, and the fidelity +harness before per-feature scores. Every audience needs After Effects running on the owner's Mac +for reference captures. + +The previous headline (5 October: breadth ≈ 99%, real use ≈ 30–50%) moved for these reasons: +breadth went **down** because this pass measured against 26.5 rather than our own catalogue and +found missing features the catalogue never listed (Object Matte, Substance materials, ACES 2.0 +configs, proportional scrubbing, copy frame to clipboard, paste SVG/AI as shapes, percentage +guides, After Effects' 621 animation presets, FBX/USD/STL models, Media Encoder-style hardware +encoding); real use moved **up** to the upper half of the old range because the 5–10 October +work closed 179 issues, most of them user-reported bugs fixed with regression tests, added six partial UI languages +and a first live After Effects fidelity measurement. It is still an estimate: nothing renders a +corpus in both apps yet. + +## How this was measured + +- **Target.** Adobe After Effects 2026, the copy installed on the owner's Mac: + `CFBundleShortVersionString` 26.5.0, build 26.5.0.89. The one live fidelity comparison + ([fidelity/README.md](fidelity/README.md)) used 26.3x87. +- **From the installed bundle (file names and listings only, per [AGENTS.md](../AGENTS.md) §2):** + `Info.plist` `CFBundleDocumentTypes` (26 document types: `.aep`, `.aepx`, `.aet`, `.aetx`, + `.mogrt`, `.mgjson`, `.aecap`, `.aegraphic`, `.ffx`, `.ars`, `.aom`, `.jsx`, `.psd`, `.cin`, + `.mov`, `.pct`, `.pic`…); the `Plug-ins/Format` importers (AIFF, Animate, JPEG, OpenEXR, PNG, + Premiere Pro import, Photoshop layers in and out, Radiance, SGI, SVG, Targa, the standard + multi-format plug-in); `Plug-ins/Effects` (306 `.plugin` bundles in 231 entries); + `Presets` (**621** `.ffx` animation presets); `Scripts` (11 sample scripts, Startup / Shutdown / + ScriptUI Panels folders); `Resources/usd_plugins` (USD, FBX, glTF, OBJ, STL); `Resources/MLModels` + (FastMask, ShotCutDetection) and tokenizer folders (Whisper, BERT, Marian); `.lproj` + localisations (11 languages, [localization-parity.md](localization-parity.md)). + After Effects was not launched for this pass, so there is no menu dump. +- **From Adobe's public release notes** for 26.2 (April 2026: Object Matte, displacement on + parametric meshes, Quick Apply, Proportional Scrubbing), 26.3 (June 2026: Advanced 3D depth of + field, paste SVG/AI as shapes, faster mask tracker, copy frame to clipboard, Curl Noise, + variable-font filter) and 26.5 (September 2026: percentage guides, Object Matte disk cache, + effect colour labels, a rebuilt Effect Controls panel, ACES 2.0 through OpenColorIO 2.5.1). +- **From this repository at `origin/main` 1446e27 (10 October 2026):** 587 menu entries in the + After Effects-shaped menu tree (`crates/engine/src/menus.rs`), 411 registered engine commands, + 306 registered effects (280 on the GPU, [effects.md](effects.md)), 12 export formats + (`OutputFormat::ALL`), the import extension lists (`crates/media/src/lib.rs`), 2,704 tests, ≈ 329,000 + lines of Rust, 6 UI language catalogs, and the 134 open / 179 closed GitHub issues. +- **Presence is not parity.** A feature counts toward *breadth* when it exists and works in the + common case; it counts toward *ready* in proportion to how much of the area a professional + could rely on, discounted for open bugs in it, for being unmeasured against After Effects, and + for being checked on one platform only. + +### Calibration of the hours + +Hours are **Opus 5.5 agent wall-clock hours**: one agent session working sequentially, tests and +verification included. Calibrated against this repository's own history: + +- The whole repository (≈ 329,000 lines including tests, 1,142 commits, 233 merged PRs) was built + between 1 and 10 October 2026, typically by 4–8 agents at once: roughly 700–1,000 agent-hours, + or ≈ 350–450 lines per agent-hour. Getting to today's ≈ 45% took that; the estimate to full + parity is about 1–1.5× it again, because what is left is measurement, compatibility and + long-tail behaviour rather than new code. +- Named arcs (from the audit notes kept below): Classic 3D ≈ 2.7 h, timeline depth (graph editor, + keyframe dialogs, time remapping, pen and masks) ≈ 2.3 h, 85 effects ≈ 1.2 h, the After Effects + menu bar ≈ 1.3 h, layer styles ≈ 0.6 h; a wave the audit priced at 21 h took ≈ 6 agent-hours + but landed at 80–90%. +- Recent PRs (commit-to-merge): seven Timeline/viewer issues (#436) ≈ 0.6 h; eight tweaks from + #290 (#383) ≈ 1 h; image-sequence import (#384) ≈ 4 h; panel i18n infrastructure plus 384 + Chinese strings (#398) ≈ 2–3 h; one 618-row menu language (#401 Ukrainian) ≈ 1.5–2 h; EXR + beauty layer + OCIO v2 configs (#449) ≈ 1.3 h. So a user-reported behaviour bug costs ≈ 0.1–0.3 h, + a mid-size feature 1–4 h. +- What history can't calibrate: fidelity work (each corpus case needs After Effects running, + which only the owner's Mac has), `.aep` import (blocked on an owner decision), and anything + needing hardware we don't have. Those carry the widest ranges. +- **Parallelism:** about 70% of the hours parallelise across 5–8 agents (effects fidelity, + languages, bugs, formats are independent). Serial chains: the G1 harness before per-feature + fidelity scores; the `.aep` decision before `.aep` work. **Needs a human:** the `.aep` + clean-room decision, running After Effects for oracle captures, native-speaker review of every + language, Windows/Linux GPU hardware for #591/#596/#613, and an optional decision on hosting + After Effects SDK plug-ins. + +## By feature area + +Weights are the share of a typical motion designer's working time in After Effects (animation, +compositing and effects dominate; audio is light). Hours are to full parity for the area. + +| Area | Weight | Breadth | Ready | Hours | Biggest gaps (details in [gaps.md](gaps.md)) | +|---|---:|---:|---:|---:|---| +| Project, footage and import | 8% | 92% | 55% | 25–45 | No relative paths or relinking when a project moves; Media Browser lists TGA/DPX/HDR/AVI/MXF it can't import; any image imports as a sequence (#431); MP3 layer goes silent (#324); multi-file drops (#552, #351) | +| Compositions, layers and compositing | 13% | 98% | 60% | 30–55 | 32-bpc blend modes differ from After Effects (#494); mask modes (#527, #510, #513); separated Position edits (#539, #540); expand precomps in place (#601) | +| Animation: keyframes, graph editor, motion paths, puppet | 15% | 97% | 55% | 35–60 | Only scalar ease is measured against After Effects (168 samples); Graph Editor jitter and clipped buttons (#456, #457); bulk keyframing (#355); pen tools on motion paths (#553); puppet lag and jump (#586, #318); Proportional Scrubbing (26.2) | +| Expressions and scripting | 7% | 93% | 65% | 20–40 | `.jsxbin`, Startup/Shutdown folders, `#include`; external editor (#364); see [scripting-parity.md](scripting-parity.md) | +| Shapes, masks and roto | 9% | 93% | 45% | 30–50 | Several open mask bugs (#510, #513, #527); Roto Brush size (#509); Mesh Warp can't be dragged (#303); paste SVG/AI as shape layers (26.3) | +| Text and typography | 7% | 94% | 50% | 20–35 | CJK text in comps renders wrongly (#453); paragraph alignment inverted (#316); squeezed text at 4:3 (#483); font search (#583); soft text at Fit (#417); variable-font filter (26.3) | +| Effects and animation presets | 14% | 85% | 40% | 70–130 | All 306 effects exist, but rendered output is unmeasured against After Effects; 8 built-in presets against After Effects' 621; open effect bugs (#580 crash, #514, #526, #463, #369, #473); effect colour labels (26.5); see [effects-parity.md](effects-parity.md) | +| 3D (Classic and Advanced) | 6% | 82% | 35% | 35–65 | No FBX/USD/STL models, no Substance materials, mesh displacement unverified; no 3D transform gizmos (#392, #347); Z position (#489); reflections (#261); camera aperture (#260); bevel options (#315) | +| Tracking and AI-assisted tools | 5% | 80% | 35% | 35–70 | No Object Matte (26.2); Roto Brush (MobileSAM) and face tracking (MediaPipe) unmeasured against After Effects; camera-tracker view (#505) | +| Preview and cache | 7% | 92% | 40% | 25–50 | Playback stutters on cached frames (#595, #304); slow scrubbing (#329); late audio (#554, #504, #335); no benchmark against After Effects | +| Render queue and export | 7% | 85% | 55% | 25–45 | No hardware encoding; ProRes 4444 colour tag (#340); rendering over an existing file (#325); default macOS output location (#446); uncompressed single-file output (#372); HEVC/AV1 without B-frames | +| Audio | 2% | 85% | 45% | 6–12 | Audio sync and crackle (#335); audio to keyframes depth | +| **Weighted** | 100% | **≈ 91%** | **≈ 50%** | **≈ 360–660** | | + +Feature depth (≈ 50%) feeds the overall *ready* number with the other dimensions below. + +## By dimension + +| Dimension | % | Hours | Evidence | Doc | +|---|---:|---:|---|---| +| Features, breadth | 91% | (in features) | Table above | this file | +| Features, ready for real work | 50% | 360–660 | Table above | this file, [gaps.md](gaps.md) | +| UI/UX fidelity | 65% | 60–120 | After Effects-shaped menus (587 entries), panels, workspaces, shortcuts; many interaction issues still open; no multi-monitor; Effect Controls not yet like 26.5's | [ui-parity.md](ui-parity.md) | +| File formats | 50% | 130–230 | `.aep` / `.aepx` / `.mogrt` / `.ffx` can't be read; strong modern codecs (H.264, HEVC, AV1, VP9, ProRes) | [file-format-parity.md](file-format-parity.md) | +| Hardware | 40% | 60–120 | wgpu GPU on Metal/Vulkan/Direct3D 12/WebGPU with CPU fallback; no hardware decode/encode; Direct3D 12 compositor fails (#613); no video-out hardware | [hardware-parity.md](hardware-parity.md) | +| Localization | 12% | 60–110 + review | Average coverage of After Effects' 10 non-English languages (measured catalog rows over ≈ 3,900 strings): menus 618/618 in 6 languages, panels in 4, effects in none; After Effects ships 11 languages fully | [localization-parity.md](localization-parity.md) | +| Performance | 35% | 50–100 | Internal numbers only; users report stutter and slow scrubbing | [gaps.md](gaps.md#g5-performance-at-real-world-scale) | +| Stability | 45% | 40–80 | Open crash reports #580, #591, #501, #426, #341, #306, #234, #511; no CI runs the tests on pull requests | [gaps.md](gaps.md#g3-stability-and-a-green-main) | +| Platforms | 65% | 30–60 | Releases for macOS, Windows x64/x86/ARM64, Linux, FreeBSD and the web (more than After Effects), but Linux and Windows users hit basic bugs | [gaps.md](gaps.md#g2-real-user-reliability-on-every-platform) | +| Ecosystem and plug-ins | 20% | 40–80 | After Effects scripts and expressions mostly carry over; no After Effects SDK plug-ins, `.ffx` presets or `.mogrt`; our WebAssembly plug-in API has no third-party plug-ins yet | [plugins.md](plugins.md) | +| AI features | 35% | 40–80 (overlaps Tracking) | Roto Brush (MobileSAM) and face tracking (MediaPipe) as optional downloads; no Object Matte; the After Effects bundle ships Whisper, BERT and Marian tokenizers and a shot-cut model we have no equivalent of | [gaps.md](gaps.md) | +| Agent control (beyond After Effects) | ahead | — | Every command is drivable over MCP, the control channel and the CLI | [agents.md](agents.md) | + +**Totals.** Summing the rows double-counts: AI overlaps Tracking, performance overlaps Preview, +UI overlaps every area. With the overlaps removed: **≈ 850–1,500 h to full parity**, of which +**≈ 350–620 h to beta**: `.aep` import (80–160 h, after the owner decision), the G1 fidelity +harness and P0/P1 corpus (60–100 h), the open bug and crash backlog (40–80 h), performance to +real-time preview at 1080p (40–70 h), Linux/Windows reliability (30–50 h), and feature depth in +animation, masks, effects and text (100–160 h). + +## Ready for real work: how the number is built + +The full-target number is a written weighted sum, not a single judgement: + +| Component | Weight | Value | Source | +|---|---:|---:|---| +| Feature depth | 60% | 50% | Weighted feature-area table above | +| Opening and delivering work files | 15% | 35% | [file-format-parity.md](file-format-parity.md): `.aep` / `.mogrt` / `.ffx` 0%, delivery formats strong | +| Performance | 10% | 35% | By dimension, below | +| Stability | 10% | 45% | By dimension, below | +| Platforms | 5% | 65% | By dimension, below | +| **Result** | | **46.2%**, reported as **≈ 45%** (40–50%) | Rounded down to the nearest 5: no live render comparison exists yet | + +Checked on 10 October 2026 when the mainstream and essentials numbers were added: it is unchanged. + +## Mainstream practitioner and essentials user + +Two narrower questions, computed the same way in every craft app (craftrules +`standards/progress-docs.md`, "Numbers"). The stage still follows the full-target number and the +[alpha gate](roadmap.md#alpha-gate). + +### Mainstream practitioner + +**≈ 47%** (estimated). + +*Can the typical professional motion designer do their weekly work here?* Areas are the alpha-gate +workflows and their everyday tools. Left out: third-party plug-ins and `.ffx` preset libraries, +generative/cloud AI (Object Matte, Firefly), Adobe services and team features, specialist hardware +(video I/O, tablets), languages other than English, and areas a typical designer uses rarely +(Advanced 3D models, 3D camera tracking, Roto Brush). Depth is for the everyday subset of each area +(the common effects, the common formats), which is why it sits above the full feature-area table. + +| Area (everyday subset) | Weight | Depth | Evidence | +|---|---:|---:|---| +| Project and import: MOV/MP4, PNG/JPEG sequences, PSD/AI, WAV/MP3 | 10% | 75% | Import tests; #431 still imported as a sequence, #324 MP3 silence | +| Comps, layers, blend modes, mattes, parenting, precomps | 16% | 80% | Compositor tests; #494 32-bpc blend modes, #539/#540 separated Position | +| Keyframes, Graph Editor, easing, motion paths | 20% | 75% | 168 live samples match After Effects; #456/#457, #553, #569 | +| Expressions: `wiggle`, `loopOut`, linking, sliders | 6% | 80% | `crates/expr` tests; no live expression oracle yet | +| Shape layers and masks | 12% | 65% | #510, #513, #527 mask bugs; #303 Mesh Warp | +| Text and text animators | 12% | 70% | #316 alignment inverted, #453 CJK, #583 font search | +| Everyday effects: blurs, glow, fill/tint, curves/levels, keying, drop shadow, displacement | 10% | 70% | All present with GPU paths; unmeasured against After Effects; #514 Apply Color LUT | +| Preview (RAM preview with audio) | 8% | 55% | #595, #304 stutter; #554, #504 late audio | +| Render to H.264 / ProRes / PNG sequences | 6% | 75% | Encoder tests; #446 default macOS output folder, #340 ProRes 4444 tag | +| **Average depth** | 100% | **72.2%** | | + +Discounts for what still stops real work: + +| Discount | Factor | Evidence | +|---|---:|---| +| Interaction fidelity | ×0.90 | UI parity ≈ 65% ([ui-parity.md](ui-parity.md)): Graph Editor jitter, snapping (#352), viewer zoom keys (#570), no multi-monitor (#486) | +| Stability on real machines | ×0.90 | Eight open crash reports (#580, #591, #501, #426, #341, #306, #234, #511); Direct3D 12 compositor fails (#613); no CI on pull requests | +| File exchange with After Effects users | ×0.80 | Can't open `.aep`, `.aepx`, `.mogrt` or `.ffx` sent by colleagues or bought as templates; PSD, AI, footage and rendered deliverables exchange fine | + +72.2% × 0.90 × 0.90 × 0.80 = **46.8%, ≈ 47%**. It sits only just above the full number because +the gap is not in After Effects' long tail: fidelity, stability and `.aep` exchange hit +mainstream users as hard as specialists. + +### Essentials user + +**≈ 52%** (estimated). + +*Can someone who only touches the core features (a social-media animator, a student, a +first-time user) get their work done?* No advanced options, pro workflows or exchange edge cases. + +| Core feature | Weight | Depth | Evidence | +|---|---:|---:|---| +| Launch, new project, undo, save, reopen | 10% | 85% | Versioned project reopen tests, auto-save and crash recovery | +| Import a video, picture or song | 10% | 75% | #431 a picture imported as a sequence, #324 MP3 silence | +| New comp; move, scale, rotate, fade and trim layers | 15% | 80% | `ui_viewer`, Timeline tests | +| Add and style text | 10% | 70% | #316, #583 | +| Draw shapes and simple masks | 10% | 70% | Shape tool tests; #513 mask drawing feedback | +| Keyframes and Easy Ease | 15% | 85% | Ease measured against After Effects | +| Apply a few common effects | 10% | 75% | Effects & Presets search, Effect Controls tests | +| Preview with sound | 10% | 65% | #595 stutter, #554 late audio | +| Export an MP4 | 10% | 80% | H.264 encoder tests; #446 | +| **Average depth** | 100% | **76.75%** | | + +| Discount | Factor | Evidence | +|---|---:|---| +| Launch and stability | ×0.88 | Start-up failures (#501, #234 macOS 27, #596 GPU not detected, #613 Direct3D 12); #426 timeline crash while playing | +| Discoverability and UI clarity | ×0.90 | After Effects' dense layout reproduced faithfully; Home screen and interactive Learn tutorials help; renders fail silently on macOS until an output folder is chosen (#446); layout nitpicks (#581) | +| Opening files people send | ×0.85 | Tutorial projects and templates arrive as `.aep` / `.mogrt`, which don't open | + +76.75% × 0.88 × 0.90 × 0.85 = **51.7%, ≈ 52%**. + +### User evidence (GitHub, 10 October 2026) + +Counted from all 315 issues and their comments, excluding the maintainer account and +agent-generated maintainer replies: + +- **Praise:** 5 people in 5 issues say so outright: "felt almost exactly like After Effects. + Really impressive" (#611), "performance has been excellent" (#326), "Awesome project" (#322), + "Great work" (#293), "great tool and AE alternative" (#290). About 6 more open with "would love…" + in a feature request. +- **"Switched from After Effects" reports:** none found. #468 is a working motion designer's + list of Mac issues, so people are trying it on real work. +- **Open issues (136):** ≈ 95 are core-path bugs (masks, keyframes, timeline, preview, audio, + text, render, crashes; this includes 15 community `fix(...)` proposals), ≈ 40 are feature + requests or niche (plug-in hosting, OpenFX, ffmpeg export, Discord presence, Store listing, + rigging tools). Mostly core-path bugs, which matches the discounts above. + +## Out of scope by design + +Adobe services (Team Projects, Libraries, Stock, Media Encoder, Dynamic Link, Frame.io, +Exchange, Firefly), third-party bundles (Cineware / Cinema 4D, Mocha), and formats without a +public specification or permissive data (`.prproj`, `.jsxbin`, Vanishing Point `.vpe`, the +predefined CJK CMaps in PDF import, Kodak film emulations) are not counted against breadth. +`.aep` / `.aepx` *are* counted: they are the main file format and decide beta. + +--- + +## Appendix: feature-checklist audit history + +Moved here from `docs/parity.md` on 10 October 2026 and kept as evidence. It scores the 92-item +catalogue in `plan/aftereffects/feature-catalog.md`, graded by the agents that built the +features; read it as breadth only. **Its per-area table ("By area", below) was stale** +(Interface 75%, Project 68%, Animation 70%, Paint 0% next to a 99% headline) and is superseded by +the feature-area table above; it is kept for its "biggest gaps" notes, which record what +landed when. + +### Checklist status (audit at commit `d39c0e8`, 4 October 2026; updated 5 October for M3.9–M4.12) | Measure | Value | |---|---| @@ -53,15 +317,15 @@ Out of clean-room scope (no public specification or no permissively licensed dat emulations. Adobe service integrations (Team Projects, Libraries, Media Encoder, Dynamic Link, Frame.io, Exchange) and third-party plug-ins (Cinema 4D, Mocha) are intentionally absent. -### Previous audit (commit `58163a2`, 3 October 2026, evening): ≈ 98% +#### Previous audit (commit `58163a2`, 3 October 2026, evening): ≈ 98% 87 done / 5 partial / 0 missing. -### Previous audit (commit `978e8d7`, 3 October 2026): ≈ 94% +#### Previous audit (commit `978e8d7`, 3 October 2026): ≈ 94% 80 done / 12 partial / 0 missing. -### Previous audit (commit `fa26ad9`, 2 October 2026, late): ≈ 93% +#### Previous audit (commit `fa26ad9`, 2 October 2026, late): ≈ 93% By area: Layers 98%, Output 95%, Compositions 95%, Automation 96%, Paint 95%, Text 95%, Import 96%, Animation 94%, Masks 94%, Preview 94%, Interface 93%, Shapes 93%, 3D 91%, Audio 90%, Project 90%, @@ -80,7 +344,7 @@ thread counts — landed in M13.1 (GPU particles landed in M12.7). Render Queue The sections below are the original audit (morning of 2 October, ≈ 64%) and its updates, kept for history. -## Summary +### Summary | Measure | Value | |---|---| @@ -107,7 +371,7 @@ Work in progress at the time of the audit, not yet counted: motion tracking, pre keyboard shortcut editor, auto-save and crash recovery, paint and puppet tools, Lottie import and export. -## Update after the second wave (same day, commit `e1f7241`) +### Update after the second wave (same day, commit `e1f7241`) Motion tracking (1-, 2- and 4-point, stabilize, corner pin), paint (Brush, Clone Stamp, Eraser with write-on) and the puppet tools, preferences, a keyboard shortcut editor that rebinds, @@ -129,7 +393,7 @@ hours** at the pace observed so far and **≈ 35–40 hours** if the audit's fig hardest remaining systems (Roto Brush, Warp Stabilizer, the 3D camera tracker, Advanced 3D, GPU rendering, on-canvas text editing) are where the conservative figure is most likely to be right. -## Update at the end of 2 October 2026 +### Update at the end of 2 October 2026 A third wave landed: viewer interactions (snapping, rulers, channels, exposure, snapshots, region of interest, shape pen and pen-tool family, motion-path handles, graph editor transform box), @@ -155,7 +419,7 @@ M6.7; native macOS menus, the Composition Flowchart, Timeline column/search dept and every After Effects workspace in the UI-polish wave; PSD/SVG import, WebM/WAV/AIFF output and the disk cache in the formats wave; the 3D Camera Tracker in M12.6.) -## By area +### By area (stale; superseded on 2026-10-10 by the feature-area table at the top) | Area | Weighted parity | Remaining (agent-hours) | Biggest gaps | |---|---|---|---| @@ -178,7 +442,7 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.) | Tracking | ≈ 96% | 0.3 | face tracking's trained model (MediaPipe Face Landmarker, M13.36) is an optional download; without it the classical (skin model + feature components + shape model) fitter is weak on profile views and occluded faces; Rolling Shutter Ripple is approximated by Subspace Warp's mesh density (face tracking (Outline Only / Detailed Features with Face Track Points and Extract & Copy Face Measurements), Subspace Warp's content-preserving mesh warp on subspace-smoothed trajectories, and radial lens distortion (k1, k2) in the camera bundle adjustment with Undistort Footage landed in M13.3; Rolling Shutter Repair in M9.11; point tracker, mask tracking, Warp Stabilizer and the 3D Camera Tracker in M6.x / M12.5 / M12.6) | | Paint | 0% | 4.0 | Brush, Clone Stamp, Eraser | -## Effects still missing +### Effects still missing The full catalogue with GPU / 32-bpc badges and per-effect status is [effects.md](effects.md) (generated from the registry). @@ -186,7 +450,7 @@ The full catalogue with GPU / 32-bpc badges and per-effect status is [effects.md None: the 3D Camera Tracker landed in M12.6. Boris FX Mocha and Cineware are third-party and not counted. -### Added in M9.11 (39 effects) +#### Added in M9.11 (39 effects) | Category | Effects | Notes | |---|---|---| @@ -201,7 +465,7 @@ Mocha and Cineware are third-party and not counted. | Utility | Color Profile Converter | Our colour spaces and ACES; rendering intents (perceptual gamut compression, relative / absolute colorimetric, saturation). | | Matte | Mocha shape | Mocha's export format is not public: reads a documented JSON shape format instead. | -## Update: M13.5 long-tail polish +### Update: M13.5 long-tail polish Stroke Taper / Wave / multi-segment dashes and the radial gradient highlight (SHP-2); variable-width mask feather points (MSK-1; mask motion blur verified by tests); the Adaptive @@ -217,7 +481,7 @@ Reference Axes; Animate Text ▸ Variable Font Axes. Remaining in these rows: Lo taper/wave (the Variable Font Axes animator changes advances since M13.12, the Character panel's axes since M13.6; Advanced 3D's DOF has iris shapes since M13.8). -## Update: M13.8 Preview panel, Align panel, Advanced 3D completion +### Update: M13.8 Preview panel, Align panel, Advanced 3D completion - **Preview panel (PRV-1)**: Shortcut popup (Spacebar, Shift+Spacebar, Numpad 0, Shift+Numpad 0, Alt+Numpad 0), each shortcut with its own saved options (settings `preview`): Include @@ -238,7 +502,7 @@ axes since M13.6; Advanced 3D's DOF has iris shapes since M13.8). as real geometry lit by the parent (2D collapsed precomps draw nested 3D layers with the parent's renderer); text and shape strokes extrude as bevelled meshes in paint order. -## Update: M13.22 GPU effects, part A +### Update: M13.22 GPU effects, part A - **GPU ports (EFF-5)**: 30 more effects run on the GPU (196 GPU effects), each matching the CPU oracle within 1/255 (8 bpc) / 1e-3 (32 bpc) on every tested pixel, directly on a buffer @@ -275,7 +539,7 @@ axes since M13.6; Advanced 3D's DOF has iris shapes since M13.8). comps. The GPU walk matches the CPU compositor on the same rasters exactly at 8 and 32 bpc, with only the frame's own readback. -## Update: M13.13 GPU performance and the remaining GPU ports +### Update: M13.13 GPU performance and the remaining GPU ports - **Small comps on the GPU (PRV-3)**: the GPU frame allocated (and zero-initialised) several full-frame RGBA f32 textures per layer; working textures now come from a pool (reused once @@ -303,7 +567,7 @@ axes since M13.6; Advanced 3D's DOF has iris shapes since M13.8). Vertical Distortion render on the CPU (`gpu_supported`), since their Newton inverse wanders chaotically near the crease and f32 settles on other pixels. -## Update: M13.11 Advanced 3D on the GPU, Essential Graphics mirrors, ScriptUI paint +### Update: M13.11 Advanced 3D on the GPU, Essential Graphics mirrors, ScriptUI paint - **Advanced 3D on the GPU (PRV-3, 3D-3)**: a whole Advanced 3D run renders on the device (`gpu::adv3d`, `Renderer::prepare_adv_run`, `Accelerator::render_3d`): every motion-blur @@ -338,7 +602,7 @@ axes since M13.6; Advanced 3D's DOF has iris shapes since M13.8). `drawImage`, image controls and icon buttons; script-host threads are compiled out of the web build (no dead code warnings). -## Update: M13.5 UI completion & fidelity +### Update: M13.5 UI completion & fidelity **Extended Viewer** (Settings ▸ 3D, the viewer's Extended Viewer button, `view.extendedViewer`): custom 3D views (and the Active Camera view with Draft 3D on) render the visible pasteboard @@ -356,7 +620,7 @@ workspace bar order (Default, Review, Learn, Small Screen, Standard) and a singl above the viewer. Not yet: Composition Settings / Render Queue / Settings dialogs compared pixel by pixel (no reference captures of them yet). -## Update: M13.6 panels and content tools +### Update: M13.6 panels and content tools The last placeholder panels became real panels with automation ids: **Lumetri Scopes** (waveform RGB / Luma / YC, vectorscope YUV / HLS, histogram, parade RGB / YUV; Rec. 601 / 709 / @@ -375,7 +639,7 @@ menu entries. Not yet: creating a reference frame by editing a still (After Effe Photoshop), Content-Aware Fill's learned model (ours is PatchMatch + flow propagation), and Media Browser in the web app (landed in M13.10: browser storage and File System Access folders). -## Update: M13.12 import and text leftovers, test hardening +### Update: M13.12 import and text leftovers, test hardening - **PDF / AI**: mesh shadings — free-form and lattice-form Gouraud triangle meshes (types 4, 5), Coons and tensor-product patch meshes (6, 7, tessellated to triangles) — and function-based @@ -401,7 +665,7 @@ Browser in the web app (landed in M13.10: browser storage and File System Access wrong signatures and versions, fuel exhaustion, traps and bit-identical output; script `Socket` validates ports, and ScriptUI resource strings accept trailing array commas. -## Update: text and viewer fidelity (G1) +### Update: text and viewer fidelity (G1) Fixes from measured reports: @@ -420,7 +684,7 @@ Fixes from measured reports: factor) instead of minified bilinearly (#417). Not yet: the viewer resolution and magnification are not remembered between launches. -## Update: plug-ins and extensions as in After Effects; ease presets become a ScriptUI panel +### Update: plug-ins and extensions as in After Effects; ease presets become a ScriptUI panel EffectCraft's core keeps After Effects parity; what After Effects users get from third parties is an extension, on the same extension points After Effects has ([plugins.md](plugins.md) compares @@ -455,7 +719,7 @@ them: scripts, ScriptUI panels, effect plug-ins, with a guide to writing a panel Extended and Snap to Features in Collapsed Compositions and Text Layers (the layers inside collapsed precomp layers snap); the per-feature toggles are gone and every feature snaps. -## Update: M5.9–M5.14 keyframes, M3.15 clipboard, M12.8 responsiveness +### Update: M5.9–M5.14 keyframes, M3.15 clipboard, M12.8 responsiveness - **The app froze every 10 s on Windows** (M12.8): the cache budgets' memory reading started PowerShell on the UI thread and waited about a second for it. It runs on a background thread @@ -585,7 +849,7 @@ rather than the keyed frame (Ctrl/Cmd+click averages 5 × 5 pixels); agents do t `effect.pickColor {effect, param, x, y, average?}` (effect space). Searching "keylight" in Effects & Presets, or `list_effects`, finds it. -## Update: M4.9–M4.11 nested comps and motion blur +### Update: M4.9–M4.11 nested comps and motion blur - **Composition Settings ▸ Advanced ▸ Preserve frame rate when nested or in render queue** and **Preserve resolution when nested** (model, renderer, Render Queue, dialog, `comp.settings`, @@ -601,7 +865,7 @@ Not yet: collapsed precomps don't blur with the precomp layer's own motion or th camera's; animated content inside a layer (shape paths, text animators, nested frames) isn't re-rendered per sub-sample; nested comp markers on the precomp layer bar. -## Update: M4.5–M4.8 cached playback +### Update: M4.5–M4.8 cached playback - **RAM preview keys** carry the render options (Fast Previews Draft / Fast Draft, Realtime Shadows), so a mode switch never shows frames rendered the other way; eviction drops the least @@ -618,7 +882,7 @@ re-rendered per sub-sample; nested comp markers on the precomp layer bar. Not yet: content-keyed RAM frames (an edit drops every comp's RAM frames; the disk cache keeps content keys), audio scrubbing, four snapshot slots. -## Update: M3.14 project files +### Update: M3.14 project files Project files name the version that wrote them (`savedBy`); opening one that a newer EffectCraft saved warns that what this version doesn't know is lost on saving (`file.open` reports @@ -627,7 +891,7 @@ fails the save with a message instead of writing a project that can never be ope on disk is left as it was). Increment and Save and Save a Copy keep an XML project (`.ecprojx`) XML; a file that isn't a project says so by name. -## Update: M3.13 menu bar +### Update: M3.13 menu bar Group Shapes (Ctrl+G) and Ungroup Shapes (Ctrl+Shift+G) moved to the Layer menu itself, where After Effects has them; Window ▸ Learn opens the Home screen's Learn tab; Composition ▸ @@ -637,7 +901,7 @@ Window's shortcut); Layer ▸ Transform ▸ Center In View shows Ctrl+Home; "Rev Viewer (several unlocked Composition viewers at once; View ▸ Split with New Locked Viewer adds the one locked viewer the interface has). -## Update: M3.12 Pre-compose and New Comp from Selection +### Update: M3.12 Pre-compose and New Comp from Selection - **Pre-compose** makes the new composition with the original's settings (pixel aspect, background, motion blur shutter / samples / switch, frame blending switch, 3D renderer); it @@ -649,7 +913,7 @@ the one locked viewer the interface has). (`file.newCompFromSelection {single, dimensionsFrom, duration, addToRenderQueue, sequence, overlap, overlapDuration, transition}`). `layer.addItem {duration}` sets a still's length. -## Update: M3.11 layer commands +### Update: M3.11 layer commands - **Lock**: locked layers can't be selected (a Timeline click, Select All, Ctrl+Up / Down step over them, as do shy layers while hidden) and are left alone by Clear / Delete, Arrange, the @@ -667,7 +931,7 @@ the one locked viewer the interface has). matte when both were copied, else the original in the same composition (paste dropped them always). -## Update: M3.10 Layer Settings and settings dialogs +### Update: M3.10 Layer Settings and settings dialogs Layer ▸ Layer Settings on a solid or adjustment layer opens Solid Settings on the layer's solid (it did nothing before): name, size, Pixel Aspect Ratio, colour and **Affect all layers that use @@ -679,7 +943,7 @@ for every control (`dialog.comp.*`, `dialog.solid.*`), Composition Settings take frame rate besides the list, New Composition starts from the settings of the last composition made with it, and their colour buttons show the stored sRGB colour (they showed it lighter). -## Update: M3.9 unsaved changes +### Update: M3.9 unsaved changes Closing a modified project asks first, as After Effects does: Quit and the window's close button, File ▸ New Project, Open Project, Open Recent, Close Project, the demo project and a Home @@ -690,7 +954,7 @@ the project's path and `*` while it has unsaved changes. The modified mark now f and edits that change nothing record no undo step. Agents' `engine.execute` / MCP calls are never asked; the control channel's `app.quit {force: true}` skips the prompt. -## Update: M13.25 Home template gallery, Simulate Output ▸ My Custom RGB +### Update: M13.25 Home template gallery, Simulate Output ▸ My Custom RGB **Home ▸ Templates** (UI-7; File ▸ New ▸ New Project from Template…): a gallery of eight original built-in project templates authored in code from engine commands — Lower Third, Title @@ -717,7 +981,7 @@ in Settings (`customRgb`) and simulated like the built-in profiles, with Preserv ICC profiles too (`lut8Type`, `lut16Type`, `lutAToBType` / `lutBToAType`, evaluated from the public ICC specification and baked into a 3D LUT for the viewer). -### M13.26: Premiere Pro interop via timeline interchange +#### M13.26: Premiere Pro interop via timeline interchange | Feature | After Effects | EffectCraft | Status | |---|---|---|---| @@ -728,7 +992,7 @@ Not yet: native `.prproj` (no public specification; awaits a decision), media em (not extracted), Premiere effects other than Motion/Opacity/Volume, speed ramps, titles/graphics, and Dynamic Link. -## Update: M13.23 GPU effects, part B (EFF-5) +### Update: M13.23 GPU effects, part B (EFF-5) 40 more effects run on the GPU (236 in all, with M13.22's 30), each checked against the CPU oracle (≤ 1/255 at 8 bpc, ≤ 1e-3 at 32 bpc) directly on a buffer (full and half resolution, adjustment) and @@ -767,7 +1031,7 @@ composited at 8 and 32 bpc: default to the layer). Shatter's wireframe views and Foam's User Defined texture, Environment Map and flow-map preview render on the CPU. -## Update: M13.28 GPU effects, part C (EFF-5) +### Update: M13.28 GPU effects, part C (EFF-5) 37 more effects run on the GPU, each checked against the CPU oracle (≤ 1/255 at 8 bpc, ≤ 1e-3 at 32 bpc) directly on a buffer with masks, another layer and an audio track (full and half @@ -799,7 +1063,7 @@ resolution, adjustment) and composited at 8 and 32 bpc: of the pixels even at low magnification, WGSL has no f64 and Metal's fast math defeats double-f32 emulation. -## Update: M13.29 GPU effects, part D (EFF-5) +### Update: M13.29 GPU effects, part D (EFF-5) 7 more effects run on the GPU (280 in all, with M13.28's 37), and five CPU fallbacks are gone. Each case is checked against the CPU oracle (≤ 1/255 at 8 bpc, ≤ 1e-3 at 32 bpc) directly on a buffer (full @@ -829,7 +1093,7 @@ are pinned bit for bit by golden hashes (`crates/effects/tests/particle_golden.r CPU solves the inverse map from the geometry alone and the GPU samples along it, so the chain stays on the GPU without a readback. -## Update: M13.30 web depth and polish +### Update: M13.30 web depth and polish - **GPU in the browser's job workers** (WEB-1): each job worker opens its own WebGPU device (deferred readbacks) like the frame workers. The Render Queue's export and the analyses' @@ -851,7 +1115,7 @@ are pinned bit for bit by golden hashes (`crates/effects/tests/particle_golden.r (default 256)}`; `templates.create {projectPath?, footageDir?}` extracts it. - **ICC A2B0 profiles** in View ▸ Simulate Output ▸ My Custom RGB (see above). -## Highest-value gaps, in order +### Highest-value gaps, in order 1. ~~On-canvas text editing and per-character styles~~ (landed: M9.9–M9.10). 2. ~~Effect Controls widgets: angle dial, point crosshair, eyedropper, curves and levels editors.~~ (second wave, 2 October) @@ -882,3 +1146,16 @@ missing was interactive tooling, settings stored but not rendered, about 69 disa and the large systems (tracking, puppet, paint, roto). All of those have landed since; one menu entry stays disabled on purpose (Import ▸ Vanishing Point), and what is left is listed at the top of this page and in items 16–18 above. + + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Readiness-by-audience table: hours to ≈ 95% for each of the three numbers; the full number stays an additive weighted sum (unchanged) | +| 2026-10-10 | minor | Added mainstream practitioner (≈ 47%) and essentials user (≈ 52%) numbers with weights, discounts and GitHub user evidence; wrote down how the full ready number is built (unchanged at ≈ 45%) | +| 2026-10-10 | minor | Stage checked against the core-workflow alpha gate: passes, still alpha | +| 2026-10-10 | major | Full re-measure against After Effects 2026 26.5 (installed bundle, 26.2–26.5 release notes, repository counts); two numbers (breadth ≈ 91%, ready ≈ 45%); feature-area and dimension tables with Opus 5.5 hours; `docs/parity.md` merged in as the appendix and its stale per-area table superseded | +| 2026-10-05 | minor | Checklist updated for M3.9–M4.12 (as `docs/parity.md`) | +| 2026-10-04 | major | Checklist audit at `d39c0e8`: ≈ 99% weighted breadth, 89 / 3 / 0 of 92 | +| 2026-10-02 | major | First audit: ≈ 64% weighted breadth | diff --git a/docs/ui-parity.md b/docs/ui-parity.md new file mode 100644 index 00000000..fd807ebc --- /dev/null +++ b/docs/ui-parity.md @@ -0,0 +1,47 @@ +# UI parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first UI parity checklist against After Effects 2026 26.5) · **Target:** Adobe After Effects 2026 + +How closely EffectCraft's interaction matches After Effects: tools, handles, snapping, nudging, +modifier keys, shortcuts, drag behaviour, numeric entry, panels, context menus and feel. Evidence +is the code at `origin/main` 1446e27, the 42 scripted UI test files in `crates/ui-egui/tests` +(real pointer and keyboard events, run headless), [fidelity/workflow-checks.md](fidelity/workflow-checks.md) +and the issue tracker. After Effects was not driven for this pass, so "same as After Effects" means +"matches its documentation and users have not reported a difference". + +**Overall ≈ 65%** (estimated): the shape of the interface is After Effects' (menus, panels, +workspaces, shortcuts), and the scripted tests cover many interactions, but users keep finding +feel and platform differences, and the 26.x interface changes are not in yet. + +| Area | Status | % | Evidence and gaps | +|---|---|---:|---| +| Menu bar | After Effects' menus, order, submenus and labels; native on macOS; keyboard-operable in-window bar | 95% | 587 entries in `crates/engine/src/menus.rs`; Adobe-service entries omitted by design; `ui_menus` tests (#279) | +| Panels and workspaces | Every After Effects panel; all standard workspaces; drag-to-dock, tabs, floating panels, maximise | 75% | `ui_tabs`, `ui_panels`; dropping a panel on a stacked panel doesn't make a tab (#302); stacked-panel headers (#301); saved workspaces don't persist (#320); no panels on a second monitor (#486) | +| Timeline | Switches, modes, parent & link, twirl-downs, reveal shortcuts (U, UU, P, S, R, T, A, E, M, F, Alt+Shift…), pick whips, graph editor, work area, markers | 75% | `ui_keyframes`, `ui_timeline_*`; Graph Editor jitter and hidden buttons (#456, #457); rotation can't go negative by dragging (#569); bulk keyframing (#355); expand precomps in place (#601); Proportional Scrubbing (26.2) missing | +| Composition viewer | Selection, handles with direction-aware cursors, rulers, guides, grids, snapping, channels, exposure, region of interest, snapshots, 3D views, Spacebar hand | 70% | `ui_viewer`, `ui_viewers`; Cmd/Ctrl +/− doesn't zoom (#570); inaccurate snapping (#352); anchor point grab on Linux (#492); no 3D transform gizmos (#392, #347); percentage guides (26.5); copy frame to clipboard (26.3) | +| Tools panel | All After Effects tools; long-press flyouts; Tool Creates Shape / Mask; Fill / Stroke options | 80% | #213, #227 fixed; pen and vertex tools on motion paths (#553) | +| Nudging and modifiers | Arrow nudge (1 px, Shift 10 px), Alt/Option duplicates and slips, Shift constrains | 75% | #290 nudging fixed; Linux multi-layer edits (#491) | +| Numeric entry | Scrubbable values, typed entry with Tab / Shift+Tab, angle revolutions, Escape cancels, non-finite rejected | 85% | `ui_angles`, `ui_inline_edit` (#93, #141, #361); rename arrows on Linux (#488) | +| Effect Controls | After Effects' twirl-downs, angle dial, point crosshair, eyedropper, curves/levels editors, effect menu on right-click | 70% | `ui_effect_controls`, `ui_fx_editors`; 26.5 rebuilt this panel with icon controls and effect colour labels, not matched | +| Context menus | Layer, property, Project, Timeline empty area, work area, labels with colours | 80% | `ui_blank_context_menus`; #227, #290, #407 fixed | +| Keyboard shortcuts | After Effects' defaults; Keyboard Shortcuts editor that rebinds and saves sets | 75% | Shortcut editor in Settings; adding shortcuts fails on Linux (#467); requests for more (#428, #336); no shortcuts for scripts | +| Drag and drop | Project → Timeline / viewer / New Comp / Render Queue; effects onto layers; files from the OS | 65% | `ui_drag_drop`; OS drops fail on Wayland (winit) and some Linux setups (#268); multi-file drops (#552, #351); crash dragging over the import dialog (#511) | +| Text editing on canvas | Point and paragraph text, per-character styles, Character / Paragraph panels, OpenType and variable axes | 70% | `ui_text_edit`; paragraph alignment inverted (#316); font search by typing (#583); toolbar font options (#550); variable-font filter (26.3) | +| Preview feel | Spacebar / Numpad 0 previews, RAM preview, cache bars, adaptive resolution | 55% | `ui_ram_preview`, `ui_live_preview`; stutter (#595, #304), scrubbing lag (#329), Shift+Space default (#551) | +| Home screen, Learn, templates | Home with recent projects, template gallery, interactive Learn tutorials | 70% | Layout nitpicks (#581); Home literals untranslated (#574) | +| Quick Apply (26.2) | Search to apply effects, presets or commands | partial | A command palette exists; not After Effects' Quick Apply | +| Accessibility | AccessKit enabled; focus labels tested | 20% | No screen-reader pass, keyboard-navigation audit or contrast check | +| UI scale | Settings ▸ Appearance ▸ UI Scale 75–200% | beyond | After Effects follows the system's scaling (#284) | + +## Estimate + +**≈ 60–120 h**: the open interaction issues above (25–45 h, about 0.2–0.5 h each with a scripted +test), 3D gizmos 8–15 h, multi-monitor panels 10–20 h, the 26.x interface changes 10–20 h, an +accessibility pass 15–30 h. Checking feel against After Effects needs the owner's Mac with After +Effects running; most of the rest parallelises. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | First UI parity checklist | diff --git a/examples/ae-spatial-tangents.jsx b/examples/ae-spatial-tangents.jsx new file mode 100644 index 00000000..a9a9218b --- /dev/null +++ b/examples/ae-spatial-tangents.jsx @@ -0,0 +1,27 @@ +// Original behavior probe: the spatial tangents a host gives auto-Bezier Position keys. +// Runs unchanged in After Effects (File > Scripts > Run Script File, or AfterFX.exe -r) and in +// EffectCraft (effectcraft-cli script examples/ae-spatial-tangents.jsx --empty). Builds three +// original comps with one solid each; run it in a scratch project. EffectCraft prints one JSON line +// per key; After Effects writes them to ae-spatial-tangents.jsonl next to this script. +// No Adobe assets, presets, application internals or implementation code are read. +var EC = String(app.version).indexOf("EffectCraft") >= 0; +var OUT = null; +if (!EC) { + OUT = new File(File($.fileName).parent.fsName + "/ae-spatial-tangents.jsonl"); + OUT.encoding = "UTF-8"; + OUT.open("w"); +} +function emit(s) { if (EC) { writeLn(s); } else { OUT.writeln(s); } } +function r(v) { var o = []; for (var i = 0; i < v.length; i++) o.push(Math.round(v[i] * 1000) / 1000); return "[" + o.join(",") + "]"; } +function dump(name, pts, times) { + var c = app.project.items.addComp(name, 1920, 1080, 1, 2, 30); + var p = c.layers.addSolid([1, 1, 1], "S", 100, 100, 1).property("Transform").property("Position"); + for (var i = 0; i < pts.length; i++) p.setValueAtTime(times[i], pts[i]); + // Pin what a preference could decide: AE can default new keys to linear spatial interpolation. + for (var k = 1; k <= p.numKeys; k++) p.setSpatialAutoBezierAtKey(k, true); + for (var k2 = 1; k2 <= p.numKeys; k2++) emit('{"case":"' + name + '","key":' + k2 + ',"in":' + r(p.keyInSpatialTangent(k2)) + ',"out":' + r(p.keyOutSpatialTangent(k2)) + "}"); +} +dump("T1 symmetric", [[200, 200], [960, 900], [1700, 200]], [0, 1, 2]); +dump("T2 uneven", [[100, 540], [400, 700], [1800, 540]], [0, 0.5, 2]); +dump("T3 four keys", [[100, 100], [500, 900], [900, 100], [1800, 600]], [0, 0.6, 1.2, 2]); +if (OUT) OUT.close(); diff --git a/packaging/windows/effectcraft.wxs b/packaging/windows/effectcraft.wxs index f4710add..00b0c5d6 100644 --- a/packaging/windows/effectcraft.wxs +++ b/packaging/windows/effectcraft.wxs @@ -4,7 +4,8 @@ wix build effectcraft.wxs installer-ui.wxs -arch x64 -d Version=1.2.3 -d BinDir=... -d IconPath=... -o out.msi - Per-machine install into Program Files (or a folder the user picks) and a Start Menu shortcut. + Per-machine install into Program Files (or a folder the user picks), a Start Menu shortcut and, + unless DESKTOPSHORTCUT=0 (the wizard's "Create a desktop shortcut" box), a desktop shortcut. --> @@ -49,10 +50,21 @@ + + + + + + + + @@ -70,11 +82,20 @@ + + + + + + + + diff --git a/packaging/windows/installer-ui.wxs b/packaging/windows/installer-ui.wxs index 14989932..16ab58d7 100644 --- a/packaging/windows/installer-ui.wxs +++ b/packaging/windows/installer-ui.wxs @@ -3,7 +3,7 @@ EffectCraft installer wizard: original dialogs built from Windows Installer's own controls and events (no WixUI library), included by effectcraft.wxs with . - fresh install: DestDlg (folder, Change... opens BrowseDlg) -> ReadyDlg -> ProgressDlg -> ExitDlg + fresh install: DestDlg (folder, Change... opens BrowseDlg, desktop shortcut) -> ReadyDlg -> ProgressDlg -> ExitDlg upgrade: ReadyDlg (the installed copy's folder, not editable) -> ProgressDlg -> ExitDlg already there: MaintDlg (Repair or Remove) -> ProgressDlg -> ExitDlg @@ -29,6 +29,7 @@ + diff --git a/packaging/windows/test-msi.ps1 b/packaging/windows/test-msi.ps1 index 0bed8c43..5868c77b 100644 --- a/packaging/windows/test-msi.ps1 +++ b/packaging/windows/test-msi.ps1 @@ -3,15 +3,17 @@ Install-test the EffectCraft MSI's folder handling with stub binaries (no Rust build). .DESCRIPTION - Builds the MSI around two stand-in executables as 0.0.1 the way 0.6.0 and earlier were (no App - Paths "Path" value), then as 0.0.2 and 0.0.3, validates them (ICE), and, silently and per-machine: + Builds the MSI around two stand-in executables as 0.0.1 the way releases before this installer + change are (no App Paths "Path" value), then as 0.0.2 and 0.0.3, validates them (ICE), and, silently and per-machine: 1. installs 0.0.1 into a folder with spaces and a non-ASCII name (INSTALLFOLDER=...), 2. upgrades to 0.0.2 without naming a folder: it must stay in that folder (found from the installed effectcraft.exe, as there is no Path value yet), 3. upgrades to 0.0.3 the same way (found from the Path value 0.0.2 wrote), 4. repairs a damaged (unversioned) CLI there with the wizard's Repair mode, - 5. uninstalls: files and the App Paths registration go, - 6. installs 0.0.3 with no folder: it must land in Program Files, then uninstalls. + 5. uninstalls: files, the App Paths registration and the desktop shortcut go, + 6. installs 0.0.3 with no folder and DESKTOPSHORTCUT=0: it must land in Program Files with no + desktop shortcut, then uninstalls. + The installs before step 6 take the default desktop shortcut, which upgrades keep. The wizard itself needs a person (or the snapshot steps in the PR); this covers what msiexec /qn and upgrades do with the folder. Needs an elevated shell and WiX v5 (`wix`) on PATH. Run by .github/workflows/packaging-lint.yml. @@ -24,6 +26,8 @@ $ErrorActionPreference = 'Stop' trap { Write-Output "FAILED: $_"; exit 1 } $Root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path $AppPaths = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\effectcraft.exe' +# A per-machine install's desktop is the Public one. +$DesktopLink = Join-Path ([Environment]::GetFolderPath('CommonDesktopDirectory')) 'EffectCraft.lnk' function Invoke-Native([string] $What, [scriptblock] $Block) { Write-Output "==> $What" @@ -50,6 +54,10 @@ function Assert-InstalledIn([string] $Dir, [switch] $NoPathValue) { } Write-Output "ok installed in $Dir" } +function Assert-DesktopShortcut([bool] $Present) { + if ((Test-Path -LiteralPath $DesktopLink) -ne $Present) { throw "desktop shortcut present: $(-not $Present), expected $Present" } + Write-Output "ok desktop shortcut present: $Present" +} Remove-Item -Recurse -Force $Work -ErrorAction SilentlyContinue $Bin = Join-Path $Work 'bin' @@ -60,7 +68,8 @@ New-Item -ItemType Directory -Force -Path $Bin | Out-Null Copy-Item (Join-Path $env:SystemRoot 'System32\notepad.exe') (Join-Path $Bin 'effectcraft.exe') [IO.File]::WriteAllText((Join-Path $Bin 'effectcraft-cli.exe'), 'effectcraft-cli stand-in') -# 0.0.1 stands in for 0.6.0 and earlier: the same package without the App Paths "Path" value. +# 0.0.1 stands in for releases before this installer change: the same package without the App +# Paths "Path" value. $Wxs = Join-Path $PSScriptRoot 'effectcraft.wxs' $LegacyWxs = Join-Path $Work 'effectcraft-legacy.wxs' $pathValue = '(?m)^.*]*Name="Path"[^>]*/>\r?\n' @@ -86,6 +95,7 @@ if (Test-Path -LiteralPath $AppPaths) { throw 'EffectCraft is already installed Invoke-Msiexec 'install the legacy package to a custom folder' @('/i', "`"$($Msi['0.0.1'])`"", "INSTALLFOLDER=`"$Custom`"") Assert-InstalledIn $Custom -NoPathValue +Assert-DesktopShortcut $true if (Test-Path -LiteralPath $Default) { throw "a custom install also wrote $Default" } Invoke-Msiexec 'upgrade the legacy install without a folder' @('/i', "`"$($Msi['0.0.2'])`"") @@ -94,6 +104,7 @@ if (Test-Path -LiteralPath $Default) { throw "the upgrade moved the install to $ Invoke-Msiexec 'upgrade again without a folder' @('/i', "`"$($Msi['0.0.3'])`"") Assert-InstalledIn $Custom +Assert-DesktopShortcut $true # Read single values: Get-ItemProperty throws on some machines' malformed Uninstall entries. $arp = @(Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' | ForEach-Object { if ($_.GetValue('DisplayName') -eq 'EffectCraft') { $_.GetValue('DisplayVersion') } }) @@ -116,9 +127,11 @@ Write-Output "ok repair ($mode) restored effectcraft-cli.exe" Invoke-Msiexec 'uninstall the custom install' @('/x', "`"$($Msi['0.0.3'])`"") if (Test-Path -LiteralPath (Join-Path $Custom 'effectcraft.exe')) { throw 'uninstall left effectcraft.exe behind' } if (Test-Path -LiteralPath $AppPaths) { throw 'uninstall left the App Paths registration behind' } +Assert-DesktopShortcut $false -Invoke-Msiexec 'install to the default folder' @('/i', "`"$($Msi['0.0.3'])`"") +Invoke-Msiexec 'install to the default folder without a desktop shortcut' @('/i', "`"$($Msi['0.0.3'])`"", 'DESKTOPSHORTCUT=0') Assert-InstalledIn $Default +Assert-DesktopShortcut $false Invoke-Msiexec 'uninstall the default install' @('/x', "`"$($Msi['0.0.3'])`"") if (Test-Path -LiteralPath (Join-Path $Default 'effectcraft.exe')) { throw 'uninstall left effectcraft.exe behind' } Write-Output 'MSI folder handling ok'