UI.9: retain shared docking across upstream workspace changes

This commit is contained in:
skylar taylor-barrick 2026-10-10 19:25:49 -06:00
commit 83ae6218ef
183 changed files with 9574 additions and 1030 deletions

View File

@ -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

View File

@ -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

2
Cargo.lock generated
View File

@ -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",

View File

@ -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.

View File

@ -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 |

View File

@ -117,6 +117,9 @@ const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|se
(app.project, comps, layers, properties…); prints writeLn
output and the result; errors exit 1 with file:line:col
mcp [--autosave | --bridge PORT] MCP server (JSON-RPC over stdio); --autosave preserves unsaved headless work
[--automation-read-root DIR] [--automation-write-root DIR]
confine the agent's file access: reads under a read or write root,
writes under a write root (each repeatable); scripts are refused
options: --project F.ecproj | --demo | --empty --save | --save-as F --bridge PORT --json --gpu
<comp>: id or name, '-' = active comp; <layer>: id, '#n' or name; <value>: 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::<Vec<_>>();
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 =

View File

@ -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 }

View File

@ -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::<f32>(&dev, &cfg, feed, latency, map),
cpal::SampleFormat::I16 => run::<i16>(&dev, &cfg, feed, latency, map),
cpal::SampleFormat::U16 => run::<u16>(&dev, &cfg, feed, latency, map),
cpal::SampleFormat::I32 => run::<i32>(&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::<f32>(&dev, cfg, feed.clone(), latency.clone(), map),
cpal::SampleFormat::I16 => run::<i16>(&dev, cfg, feed.clone(), latency.clone(), map),
cpal::SampleFormat::U16 => run::<u16>(&dev, cfg, feed.clone(), latency.clone(), map),
cpal::SampleFormat::I32 => run::<i32>(&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());
}
}

View File

@ -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<Backends>,
/// 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<String>,
marker: Option<PathBuf>,
/// 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<File>,
/// When the window drew its first frame, until the launch settles.
first_frame: Option<Instant>,
}
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<Duration> {
self.frame_drawn_at(Instant::now())
}
fn frame_drawn_at(&mut self, now: Instant) -> Option<Duration> {
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]

View File

@ -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<Result<String, String>>| {
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();
}

View File

@ -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<Box<dyn Future<Output = Vec<String>> + 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<FileHandle>>) -> Vec<String> {
files.unwrap_or_default().into_iter().map(|f| f.path().to_string_lossy().to_string()).collect()
}
fn path(file: Option<FileHandle>) -> Vec<String> {
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) })
}
}
}

View File

@ -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

View File

@ -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<Confinement>,
}
enum Target {
/// An in-process session (no window): engine commands, inspection and rendering.
Headless(Box<Session>),
/// 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<Self> {
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<Value> {
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, &params)? {
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<Value> {
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<Value> {
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<f64>, max_side: u32, transparent: bool) -> Result<Frame> {
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);

View File

@ -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<PathBuf>,
write: Vec<PathBuf>,
}
/// 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<Self> {
let real = |p: &PathBuf| p.canonicalize().map_err(|e| Error::BadArgs(format!("automation root {}: {e}", p.display())));
let read = read.iter().map(real).collect::<Result<Vec<_>>>()?;
let write = write.iter().map(real).collect::<Result<Vec<_>>>()?;
Ok(Self { read, write })
}
fn roots(&self, access: Access) -> impl Iterator<Item = &PathBuf> {
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<PathBuf> {
let denied = |why: &str| {
let kind = if access == Access::Read { "read" } else { "write" };
let roots: Vec<String> = 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<Option<Lookup>> {
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<String>) {
fn walk(v: &Value, out: &mut Vec<String>, 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<PathBuf> {
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()?;
}
}

View File

@ -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;

View File

@ -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);
}

View File

@ -103,12 +103,19 @@ pub fn available(bridge: bool) -> impl Iterator<Item = &'static ToolDef> {
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<Reply> {
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<Reply> {
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<Reply> {
fn screenshot(b: &mut Backend, a: &Value) -> Result<Reply> {
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"))]))?;

View File

@ -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<f32> {
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]

View File

@ -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;

View File

@ -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", &[]));
}

View File

@ -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<crate::ParamSpec>, 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);

View File

@ -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: &params, 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);

View File

@ -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<crate::ParamSpec>, 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<LayerPixels> {
None
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
fn params_at(&self, t: f64) -> Option<Params> {
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<LayerPixels> {
None
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
fn params_at(&self, t: f64) -> Option<Params> {
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]);

View File

@ -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<LayerPixels> {
None
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
fn self_at(&self, t: f64, _: usize) -> Option<Buf> {
// 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 };

View File

@ -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() }
}

View File

@ -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();

View File

@ -1427,7 +1427,7 @@ pub fn specs() -> Vec<EffectSpec> {
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<EffectSpec> {
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::<Vec<_>>())),
@ -1459,7 +1459,7 @@ pub fn specs() -> Vec<EffectSpec> {
"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(),

View File

@ -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 {

View File

@ -385,8 +385,9 @@ pub fn specs() -> Vec<EffectSpec> {
],
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),
]
}

View File

@ -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();

View File

@ -105,7 +105,7 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
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 {

View File

@ -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",

View File

@ -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<Value> {
_ => {}
}
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<Value> {
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);

View File

@ -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 {

View File

@ -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<Value> {
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<Value> {
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<Option<Value>> {
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<Value> {
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<String> {
let cid = s.active_comp_id()?;
@ -392,6 +495,9 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
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());
}

View File

@ -387,7 +387,8 @@ fn reset(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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::<Vec<_>>()})
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::<Vec<_>>());
}
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::<Vec<_>>()})
})
.collect();
Ok(json!(v))
@ -469,7 +487,12 @@ pub fn specs() -> Vec<CommandSpec> {
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<CommandSpec> {
),
]
}
#[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());
}
}

View File

@ -43,7 +43,8 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<PendingImport> {
}
// Numbered stills as image sequences (the Import dialog's "<format> 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<Value> {
),
};
}
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<CommandSpec> {
"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<CommandSpec> {
"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
),

View File

@ -465,6 +465,8 @@ fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
}
_ => 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<Value> {
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}))

View File

@ -1270,6 +1270,37 @@ fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
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]) {

View File

@ -192,6 +192,32 @@ fn set_transform(s: &mut Session, p: &Value) -> Result<Value> {
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.

View File

@ -105,7 +105,10 @@ fn browser_import(s: &mut Session, p: &Value) -> Result<Value> {
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<CommandSpec> {
"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
),

View File

@ -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/<effect>#n/<param>`) 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<Vec<(LayerId, Uid)>> {
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<Vec<f64>> {
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<Vec<f64>> {
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<Value> {
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::<Vec<f64>>()),
_ => None,
};
let run = |s: &mut Session| -> Result<Value> {
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<f64> = 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<Value> {
let twins = selected_twins(s, p, "prop.toggleAnimation")?;
if twins.is_empty() {
return toggle_anim(s, p);
}
let run = |s: &mut Session| -> Result<Value> {
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<Value> {
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<Value> {
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<Value> {
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<CommandSpec> {
"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",

View File

@ -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<Value> {
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<Value> {
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<Value> {
}
};
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<Value> {
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);

View File

@ -185,13 +185,13 @@ fn capabilities(s: &mut Session, _: &Value) -> Result<Value> {
"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<Value> {
Ok(serde_json::to_value(&s.state).unwrap_or_default())

View File

@ -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<Value> {
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<Value> {
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<Value> {
fn prefs_reset(s: &mut Session, p: &Value) -> Result<Value> {
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 {

View File

@ -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::<i64>()
{
// 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)))
}
}
}
}

View File

@ -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;

View File

@ -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<String> =
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"]);

View File

@ -190,8 +190,8 @@ fn forget(s: &mut Session, paths: &BTreeSet<String>) {
/// 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<Stamp>)>) -> crate::Result<Value> {
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<Stamp>)>) -> crate
}
let ids: Vec<ItemId> = 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() {

View File

@ -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<Project>) {
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

View File

@ -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<String>,
/// 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<String>,
/// Expression syntax checker (set by the host that links the expression engine).
pub expr_check: Option<fn(&str) -> std::result::Result<(), String>>,
pub importer: Option<Arc<dyn Importer>>,
@ -472,8 +479,16 @@ pub struct Session {
pub footage_stamps: std::collections::HashMap<String, footage_reload::Stamp>,
/// The stamps being taken on a worker thread ([`footage_reload::start_scan`]).
pub footage_scan: Option<footage_reload::Scan>,
/// 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<dyn FnOnce() -> Option<(u32, u32, Vec<u8>)> + 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<Value> {
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())
}

View File

@ -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<Page> {
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: "{}" },

View File

@ -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<std::path::PathBuf>) -> Option<std::path::PathBuf> {
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<String> {
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())

View File

@ -20,15 +20,17 @@ use crate::{Importer, Services};
/// How an import treats numbered stills.
#[derive(Clone, Copy, Debug)]
pub struct SequenceOptions {
/// The "<format> Sequence" checkbox: numbered stills of one run import as a sequence.
/// The "<format> Sequence" checkbox: picked numbered stills of one run import as a sequence.
pub sequence: bool,
/// The numbered stills inside a picked or dropped folder import as sequences.
pub folders: bool,
/// 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 "<format> Sequence" checkbox (off
/// unless ticked) as `sequence`. Without `sequence` or `drag` (scripts, agents) a numbered
/// still brings its run, as it always has.
pub fn for_import(p: &serde_json::Value) -> SequenceOptions {
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<Str
}
/// What importing `paths` makes, in the order picked: folders become their importable files,
/// and (with the Sequence option) numbered stills of one run one [`Source::Sequence`].
/// and (with the Sequence option, or [`SequenceOptions::folders`] for a folder's files) numbered
/// stills of one run one [`Source::Sequence`].
pub fn group(services: &dyn Services, paths: &[String], opts: SequenceOptions) -> Vec<Source> {
let mut files: Vec<String> = 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<String> =
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<String>)> = vec![];
let mut out: Vec<Option<Source>> = vec![];
let mut slot: Vec<usize> = 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<Vec<String>> {
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"]);

View File

@ -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();

View File

@ -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<String> {
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, &current);
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(&current).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, &current);
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(&current).len(), 2);
}

View File

@ -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);
}

View File

@ -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();

View File

@ -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();

View File

@ -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<String> = 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;

View File

@ -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);
}

View File

@ -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]

View File

@ -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<u8> = 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<wgpu::Buffer> = 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<wgpu::Buffer> = 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: &params(u0, f0), usage: wgpu::BufferUsages::UNIFORM });
let ub = g.buffer_init(None, &params(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));

View File

@ -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<T>(_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::<Result<HashMap<_, _>, 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::<Result<HashMap<_, _>, 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),

View File

@ -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);

View File

@ -476,6 +476,10 @@ fn wire_removal(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
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"));

View File

@ -299,10 +299,13 @@ fn cc_slant(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
)
}
fn cc_smear(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
fn cc_smear(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
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<GBuf> {
}
fn power_pin(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
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<GBuf> {
table(e, b, &data, false, blend.max(0.0))
}
fn cc_tiler(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
fn cc_tiler(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
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);

View File

@ -102,6 +102,8 @@ pub struct Gpu {
ctx: Arc<GpuContext>,
/// Backend::Auto's per-comp CPU / GPU timings.
auto: Arc<effectcraft_render::AutoPick>,
/// (comp, reason) pairs already warned about when the GPU declined a frame.
declined: Arc<std::sync::Mutex<std::collections::HashSet<(u64, String)>>>,
}
/// 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<T>(&self, f: impl FnOnce() -> T) -> Result<T, String> {
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<Image> {
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 {

View File

@ -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<Vec<SimPartic
};
let d = &g.device;
let births: Vec<u8> = 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<Vec<SimPartic
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let ub = d.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("particles"),
contents: &uniforms(req, n, s0, prev_n),
usage: wgpu::BufferUsages::UNIFORM,
});
let ub = g.buffer_init(Some("particles"), &uniforms(req, n, s0, prev_n), wgpu::BufferUsages::UNIFORM);
let bg = d.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &p.bgl,

View File

@ -235,7 +235,7 @@ fn separable(mode: u32, cb: f32, cs: f32) -> 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<f32>, cs: vec3<f32>) -> vec3<f32> {
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<f32>, src: vec4<f32>, noise: f32) -> vec4<f3
cs = clamp(cs, vec3<f32>(0.0), vec3<f32>(1.0));
cb = clamp(cb, vec3<f32>(0.0), vec3<f32>(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<f32>((1.0 - da) * src.xyz + da * bc, ao);
}
let b = non_separable(mode, cb, cs);
return vec4<f32>((1.0 - da) * src.xyz + (1.0 - sa) * dst.xyz + sa * da * b, ao);
}

View File

@ -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);
}
}

View File

@ -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())]);

View File

@ -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<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<f32>,
) -> Option<GpuImage> {
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));

View File

@ -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 }

View File

@ -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() {

View File

@ -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::<f64>().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::<f64>() * 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<f64> = (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<Keyframe> = [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::<Vec<_>>();
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<Keyframe> = 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::<Vec<_>>());
}
}
#[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()];

View File

@ -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<AuxChannels> {
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<image::DynamicImage> {
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<image::DynamicImage> {
let aux = cached(path, || Some(bytes))?;
picture_of(&aux)
}
/// The picture of decoded channels (see [`layered_image`]).
fn picture_of(aux: &AuxChannels) -> Option<image::DynamicImage> {
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::*;

View File

@ -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<HashSet<std::path::PathBuf>>,
/// 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<HashSet<std::path::PathBuf>>,
}
/// 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<f32> {
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<Vec<f32>, String> {
let path: Arc<str> = 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<f32> {
/// channels; silence before the start. An error where decoding fails.
fn read_stereo(src: &SharedSource, s0: i64, frames: usize, rate: u32) -> std::result::Result<Vec<f32>, 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<T>(m: &Mutex<T>) -> 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.

View File

@ -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<Footage> {
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()),

View File

@ -221,11 +221,9 @@ fn ps_parse(lx: &mut crate::object::Lexer, depth: usize) -> Vec<PsOp> {
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)),
_ => {}

View File

@ -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));

View File

@ -219,6 +219,23 @@ pub struct ProjectSettings {
/// sRGB; Rec. 2100 PQ / HLG give HDR output.
#[serde(default)]
pub output_space: Option<ColorSpace>,
/// 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,
}
}
}

View File

@ -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<String>,
},
Gradient,
/// Not shown in Effect Controls / the timeline (internal data).
Hidden,

View File

@ -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()?;

View File

@ -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
}
}

View File

@ -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<Arc<Image>> {
@ -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<Arc<Buf>> {
@ -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];

View File

@ -114,10 +114,8 @@ pub fn shapes(ctx: &EvalCtx, layer: &Layer) -> Vec<effectcraft_effects::MaskShap
let mut pts: Vec<[f64; 2]> = 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 {

View File

@ -517,6 +517,20 @@ fn draw_bounds(d: &Draw, paths: &[BezPath]) -> Option<kurbo::Rect> {
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<kurbo::Rect> {
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<BezPath>
(!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<kurbo::Rect> {
let mut arena = Vec::new();
let (draws, _) = collect(ctx, layer, contents, &mut arena);
draws
.iter()
.filter_map(|d| {
let paths: Vec<BezPath> = d.slots.iter().map(|&i| arena[i].clone()).filter(|p| !p.elements().is_empty()).collect();
draw_bounds(d, &paths)
let paths: Vec<BezPath> = 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))
}

View File

@ -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:?}");
}
}

View File

@ -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<ColorSpace>| {
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()
});
}

View File

@ -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,

View File

@ -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"; },

View File

@ -152,7 +152,7 @@ pub(crate) fn exec(s: &mut Session, id: &str, params: J) -> Result<J, String> {
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)

View File

@ -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();

View File

@ -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<PathBuf> {
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<PathBuf>) {
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<FaceId> {
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();

View File

@ -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;

View File

@ -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<bool>,
}
/// 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::<usize>(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::<String>(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::<String>(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)]
}

View File

@ -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<PanelKind>,
/// 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<f32>,
}
/// A saved [`StackEntry`]: workspaces saved before stacked frames held tabs name one `panel`.
#[derive(Deserialize)]
struct StackEntryRepr {
#[serde(default)]
panel: Option<PanelKind>,
#[serde(default)]
panels: Vec<PanelKind>,
#[serde(default)]
active: usize,
open: bool,
#[serde(default)]
height: Option<f32>,
}
impl From<StackEntryRepr> 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<f32>) -> 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<PanelKind> {
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<f32>)]) -> 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<PanelKind>) -> 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<PanelKind>) -> 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<PanelKind>) -> 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<f32> {
.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<f32>) -> 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::<DockNode>(&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::*;

View File

@ -25,7 +25,15 @@ fn encode_node(node: &DockNode, depth: usize, count: &mut usize) -> Result<Node<
if entries.len() > 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<PanelKind>) -> 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<PanelKind>) -> 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<StackEntry>) {
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<StackEntry> = 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<PanelKind>) -> 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<f32>) {
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<PanelKind>) -> 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<PanelKind>) -> 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<PanelKind>) {
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<PanelKind>) {
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]);
}
}

View File

@ -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;
}

Some files were not shown because too many files have changed in this diff Show More