mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 15:48:33 +00:00
Merge remote-tracking branch 'origin/main' into HEAD
This commit is contained in:
commit
b766bf2fd4
80 files changed
+2467
-213
No files matched your search
@@ -28,8 +28,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
windows-arm64:
|
||||
# Immutable supporting workflow revision until the new install contract lands upstream.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@fa5ef18780cf5e7d5d3b8de068de56cffc83cac5
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
|
||||
with:
|
||||
app-name: effectcraft
|
||||
app-dir: EffectCraft
|
||||
|
||||
@@ -144,3 +144,8 @@ as PhotoCraft does with `cargo xtask corpus`.
|
||||
GitHub profiles). Never hand-edit `contributors.json`.
|
||||
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
|
||||
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
@@ -30,3 +30,8 @@ EffectCraft is a clean-room, open-source, pure-Rust motion graphics and visual e
|
||||
- For UI work, **look at the result**. Headless (no window, works while the user is busy on the machine): `cargo run -p effectcraft-ui-egui --example snapshot -- --out ui.png --step '{"method":"engine.execute","params":{"command":"layer.select","params":{"layers":["#2"]}}}'` (steps are control-channel requests; `--script steps.jsonl`; `{"method":"snap","params":{"path":…}}` for intermediate shots). Or drive the live app via the control channel and take `ui.screenshot` (needs the window visible on screen).
|
||||
- After Effects reference: drive AE via ExtendScript (`osascript -e 'tell application "Adobe After Effects 2026" to DoScriptFile "x.jsx"'`) and capture only AE's window (`screencapture -l <windowid>`); keep captures in `plan/aftereffects/ref/` (gitignored), never commit them, never capture the Home screen.
|
||||
- Parallel agents: separate git worktrees and `CARGO_TARGET_DIR=target/agent-<name>`; keep every `Cargo.toml` valid (the `crates/*` glob).
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
@@ -37,6 +37,45 @@ debugger session use the `dev-full` profile, which is the same build with full d
|
||||
cargo run --profile dev-full -p effectcraft -- --demo
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
The MSVC toolchain, rustup's default on Windows, is what the releases use: with the Visual Studio
|
||||
C++ build tools installed, the commands above work as they are.
|
||||
|
||||
The GNU toolchain (`x86_64-pc-windows-gnu`) needs a current mingw-w64 GCC and binutils first on
|
||||
`PATH`. They provide the linker and the Windows import libraries, such as `libshlwapi.a` for
|
||||
opening links from the app. With MSYS2:
|
||||
|
||||
```powershell
|
||||
C:\msys64\usr\bin\pacman.exe -S --needed mingw-w64-x86_64-gcc
|
||||
$env:PATH = "C:\msys64\mingw64\bin;$env:PATH"
|
||||
cargo +stable-x86_64-pc-windows-gnu run -p effectcraft -- --demo
|
||||
```
|
||||
|
||||
An older MinGW earlier on `PATH` can break the build: CodeBlocks' GCC 8.1 fails to link
|
||||
`boa_engine` ([#1882](https://github.com/storytold/effectcraft/issues/1882)).
|
||||
|
||||
Rust's bundled LLD and self-contained libraries don't include every import library EffectCraft
|
||||
needs, so on their own they stop with `unable to find library` for `-lshlwapi`, `-lktmw32`,
|
||||
`-lpropsys` and `-lversion` ([#1886](https://github.com/storytold/effectcraft/issues/1886)). To
|
||||
link that way, copy those four from mingw-w64 into a folder of its own and add that folder to the
|
||||
search path in your own Cargo config (`%USERPROFILE%\.cargo\config.toml`). Don't add all of
|
||||
mingw-w64's `lib` folder: its C runtime clashes with Rust's bundled one
|
||||
(`undefined symbol: DllEntryPoint`).
|
||||
|
||||
```powershell
|
||||
New-Item -ItemType Directory -Force C:\gnu-import-libs
|
||||
foreach ($lib in "shlwapi", "ktmw32", "propsys", "version") {
|
||||
Copy-Item "C:\msys64\mingw64\lib\lib$lib.a" C:\gnu-import-libs\
|
||||
}
|
||||
```
|
||||
|
||||
```toml
|
||||
[target.x86_64-pc-windows-gnu]
|
||||
linker = "rust-lld"
|
||||
rustflags = ["-C", "link-self-contained=yes", "-L", "native=C:/gnu-import-libs"]
|
||||
```
|
||||
|
||||
## Ground rules
|
||||
|
||||
- **Clean room.** Work from After Effects' public documentation and its observable behaviour only.
|
||||
|
||||
Generated
+6
-5
@@ -1454,6 +1454,7 @@ dependencies = [
|
||||
"kurbo",
|
||||
"log",
|
||||
"rayon",
|
||||
"same-file",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
@@ -4292,7 +4293,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.45.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4884,9 +4885,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "roxmltree"
|
||||
version = "0.21.1"
|
||||
version = "0.21.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
|
||||
checksum = "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
@@ -7469,9 +7470,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "9.1.0"
|
||||
version = "9.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2886b00003a72c4ccee3f42cfc34908d33b2508a74d827dc2a4244706475c704"
|
||||
checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
|
||||
@@ -74,6 +74,7 @@ skrifa = { version = "0.44", default-features = false, features = ["std"] }
|
||||
harfrust = { version = "0.12", default-features = false, features = ["std"] }
|
||||
exr = "1.74"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
||||
same-file = "1.0"
|
||||
|
||||
# media codecs + containers: FilmCraft's pure-Rust crates, pinned to a revision (plan/adr/0001).
|
||||
# Only `crates/media`, `crates/export` and `crates/interchange` may depend on these.
|
||||
|
||||
@@ -890,6 +890,7 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
r["outputModuleSummary"].as_str().unwrap_or("")
|
||||
);
|
||||
}
|
||||
let attempted: std::collections::HashSet<u64> = s.project.render_queue.iter().filter(|i| i.is_queued()).map(|i| i.id).collect();
|
||||
s.execute("renderQueue.render", json!({"wait": false})).map_err(err)?;
|
||||
let tty = std::io::IsTerminal::is_terminal(&std::io::stderr());
|
||||
// `--progress`: one line per update for a program reading stderr (a terminal gets the live line).
|
||||
@@ -928,7 +929,7 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
}
|
||||
let mut results = vec![];
|
||||
let mut failed = vec![];
|
||||
for it in s.project.render_queue.iter().filter(|i| i.render && i.started.is_some()) {
|
||||
for it in s.project.render_queue.iter().filter(|i| attempted.contains(&i.id)) {
|
||||
let name = s.project.item(it.comp).map(|i| i.name.clone()).unwrap_or_else(|| "?".into());
|
||||
match &it.status {
|
||||
RenderStatus::Done => {
|
||||
|
||||
@@ -605,6 +605,49 @@ fn a_failed_render_under_run_and_exec_exits_1() {
|
||||
assert!(v["error"].as_str().is_some_and(|e| e.starts_with("T: ")), "{v}");
|
||||
}
|
||||
|
||||
/// A source refusal happens before encoding and has no started timestamp. It must still
|
||||
/// fail the CLI, while the valid queue item finishes and the imported frame stays intact.
|
||||
#[test]
|
||||
fn a_partly_protected_queue_exits_1_under_render_run_and_exec() {
|
||||
let root = tmp("protected-mixed-queue");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let source = root.join("source_00000.png");
|
||||
image::RgbaImage::from_pixel(4, 4, image::Rgba([40, 80, 120, 255])).save(&source).unwrap();
|
||||
let original = std::fs::read(&source).unwrap();
|
||||
let project = root.join("q.ecproj");
|
||||
let good = root.join("good.gif");
|
||||
let import = json!({"paths": [source]}).to_string();
|
||||
let blocked = json!({"format": "png", "output": root.join("source_[#####].png")}).to_string();
|
||||
let valid = json!({"format": "gif", "output": good}).to_string();
|
||||
ok_json(&[
|
||||
"run",
|
||||
"--empty",
|
||||
"--save-as",
|
||||
project.to_str().unwrap(),
|
||||
"comp.new",
|
||||
r#"{"name":"Main","width":16,"height":16,"frameRate":10,"duration":0.1}"#,
|
||||
"layer.newSolid",
|
||||
"{}",
|
||||
"file.import",
|
||||
&import,
|
||||
"renderQueue.add",
|
||||
&blocked,
|
||||
"renderQueue.add",
|
||||
&valid,
|
||||
]);
|
||||
let p = project.to_str().unwrap();
|
||||
for args in [vec!["render", "--queue", "--project", p], vec!["run", p, "renderQueue.render", "{}"], vec!["exec", "renderQueue.render", "--project", p]] {
|
||||
std::fs::write(&good, b"previous output").unwrap();
|
||||
let (code, result) = run_json(&args);
|
||||
assert_eq!(code, 1, "{args:?}: {result}");
|
||||
assert!(result["error"].as_str().is_some_and(|e| e.contains("footage") && e.contains("source_00000.png")), "{result}");
|
||||
assert_eq!(std::fs::read(&source).unwrap(), original);
|
||||
assert!(std::fs::read(&good).unwrap().starts_with(b"GIF8"), "the valid item still rendered");
|
||||
}
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
/// `render --bitrate` (#440) targets the bitrate in VP9 WebM.
|
||||
#[test]
|
||||
fn render_bitrate_applies_to_vp9_webm() {
|
||||
|
||||
@@ -57,6 +57,50 @@ fn property_group_error_points_to_layer_inspection() {
|
||||
assert!(call_json(&mut s, "get_property", json!({"layer": l, "path": "geometryOptions/extrusionDepth"}))["value"].is_number());
|
||||
}
|
||||
|
||||
/// Enum properties list their values and labels, so a client can set one without guessing (#2085).
|
||||
#[test]
|
||||
fn enum_properties_list_their_options() {
|
||||
let mut s = server();
|
||||
call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "probe"}}));
|
||||
call_json(&mut s, "execute_command", json!({"command": "layer.newText", "params": {"name": "T", "text": "Hello"}}));
|
||||
call_json(&mut s, "execute_command", json!({"command": "layer.addTextAnimator", "params": {"layer": "T", "properties": ["opacity"], "name": "A"}}));
|
||||
let path = "text/animators/animator/selectors/rangeSelector/advanced/shape";
|
||||
let shapes = ["Square", "Ramp Up", "Ramp Down", "Triangle", "Round", "Smooth"];
|
||||
let expected = json!(shapes.iter().enumerate().map(|(value, label)| json!({"value": value, "label": label})).collect::<Vec<_>>());
|
||||
|
||||
let p = call_json(&mut s, "get_property", json!({"layer": "T", "path": path}));
|
||||
assert_eq!(p["type"], "enum");
|
||||
assert_eq!(p["options"], expected, "{p}");
|
||||
let opacity = call_json(&mut s, "get_property", json!({"layer": "T", "path": "transform/opacity"}));
|
||||
assert!(opacity.get("options").is_none(), "{opacity}");
|
||||
|
||||
fn find<'a>(node: &'a Value, path: &str) -> Option<&'a Value> {
|
||||
if node["path"] == path {
|
||||
return Some(node);
|
||||
}
|
||||
node["children"].as_array()?.iter().find_map(|c| find(c, path))
|
||||
}
|
||||
let tree = call_json(&mut s, "get_layer", json!({"layer": "T"}));
|
||||
let node = find(&tree["properties"], path).unwrap_or_else(|| panic!("{tree}"));
|
||||
assert_eq!(node["options"], expected);
|
||||
assert!(find(&tree["properties"], "transform/opacity").unwrap()["options"].is_null());
|
||||
|
||||
let flat = call_json(&mut s, "get_layer", json!({"layer": "T", "flat": true}));
|
||||
let props = flat["properties"].as_array().unwrap();
|
||||
assert_eq!(props.iter().find(|p| p["path"] == path).unwrap()["options"], expected);
|
||||
assert!(props.iter().find(|p| p["path"] == "transform/opacity").unwrap().get("options").is_none());
|
||||
|
||||
// Path Options ▸ Path offers None and the layer's masks, as the Timeline does.
|
||||
for _ in 0..2 {
|
||||
call_json(&mut s, "execute_command", json!({"command": "mask.new", "params": {"layer": "T", "vertices": [[0, 0], [40, 0], [40, 40]], "closed": true}}));
|
||||
}
|
||||
let masks = json!([{"value": 0, "label": "None"}, {"value": 1, "label": "Mask 1"}, {"value": 2, "label": "Mask 2"}]);
|
||||
let p = call_json(&mut s, "get_property", json!({"layer": "T", "path": "text/pathOptions/path"}));
|
||||
assert_eq!(p["options"], masks, "{p}");
|
||||
let tree = call_json(&mut s, "get_layer", json!({"layer": "T"}));
|
||||
assert_eq!(find(&tree["properties"], "text/pathOptions/path").unwrap_or_else(|| panic!("{tree}"))["options"], masks);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initialize_and_list_tools() {
|
||||
let mut s = server();
|
||||
|
||||
@@ -285,7 +285,7 @@ fn get_comp(b: &mut Backend, a: &Value) -> Result<Reply> {
|
||||
json_reply(b.exec("comp.info", obj(&[("comp", get(a, "comp"))]))?)
|
||||
}
|
||||
|
||||
/// Flatten a `layer.tree` property tree into `[{path, type, value, keys?, expression?}]`.
|
||||
/// Flatten a `layer.tree` property tree into `[{path, type, value, keys?, expression?, options?}]`.
|
||||
fn flatten(node: &Value, out: &mut Vec<Value>) {
|
||||
// A group cut off by `depth` stays visible (instead of vanishing from the flat list).
|
||||
if node.get("truncated").and_then(Value::as_bool) == Some(true) {
|
||||
@@ -302,6 +302,9 @@ fn flatten(node: &Value, out: &mut Vec<Value>) {
|
||||
if let Some(e) = node.get("expression") {
|
||||
o["expression"] = e.clone();
|
||||
}
|
||||
if let Some(opts) = node.get("options") {
|
||||
o["options"] = opts.clone();
|
||||
}
|
||||
out.push(o);
|
||||
}
|
||||
}
|
||||
@@ -626,7 +629,7 @@ static TOOLS: &[ToolDef] = &[
|
||||
},
|
||||
ToolDef {
|
||||
name: "get_layer",
|
||||
description: "A layer's property tree (transform, masks, effects, text, shape contents, ...): leaf properties have a `path` usable with get_property/set_property/add_keyframe, plus uid, match id, name, type, value at `time`, keyframes and expression. Groups contain children; inspect them here, then select a leaf path. `flat: true` returns just a list of {path, type, value, keys, expression} (compact).",
|
||||
description: "A layer's property tree (transform, masks, effects, text, shape contents, ...): leaf properties have a `path` usable with get_property/set_property/add_keyframe, plus uid, match id, name, type, value at `time`, keyframes and expression; popup (enum) properties add `options` ([{value, label}]). Groups contain children; inspect them here, then select a leaf path. `flat: true` returns just a list of {path, type, value, keys, expression, options} (compact).",
|
||||
bridge_only: false,
|
||||
schema: || {
|
||||
schema(
|
||||
@@ -643,7 +646,7 @@ static TOOLS: &[ToolDef] = &[
|
||||
},
|
||||
ToolDef {
|
||||
name: "get_property",
|
||||
description: "Read one property: type, value at `time` (comp seconds; default the current time), whether it is animated, its keyframes (time, value, in/out interpolation) and expression.",
|
||||
description: "Read one property: type, value at `time` (comp seconds; default the current time), whether it is animated, its keyframes (time, value, in/out interpolation) and expression. Popup (enum) properties add `options`: [{value, label}], the values set_property takes.",
|
||||
bridge_only: false,
|
||||
schema: || {
|
||||
schema(
|
||||
|
||||
@@ -92,8 +92,8 @@ const CPU_ONLY_CONTROLS: &[(&str, &[&str])] = &[
|
||||
];
|
||||
|
||||
/// Whether the GPU kernel of effect `id` covers the instance's current settings (see
|
||||
/// `CPU_ONLY_CONTROLS`; plus Cell Pattern's HQ variants and Turbulent Displace's locked
|
||||
/// pinning).
|
||||
/// `CPU_ONLY_CONTROLS`; plus Cell Pattern's HQ variants, Turbulent Displace's locked
|
||||
/// pinning and CC Particle World's motion trails and textured squares).
|
||||
pub fn gpu_supported(id: &str, ctx: &crate::EffectCtx) -> bool {
|
||||
match id {
|
||||
"ec.generate.cellpattern" if (6..=10).contains(&ctx.params.e("cellPattern")) => return false,
|
||||
@@ -101,6 +101,8 @@ pub fn gpu_supported(id: &str, ctx: &crate::EffectCtx) -> bool {
|
||||
// #1195: CC Particle World's motion trails (the averaged shutter samples) render on the
|
||||
// CPU kernel until the GPU particle pass supports them.
|
||||
"ec.sim.ccparticleworld" if ctx.env.shutter.filter(|(a, _, _)| *a > 0.0).is_some() => return false,
|
||||
// #1465: Textured Square's Texture Layer is drawn on the CPU only.
|
||||
"ec.sim.ccparticleworld" if crate::sim::particle_world_textured(ctx) => return false,
|
||||
_ => {}
|
||||
}
|
||||
let Some((_, controls)) = CPU_ONLY_CONTROLS.iter().find(|(e, _)| *e == id) else { return true };
|
||||
@@ -224,6 +226,10 @@ mod tests {
|
||||
assert!(!super::gpu_supported("ec.sim.ccparticleworld", &ctx));
|
||||
}
|
||||
assert!(ok("ec.sim.ccparticleworld", &[]));
|
||||
// #1465: the Texture Layer keeps CC Particle World on the CPU only when Textured Square draws it.
|
||||
let tex = ("particle/texture/textureLayer", Value::Layer(Some(1)));
|
||||
assert!(ok("ec.sim.ccparticleworld", &[tex.clone(), ("particle/particleType", Value::Enum(2))]));
|
||||
assert!(!ok("ec.sim.ccparticleworld", &[tex, ("particle/particleType", Value::Enum(8))]));
|
||||
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))]));
|
||||
|
||||
@@ -32,6 +32,7 @@ use engine::{MERCURY, PS2, PW_EFFECT_CAMERA, Stepped, rate_history, sim_key, sim
|
||||
#[cfg(test)]
|
||||
pub(crate) use engine::{PState, PW_ANIMS, Phys, simulate};
|
||||
pub(crate) use engine::{SPS, steps_at};
|
||||
pub(crate) use particle_fx::particle_world_textured;
|
||||
pub use particle_fx::{Blob, mercury_blobs};
|
||||
#[cfg(test)]
|
||||
use particle_fx::{PW_RATE, mercury_phys, ps2_phys, pw_phys};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::engine::{MERCURY, PS2, PState, PW_EFFECT_CAMERA, Phys, norm3, particle_look, pw_state, type_sprite};
|
||||
use super::sprites::{Acc, Post, Sprite, SpritePlan, raster, splat};
|
||||
use super::sprites::{Acc, Post, Sprite, SpritePlan, aa, raster, splat, sprite_bbox};
|
||||
use super::{Buf, EffectCtx, Image, IntoParallelRefIterator, ParallelIterator, SimCache, Value, unpremul};
|
||||
|
||||
// ------------------------------------------------------------------ CC Particle World
|
||||
@@ -35,20 +35,44 @@ pub(super) fn pw_phys(ctx: &EffectCtx) -> Phys {
|
||||
}
|
||||
}
|
||||
|
||||
/// CC Particle World's Particle Type ▸ Textured Square.
|
||||
const PW_TEXTURED_SQUARE: u32 = 8;
|
||||
/// CC Particle World's Particle ▸ Texture ▸ Texture Layer.
|
||||
pub(super) const PW_TEXTURE_LAYER: &str = "particle/texture/textureLayer";
|
||||
|
||||
/// Whether CC Particle World draws its Texture Layer on the particles (Textured Square with a
|
||||
/// layer chosen). Only the CPU does: `catalog::gpu_supported` keeps this case off the GPU.
|
||||
pub(crate) fn particle_world_textured(ctx: &EffectCtx) -> bool {
|
||||
ctx.params.e("particle/particleType") == PW_TEXTURED_SQUARE && ctx.params.get(PW_TEXTURE_LAYER).and_then(Value::as_layer).is_some()
|
||||
}
|
||||
|
||||
pub(super) fn particle_world(ctx: &EffectCtx, b: Buf) -> Buf {
|
||||
// The texture at the current comp time, shared by every shutter sample. A layer the host
|
||||
// can't give (the effect's own layer with its effects, a reference cycle) leaves the
|
||||
// squares in the particle colour. Authored buffers (text, shapes) span only their content:
|
||||
// covering the whole layer keeps the clamped sampling from stretching their edge pixels.
|
||||
let tex = particle_world_textured(ctx).then(|| ctx.layer_param(PW_TEXTURE_LAYER, true)).flatten().map(|mut t| {
|
||||
t.buf.cover(t.size);
|
||||
t
|
||||
});
|
||||
let (w, h) = (b.img.width, b.img.height);
|
||||
let draw = |plan: &SpritePlan| match &tex {
|
||||
Some(t) => splat_textured(w, h, &plan.sprites, plan.acc, t),
|
||||
None => splat(w, h, &plan.sprites, plan.acc),
|
||||
};
|
||||
// #1195: with the comp's motion-blur shutter open, particles draw as motion trails: the
|
||||
// sprite fields of samples across the shutter are averaged, as Transform averages its
|
||||
// matrix samples. (The GPU compositor runs this case on the CPU: catalog::gpu_supported.)
|
||||
if let Some((angle, phase, n)) = ctx.env.shutter.filter(|(a, _, _)| *a > 0.0) {
|
||||
let n = (n.clamp(2, 64) as usize).max(2);
|
||||
let fd = 1.0 / ctx.fps().max(0.001);
|
||||
let mut acc = Image::new(b.img.width, b.img.height);
|
||||
let mut acc = Image::new(w, h);
|
||||
let mut post: Option<Post> = None;
|
||||
for k in 0..n {
|
||||
let t = ctx.time + (phase + angle * k as f64 / (n - 1) as f64) / 360.0 * fd;
|
||||
let sub = EffectCtx { params: ctx.params, time: t, layer_size: ctx.layer_size, seed: ctx.seed, adjustment: ctx.adjustment, env: ctx.env };
|
||||
if let Some(plan) = particle_world_plan(&sub, &b) {
|
||||
let fx = splat(b.img.width, b.img.height, &plan.sprites, plan.acc);
|
||||
let fx = draw(&plan);
|
||||
acc.data.iter_mut().zip(&fx.data).for_each(|(a, o)| (0..4).for_each(|c| a[c] += o[c]));
|
||||
post.get_or_insert(plan.post);
|
||||
}
|
||||
@@ -60,11 +84,38 @@ pub(super) fn particle_world(ctx: &EffectCtx, b: Buf) -> Buf {
|
||||
};
|
||||
}
|
||||
match particle_world_plan(ctx, &b) {
|
||||
Some(plan) => plan.finish(b),
|
||||
Some(plan) => {
|
||||
let fx = draw(&plan);
|
||||
plan.finish_with(b, fx)
|
||||
}
|
||||
None => b,
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw square sprites into a new transparent image of `w`×`h`, each showing the layer `tex`
|
||||
/// stretched over the square (turned with it) at the sprite's opacity.
|
||||
fn splat_textured(w: u32, hgt: u32, sprites: &[Sprite], acc: Acc, tex: &crate::LayerPixels) -> Image {
|
||||
let mut img = Image::new(w, hgt);
|
||||
let (tw, th) = (tex.size[0], tex.size[1]);
|
||||
let shade = |s: &Sprite, x: f32, y: f32| {
|
||||
// As [`sprite_shade`]'s Square, sub-pixel sprites included.
|
||||
let (r, fade) = if s.r < 0.5 { (0.5, s.r / 0.5) } else { (s.r, 1.0) };
|
||||
let (sn, cs) = s.rot.sin_cos();
|
||||
let (dx, dy) = (x - s.x, y - s.y);
|
||||
let (ux, uy) = (dx * cs + dy * sn, -dx * sn + dy * cs);
|
||||
let e = r * 0.75;
|
||||
let a = aa(e - ux.abs()) * aa(e - uy.abs()) * s.c[3] * fade;
|
||||
if a <= 1e-6 {
|
||||
return None;
|
||||
}
|
||||
let (u, v) = (((ux / e + 1.0) * 0.5).clamp(0.0, 1.0) as f64, ((uy / e + 1.0) * 0.5).clamp(0.0, 1.0) as f64);
|
||||
let (px, py) = tex.buf.to_px([u * tw, v * th]);
|
||||
Some(tex.buf.img.sample_bilinear_clamped(px, py).map(|c| c * a))
|
||||
};
|
||||
raster(&mut img, sprites, sprite_bbox, shade, acc);
|
||||
img
|
||||
}
|
||||
|
||||
/// Effect Camera ▸ FOV's default (degrees).
|
||||
pub(super) const PW_FOV: f64 = 50.0;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::common::{pct, pt, spec};
|
||||
use super::engine::{PARTICLE_TYPES, PW_ANIMS};
|
||||
use super::particle_fx::{PW_FOV, mr_mercury, particle_systems2, particle_systems2_plan, particle_world, particle_world_plan};
|
||||
use super::particle_fx::{PW_FOV, PW_TEXTURE_LAYER, mr_mercury, particle_systems2, particle_systems2_plan, particle_world, particle_world_plan};
|
||||
use super::sprites::SpritePlan;
|
||||
use super::weather::{bubbles, drizzle, rainfall, rainfall_plan, snowfall, snowfall_plan, star_burst, star_burst_plan};
|
||||
use super::{Buf, EffectCtx, EffectSpec, ParamUi, Value, col, num, p, popup, slider};
|
||||
@@ -151,6 +151,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
p("physics/gravityVector/gravityY", "Gravity Y", num(1.0), slider(-1.0, 1.0, -1.0, 1.0, 2)),
|
||||
p("physics/gravityVector/gravityZ", "Gravity Z", num(0.0), slider(-1.0, 1.0, -1.0, 1.0, 2)),
|
||||
p("particle/particleType", "Particle Type", Value::Enum(0), popup(PARTICLE_TYPES)),
|
||||
p(PW_TEXTURE_LAYER, "Texture Layer", Value::Layer(None), ParamUi::Layer),
|
||||
p("particle/birthSize", "Birth Size", num(0.2), slider(0.0, 10.0, 0.0, 1.0, 3)),
|
||||
p("particle/deathSize", "Death Size", num(0.2), slider(0.0, 10.0, 0.0, 1.0, 3)),
|
||||
p("particle/sizeVariation", "Size Variation", num(0.0), pct()),
|
||||
|
||||
@@ -225,7 +225,7 @@ impl Post {
|
||||
}
|
||||
}
|
||||
|
||||
fn sprite_bbox(s: &Sprite) -> Option<[f32; 4]> {
|
||||
pub(super) fn sprite_bbox(s: &Sprite) -> Option<[f32; 4]> {
|
||||
if s.c[3] <= 1e-5 || s.r <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -383,6 +383,110 @@ fn particle_world_effect_camera() {
|
||||
assert_ne!(key(¶ms), key(&moved));
|
||||
}
|
||||
|
||||
/// A host whose every layer is `img`.
|
||||
struct TexHost(Image);
|
||||
impl crate::EffectHost for TexHost {
|
||||
fn layer(&self, _: u64, _: bool) -> Option<crate::LayerPixels> {
|
||||
let s = [self.0.width as f64, self.0.height as f64];
|
||||
Some(crate::LayerPixels { buf: Buf { img: self.0.clone(), offset: [0.0; 2], scale: 1.0 }, size: s })
|
||||
}
|
||||
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// A texture whose left half is green and right half blue.
|
||||
fn two_tone_tex() -> Image {
|
||||
let mut tex = Image::new(8, 8);
|
||||
for y in 0..8 {
|
||||
for x in 0..8 {
|
||||
tex.set(x, y, if x < 4 { [0.0, 1.0, 0.0, 1.0] } else { [0.0, 0.0, 1.0, 1.0] });
|
||||
}
|
||||
}
|
||||
tex
|
||||
}
|
||||
|
||||
/// CC Particle World drawing red Textured Squares with Texture Layer `layer`.
|
||||
fn pw_textured_vals(layer: Option<u64>) -> Vec<(&'static str, Value)> {
|
||||
vec![
|
||||
("birthRate", num(1.0)),
|
||||
("extras/randomSeed", num(4.0)),
|
||||
("particle/particleType", Value::Enum(8)),
|
||||
("particle/texture/textureLayer", Value::Layer(layer)),
|
||||
("particle/birthSize", num(1.0)),
|
||||
("particle/deathSize", num(1.0)),
|
||||
("particle/birthColor", col(1.0, 0.0, 0.0)),
|
||||
("particle/deathColor", col(1.0, 0.0, 0.0)),
|
||||
]
|
||||
}
|
||||
|
||||
/// #1465: Textured Square draws the Texture Layer on each particle, not the particle colour.
|
||||
#[test]
|
||||
fn particle_world_textured_square_draws_the_texture_layer() {
|
||||
let host = TexHost(two_tone_tex());
|
||||
let vals = pw_textured_vals;
|
||||
let env = EffectEnv { host: Some(&host), ..Default::default() };
|
||||
let out = run_fx("ec.sim.ccparticleworld", &vals(Some(1)), Image::new(48, 48), 1.0, env).img;
|
||||
let seen: Vec<_> = out.data.iter().filter(|p| p[3] > 0.05).collect();
|
||||
assert!(!seen.is_empty(), "particles drawn");
|
||||
assert!(seen.iter().all(|p| p[0] < 0.02 * p[3]), "no particle colour: {:?}", seen.iter().find(|p| p[0] >= 0.02 * p[3]));
|
||||
// Both halves of the texture show: it is mapped across the square, not sampled once.
|
||||
assert!(seen.iter().any(|p| p[1] > 2.0 * p[2]) && seen.iter().any(|p| p[2] > 2.0 * p[1]), "both texture halves");
|
||||
// Without a texture layer the squares keep the particle colour.
|
||||
let plain = run_fx("ec.sim.ccparticleworld", &vals(None), Image::new(48, 48), 1.0, env).img;
|
||||
assert!(plain.data.iter().any(|p| p[3] > 0.05 && p[0] > 0.9 * p[3]));
|
||||
}
|
||||
|
||||
/// #1465 with #1195's motion trails: with the shutter open the averaged samples still show
|
||||
/// the Texture Layer, not the particle colour.
|
||||
#[test]
|
||||
fn particle_world_textured_square_motion_trails_draw_the_texture_layer() {
|
||||
let host = TexHost(two_tone_tex());
|
||||
let env = EffectEnv { host: Some(&host), frame_rate: 10.0, shutter: Some((360.0, -180.0, 8)), ..Default::default() };
|
||||
let blurred = run_fx("ec.sim.ccparticleworld", &pw_textured_vals(Some(1)), Image::new(48, 48), 1.0, env).img;
|
||||
let sharp = run_fx("ec.sim.ccparticleworld", &pw_textured_vals(Some(1)), Image::new(48, 48), 1.0, EffectEnv { shutter: None, ..env }).img;
|
||||
assert_ne!(sharp.data, blurred.data, "the shutter smears the particles");
|
||||
let seen: Vec<_> = blurred.data.iter().filter(|p| p[3] > 0.02).collect();
|
||||
assert!(!seen.is_empty(), "particles drawn");
|
||||
assert!(seen.iter().all(|p| p[0] < 0.02 * p[3]), "no particle colour: {:?}", seen.iter().find(|p| p[0] >= 0.02 * p[3]));
|
||||
assert!(seen.iter().any(|p| p[1] > 2.0 * p[2]) && seen.iter().any(|p| p[2] > 2.0 * p[1]), "both texture halves");
|
||||
}
|
||||
|
||||
/// A host whose every layer is `0` as given (a buffer that may span only its content).
|
||||
struct PixelsHost(crate::LayerPixels);
|
||||
impl crate::EffectHost for PixelsHost {
|
||||
fn layer(&self, _: u64, _: bool) -> Option<crate::LayerPixels> {
|
||||
Some(self.0.clone())
|
||||
}
|
||||
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// #1465: a Texture Layer whose buffer spans only its content (text, shapes) is mapped as
|
||||
/// the whole layer, transparent around the content, not with its edge pixels stretched.
|
||||
#[test]
|
||||
fn particle_world_texture_layer_with_a_content_sized_buffer() {
|
||||
// An 8×8 layer with a green 4×4 square in the middle: once as the full layer, once as
|
||||
// just the square's pixels.
|
||||
let mut full = Image::new(8, 8);
|
||||
for y in 2..6 {
|
||||
for x in 2..6 {
|
||||
full.set(x, y, [0.0, 1.0, 0.0, 1.0]);
|
||||
}
|
||||
}
|
||||
let content = Image::filled(4, 4, [0.0, 1.0, 0.0, 1.0]);
|
||||
let render = |buf: Buf| {
|
||||
let host = PixelsHost(crate::LayerPixels { buf, size: [8.0, 8.0] });
|
||||
let env = EffectEnv { host: Some(&host), ..Default::default() };
|
||||
run_fx("ec.sim.ccparticleworld", &pw_textured_vals(Some(1)), Image::new(48, 48), 1.0, env).img
|
||||
};
|
||||
let want = render(Buf { img: full, offset: [0.0; 2], scale: 1.0 });
|
||||
let got = render(Buf { img: content, offset: [-2.0, -2.0], scale: 1.0 });
|
||||
assert!(want.data.iter().any(|p| p[3] > 0.05), "particles drawn");
|
||||
assert_eq!(want.data, got.data, "the content-sized buffer draws as the full layer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn particle_systems2_seek_and_direction() {
|
||||
let v = vec![
|
||||
|
||||
@@ -39,3 +39,7 @@ rayon = { workspace = true }
|
||||
image = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
exr = { workspace = true }
|
||||
|
||||
# Native only: the volume's own identity for a file, to tell that a hard link is the footage it leads to.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
same-file = { workspace = true }
|
||||
@@ -164,8 +164,8 @@ pub use enabled::always;
|
||||
pub(crate) use enabled::{has_comp, has_keys, has_layers, has_project_selection, not_yet, not_yet_run};
|
||||
pub use params::match_path_of;
|
||||
pub(crate) use params::{
|
||||
b_p, bad, comp_id, f_p, frontend, layer_mut, layer_p, layers_p, merge_p, resolve_effect_path, resolve_layer, selectable, selected_leaf_props, str_p,
|
||||
time_p, unlocked, visible_p,
|
||||
b_p, bad, comp_id, f_p, frontend, layer_mut, layer_p, layers_p, merge_p, popup_options, resolve_effect_path, resolve_layer, selectable,
|
||||
selected_leaf_props, str_p, time_p, unlocked, visible_p,
|
||||
};
|
||||
pub use schema::{accepted_params, check_params, params_schema};
|
||||
|
||||
|
||||
@@ -254,8 +254,11 @@ fn queue_frame(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
if let Some(path) = str_p(p, "path") {
|
||||
it.output.output = path.to_string();
|
||||
} else if !it.output.format.is_sequence() {
|
||||
it.output.output = "[compName]_frame.[fileExtension]".into();
|
||||
} else {
|
||||
if !it.output.format.is_sequence() {
|
||||
it.output.output = "[compName]_frame.[fileExtension]".into();
|
||||
}
|
||||
s.avoid_sources(&mut it);
|
||||
}
|
||||
let id = s.edit("Save Frame As", None, |proj, _| {
|
||||
it.id = proj.render_queue.iter().map(|i| i.id).max().unwrap_or(0) + 1;
|
||||
|
||||
@@ -1000,8 +1000,7 @@ fn list(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<_>>());
|
||||
out["options"] = super::popup_options(options);
|
||||
}
|
||||
effectcraft_project::ParamUi::Slider { min, max, slider_min, slider_max, decimals } => {
|
||||
out["min"] = json!(min);
|
||||
|
||||
@@ -58,6 +58,12 @@ pub(crate) fn selected_leaf_props(s: &Session) -> Vec<(LayerId, effectcraft_proj
|
||||
out
|
||||
}
|
||||
|
||||
/// A popup's `[{value, label}]`, as `effect.list`, `prop.get` and `layer.tree` report it.
|
||||
/// Engine/MCP popup values are zero-based; JSX exposes one-based values.
|
||||
pub(crate) fn popup_options(options: &[String]) -> Value {
|
||||
serde_json::json!(options.iter().enumerate().map(|(value, label)| serde_json::json!({"value": value, "label": label})).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
// ---------- params ----------
|
||||
|
||||
pub(crate) fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||||
|
||||
@@ -491,6 +491,9 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"time": t.seconds(), "value": raw.to_json(), "animated": !pr.keys.is_empty(),
|
||||
"keys": keys, "expression": pr.expr.as_ref().map(|e| e.text.clone()),
|
||||
});
|
||||
if let Some(o) = l.popup_options(pr) {
|
||||
out["options"] = super::popup_options(&o);
|
||||
}
|
||||
// With an expression, `value` is the keyframed (pre-expression) value; also report what the
|
||||
// expression makes of it (what renders), or why it fails. A disabled one renders `value`; one
|
||||
// disabled by its syntax error (prop.setExpression) reports that error.
|
||||
|
||||
@@ -191,6 +191,8 @@ fn create_proxy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
it.output.channels = Channels::Rgba;
|
||||
if let Some(o) = str_p(p, "path").or(str_p(p, "output")) {
|
||||
it.output.output = o.to_string();
|
||||
} else {
|
||||
s.avoid_sources(&mut it);
|
||||
}
|
||||
it.post_render = PostRenderAction::SetProxy;
|
||||
let id = s.edit("Create Proxy", None, |proj, _| {
|
||||
|
||||
@@ -127,6 +127,9 @@ fn group_json(g: &PropGroup, path: &str, l: &Layer, t: effectcraft_time::Tick, d
|
||||
if let Some(e) = &p.expr {
|
||||
o["expression"] = json!(e.text);
|
||||
}
|
||||
if let Some(opts) = l.popup_options(p) {
|
||||
o["options"] = super::popup_options(&opts);
|
||||
}
|
||||
o
|
||||
}
|
||||
Node::Group(g) => group_json(g, &cpath, l, t, depth + 1, max_depth),
|
||||
|
||||
@@ -550,6 +550,9 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
object.remove("template");
|
||||
}
|
||||
apply_output(&mut it.output, &templates, s.state.region_of_interest, &output_params, "renderQueue.add")?;
|
||||
if str_p(p, "output").or(str_p(p, "path")).is_none() {
|
||||
s.avoid_sources(&mut it);
|
||||
}
|
||||
if let Some(l) = enum_p(p, "log", "renderQueue.add", RenderLog::parse, "errorsOnly|plusSettings|plusPerFrameInfo")? {
|
||||
it.log = l;
|
||||
}
|
||||
@@ -696,8 +699,18 @@ fn render(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
{
|
||||
log::warn!("{e}");
|
||||
}
|
||||
let queued: std::collections::HashSet<u64> = s.project.render_queue.iter().filter(|i| i.is_queued()).map(|i| i.id).collect();
|
||||
let ids = s.start_render(wait).map_err(EngineError::Other)?;
|
||||
let items: Vec<Value> = s.project.render_queue.iter().enumerate().filter(|(_, i)| ids.contains(&i.id)).map(|(k, i)| item_json(s, i, k)).collect();
|
||||
// Source refusals fail before the exporter starts. Report them just like encoding
|
||||
// failures, so CLI/MCP callers do not mistake a partly refused queue for full success.
|
||||
let items: Vec<Value> = s
|
||||
.project
|
||||
.render_queue
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, i)| ids.contains(&i.id) || (queued.contains(&i.id) && matches!(i.status, RenderStatus::Failed(_))))
|
||||
.map(|(k, i)| item_json(s, i, k))
|
||||
.collect();
|
||||
Ok(json!({"rendering": s.is_rendering(), "items": items}))
|
||||
}
|
||||
|
||||
@@ -788,6 +801,9 @@ fn pre_render(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
it.output = templates.default_output_module(TemplateSlot::PreRender);
|
||||
it.output.output = "[compName]_prerender.[fileExtension]".into();
|
||||
apply_output(&mut it.output, &templates, s.state.region_of_interest, p, "render.preRender")?;
|
||||
if str_p(p, "output").or(str_p(p, "path")).is_none() {
|
||||
s.avoid_sources(&mut it);
|
||||
}
|
||||
it.post_render = PostRenderAction::ImportAndReplace;
|
||||
let id = s.edit("Pre-render", None, |proj, _| {
|
||||
it.id = proj.render_queue.iter().map(|i| i.id).max().unwrap_or(0) + 1;
|
||||
@@ -813,6 +829,9 @@ fn save_current_preview(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut item = RenderQueueItem::new(0, cid);
|
||||
item.output = OutputModule::for_format(format);
|
||||
item.output.output = path.clone();
|
||||
if let Some(refusal) = s.check_outputs(&[vec![(item.clone(), path.clone())]]).first() {
|
||||
return Err(EngineError::Other(refusal.reason.clone()));
|
||||
}
|
||||
let job = crate::ExportJob {
|
||||
project: &s.project,
|
||||
footage: s.footage.as_ref(),
|
||||
|
||||
@@ -18,6 +18,7 @@ pub mod history;
|
||||
mod home;
|
||||
mod interchange;
|
||||
pub mod menus;
|
||||
mod output_guard;
|
||||
pub mod preview;
|
||||
pub mod render_queue;
|
||||
pub mod scriptui;
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
//! Protect files the project reads from render outputs (#1743).
|
||||
//!
|
||||
//! This guard is independent of replacement consent and export staging: it never authorizes
|
||||
//! overwriting ordinary outputs or opens a second confirmation flow. Those belong to the
|
||||
//! exporter. Existing aliases are compared by native file identity, including hard links.
|
||||
//! Missing sources remain protected by their normalized path. Checks happen before dispatch;
|
||||
//! filesystem changes between the check and the write require exporter-side revalidation.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_project::render_queue::{RenderQueueItem, log_path, numbered_template, sequence_frame, sequence_path};
|
||||
use effectcraft_project::{Footage, ItemKind};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// Volumes on Windows and macOS ignore case unless they were set up not to.
|
||||
const IGNORES_CASE: bool = cfg!(any(windows, target_os = "macos"));
|
||||
|
||||
/// How many frames are looked at one by one for a sequence whose folder cannot be read at once
|
||||
/// (its frame number is in a folder's name).
|
||||
const PROBE_LIMIT: u64 = 100_000;
|
||||
|
||||
/// How far a default output name is numbered (`_1` … `_1000`) before it is left to the check at
|
||||
/// render time.
|
||||
const MAX_NUMBER: u32 = 1_000;
|
||||
|
||||
/// What reads a file the project depends on.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Source {
|
||||
/// A footage item, by name.
|
||||
Footage(Arc<str>),
|
||||
/// An item's proxy, by the item's name.
|
||||
Proxy(Arc<str>),
|
||||
/// The project file.
|
||||
Project,
|
||||
}
|
||||
|
||||
impl Source {
|
||||
/// Why `path`, a file this reads, cannot be a render's output.
|
||||
pub(crate) fn refusal(&self, path: &str) -> String {
|
||||
match self {
|
||||
Source::Footage(name) => format!("{path} is the footage “{name}” of this project, and a render never replaces footage; choose another Output To"),
|
||||
Source::Proxy(name) => format!("{path} is the proxy of “{name}”, and a render never replaces a proxy; choose another Output To"),
|
||||
Source::Project => format!("{path} is this project's own file, and a render never replaces it; choose another Output To"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The files the project reads: footage (every frame of a sequence), proxies and the project file.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Protected {
|
||||
files: Vec<(PathBuf, Source)>,
|
||||
by_path: HashMap<String, usize>,
|
||||
/// Hashes of native identities, built once. Handles are closed after indexing so long
|
||||
/// sequences do not exhaust the process's file descriptors. Hash hits are verified with
|
||||
/// native handle equality; a hash collision cannot make another file a protected source.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
by_identity: std::sync::OnceLock<HashMap<u64, Vec<usize>>>,
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
identity_reads: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
|
||||
impl Protected {
|
||||
pub(crate) fn of(s: &Session) -> Protected {
|
||||
let mut found = Protected::default();
|
||||
for item in s.project.items.values() {
|
||||
let name: Arc<str> = item.name.as_str().into();
|
||||
if let ItemKind::Footage(f) = &item.kind {
|
||||
found.add_footage(s, f, &Source::Footage(name.clone()));
|
||||
}
|
||||
if let Some(proxy) = &item.proxy {
|
||||
found.add_footage(s, &proxy.footage, &Source::Proxy(name));
|
||||
}
|
||||
}
|
||||
if let Some(path) = s.path.as_deref().filter(|p| !p.is_empty()) {
|
||||
found.add(PathBuf::from(path), Source::Project);
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
fn add_footage(&mut self, s: &Session, f: &Footage, source: &Source) {
|
||||
for stored in std::iter::once(&f.path).chain(&f.sequence).filter(|p| !p.is_empty()) {
|
||||
// The file the media layer opens for the path the project stores.
|
||||
let path = s.stored_path(stored).unwrap_or_else(|_| PathBuf::from(stored));
|
||||
self.add(path, source.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn add(&mut self, path: PathBuf, source: Source) {
|
||||
self.by_path.entry(key(&path)).or_insert(self.files.len());
|
||||
self.files.push((path, source));
|
||||
}
|
||||
|
||||
pub(crate) fn is_empty(&self) -> bool {
|
||||
self.files.is_empty()
|
||||
}
|
||||
|
||||
/// The source at this normalized path or native file identity. A new output only needs
|
||||
/// its path lookup and one failed filesystem probe; existing outputs build the index once.
|
||||
pub(crate) fn find(&self, path: &Path) -> Option<&Source> {
|
||||
self.by_path.get(&key(path)).and_then(|i| self.files.get(*i)).map(|(_, source)| source).or_else(|| self.alias_of(path))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn handle(&self, path: &Path) -> Option<same_file::Handle> {
|
||||
#[cfg(test)]
|
||||
self.identity_reads.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
// Opening a FIFO can wait for a writer. Only regular files can be protected media;
|
||||
// preserve the old size-indexed lookup's exclusion of special files and directories.
|
||||
if !std::fs::metadata(path).ok()?.is_file() {
|
||||
return None;
|
||||
}
|
||||
same_file::Handle::from_path(path).ok()
|
||||
}
|
||||
|
||||
/// Hashes narrow the search, while native handle equality also checks any hash collisions.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn alias_of(&self, path: &Path) -> Option<&Source> {
|
||||
let output = self.handle(path)?;
|
||||
let indexed = self.by_identity.get_or_init(|| {
|
||||
let mut ids: HashMap<u64, Vec<usize>> = HashMap::new();
|
||||
for (i, (known, _)) in self.files.iter().enumerate() {
|
||||
if let Some(handle) = self.handle(known) {
|
||||
ids.entry(identity_hash(&handle)).or_default().push(i);
|
||||
}
|
||||
}
|
||||
ids
|
||||
});
|
||||
indexed.get(&identity_hash(&output))?.iter().find_map(|i| {
|
||||
let (known, source) = self.files.get(*i)?;
|
||||
(self.handle(known)? == output).then_some(source)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn alias_of(&self, _: &Path) -> Option<&Source> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn identity_hash(handle: &same_file::Handle) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hash = std::collections::hash_map::DefaultHasher::new();
|
||||
handle.hash(&mut hash);
|
||||
hash.finish()
|
||||
}
|
||||
|
||||
/// `path` made absolute, with `.` and `..` resolved by name alone (no file is looked at).
|
||||
fn lexical(path: &Path) -> PathBuf {
|
||||
let absolute = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
|
||||
let mut out = PathBuf::new();
|
||||
for part in absolute.components() {
|
||||
match part {
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => match out.components().next_back() {
|
||||
Some(Component::Normal(_)) => {
|
||||
out.pop();
|
||||
}
|
||||
Some(Component::RootDir | Component::Prefix(_)) => {}
|
||||
_ => out.push(".."),
|
||||
},
|
||||
other => out.push(other.as_os_str()),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The name a path is compared by: absolute, resolved by name, without case where volumes ignore it.
|
||||
fn key(path: &Path) -> String {
|
||||
let parts: Vec<String> = lexical(path).components().map(|c| c.as_os_str().to_string_lossy().into_owned()).collect();
|
||||
fold(&parts.join("/"))
|
||||
}
|
||||
|
||||
fn fold(name: &str) -> String {
|
||||
if IGNORES_CASE { name.to_lowercase() } else { name.to_string() }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn same_file(a: &Path, b: &Path) -> bool {
|
||||
if key(a) == key(b) {
|
||||
return true;
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
return same_file::is_same_file(a, b).unwrap_or(false);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether a file (or a link, even one leading nowhere) is at `path`.
|
||||
fn exists(path: &str) -> bool {
|
||||
std::fs::symlink_metadata(path).is_ok_and(|m| !m.is_dir())
|
||||
}
|
||||
|
||||
/// The files of a numbered sequence (`count` frames from `first`) that are on disk: its folder is
|
||||
/// read once, not asked about frame by frame.
|
||||
fn frames_on_disk(path: &str, first: i64, count: u64) -> Result<Vec<PathBuf>, String> {
|
||||
let last = first.saturating_add(i64::try_from(count).unwrap_or(i64::MAX));
|
||||
let p = Path::new(path);
|
||||
let dir = p.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new("."));
|
||||
let name = p.file_name().map(|n| n.to_string_lossy().into_owned());
|
||||
let Some(name) = name.filter(|_| !dir.to_string_lossy().contains('#')) else {
|
||||
// The frame number is in a folder's name: every frame has a folder of its own.
|
||||
if count > PROBE_LIMIT {
|
||||
return Err(format!(
|
||||
"cannot safely check {count} output frames with a number in a folder name (limit {PROBE_LIMIT}); choose a fixed output folder or a shorter range"
|
||||
));
|
||||
}
|
||||
return Ok((0..count)
|
||||
.map(|k| sequence_path(path, first.saturating_add(i64::try_from(k).unwrap_or(i64::MAX))))
|
||||
.filter(|frame| exists(frame))
|
||||
.map(PathBuf::from)
|
||||
.collect());
|
||||
};
|
||||
let pattern = fold(&name);
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
|
||||
Err(e) => return Err(format!("cannot check render output folder {}: {e}", dir.display())),
|
||||
};
|
||||
Ok(entries
|
||||
.filter_map(Result::ok)
|
||||
.filter(|e| sequence_frame(&pattern, &fold(&e.file_name().to_string_lossy())).is_some_and(|frame| (first..last).contains(&frame)))
|
||||
.filter(|e| !e.file_type().is_ok_and(|t| t.is_dir()))
|
||||
.map(|e| e.path())
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// A queue item refused before any of its output modules are dispatched.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct Refused {
|
||||
pub item: u64,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The files the project reads.
|
||||
pub(crate) fn protected_files(&self) -> Protected {
|
||||
Protected::of(self)
|
||||
}
|
||||
|
||||
/// Reject protected outputs and ranges that cannot be checked completely. Ordinary-file
|
||||
/// replacement consent is left to the exporter, independently of this source guard.
|
||||
pub(crate) fn check_outputs(&self, items: &[Vec<(RenderQueueItem, String)>]) -> Vec<Refused> {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return Vec::new();
|
||||
}
|
||||
let protected = self.protected_files();
|
||||
let mut seen = HashSet::new();
|
||||
let mut refused = Vec::new();
|
||||
for (item, path) in items.iter().flatten() {
|
||||
if seen.contains(&item.id) {
|
||||
continue;
|
||||
}
|
||||
let reason = match self.check_output(&protected, item, path) {
|
||||
Ok(reason) => reason,
|
||||
Err(reason) => Some(reason),
|
||||
};
|
||||
if let Some(reason) = reason {
|
||||
seen.insert(item.id);
|
||||
refused.push(Refused { item: item.id, reason });
|
||||
}
|
||||
}
|
||||
refused
|
||||
}
|
||||
|
||||
fn check_output(&self, protected: &Protected, item: &RenderQueueItem, path: &str) -> Result<Option<String>, String> {
|
||||
if protected.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let sequence = item.output.format.is_sequence();
|
||||
// Skip Existing Files leaves existing frames untouched, but not the render log.
|
||||
let frames = if sequence && !item.settings.skip_existing {
|
||||
match self.project.comp(item.comp) {
|
||||
Some(comp) => frames_on_disk(path, item.settings.first_frame(comp), item.settings.frame_count(comp))?,
|
||||
None => Vec::new(),
|
||||
}
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let log = log_path(path);
|
||||
let movie = (!sequence).then_some(Path::new(path));
|
||||
Ok(frames
|
||||
.iter()
|
||||
.map(PathBuf::as_path)
|
||||
.chain(movie)
|
||||
.chain(Some(Path::new(&log)))
|
||||
.find_map(|path| protected.find(path).map(|source| source.refusal(&path.to_string_lossy()))))
|
||||
}
|
||||
|
||||
/// A new item's default output must not be a file the project reads: `[compName]_1`,
|
||||
/// `[compName]_2`… take its place (and avoid the queue's other outputs), as After Effects
|
||||
/// numbers a second item of the same composition. An output nothing is in the way of keeps
|
||||
/// the composition's own name, and one the caller chose is not touched: rendering it is
|
||||
/// refused instead.
|
||||
pub(crate) fn avoid_sources(&self, item: &mut RenderQueueItem) {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return;
|
||||
}
|
||||
let protected = self.protected_files();
|
||||
if protected.is_empty() {
|
||||
return;
|
||||
}
|
||||
let template = item.output.output.clone();
|
||||
let mut taken: Option<HashSet<String>> = None;
|
||||
'candidate: for n in 0..=MAX_NUMBER {
|
||||
item.output.output = if n == 0 { template.clone() } else { numbered_template(&template, n) };
|
||||
let Ok(outputs) = self.outputs_of(item, None) else { break };
|
||||
for (module, path) in &outputs {
|
||||
match self.check_output(&protected, module, path) {
|
||||
Ok(Some(_)) => continue 'candidate,
|
||||
Err(_) => break 'candidate,
|
||||
Ok(None) => {}
|
||||
}
|
||||
}
|
||||
// Only a name that had to move is kept clear of the other items.
|
||||
let others = if n == 0 { None } else { Some(taken.get_or_insert_with(|| self.queue_output_keys())) };
|
||||
if others.is_none_or(|others| !outputs.iter().any(|(_, path)| others.contains(&key(Path::new(path))))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
item.output.output = template;
|
||||
}
|
||||
|
||||
/// [`key`] of every output of the queue's items.
|
||||
fn queue_output_keys(&self) -> HashSet<String> {
|
||||
self.project.render_queue.iter().filter_map(|it| self.outputs_of(it, None).ok()).flatten().map(|(_, path)| key(Path::new(&path))).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn unrelated_same_size_frames_are_indexed_once() {
|
||||
let dir = std::env::temp_dir().join(format!("effectcraft-identity-index-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut protected = Protected::default();
|
||||
for i in 0..128 {
|
||||
let source = dir.join(format!("source{i}")).join("plate.png");
|
||||
let output = dir.join(format!("output{i}")).join("plate.png");
|
||||
for path in [&source, &output] {
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, b"same-size frame").unwrap();
|
||||
}
|
||||
protected.add(source, Source::Footage(format!("plate{i}").into()));
|
||||
}
|
||||
for i in 0..128 {
|
||||
assert!(protected.find(&dir.join(format!("output{i}")).join("plate.png")).is_none());
|
||||
}
|
||||
assert_eq!(protected.identity_reads.load(std::sync::atomic::Ordering::Relaxed), 256, "one source-index pass plus one lookup per output");
|
||||
let alias = dir.join("renamed.png");
|
||||
std::fs::hard_link(dir.join("source64").join("plate.png"), &alias).unwrap();
|
||||
assert_eq!(protected.find(&alias), Some(&Source::Footage("plate64".into())));
|
||||
assert_eq!(protected.identity_reads.load(std::sync::atomic::Ordering::Relaxed), 258, "native equality verifies a matching identity");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_numbered_sequences_are_never_partially_checked() {
|
||||
let pattern = std::env::temp_dir().join("frames[######]").join("plate.png").to_string_lossy().into_owned();
|
||||
let error = frames_on_disk(&pattern, 0, PROBE_LIMIT + 1).unwrap_err();
|
||||
assert!(error.contains("cannot safely check") && error.contains("100000"), "{error}");
|
||||
assert!(frames_on_disk(&pattern, 0, u64::MAX).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn non_file_outputs_do_not_build_the_identity_index() {
|
||||
let protected = Protected::default();
|
||||
assert!(protected.find(&std::env::temp_dir()).is_none());
|
||||
assert!(protected.by_identity.get().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths_compare_by_name_once_resolved() {
|
||||
let here = std::env::current_dir().unwrap();
|
||||
assert_eq!(key(Path::new("a/b/../c.mp4")), key(Path::new("a/c.mp4")));
|
||||
assert_eq!(key(Path::new("./a/c.mp4")), key(&here.join("a/c.mp4")));
|
||||
assert_ne!(key(Path::new("a/c.mp4")), key(Path::new("b/c.mp4")));
|
||||
assert_eq!(key(Path::new(&format!("{}x", "../".repeat(64)))), key(Path::new(&format!("{}x", "../".repeat(80)))), "past the root stays at the root");
|
||||
assert_eq!(IGNORES_CASE, key(Path::new("A/C.MP4")) == key(Path::new("a/c.mp4")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_is_the_same_file_under_every_spelling_the_volume_allows() {
|
||||
let dir = std::env::temp_dir().join(format!("effectcraft-output-guard-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("sub")).unwrap();
|
||||
std::fs::write(dir.join("clip.mov"), b"x").unwrap();
|
||||
std::fs::write(dir.join("other.mov"), b"y").unwrap();
|
||||
assert!(same_file(&dir.join("clip.mov"), &dir.join("sub").join("..").join("clip.mov")));
|
||||
assert!(!same_file(&dir.join("clip.mov"), &dir.join("other.mov")));
|
||||
// Nothing there yet: only the name decides.
|
||||
assert!(same_file(&dir.join("gone.mov"), &dir.join("sub").join("..").join("gone.mov")));
|
||||
assert!(!same_file(&dir.join("gone.mov"), &dir.join("clip.mov")));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sequences_folder_is_read_once_for_its_frames() {
|
||||
let dir = std::env::temp_dir().join(format!("effectcraft-output-frames-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("a_00003.png")).unwrap(); // a folder is not a frame
|
||||
for name in ["a_00001.png", "a_00002.png", "a_0004.png", "a_00004.jpg", "b_00002.png", "a_00009.png"] {
|
||||
std::fs::write(dir.join(name), b"x").unwrap();
|
||||
}
|
||||
let pattern = dir.join("a_[#####].png").to_string_lossy().into_owned();
|
||||
let names = |frames: Vec<PathBuf>| {
|
||||
let mut names: Vec<String> = frames.iter().filter_map(|f| f.file_name()).map(|n| n.to_string_lossy().into_owned()).collect();
|
||||
names.sort();
|
||||
names
|
||||
};
|
||||
assert_eq!(
|
||||
names(frames_on_disk(&pattern, 0, 5).unwrap()),
|
||||
["a_00001.png", "a_00002.png"],
|
||||
"frames 0-4: not the 4-digit one, the jpg, the folder or frame 9"
|
||||
);
|
||||
assert_eq!(names(frames_on_disk(&pattern, 2, 1).unwrap()), ["a_00002.png"]);
|
||||
assert_eq!(names(frames_on_disk(&pattern, 9, 100).unwrap()), ["a_00009.png"]);
|
||||
assert_eq!(names(frames_on_disk(&pattern, 0, u64::MAX).unwrap()), ["a_00001.png", "a_00002.png", "a_00009.png"], "a hostile frame count is not walked");
|
||||
// A frame number in a folder's name: each frame has its own folder.
|
||||
std::fs::create_dir_all(dir.join("f02")).unwrap();
|
||||
std::fs::write(dir.join("f02").join("x.png"), b"x").unwrap();
|
||||
let in_folder = dir.join("f[##]").join("x.png").to_string_lossy().into_owned();
|
||||
assert_eq!(names(frames_on_disk(&in_folder, 0, 4).unwrap()), ["x.png"]);
|
||||
// A folder that is not there holds nothing.
|
||||
assert!(frames_on_disk(&dir.join("none").join("a_[#####].png").to_string_lossy(), 0, 5).unwrap().is_empty());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -442,10 +442,46 @@ impl Session {
|
||||
self.render_job.as_ref().map(|j| j.shared.snapshot())
|
||||
}
|
||||
|
||||
/// Start rendering every queued item. `wait`: block until done (otherwise a background thread
|
||||
/// renders, or the [`Session::offload`] worker; call [`Session::poll_render`] regularly).
|
||||
/// Returns the ids being rendered. Without threads or an offload (wasm32) the render always
|
||||
/// runs to completion before this returns.
|
||||
/// The (item, resolved output path) of each output of `it`: one per output module and, with
|
||||
/// Segment Sequences / Segment Movie Files, per segment. `formats`: the ones that can be
|
||||
/// written (`None`: all). Fails with the status the item gets instead.
|
||||
pub(crate) fn outputs_of(&self, it: &RenderQueueItem, formats: Option<&[OutputFormat]>) -> Result<Vec<(RenderQueueItem, String)>, RenderStatus> {
|
||||
let mut modules = vec![];
|
||||
for om in it.output_modules() {
|
||||
let mut m = it.clone();
|
||||
m.output = om.clone();
|
||||
match self.resolve_output(&m) {
|
||||
Some(p) if formats.is_none_or(|f| f.contains(&om.format)) => {
|
||||
// Settings ▸ Export: Append Bit Depth to File Name, Segment Sequences /
|
||||
// Movie Files.
|
||||
let bits = output_bits(om.format, self.project.settings.bit_depth);
|
||||
let p = self.prefs.output_name(&p, bits);
|
||||
modules.extend(self.segments(&m, &p));
|
||||
}
|
||||
Some(_) => return Err(RenderStatus::Failed(format!("{} export is not available", om.format.label()))),
|
||||
None if self.project.comp(it.comp).is_none() => return Err(RenderStatus::Failed("composition missing".into())),
|
||||
None => return Err(RenderStatus::NeedsOutput),
|
||||
}
|
||||
}
|
||||
Ok(modules)
|
||||
}
|
||||
|
||||
/// What the next Render would write: for each queued item its [`Session::outputs_of`], and
|
||||
/// the status of each queued item that has none.
|
||||
pub(crate) fn queued_outputs(&self, formats: &[OutputFormat]) -> (Vec<Vec<(RenderQueueItem, String)>>, Vec<(u64, RenderStatus)>) {
|
||||
let (mut items, mut problems) = (Vec::new(), Vec::new());
|
||||
for it in self.project.render_queue.iter().filter(|i| i.is_queued()) {
|
||||
match self.outputs_of(it, Some(formats)) {
|
||||
Ok(modules) => items.push(modules),
|
||||
Err(status) => problems.push((it.id, status)),
|
||||
}
|
||||
}
|
||||
(items, problems)
|
||||
}
|
||||
|
||||
/// Start rendering every queued item, refusing outputs that replace footage, proxies or
|
||||
/// the project file. Other existing outputs keep the exporter's usual replacement policy.
|
||||
/// `wait`: block until done, otherwise render in the background and poll regularly.
|
||||
pub fn start_render(&mut self, wait: bool) -> Result<Vec<u64>, String> {
|
||||
let offload = self.offload.clone().filter(|_| !wait);
|
||||
let wait = wait || (cfg!(target_arch = "wasm32") && offload.is_none());
|
||||
@@ -455,46 +491,33 @@ impl Session {
|
||||
self.poll_render();
|
||||
let exporter = self.exporter.clone().ok_or("export is not available in this build")?;
|
||||
let formats = exporter.formats();
|
||||
let mut items = Vec::new();
|
||||
let mut needs_output = Vec::new();
|
||||
for it in self.project.render_queue.iter().filter(|i| i.is_queued()) {
|
||||
let mut modules = vec![];
|
||||
let mut problem = None;
|
||||
for om in it.output_modules() {
|
||||
let mut m = it.clone();
|
||||
m.output = om.clone();
|
||||
match self.resolve_output(&m) {
|
||||
Some(p) if formats.contains(&om.format) => {
|
||||
// Settings ▸ Export: Append Bit Depth to File Name, Segment Sequences /
|
||||
// Movie Files.
|
||||
let bits = output_bits(om.format, self.project.settings.bit_depth);
|
||||
let p = self.prefs.output_name(&p, bits);
|
||||
modules.extend(self.segments(&m, &p));
|
||||
}
|
||||
Some(_) => problem = Some(RenderStatus::Failed(format!("{} export is not available", om.format.label()))),
|
||||
None if self.project.comp(it.comp).is_none() => problem = Some(RenderStatus::Failed("composition missing".into())),
|
||||
None => problem = Some(RenderStatus::NeedsOutput),
|
||||
}
|
||||
if problem.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
match problem {
|
||||
Some(st) => needs_output.push((it.id, st)),
|
||||
None => items.push(modules),
|
||||
}
|
||||
}
|
||||
if !needs_output.is_empty() {
|
||||
let (mut items, mut problems) = self.queued_outputs(&formats);
|
||||
let refused = self.check_outputs(&items);
|
||||
items.retain(|modules| modules.first().is_none_or(|(it, _)| !refused.iter().any(|r| r.item == it.id)));
|
||||
problems.extend(refused.iter().map(|r| (r.item, RenderStatus::Failed(r.reason.clone()))));
|
||||
if !problems.is_empty() {
|
||||
let p = Arc::make_mut(&mut self.project);
|
||||
for (id, st) in needs_output {
|
||||
for (id, st) in problems {
|
||||
if let Some(i) = p.render_queue.iter_mut().find(|i| i.id == id) {
|
||||
i.status = st;
|
||||
}
|
||||
}
|
||||
self.bump();
|
||||
}
|
||||
if let Some(first) = refused.first() {
|
||||
let message = if refused.len() == 1 {
|
||||
format!("Not rendered: {}", first.reason)
|
||||
} else {
|
||||
format!("{} items were not rendered; first: {}", refused.len(), first.reason)
|
||||
};
|
||||
self.events.push(Event::Toast { message, error: true });
|
||||
}
|
||||
if items.is_empty() {
|
||||
return Err("nothing is queued: add a composition (Composition ▸ Add to Render Queue) and tick Render".into());
|
||||
return Err(match refused.as_slice() {
|
||||
[] => "nothing is queued: add a composition (Composition ▸ Add to Render Queue) and tick Render".into(),
|
||||
[only] => only.reason.clone(),
|
||||
[first, ..] => format!("{} items would replace files the project reads; first: {}", refused.len(), first.reason),
|
||||
});
|
||||
}
|
||||
let ids: Vec<u64> = items.iter().filter_map(|m| m.first().map(|(i, _)| i.id)).collect();
|
||||
if let Some(off) = offload {
|
||||
|
||||
@@ -36,6 +36,7 @@ mod panels;
|
||||
mod project_items;
|
||||
mod proxy;
|
||||
mod quick_offset;
|
||||
mod render_overwrite;
|
||||
mod replace_source;
|
||||
mod rig3d;
|
||||
pub(crate) mod roto;
|
||||
|
||||
@@ -0,0 +1,606 @@
|
||||
//! Rendering never replaces a file the project reads (#1743). Ordinary-file replacement
|
||||
//! behavior stays with the exporter and its confirmation API.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_project::render_queue::{OutputFormat, RenderStatus, sequence_path};
|
||||
use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{ExportJob, ExportResult, Exporter, Session};
|
||||
|
||||
const SOURCE: &[u8] = b"SOURCE FOOTAGE";
|
||||
const EARLIER: &[u8] = b"AN EARLIER RENDER";
|
||||
const RENDERED: &[u8] = b"RENDERED";
|
||||
|
||||
/// An exporter that writes like the real one: `RENDERED` at the job's path, or at the path of
|
||||
/// every frame for a sequence (leaving the frames that exist alone when the item skips them).
|
||||
#[derive(Default)]
|
||||
struct Writer {
|
||||
jobs: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl Exporter for Writer {
|
||||
fn formats(&self) -> Vec<OutputFormat> {
|
||||
OutputFormat::ALL.to_vec()
|
||||
}
|
||||
fn export(&self, job: &ExportJob, _: &mut dyn FnMut(u64, u64) -> bool) -> Result<ExportResult, String> {
|
||||
self.jobs.lock().unwrap().push(job.path.to_string());
|
||||
let comp = job.project.comp(job.item.comp).ok_or("no comp")?;
|
||||
let paths: Vec<String> = if job.item.output.format.is_sequence() {
|
||||
let first = job.item.settings.first_frame(comp);
|
||||
(0..job.item.settings.frame_count(comp)).map(|k| sequence_path(job.path, first + k as i64)).collect()
|
||||
} else {
|
||||
vec![job.path.to_string()]
|
||||
};
|
||||
for path in &paths {
|
||||
if job.item.settings.skip_existing && Path::new(path).exists() {
|
||||
continue;
|
||||
}
|
||||
if let Some(dir) = Path::new(path).parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
std::fs::write(path, RENDERED).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(ExportResult { path: paths.first().cloned().unwrap_or_default(), ..Default::default() })
|
||||
}
|
||||
}
|
||||
|
||||
/// A session whose project is saved in a fresh folder (the footage goes there too), with an
|
||||
/// exporter that writes real files.
|
||||
struct Fixture {
|
||||
s: Session,
|
||||
writer: Arc<Writer>,
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new(name: &str) -> Fixture {
|
||||
let dir = std::env::temp_dir().join("effectcraft-render-overwrite").join(format!("{}-{name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut s = Session::default();
|
||||
let writer = Arc::new(Writer::default());
|
||||
s.exporter = Some(writer.clone());
|
||||
s.execute("file.saveAs", json!({"path": dir.join("Project.ecproj").to_string_lossy()})).unwrap();
|
||||
Fixture { s, writer, dir }
|
||||
}
|
||||
|
||||
fn path(&self, file: &str) -> PathBuf {
|
||||
self.dir.join(file)
|
||||
}
|
||||
|
||||
fn text(&self, file: &str) -> String {
|
||||
self.path(file).to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn read(&self, file: &str) -> Vec<u8> {
|
||||
std::fs::read(self.path(file)).unwrap()
|
||||
}
|
||||
|
||||
fn write(&self, file: &str, bytes: &[u8]) {
|
||||
std::fs::write(self.path(file), bytes).unwrap();
|
||||
}
|
||||
|
||||
/// A video file in the project's folder, imported.
|
||||
fn footage(&mut self, file: &str) -> ItemId {
|
||||
self.write(file, SOURCE);
|
||||
let f = Footage {
|
||||
path: self.text(file),
|
||||
kind: FootageKind::Video,
|
||||
width: 64,
|
||||
height: 36,
|
||||
has_video: true,
|
||||
duration: Tick::from_seconds_f64(1.0),
|
||||
..Default::default()
|
||||
};
|
||||
Arc::make_mut(&mut self.s.project).add_item(file, Label::None, None, ItemKind::Footage(f))
|
||||
}
|
||||
|
||||
/// Numbered stills in the project's folder, imported as one image sequence.
|
||||
fn sequence(&mut self, name: &str, frames: std::ops::Range<u32>) -> ItemId {
|
||||
let files: Vec<String> = frames
|
||||
.map(|n| {
|
||||
let file = format!("{name}_{n:05}.png");
|
||||
self.write(&file, SOURCE);
|
||||
self.text(&file)
|
||||
})
|
||||
.collect();
|
||||
let f = Footage {
|
||||
path: files[0].clone(),
|
||||
kind: FootageKind::Sequence,
|
||||
sequence: files,
|
||||
width: 16,
|
||||
height: 16,
|
||||
has_video: true,
|
||||
duration: Tick::from_seconds_f64(0.5),
|
||||
..Default::default()
|
||||
};
|
||||
Arc::make_mut(&mut self.s.project).add_item(&format!("{name}_[00000-00004].png"), Label::None, None, ItemKind::Footage(f))
|
||||
}
|
||||
|
||||
/// A five-frame composition.
|
||||
fn comp(&mut self, name: &str) {
|
||||
self.s.execute("comp.new", json!({"name": name, "width": 16, "height": 16, "frameRate": 10, "duration": 0.5})).unwrap();
|
||||
}
|
||||
|
||||
/// The output path of queue item `index` (0-based).
|
||||
fn output(&self, index: usize) -> PathBuf {
|
||||
PathBuf::from(self.s.resolve_output(&self.s.project.render_queue[index]).unwrap())
|
||||
}
|
||||
|
||||
/// The paths the exporter was started for.
|
||||
fn exported(&self) -> Vec<String> {
|
||||
self.writer.jobs.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
fn render(&mut self, params: Value) -> crate::Result<Value> {
|
||||
self.s.execute("renderQueue.render", params)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.dir);
|
||||
}
|
||||
}
|
||||
|
||||
fn link(target: &Path, link: &Path) -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
return std::os::unix::fs::symlink(target, link);
|
||||
#[cfg(windows)]
|
||||
return std::os::windows::fs::symlink_file(target, link);
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
let _ = (target, link);
|
||||
Err(std::io::ErrorKind::Unsupported.into())
|
||||
}
|
||||
}
|
||||
|
||||
/// A folder that leads to `target`: a symbolic link, or on Windows, where that takes a privilege
|
||||
/// most accounts lack, a junction.
|
||||
fn link_dir(target: &Path, link: &Path) -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
return std::os::unix::fs::symlink(target, link);
|
||||
#[cfg(windows)]
|
||||
return std::os::windows::fs::symlink_dir(target, link).or_else(|_| {
|
||||
let made = std::process::Command::new("cmd").args(["/C", "mklink", "/J"]).arg(link).arg(target).output()?;
|
||||
if made.status.success() { Ok(()) } else { Err(std::io::Error::other(String::from_utf8_lossy(&made.stdout).into_owned())) }
|
||||
});
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
let _ = (target, link);
|
||||
Err(std::io::ErrorKind::Unsupported.into())
|
||||
}
|
||||
}
|
||||
|
||||
/// The report: save a new project beside `footage.mp4`, import it, make a composition from it
|
||||
/// (it is named after the file), add that to the Render Queue and click Render. The composition's
|
||||
/// default output is `<project folder>/footage.mp4`, the footage itself.
|
||||
#[test]
|
||||
fn a_comp_made_from_footage_does_not_render_over_it() {
|
||||
let mut f = Fixture::new("comp-from-footage");
|
||||
let footage = f.footage("footage.mp4");
|
||||
f.s.state.project_selection = vec![footage];
|
||||
f.s.execute("file.newCompFromSelection", json!({"addToRenderQueue": true})).unwrap();
|
||||
assert_eq!(f.s.project.render_queue.len(), 1);
|
||||
f.render(json!({})).unwrap();
|
||||
assert_eq!(f.read("footage.mp4"), SOURCE, "the footage is still the footage");
|
||||
assert_eq!(f.s.project.render_queue[0].status, RenderStatus::Done);
|
||||
// The default name stepped aside: the render sits next to it.
|
||||
assert_eq!(f.output(0), f.path("footage_1.mp4"));
|
||||
assert_eq!(f.read("footage_1.mp4"), RENDERED);
|
||||
// Another item for the same composition takes the next free number.
|
||||
f.s.execute("renderQueue.add", json!({})).unwrap();
|
||||
assert_eq!(f.output(1), f.path("footage_2.mp4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_output_that_is_the_footage_is_refused_with_or_without_overwrite() {
|
||||
for (n, params) in [json!({}), json!({"overwrite": true})].into_iter().enumerate() {
|
||||
let mut f = Fixture::new(&format!("explicit-footage-{n}"));
|
||||
f.footage("clip.mov");
|
||||
f.comp("Main");
|
||||
let clip = f.text("clip.mov");
|
||||
f.s.execute("renderQueue.add", json!({"format": "prores", "output": clip})).unwrap();
|
||||
let e = f.render(params).unwrap_err().to_string();
|
||||
assert!(e.contains("clip.mov") && e.contains("footage"), "{e}");
|
||||
assert_eq!(f.read("clip.mov"), SOURCE);
|
||||
assert!(f.exported().is_empty(), "the exporter was not started");
|
||||
let RenderStatus::Failed(why) = &f.s.project.render_queue[0].status else { panic!("{:?}", f.s.project.render_queue[0].status) };
|
||||
assert!(why.contains("clip.mov"), "{why}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An item that would replace the footage fails on its own; the rest of the queue renders.
|
||||
#[test]
|
||||
fn the_rest_of_the_queue_renders_when_one_item_would_replace_footage() {
|
||||
let mut f = Fixture::new("one-refused");
|
||||
f.footage("clip.mov");
|
||||
f.comp("Main");
|
||||
let clip = f.text("clip.mov");
|
||||
f.s.execute("renderQueue.add", json!({"format": "prores", "output": clip})).unwrap();
|
||||
let ok = f.text("main.mp4");
|
||||
f.s.execute("renderQueue.add", json!({"output": ok})).unwrap();
|
||||
let r = f.render(json!({})).unwrap();
|
||||
assert_eq!(f.read("clip.mov"), SOURCE);
|
||||
assert_eq!(f.read("main.mp4"), RENDERED);
|
||||
assert!(matches!(f.s.project.render_queue[0].status, RenderStatus::Failed(_)));
|
||||
assert_eq!(f.s.project.render_queue[1].status, RenderStatus::Done);
|
||||
assert!(r["items"].as_array().unwrap().iter().any(|i| i["status"].get("Failed").is_some()), "the reply includes the refused item: {r}");
|
||||
assert!(r["items"].as_array().unwrap().iter().any(|i| i["status"] == "Done"), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_project_file_is_never_an_output() {
|
||||
let mut f = Fixture::new("project-file");
|
||||
// A project saved under a name a movie could have.
|
||||
let name = f.text("final.mp4");
|
||||
f.s.execute("file.saveAs", json!({"path": name})).unwrap();
|
||||
f.comp("final");
|
||||
f.s.execute("renderQueue.add", json!({})).unwrap();
|
||||
assert_eq!(f.output(0), f.path("final_1.mp4"), "the default name moves aside");
|
||||
f.s.execute("renderQueue.remove", json!({"index": 1})).unwrap();
|
||||
f.s.execute("renderQueue.add", json!({"output": name})).unwrap();
|
||||
let e = f.render(json!({"overwrite": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("final.mp4") && e.contains("project"), "{e}");
|
||||
assert!(std::fs::read_to_string(f.path("final.mp4")).unwrap().contains("\"schema\""), "the project file is still a project");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_proxy_is_not_an_output_either() {
|
||||
let mut f = Fixture::new("proxy");
|
||||
f.footage("clip.mov");
|
||||
f.write("proxy.mov", SOURCE);
|
||||
f.comp("Main");
|
||||
let proxy = Footage { path: f.text("proxy.mov"), kind: FootageKind::Video, has_video: true, ..Default::default() };
|
||||
let comp = f.s.active_comp_id().unwrap();
|
||||
Arc::make_mut(&mut f.s.project).item_mut(comp).unwrap().proxy = Some(Box::new(effectcraft_project::Proxy { footage: proxy, enabled: true }));
|
||||
let target = f.text("proxy.mov");
|
||||
f.s.execute("renderQueue.add", json!({"format": "prores", "output": target})).unwrap();
|
||||
let e = f.render(json!({"overwrite": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("proxy"), "{e}");
|
||||
assert_eq!(f.read("proxy.mov"), SOURCE);
|
||||
}
|
||||
|
||||
/// Recreating a default proxy writes a new file and attaches it, without replacing the proxy
|
||||
/// the project is currently reading. Explicitly choosing that protected path is still refused.
|
||||
#[test]
|
||||
fn creating_a_proxy_again_chooses_a_new_default_and_attaches_it() {
|
||||
struct Probe;
|
||||
impl crate::Importer for Probe {
|
||||
fn probe(&self, path: &str) -> Result<Footage, String> {
|
||||
if !Path::new(path).is_file() {
|
||||
return Err("proxy file missing".into());
|
||||
}
|
||||
Ok(Footage {
|
||||
path: path.into(),
|
||||
kind: if path.ends_with(".png") { FootageKind::Still } else { FootageKind::Video },
|
||||
width: 16,
|
||||
height: 16,
|
||||
has_video: true,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
for kind in ["movie", "still"] {
|
||||
let mut f = Fixture::new(&format!("recreate-proxy-{kind}"));
|
||||
f.s.importer = Some(Arc::new(Probe));
|
||||
f.comp("Main");
|
||||
let comp = f.s.active_comp_id().unwrap();
|
||||
f.s.execute("file.createProxy", json!({"kind": kind})).unwrap();
|
||||
f.render(json!({})).unwrap();
|
||||
let first = f.s.project.item(comp).unwrap().proxy.as_ref().unwrap().footage.path.clone();
|
||||
assert_eq!(std::fs::read(&first).unwrap(), RENDERED);
|
||||
|
||||
f.s.execute("file.createProxy", json!({"kind": kind})).unwrap();
|
||||
f.render(json!({})).unwrap();
|
||||
let second = f.s.project.item(comp).unwrap().proxy.as_ref().unwrap().footage.path.clone();
|
||||
assert_ne!(second, first, "the replacement proxy gets its own file");
|
||||
assert_eq!(std::fs::read(&second).unwrap(), RENDERED);
|
||||
assert_eq!(std::fs::read(&first).unwrap(), RENDERED);
|
||||
assert_eq!(f.exported().len(), 2);
|
||||
|
||||
// For a still proxy, an explicit output is the sequence pattern, not its first
|
||||
// rendered frame (a bare PNG name would have another frame number appended).
|
||||
let protected_output = f.output(1);
|
||||
f.s.execute("file.createProxy", json!({"kind": kind, "path": protected_output})).unwrap();
|
||||
let error = f.render(json!({})).unwrap_err().to_string();
|
||||
assert!(error.contains("never replaces a proxy"), "{error}");
|
||||
assert_eq!(f.exported().len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
/// A pre-render becomes imported footage. Its old output stays protected even from the
|
||||
/// same queue item; issuing Pre-render again supplies a fresh default output instead.
|
||||
#[test]
|
||||
fn refreshing_a_pre_render_uses_a_new_default_without_exempting_imported_footage() {
|
||||
struct Probe;
|
||||
impl crate::Importer for Probe {
|
||||
fn probe(&self, path: &str) -> Result<Footage, String> {
|
||||
if !Path::new(path).is_file() {
|
||||
return Err("pre-render missing".into());
|
||||
}
|
||||
Ok(Footage { path: path.into(), kind: FootageKind::Video, width: 16, height: 16, has_video: true, ..Default::default() })
|
||||
}
|
||||
}
|
||||
let mut f = Fixture::new("refresh-pre-render");
|
||||
f.s.importer = Some(Arc::new(Probe));
|
||||
f.comp("Main");
|
||||
f.s.execute("render.preRender", json!({"format": "gif"})).unwrap();
|
||||
let first = f.output(0);
|
||||
f.render(json!({})).unwrap();
|
||||
f.s.execute("renderQueue.setRender", json!({"index": 1, "render": true})).unwrap();
|
||||
let error = f.render(json!({})).unwrap_err().to_string();
|
||||
assert!(error.contains("footage"), "{error}");
|
||||
assert_eq!(std::fs::read(&first).unwrap(), RENDERED);
|
||||
assert_eq!(f.exported().len(), 1);
|
||||
f.s.execute("renderQueue.setRender", json!({"index": 1, "render": false})).unwrap();
|
||||
f.s.execute("render.preRender", json!({"format": "gif"})).unwrap();
|
||||
let second = f.output(1);
|
||||
assert_ne!(first, second);
|
||||
f.render(json!({})).unwrap();
|
||||
assert_eq!(std::fs::read(first).unwrap(), RENDERED);
|
||||
assert_eq!(std::fs::read(second).unwrap(), RENDERED);
|
||||
assert_eq!(f.exported().len(), 2);
|
||||
}
|
||||
|
||||
/// Exercise the engine's import and project serialization paths, rather than injecting a
|
||||
/// Footage item directly. The importer reads dimensions from a real, generated PNG.
|
||||
#[test]
|
||||
fn imported_footage_stays_protected_after_the_project_is_reopened() {
|
||||
struct PngProbe;
|
||||
impl crate::Importer for PngProbe {
|
||||
fn probe(&self, path: &str) -> Result<Footage, String> {
|
||||
let (width, height) = image::image_dimensions(path).map_err(|e| e.to_string())?;
|
||||
Ok(Footage { path: path.into(), kind: FootageKind::Still, width, height, has_video: true, ..Default::default() })
|
||||
}
|
||||
}
|
||||
let mut f = Fixture::new("import-reopen");
|
||||
f.s.importer = Some(Arc::new(PngProbe));
|
||||
let path = f.path("plate_00002.png");
|
||||
image::RgbaImage::from_pixel(4, 4, image::Rgba([40, 80, 120, 255])).save(&path).unwrap();
|
||||
let original = std::fs::read(&path).unwrap();
|
||||
let result = f.s.execute("file.import", json!({"paths": [path]})).unwrap();
|
||||
assert_eq!(result["items"].as_array().unwrap().len(), 1);
|
||||
f.comp("Main");
|
||||
f.s.execute("renderQueue.add", json!({"format": "png", "output": f.text("plate_[#####].png")})).unwrap();
|
||||
f.s.execute("file.save", json!({})).unwrap();
|
||||
f.s.execute("file.open", json!({"path": f.text("Project.ecproj")})).unwrap();
|
||||
let error = f.render(json!({})).unwrap_err().to_string();
|
||||
assert!(error.contains("plate_00002.png") && error.contains("footage"), "{error}");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), original);
|
||||
assert!(f.exported().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_protected_frame_past_the_probe_limit_is_not_rendered() {
|
||||
struct LastFrame(PathBuf);
|
||||
impl Exporter for LastFrame {
|
||||
fn formats(&self) -> Vec<OutputFormat> {
|
||||
vec![OutputFormat::PngSequence]
|
||||
}
|
||||
fn export(&self, _: &ExportJob, _: &mut dyn FnMut(u64, u64) -> bool) -> Result<ExportResult, String> {
|
||||
// A synthetic exporter writes only the vulnerable late frame, so a broken guard
|
||||
// can be reproduced without creating 100,001 output directories.
|
||||
std::fs::write(&self.0, RENDERED).map_err(|e| e.to_string())?;
|
||||
Ok(ExportResult { path: self.0.to_string_lossy().into_owned(), ..Default::default() })
|
||||
}
|
||||
}
|
||||
let mut f = Fixture::new("late-frame");
|
||||
std::fs::create_dir_all(f.path("f100000")).unwrap();
|
||||
f.footage("f100000/plate.png");
|
||||
f.comp("Main");
|
||||
f.s.execute("comp.settings", json!({"duration": 10000.1})).unwrap();
|
||||
f.s.exporter = Some(Arc::new(LastFrame(f.path("f100000/plate.png"))));
|
||||
f.s.execute("renderQueue.add", json!({"format": "png", "output": f.text("f[######]/plate.png")})).unwrap();
|
||||
let error = f.render(json!({})).unwrap_err().to_string();
|
||||
assert!(error.contains("cannot safely check"), "{error}");
|
||||
assert_eq!(f.read("f100000/plate.png"), SOURCE);
|
||||
}
|
||||
|
||||
/// A numbered sequence is footage frame by frame: one colliding frame is enough, and nothing of
|
||||
/// the item is rendered.
|
||||
#[test]
|
||||
fn a_sequence_never_replaces_one_of_its_footage_frames() {
|
||||
let mut f = Fixture::new("sequence");
|
||||
f.sequence("shot", 0..5);
|
||||
f.comp("shot");
|
||||
// The default name steps aside as a whole: shot_1_00000.png …
|
||||
f.s.execute("renderQueue.add", json!({"format": "png"})).unwrap();
|
||||
assert_eq!(f.output(0), f.path("shot_1_[#####].png"));
|
||||
f.render(json!({})).unwrap();
|
||||
for n in 0..5 {
|
||||
assert_eq!(f.read(&format!("shot_{n:05}.png")), SOURCE, "frame {n}");
|
||||
assert_eq!(f.read(&format!("shot_1_{n:05}.png")), RENDERED, "frame {n}");
|
||||
}
|
||||
|
||||
// Named by hand, one frame of five is a footage file.
|
||||
let mut f = Fixture::new("sequence-one-frame");
|
||||
f.write("shot_00002.png", SOURCE);
|
||||
let still = Footage { path: f.text("shot_00002.png"), kind: FootageKind::Still, width: 16, height: 16, has_video: true, ..Default::default() };
|
||||
Arc::make_mut(&mut f.s.project).add_item("shot_00002.png", Label::None, None, ItemKind::Footage(still));
|
||||
f.comp("shot");
|
||||
let pattern = f.text("shot_[#####].png");
|
||||
f.s.execute("renderQueue.add", json!({"format": "png", "output": pattern})).unwrap();
|
||||
let e = f.render(json!({"overwrite": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("shot_00002.png"), "{e}");
|
||||
assert_eq!(f.read("shot_00002.png"), SOURCE);
|
||||
assert!(!f.path("shot_00000.png").exists(), "the frames before it were not rendered either");
|
||||
assert!(f.exported().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_existing_outputs_keep_the_exporters_replacement_policy() {
|
||||
let mut f = Fixture::new("earlier-render");
|
||||
f.comp("Main");
|
||||
f.s.execute("renderQueue.add", json!({"output": f.text("render.mp4")})).unwrap();
|
||||
f.write("render.mp4", EARLIER);
|
||||
f.render(json!({})).unwrap();
|
||||
assert_eq!(f.read("render.mp4"), RENDERED);
|
||||
assert_eq!(f.s.project.render_queue[0].status, RenderStatus::Done);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_render_with_nothing_in_the_way_needs_no_flag() {
|
||||
let mut f = Fixture::new("free");
|
||||
f.comp("Main");
|
||||
f.s.execute("renderQueue.add", json!({})).unwrap();
|
||||
assert_eq!(f.output(0), f.path("Main.mp4"), "no footage in the way: the comp's own name");
|
||||
f.render(json!({})).unwrap();
|
||||
assert_eq!(f.read("Main.mp4"), RENDERED);
|
||||
}
|
||||
|
||||
/// Skip Existing Files leaves the frames that exist alone, so they need no answer.
|
||||
#[test]
|
||||
fn a_sequence_that_skips_existing_frames_needs_no_flag() {
|
||||
let mut f = Fixture::new("skip-existing");
|
||||
f.comp("Main");
|
||||
let pattern = f.text("render_[#####].png");
|
||||
f.s.execute("renderQueue.add", json!({"format": "png", "output": pattern, "skipExisting": true})).unwrap();
|
||||
for n in 0..3 {
|
||||
f.write(&format!("render_{n:05}.png"), EARLIER);
|
||||
}
|
||||
f.render(json!({})).unwrap();
|
||||
assert_eq!(f.read("render_00002.png"), EARLIER);
|
||||
assert_eq!(f.read("render_00003.png"), RENDERED);
|
||||
// Ordinary files still follow the exporter's replacement policy when skipping is off.
|
||||
f.s.execute("renderQueue.setRenderSettings", json!({"index": 1, "skipExisting": false})).unwrap();
|
||||
f.render(json!({})).unwrap();
|
||||
assert_eq!(f.read("render_00002.png"), RENDERED);
|
||||
}
|
||||
|
||||
/// A link is another name for the file it points to.
|
||||
#[test]
|
||||
fn a_link_to_the_footage_is_the_footage() {
|
||||
let mut f = Fixture::new("link");
|
||||
f.footage("clip.mov");
|
||||
f.comp("Main");
|
||||
let alias = f.path("render.mov");
|
||||
if link(&f.path("clip.mov"), &alias).is_err() {
|
||||
eprintln!("this account cannot make symbolic links: skipped");
|
||||
return;
|
||||
}
|
||||
f.s.execute("renderQueue.add", json!({"format": "prores", "output": f.text("render.mov")})).unwrap();
|
||||
let e = f.render(json!({"overwrite": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("render.mov") && e.contains("footage"), "{e}");
|
||||
assert_eq!(f.read("clip.mov"), SOURCE);
|
||||
assert!(std::fs::symlink_metadata(&alias).unwrap().file_type().is_symlink(), "the link was not replaced either");
|
||||
}
|
||||
|
||||
/// A hard link to the footage under another name is the same file on disk: replacing it would truncate the footage.
|
||||
#[test]
|
||||
fn a_hard_link_to_the_footage_is_the_footage() {
|
||||
let mut f = Fixture::new("hardlink");
|
||||
f.footage("clip.mov");
|
||||
f.comp("Main");
|
||||
if std::fs::hard_link(f.path("clip.mov"), f.path("render.mov")).is_err() {
|
||||
eprintln!("this volume cannot make hard links: skipped");
|
||||
return;
|
||||
}
|
||||
f.s.execute("renderQueue.add", json!({"format": "prores", "output": f.text("render.mov")})).unwrap();
|
||||
// not even with overwrite
|
||||
let e = f.render(json!({"overwrite": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("render.mov") && e.contains("footage"), "{e}");
|
||||
assert_eq!(f.read("clip.mov"), SOURCE);
|
||||
assert_eq!(f.read("render.mov"), SOURCE, "the other name still leads to the footage");
|
||||
assert!(f.exported().is_empty(), "nothing was rendered");
|
||||
}
|
||||
|
||||
/// A copy of the footage has its size and bytes, but it is another file: an earlier render may replace it.
|
||||
#[test]
|
||||
fn a_copy_of_the_footage_is_not_the_footage() {
|
||||
let mut f = Fixture::new("copy");
|
||||
f.footage("clip.mov");
|
||||
f.comp("Main");
|
||||
std::fs::copy(f.path("clip.mov"), f.path("copy.mov")).unwrap();
|
||||
f.s.execute("renderQueue.add", json!({"format": "prores", "output": f.text("copy.mov")})).unwrap();
|
||||
f.render(json!({})).unwrap();
|
||||
assert_eq!(f.read("copy.mov"), RENDERED);
|
||||
assert_eq!(f.read("clip.mov"), SOURCE, "the footage is as it was");
|
||||
}
|
||||
|
||||
/// Footage imported through a linked folder is the same file as the one in the real folder.
|
||||
#[test]
|
||||
fn footage_reached_through_a_linked_folder_is_still_footage() {
|
||||
let mut f = Fixture::new("linked-folder");
|
||||
std::fs::create_dir_all(f.path("real")).unwrap();
|
||||
std::fs::write(f.path("real").join("clip.mov"), SOURCE).unwrap();
|
||||
if link_dir(&f.path("real"), &f.path("linked")).is_err() {
|
||||
eprintln!("this account cannot link folders: skipped");
|
||||
return;
|
||||
}
|
||||
let via_link =
|
||||
Footage { path: f.path("linked").join("clip.mov").to_string_lossy().into_owned(), kind: FootageKind::Video, has_video: true, ..Default::default() };
|
||||
Arc::make_mut(&mut f.s.project).add_item("clip.mov", Label::None, None, ItemKind::Footage(via_link));
|
||||
f.comp("Main");
|
||||
f.s.execute("renderQueue.add", json!({"format": "prores", "output": f.path("real").join("clip.mov").to_string_lossy()})).unwrap();
|
||||
let e = f.render(json!({"overwrite": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("footage"), "{e}");
|
||||
assert_eq!(std::fs::read(f.path("real").join("clip.mov")).unwrap(), SOURCE);
|
||||
}
|
||||
|
||||
/// Where the volume ignores case, `CLIP.MOV` is `clip.mov`.
|
||||
#[test]
|
||||
fn another_spelling_of_the_footage_name_is_the_footage_where_the_volume_ignores_case() {
|
||||
let mut f = Fixture::new("case");
|
||||
f.footage("clip.mov");
|
||||
f.comp("Main");
|
||||
if !f.path("CLIP.MOV").exists() {
|
||||
eprintln!("this volume tells the two names apart: skipped");
|
||||
return;
|
||||
}
|
||||
let shouting = f.text("CLIP.MOV");
|
||||
f.s.execute("renderQueue.add", json!({"format": "prores", "output": shouting})).unwrap();
|
||||
let e = f.render(json!({"overwrite": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("footage"), "{e}");
|
||||
assert_eq!(f.read("clip.mov"), SOURCE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_current_preview_never_replaces_footage() {
|
||||
let mut f = Fixture::new("preview");
|
||||
f.footage("clip.mov");
|
||||
f.comp("Main");
|
||||
let clip = f.text("clip.mov");
|
||||
let e = f.s.execute("render.saveCurrentPreview", json!({"path": clip})).unwrap_err().to_string();
|
||||
assert!(e.contains("clip.mov") && e.contains("footage"), "{e}");
|
||||
assert_eq!(f.read("clip.mov"), SOURCE);
|
||||
assert!(f.exported().is_empty());
|
||||
// Anywhere else it renders.
|
||||
let elsewhere = f.text("preview.mp4");
|
||||
f.s.execute("render.saveCurrentPreview", json!({"path": elsewhere})).unwrap();
|
||||
assert_eq!(f.read("preview.mp4"), RENDERED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_current_preview_checks_sequence_frames_as_well_as_the_pattern() {
|
||||
let mut f = Fixture::new("preview-sequence");
|
||||
f.sequence("plate", 0..5);
|
||||
f.comp("Main");
|
||||
let error = f.s.execute("render.saveCurrentPreview", json!({"path": f.text("plate_[#####].png")})).unwrap_err().to_string();
|
||||
assert!(error.contains("plate_000") && error.contains("footage"), "{error}");
|
||||
for frame in 0..5 {
|
||||
assert_eq!(f.read(&format!("plate_{frame:05}.png")), SOURCE);
|
||||
}
|
||||
assert!(f.exported().is_empty());
|
||||
}
|
||||
|
||||
/// A watched folder renders unattended; delete the status file to render a project again.
|
||||
#[test]
|
||||
fn a_watched_folder_still_renders_a_project_again_over_its_outputs() {
|
||||
let mut f = Fixture::new("watch");
|
||||
f.comp("Main");
|
||||
let out = f.text("Main.mp4");
|
||||
f.s.execute("renderQueue.add", json!({"output": out})).unwrap();
|
||||
f.s.execute("file.save", json!({})).unwrap();
|
||||
f.write("Main.mp4", EARLIER);
|
||||
let folder = f.dir.to_string_lossy().into_owned();
|
||||
let done = crate::commands::watch_folder::poll(&mut f.s, &folder).unwrap();
|
||||
assert_eq!(done.len(), 1, "{done:?}");
|
||||
assert_eq!(done[0]["state"], "done", "{:?}", done[0]);
|
||||
assert_eq!(f.read("Main.mp4"), RENDERED);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::item::{ItemId, LayerId};
|
||||
use super::settings::Marker;
|
||||
use super::{BlendMode, Deserialize, Label, PropGroup, Serialize, Tick};
|
||||
use super::{BlendMode, Deserialize, Label, ParamUi, PropGroup, Property, Serialize, Tick};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum LightKind {
|
||||
@@ -357,6 +357,20 @@ impl Layer {
|
||||
self.props.sub("masks")
|
||||
}
|
||||
|
||||
/// The labels popup property `p` of this layer offers: Text Path Options ▸ Path lists None and
|
||||
/// the layer's masks (value k = mask k), every other popup its own options. `None` when `p` is
|
||||
/// not a popup.
|
||||
pub fn popup_options<'a>(&'a self, p: &'a Property) -> Option<std::borrow::Cow<'a, [String]>> {
|
||||
if self.props.prop("text/pathOptions/path").is_some_and(|q| q.uid == p.uid) {
|
||||
let masks = self.masks().into_iter().flat_map(|m| m.groups().map(|g| g.name.clone()));
|
||||
return Some(std::iter::once("None".to_string()).chain(masks).collect::<Vec<_>>().into());
|
||||
}
|
||||
match &p.ui {
|
||||
ParamUi::Popup { options } => Some(options.as_slice().into()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_camera(&self) -> bool {
|
||||
matches!(self.source, LayerSource::Camera)
|
||||
}
|
||||
|
||||
@@ -1477,22 +1477,56 @@ pub fn expand_template(template: &str, format: OutputFormat, v: &TemplateVars) -
|
||||
/// Replace the first run of `#` (optionally in brackets, `[####]`) with the zero-padded frame
|
||||
/// number. Paths without a run get `_NNNNN` before the extension.
|
||||
pub fn sequence_path(path: &str, frame: i64) -> String {
|
||||
let (before, width, after) = sequence_parts(path);
|
||||
format!("{before}{frame:0width$}{after}")
|
||||
}
|
||||
|
||||
/// A sequence output path around its frame number: what comes before it, how many digits it is
|
||||
/// padded to and what comes after (`out/a_[#####].png` → `out/a_`, 5, `.png`).
|
||||
pub fn sequence_parts(path: &str) -> (String, usize, String) {
|
||||
if let Some(start) = path.find('#') {
|
||||
let len = path[start..].chars().take_while(|c| *c == '#').count();
|
||||
let (mut a, mut b) = (start, start + len);
|
||||
if a > 0 && path.as_bytes()[a - 1] == b'[' && path.as_bytes().get(b) == Some(&b']') {
|
||||
if a > 0 && path.as_bytes().get(a - 1) == Some(&b'[') && path.as_bytes().get(b) == Some(&b']') {
|
||||
a -= 1;
|
||||
b += 1;
|
||||
}
|
||||
return format!("{}{:0width$}{}", &path[..a], frame, &path[b..], width = len);
|
||||
return (path[..a].to_string(), len, path[b..].to_string());
|
||||
}
|
||||
let p = std::path::Path::new(path);
|
||||
match p.extension() {
|
||||
Some(ext) => format!("{}_{frame:05}.{}", p.with_extension("").to_string_lossy(), ext.to_string_lossy()),
|
||||
None => format!("{path}_{frame:05}"),
|
||||
Some(ext) => (format!("{}_", p.with_extension("").to_string_lossy()), 5, format!(".{}", ext.to_string_lossy())),
|
||||
None => (format!("{path}_"), 5, String::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame number `candidate` is the file of, when `path` (a sequence output path) writes it:
|
||||
/// the inverse of [`sequence_path`].
|
||||
pub fn sequence_frame(path: &str, candidate: &str) -> Option<i64> {
|
||||
let (before, width, after) = sequence_parts(path);
|
||||
let digits = candidate.strip_prefix(before.as_str())?.strip_suffix(after.as_str())?;
|
||||
let frame: i64 = digits.parse().ok()?;
|
||||
(format!("{frame:0width$}") == digits).then_some(frame)
|
||||
}
|
||||
|
||||
/// `template` with `_n` added to the name: before the frame-number run of a sequence, else before
|
||||
/// the extension (`[compName].[fileExtension]` → `[compName]_1.[fileExtension]`,
|
||||
/// `[compName]_[#####].[fileExtension]` → `[compName]_1_[#####].[fileExtension]`).
|
||||
pub fn numbered_template(template: &str, n: u32) -> String {
|
||||
let at = match template.find('#') {
|
||||
Some(hash) => {
|
||||
// The run's `[`, then the separator that sets it off from the name.
|
||||
let run = if template[..hash].ends_with('[') { hash - 1 } else { hash };
|
||||
if template[..run].ends_with(['_', '-', '.', ' ']) { run - 1 } else { run }
|
||||
}
|
||||
None => {
|
||||
let name = template.rfind(['/', '\\']).map_or(0, |i| i + 1);
|
||||
template[name..].rfind('.').map_or(template.len(), |i| name + i)
|
||||
}
|
||||
};
|
||||
format!("{}_{n}{}", &template[..at], &template[at..])
|
||||
}
|
||||
|
||||
/// Make a name safe as a file name component.
|
||||
pub fn sanitize(name: &str) -> String {
|
||||
name.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') { '_' } else { c }).collect()
|
||||
@@ -1512,6 +1546,44 @@ mod tests {
|
||||
assert_eq!(sequence_path("a.png", 3), "a_00003.png");
|
||||
}
|
||||
|
||||
/// `sequence_frame` reads back what `sequence_path` writes, and nothing else.
|
||||
#[test]
|
||||
fn sequence_frames_read_back() {
|
||||
for path in ["out/a_[#####].png", "a###.tif", "a.png", "/x/[compName]/shot_[##].exr", "noext", "dir.v2/clip"] {
|
||||
for frame in [0, 7, 42, 99_999, 123_456, -3] {
|
||||
assert_eq!(sequence_frame(path, &sequence_path(path, frame)), Some(frame), "{path} {frame}");
|
||||
}
|
||||
}
|
||||
let (before, width, after) = sequence_parts("out/a_[#####].png");
|
||||
assert_eq!((before.as_str(), width, after.as_str()), ("out/a_", 5, ".png"));
|
||||
let (before, width, after) = sequence_parts("a.png");
|
||||
assert_eq!((before.as_str(), width, after.as_str()), ("a_", 5, ".png"));
|
||||
// Other numbers, other widths, other names are not frames of the sequence.
|
||||
assert_eq!(sequence_frame("a_[#####].png", "a_0042.png"), None, "padded to four");
|
||||
assert_eq!(sequence_frame("a_[####].png", "a_+042.png"), None, "a sign is not padding");
|
||||
assert_eq!(sequence_frame("a_[#####].png", "a_00042.jpg"), None);
|
||||
assert_eq!(sequence_frame("a_[#####].png", "b_00042.png"), None);
|
||||
assert_eq!(sequence_frame("a_[#####].png", "a_00x42.png"), None);
|
||||
assert_eq!(sequence_frame("a_[#####].png", "a_.png"), None);
|
||||
assert_eq!(sequence_frame("a_[#####].png", "a_99999999999999999999.png"), None, "too big for a frame number");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbered_templates() {
|
||||
assert_eq!(numbered_template(DEFAULT_TEMPLATE, 1), "[compName]_1.[fileExtension]");
|
||||
assert_eq!(numbered_template(DEFAULT_SEQUENCE_TEMPLATE, 2), "[compName]_2_[#####].[fileExtension]");
|
||||
assert_eq!(numbered_template("[compName]_frame.[fileExtension]", 3), "[compName]_frame_3.[fileExtension]");
|
||||
assert_eq!(numbered_template("out/[compName]###.png", 1), "out/[compName]_1###.png");
|
||||
assert_eq!(numbered_template("my.dir/clip", 4), "my.dir/clip_4", "a dot in a folder is not an extension");
|
||||
assert_eq!(numbered_template("clip.v2.mov", 1), "clip.v2_1.mov");
|
||||
assert_eq!(numbered_template("", 1), "_1");
|
||||
assert_eq!(numbered_template("#", 1), "_1#");
|
||||
assert_eq!(numbered_template("[#]", 1), "_1[#]");
|
||||
// A numbered default is still a default: it expands to the numbered name.
|
||||
let v = TemplateVars { comp_name: "footage", project_name: "P", width: 64, height: 36, frame_rate: 30.0, start_frame: 0, end_frame: 29 };
|
||||
assert_eq!(expand_template(&numbered_template(DEFAULT_TEMPLATE, 1), OutputFormat::H264, &v), "footage_1.mp4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_format_fixes_names() {
|
||||
let mut m = OutputModule::default();
|
||||
|
||||
@@ -378,6 +378,10 @@ impl EffectcraftApp {
|
||||
// Video Preview is a second viewport: only a real pass can update it.
|
||||
prefs_live::frame(self, &ctx);
|
||||
self.auto.begin_frame();
|
||||
panels::viewer_tools::clear_sample_source(&ctx);
|
||||
if let Some(rect) = crate::color_picker::take_automation(&ctx) {
|
||||
self.auto.add("colorPicker.eyedropper", rect, "Eyedropper");
|
||||
}
|
||||
let tab_had_widget_focus = ctx.egui_wants_keyboard_input();
|
||||
menus::handle_shortcuts(self, &ctx);
|
||||
if !ctx.input(|i| i.pointer.primary_down()) {
|
||||
@@ -406,6 +410,7 @@ impl EffectcraftApp {
|
||||
let header = egui::Rect::from_min_size(egui::pos2(full.min.x, top), egui::vec2(full.width(), header_h));
|
||||
header::show(self, ui, header);
|
||||
let body = egui::Rect::from_min_max(egui::pos2(full.min.x + 4.0, header.max.y + 2.0), egui::pos2(full.max.x - 4.0, full.max.y - 4.0));
|
||||
crate::color_picker::note_dialog(&ctx, self.dialog);
|
||||
self.dock_area(ui, body);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
prefs_live::dropped_files(self, ui, full);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! 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.
|
||||
//! colour over the original, the HSB and RGB values, the hex code with an eyedropper that samples
|
||||
//! the Composition viewer, 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.
|
||||
@@ -30,35 +31,55 @@ pub fn srgb_color_button(ui: &mut Ui, c: &mut [f32; 3]) -> Response {
|
||||
}
|
||||
|
||||
/// 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).
|
||||
/// when the colour changed (Cancel, Escape and the original swatch change it back; the eyedropper
|
||||
/// changes it to the colour it samples).
|
||||
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));
|
||||
// Read before a sample is taken: the press that samples the viewer lands outside the picker, and
|
||||
// must not close it.
|
||||
let armed = armed_for(ui.ctx(), id);
|
||||
let mut sampled = false;
|
||||
if let Some(rgb) = take_sample(ui.ctx(), id)
|
||||
&& rgb != *c
|
||||
{
|
||||
*c = rgb;
|
||||
sampled = true;
|
||||
}
|
||||
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));
|
||||
});
|
||||
// Escape is the picker's: consume it, so nothing later this pass takes it too.
|
||||
// Escape is the picker's: consume it, so nothing later this pass takes it too. While the
|
||||
// eyedropper is armed, Escape cancels it and the picker stays open.
|
||||
if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
|
||||
out.close = Some(false);
|
||||
if armed {
|
||||
disarm(ui.ctx(), id);
|
||||
} else {
|
||||
out.close = Some(false);
|
||||
}
|
||||
}
|
||||
// A press outside closes the picker, unless it is the armed eyedropper's press on the viewer.
|
||||
let on_viewer = armed && eyedropper_point(ui.ctx()).is_some();
|
||||
// A press in the pass that first draws the picker must not close it: an Area's rect is only
|
||||
// at its requested position from its second pass on (the first pass reports it at the
|
||||
// origin), so the click that just opened the picker would be read as a press outside it and
|
||||
// the picker would flash and close in that one frame (#1476).
|
||||
let pass_id = id.with("open-pass");
|
||||
let drawn_before = ui.data(|d| d.get_temp::<u64>(pass_id)).is_some_and(|p| p < ui.ctx().cumulative_pass_nr());
|
||||
if drawn_before && pressed_outside(ui.ctx(), &area.response) {
|
||||
if drawn_before && pressed_outside(ui.ctx(), &area.response) && !on_viewer {
|
||||
out.close.get_or_insert(true);
|
||||
}
|
||||
note_overlay(ui.ctx(), area.response.rect);
|
||||
if let Some(keep) = out.close {
|
||||
if !keep && *c != original {
|
||||
*c = original;
|
||||
out.changed = true;
|
||||
}
|
||||
disarm(ui.ctx(), id);
|
||||
ui.data_mut(|d| {
|
||||
d.insert_temp(id.with("open"), false);
|
||||
d.remove::<[f32; 3]>(orig_id);
|
||||
@@ -67,6 +88,7 @@ pub fn color_popup(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, c: &mut [f32; 3])
|
||||
d.remove::<u64>(open_pass_id());
|
||||
});
|
||||
} else {
|
||||
refresh_armed(ui.ctx(), id);
|
||||
// Still open after this pass: dialogs leave Escape to the picker (see `open_recently`).
|
||||
let pass = ui.ctx().cumulative_pass_nr();
|
||||
ui.data_mut(|d| {
|
||||
@@ -74,7 +96,7 @@ pub fn color_popup(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, c: &mut [f32; 3])
|
||||
d.insert_temp(open_pass_id(), pass);
|
||||
});
|
||||
}
|
||||
out.changed
|
||||
out.changed || sampled
|
||||
}
|
||||
|
||||
/// The `egui::Id` of the pass number in which a colour picker last stayed open, which
|
||||
@@ -92,6 +114,196 @@ pub fn open_recently(ctx: &egui::Context) -> bool {
|
||||
pass.is_some_and(|p| p.saturating_add(1) >= ctx.cumulative_pass_nr())
|
||||
}
|
||||
|
||||
/// The armed eyedropper of a picker: the picker's popup id, the pass it was last drawn in, and the
|
||||
/// colour the viewer sampled for it, if one is waiting for the picker's next draw. It lives in the
|
||||
/// egui context, since the viewer that samples has no picker in hand.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Armed {
|
||||
popup: egui::Id,
|
||||
pass: u64,
|
||||
sample: Option<[f32; 3]>,
|
||||
dialog: Option<crate::Dialog>,
|
||||
}
|
||||
|
||||
fn armed_key() -> egui::Id {
|
||||
egui::Id::unique("color-picker-eyedropper")
|
||||
}
|
||||
|
||||
fn dialog_key() -> egui::Id {
|
||||
egui::Id::unique("color-picker-dialog")
|
||||
}
|
||||
|
||||
/// Record the modal before drawing the viewer. A picker may sample behind its own dialog, but
|
||||
/// a new, unrelated modal must block it from its very first pass, before its rect is known.
|
||||
pub(crate) fn note_dialog(ctx: &egui::Context, dialog: Option<crate::Dialog>) {
|
||||
ctx.data_mut(|data| data.insert_temp(dialog_key(), dialog));
|
||||
}
|
||||
|
||||
fn current_dialog(ctx: &egui::Context) -> Option<crate::Dialog> {
|
||||
ctx.data(|data| data.get_temp::<Option<crate::Dialog>>(dialog_key())).flatten()
|
||||
}
|
||||
|
||||
/// The armed eyedropper, if its picker was drawn in this pass or the last: one whose picker stops
|
||||
/// being drawn (its dialog gone) lapses.
|
||||
fn armed_record(ctx: &egui::Context) -> Option<Armed> {
|
||||
let armed: Option<Armed> = ctx.data(|d| d.get_temp(armed_key()));
|
||||
let pass = ctx.cumulative_pass_nr();
|
||||
armed.filter(|a| a.pass.saturating_add(1) >= pass)
|
||||
}
|
||||
|
||||
fn armed_for(ctx: &egui::Context, popup: egui::Id) -> bool {
|
||||
armed_record(ctx).is_some_and(|a| a.popup == popup)
|
||||
}
|
||||
|
||||
/// Some picker's eyedropper is armed: a press on the Composition viewer samples the frame for it.
|
||||
pub fn eyedropper_armed(ctx: &egui::Context) -> bool {
|
||||
armed_record(ctx).is_some()
|
||||
}
|
||||
|
||||
fn arm(ctx: &egui::Context, popup: egui::Id) {
|
||||
let pass = ctx.cumulative_pass_nr();
|
||||
let dialog = current_dialog(ctx);
|
||||
ctx.data_mut(|d| d.insert_temp(armed_key(), Armed { popup, pass, sample: None, dialog }));
|
||||
}
|
||||
|
||||
fn disarm(ctx: &egui::Context, popup: egui::Id) {
|
||||
let armed: Option<Armed> = ctx.data(|d| d.get_temp(armed_key()));
|
||||
if armed.is_some_and(|a| a.popup == popup) {
|
||||
ctx.data_mut(|d| d.remove::<Armed>(armed_key()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps `popup`'s eyedropper armed while its picker is drawn.
|
||||
fn refresh_armed(ctx: &egui::Context, popup: egui::Id) {
|
||||
if let Some(armed) = armed_record(ctx).filter(|a| a.popup == popup) {
|
||||
let pass = ctx.cumulative_pass_nr();
|
||||
ctx.data_mut(|d| d.insert_temp(armed_key(), Armed { pass, ..armed }));
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour the viewer sampled for `popup`, once: taking it disarms the eyedropper.
|
||||
fn take_sample(ctx: &egui::Context, popup: egui::Id) -> Option<[f32; 3]> {
|
||||
let armed = armed_record(ctx).filter(|a| a.popup == popup)?;
|
||||
let sample = armed.sample?;
|
||||
disarm(ctx, popup);
|
||||
Some(sample)
|
||||
}
|
||||
|
||||
/// The viewer sampled `rgb` for the armed picker, which takes it on its next draw.
|
||||
pub fn deliver_sample(ctx: &egui::Context, rgb: [f32; 3]) {
|
||||
if let Some(armed) = armed_record(ctx) {
|
||||
ctx.data_mut(|d| d.insert_temp(armed_key(), Armed { sample: Some(rgb), ..armed }));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
fn viewer_key() -> egui::Id {
|
||||
egui::Id::unique("color-picker-viewer")
|
||||
}
|
||||
|
||||
/// The Composition viewer's rect as it was drawn this pass: where an armed eyedropper samples.
|
||||
pub fn note_viewer(ctx: &egui::Context, rect: Rect) {
|
||||
let pass = ctx.cumulative_pass_nr();
|
||||
ctx.data_mut(|d| d.insert_temp(viewer_key(), (pass, rect)));
|
||||
}
|
||||
|
||||
fn overlay_key() -> egui::Id {
|
||||
egui::Id::unique("color-picker-overlays")
|
||||
}
|
||||
|
||||
/// A popup or dialog drawn over the viewer: a press inside `rect` is not a sample.
|
||||
pub fn note_overlay(ctx: &egui::Context, rect: Rect) {
|
||||
let pass = ctx.cumulative_pass_nr();
|
||||
ctx.data_mut(|d| {
|
||||
// This pass's and the last's: the viewer may be drawn before an overlay in a pass.
|
||||
let mut rects = d.get_temp::<Vec<(u64, Rect)>>(overlay_key()).unwrap_or_default();
|
||||
rects.retain(|(p, _)| p.saturating_add(1) >= pass);
|
||||
rects.push((pass, rect));
|
||||
d.insert_temp(overlay_key(), rects);
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether `pos` is over a popup or dialog drawn this pass or the last.
|
||||
fn overlaid(ctx: &egui::Context, pos: egui::Pos2) -> bool {
|
||||
let pass = ctx.cumulative_pass_nr();
|
||||
let rects: Option<Vec<(u64, Rect)>> = ctx.data(|d| d.get_temp(overlay_key()));
|
||||
rects.unwrap_or_default().iter().any(|(p, r)| p.saturating_add(1) >= pass && r.contains(pos))
|
||||
}
|
||||
|
||||
/// Where an armed eyedropper samples: the pointer, if it is over the viewer and not over a popup or
|
||||
/// dialog. The pointer is read rather than the viewer's responses, since a modal dialog over the
|
||||
/// viewer takes its clicks.
|
||||
pub fn eyedropper_point(ctx: &egui::Context) -> Option<egui::Pos2> {
|
||||
if armed_record(ctx).is_some_and(|armed| armed.dialog != current_dialog(ctx)) {
|
||||
return None;
|
||||
}
|
||||
let viewer: Option<(u64, Rect)> = ctx.data(|d| d.get_temp(viewer_key()));
|
||||
let viewer = viewer.filter(|(p, _)| p.saturating_add(1) >= ctx.cumulative_pass_nr())?.1;
|
||||
let pos = ctx.input(|i| i.pointer.interact_pos())?;
|
||||
(viewer.contains(pos) && !overlaid(ctx, pos)).then_some(pos)
|
||||
}
|
||||
|
||||
/// The press that samples for an armed eyedropper this frame, if there is one.
|
||||
pub fn sample_press(ctx: &egui::Context) -> Option<egui::Pos2> {
|
||||
if ctx.input(|i| i.pointer.primary_pressed()) { eyedropper_point(ctx) } else { None }
|
||||
}
|
||||
|
||||
fn automation_key() -> egui::Id {
|
||||
egui::Id::unique("color-picker-eyedropper-rect")
|
||||
}
|
||||
|
||||
/// The eyedropper's rect, for the automation registry; [`take_automation`] hands it over.
|
||||
fn note_automation(ctx: &egui::Context, rect: Rect) {
|
||||
ctx.data_mut(|d| d.insert_temp(automation_key(), rect));
|
||||
}
|
||||
|
||||
/// The eyedropper rect the last picker drawing noted, once. Pickers have no registry in hand, so the
|
||||
/// app registers it each frame.
|
||||
pub fn take_automation(ctx: &egui::Context) -> Option<Rect> {
|
||||
ctx.data_mut(|d| {
|
||||
let rect = d.get_temp::<Rect>(automation_key());
|
||||
d.remove::<Rect>(automation_key());
|
||||
rect
|
||||
})
|
||||
}
|
||||
|
||||
/// The eyedropper of the hex row. A press arms it, or disarms it while armed; the next press on the
|
||||
/// Composition viewer then samples the colour under the pointer into this picker.
|
||||
fn eyedropper(ui: &mut Ui, popup: egui::Id) {
|
||||
let armed = armed_for(ui.ctx(), popup);
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click());
|
||||
let resp = resp.on_hover_text(tr("Eyedropper: click, then click in the Composition panel"));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, resp.enabled(), "Eyedropper"));
|
||||
let fill = if armed {
|
||||
ui.visuals().selection.bg_fill
|
||||
} else if resp.hovered() {
|
||||
ui.visuals().widgets.hovered.bg_fill
|
||||
} else {
|
||||
Color32::TRANSPARENT
|
||||
};
|
||||
ui.painter().rect_filled(rect, 2.0, fill);
|
||||
let col = if armed { ui.visuals().selection.stroke.color } else { ui.visuals().strong_text_color() };
|
||||
draw_pipette(ui.painter(), rect, col);
|
||||
if resp.clicked() {
|
||||
if armed {
|
||||
disarm(ui.ctx(), popup);
|
||||
} else {
|
||||
arm(ui.ctx(), popup);
|
||||
}
|
||||
}
|
||||
note_automation(ui.ctx(), rect);
|
||||
}
|
||||
|
||||
/// A pipette: a diagonal glass tube with a bulb at the top right and a tip at the bottom left, drawn
|
||||
/// within `rect`.
|
||||
pub fn draw_pipette(p: &egui::Painter, rect: Rect, col: Color32) {
|
||||
let r = rect.shrink(rect.width() * 0.2);
|
||||
let at = |x: f32, y: f32| pos2(r.min.x + x * r.width(), r.min.y + y * r.height());
|
||||
p.line_segment([at(0.08, 0.92), at(0.62, 0.38)], Stroke::new(2.2, col));
|
||||
p.circle_filled(at(0.76, 0.24), r.width() * 0.2, col);
|
||||
p.line_segment([at(0.5, 0.3), at(0.7, 0.5)], Stroke::new(1.4, col));
|
||||
}
|
||||
|
||||
/// What the picker did this frame.
|
||||
#[derive(Default)]
|
||||
struct Outcome {
|
||||
@@ -255,6 +467,7 @@ fn color_picker(ui: &mut Ui, id: egui::Id, c: &mut [f32; 3], original: [f32; 3])
|
||||
if hex_field(ui, id.with("hex"), &mut rgb) {
|
||||
pick = Pick { hsv: keep_hue(rgb_to_hsv(rgb), pick.hsv), rgb };
|
||||
}
|
||||
eyedropper(ui, id);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -447,3 +660,125 @@ pub fn hsv_to_rgb([h, s, v]: [f32; 3]) -> [f32; 3] {
|
||||
};
|
||||
[f(5.0), f(3.0), f(1.0)]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sampling_viewer() -> egui_kittest::Harness<'static, crate::EffectcraftApp> {
|
||||
let mut session = effectcraft_engine::Session::default();
|
||||
session.execute("comp.new", serde_json::json!({"width": 320, "height": 180, "duration": 2})).unwrap();
|
||||
session.execute("layer.newSolid", serde_json::json!({"color": "#204060"})).unwrap();
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_eframe(|_| crate::EffectcraftApp::new(session));
|
||||
settle_sampling_viewer(&mut harness);
|
||||
harness
|
||||
}
|
||||
|
||||
fn settle_sampling_viewer(harness: &mut egui_kittest::Harness<'_, crate::EffectcraftApp>) {
|
||||
for _ in 0..200 {
|
||||
harness.step();
|
||||
if harness.state().frames.inflight() == 0 && harness.state().viewer_image.is_some() {
|
||||
harness.run_steps(3);
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
panic!("the synthetic viewer frame did not arrive");
|
||||
}
|
||||
|
||||
fn press_sampling_viewer(harness: &mut egui_kittest::Harness<'_, crate::EffectcraftApp>) -> Option<[f32; 3]> {
|
||||
let ctx = harness.ctx.clone();
|
||||
arm(&ctx, egui::Id::unique("test-popup"));
|
||||
let point = crate::panels::viewer::comp_to_screen(&ctx, [20.0, 20.0]).unwrap();
|
||||
harness.event(egui::Event::PointerMoved(point));
|
||||
harness.event(egui::Event::PointerButton { pos: point, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE });
|
||||
harness.step();
|
||||
let sample = ctx.data(|data| data.get_temp::<Armed>(armed_key())).and_then(|armed| armed.sample);
|
||||
harness.event(egui::Event::PointerButton { pos: point, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE });
|
||||
harness.step();
|
||||
sample
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn popup_eyedropper_samples_the_displayed_channel_and_exposure() {
|
||||
let mut harness = sampling_viewer();
|
||||
harness.state_mut().session.state.viewer.channel = effectcraft_engine::viewer::Channel::Green;
|
||||
let green = press_sampling_viewer(&mut harness).unwrap();
|
||||
assert!(green.iter().all(|value| (*value - 64.0 / 255.0).abs() < 0.01), "{green:?}");
|
||||
let options = &mut harness.state_mut().session.state.viewer;
|
||||
options.channel = effectcraft_engine::viewer::Channel::Rgb;
|
||||
options.exposure = 1.0;
|
||||
let exposed = press_sampling_viewer(&mut harness).unwrap();
|
||||
let mut expected = [[32, 64, 96, 255]];
|
||||
effectcraft_engine::viewer::display_transform(&mut expected, effectcraft_engine::viewer::Channel::Rgb, false, 1.0);
|
||||
for (actual, expected) in exposed.iter().zip(expected[0]) {
|
||||
assert!((*actual - expected as f32 / 255.0).abs() < 0.01, "{exposed:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn popup_eyedropper_samples_snapshot_and_refuses_wireframe() {
|
||||
let mut harness = sampling_viewer();
|
||||
harness.state_mut().session.execute("view.takeSnapshot", serde_json::json!({})).unwrap();
|
||||
harness.state_mut().session.execute("layer.newSolid", serde_json::json!({"color": "#a0b0c0"})).unwrap();
|
||||
settle_sampling_viewer(&mut harness);
|
||||
harness.event(egui::Event::Key { key: egui::Key::F5, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE });
|
||||
// Let the snapshot texture's discard passes finish before arming this synthetic picker.
|
||||
harness.step();
|
||||
let snapshot = press_sampling_viewer(&mut harness).unwrap();
|
||||
for (actual, expected) in snapshot.iter().zip([32, 64, 96]) {
|
||||
assert!((*actual - expected as f32 / 255.0).abs() < 0.01, "{snapshot:?}");
|
||||
}
|
||||
let options = &mut harness.state_mut().session.state.viewer;
|
||||
options.show_snapshot = false;
|
||||
options.fast_previews = effectcraft_engine::commands::viewer_cmds::FastPreviews::Wireframe;
|
||||
harness.event(egui::Event::Key { key: egui::Key::F5, physical_key: None, pressed: false, repeat: false, modifiers: egui::Modifiers::NONE });
|
||||
assert_eq!(press_sampling_viewer(&mut harness), None, "wireframe must not sample an old pixel frame");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_modal_backdrop_cannot_deliver_a_pending_sample() {
|
||||
let mut harness = sampling_viewer();
|
||||
harness.state_mut().dialog = Some(crate::Dialog::About);
|
||||
assert_eq!(press_sampling_viewer(&mut harness), None, "the first modal pass must already exclude its backdrop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picker_can_sample_behind_its_own_dialog_but_not_a_new_modal() {
|
||||
let ctx = egui::Context::default();
|
||||
let point = pos2(20.0, 20.0);
|
||||
let mut output = ctx.run_ui(egui::RawInput { events: vec![egui::Event::PointerMoved(point)], ..Default::default() }, |ui| {
|
||||
let ctx = ui.ctx();
|
||||
note_dialog(ctx, Some(crate::Dialog::SolidSettings));
|
||||
note_viewer(ctx, Rect::from_min_size(pos2(0.0, 0.0), vec2(320.0, 180.0)));
|
||||
note_overlay(ctx, Rect::from_min_size(pos2(100.0, 100.0), vec2(120.0, 80.0)));
|
||||
arm(ctx, egui::Id::unique("test-picker"));
|
||||
assert_eq!(eyedropper_point(ctx), Some(point));
|
||||
note_dialog(ctx, Some(crate::Dialog::About));
|
||||
assert_eq!(eyedropper_point(ctx), None);
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn armed_eyedropper_takes_one_sample_then_disarms() {
|
||||
let ctx = egui::Context::default();
|
||||
let popup = egui::Id::unique("test-picker");
|
||||
arm(&ctx, popup);
|
||||
assert!(eyedropper_armed(&ctx));
|
||||
assert_eq!(take_sample(&ctx, popup), None);
|
||||
deliver_sample(&ctx, [0.1, 0.2, 0.3]);
|
||||
assert_eq!(take_sample(&ctx, popup), Some([0.1, 0.2, 0.3]));
|
||||
assert!(!eyedropper_armed(&ctx));
|
||||
assert_eq!(take_sample(&ctx, popup), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn points_over_a_dialog_are_not_samples() {
|
||||
let ctx = egui::Context::default();
|
||||
let dialog = Rect::from_min_size(pos2(100.0, 100.0), vec2(120.0, 80.0));
|
||||
note_overlay(&ctx, dialog);
|
||||
assert!(overlaid(&ctx, pos2(150.0, 150.0)));
|
||||
assert!(!overlaid(&ctx, pos2(20.0, 20.0)));
|
||||
}
|
||||
}
|
||||
@@ -539,18 +539,16 @@ impl Default for Frames {
|
||||
/// Premultiplied f32 → egui premultiplied Color32 (alpha kept, so the viewer can show the
|
||||
/// transparency grid or the comp background colour underneath).
|
||||
pub fn to_color_image(img: &effectcraft_engine::render::Image) -> egui::ColorImage {
|
||||
let px: Vec<egui::Color32> = img
|
||||
.data
|
||||
.par_iter()
|
||||
.map(|p| {
|
||||
let a = p[3].clamp(0.0, 1.0);
|
||||
let c = |v: f32| (v.clamp(0.0, a) * 255.0 + 0.5) as u8;
|
||||
egui::Color32::from_rgba_premultiplied(c(p[0]), c(p[1]), c(p[2]), (a * 255.0 + 0.5) as u8)
|
||||
})
|
||||
.collect();
|
||||
let px: Vec<egui::Color32> = img.data.par_iter().map(|p| display_pixel(*p)).collect();
|
||||
egui::ColorImage::new([img.width as usize, img.height as usize], px)
|
||||
}
|
||||
|
||||
pub(crate) fn display_pixel(pixel: [f32; 4]) -> egui::Color32 {
|
||||
let alpha = if pixel[3].is_nan() { 0.0 } else { pixel[3].clamp(0.0, 1.0) };
|
||||
let component = |value: f32| (value.clamp(0.0, alpha) * 255.0 + 0.5) as u8;
|
||||
egui::Color32::from_rgba_premultiplied(component(pixel[0]), component(pixel[1]), component(pixel[2]), (alpha * 255.0 + 0.5) as u8)
|
||||
}
|
||||
|
||||
/// The whole factor an image of `size` is averaged down by for a texture: at least `min_k`, and
|
||||
/// the smallest that fits its sides in `max_side` (frames of comps wider than the GPU's texture
|
||||
/// limit, #201).
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn converting_a_frame_with_nan_alpha_keeps_the_viewer_alive() {
|
||||
let image = effectcraft_engine::render::Image::filled(1, 1, [0.5, 0.25, 0.1, f32::NAN]);
|
||||
assert_eq!(to_color_image(&image).pixels, [egui::Color32::TRANSPARENT]);
|
||||
}
|
||||
|
||||
struct ProbeDisk {
|
||||
keys: Mutex<HashSet<u128>>,
|
||||
probes: std::sync::atomic::AtomicUsize,
|
||||
|
||||
+15
-15
@@ -1874,7 +1874,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[
|
||||
("comp.settings", "Composition Settings...", "コンポジション設定..."),
|
||||
("comp.setPosterTime", "Set Poster Time", "ポスター時間を設定"),
|
||||
("comp.trimToWorkArea", "Trim Comp to Work Area", "コンポジションをワークエリアにトリミング"),
|
||||
("comp.cropToRegionOfInterest", "Crop Comp to Region of Interest", "コンポジションを目標範囲にクロップ"),
|
||||
("comp.cropToRegionOfInterest", "Crop Comp to Region of Interest", "コンポジションを関心領域にクロップ"),
|
||||
("comp.cropToLayerBounds", "Crop Comp to Selected Layer(s) Bounds", "コンポジションを選択レイヤーの境界にクロップ"),
|
||||
("render.addOutputModule", "Add Output Module", "出力モジュールを追加"),
|
||||
("", "Preview", "プレビュー"),
|
||||
@@ -1927,7 +1927,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[
|
||||
("layer.mask.reset", "Reset Mask", "マスクをリセット"),
|
||||
("layer.mask.remove", "Remove Mask", "マスクを削除"),
|
||||
("layer.mask.removeAll", "Remove All Masks", "すべてのマスクを削除"),
|
||||
("track.mask", "Track Mask", "マスクをトラック"),
|
||||
("track.mask", "Track Mask", "マスクをトラッキング"),
|
||||
("", "Mode", "モード"),
|
||||
("layer.mask.mode", "None", "なし"),
|
||||
("layer.mask.mode", "Add", "加算"),
|
||||
@@ -1992,8 +1992,8 @@ const JAPANESE: &[(&str, &str, &str)] = &[
|
||||
("layer.transform", "Fit to Comp Height", "コンポジションの高さに合わせる"),
|
||||
("layer.autoOrient", "Auto-Orient...", "自動方向..."),
|
||||
("", "Time", "時間"),
|
||||
("layer.enableTimeRemap", "Enable Time Remapping", "タイムリマップ使用可能"),
|
||||
("layer.timeReverse", "Time-Reverse Layer", "時間反転レイヤー"),
|
||||
("layer.enableTimeRemap", "Enable Time Remapping", "時間リマップを有効化"),
|
||||
("layer.timeReverse", "Time-Reverse Layer", "レイヤーを時間反転"),
|
||||
("layer.timeStretch", "Time Stretch...", "時間伸縮..."),
|
||||
("layer.freezeFrame", "Freeze Frame", "フレームを固定"),
|
||||
("layer.freezeOnLastFrame", "Freeze On Last Frame", "最後のフレームで固定"),
|
||||
@@ -2061,7 +2061,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[
|
||||
("layer.trackMatte", "Matte with Layer Above", "上のレイヤーをマットにする"),
|
||||
("layer.trackMatte", "Matte with Layer Below", "下のレイヤーをマットにする"),
|
||||
("", "Layer Styles", "レイヤースタイル"),
|
||||
("layer.style.options", "Layer Style Options...", "レイヤースタイルオプション..."),
|
||||
("layer.style.options", "Layer Style Options...", "レイヤースタイルのオプション..."),
|
||||
("layer.style.convertToEditable", "Convert to Editable Styles", "編集可能なスタイルに変換"),
|
||||
("layer.style.showAll", "Show All", "すべてを表示"),
|
||||
("layer.style.removeAll", "Remove All", "すべてを削除"),
|
||||
@@ -2090,7 +2090,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[
|
||||
("layer.create", "Convert to Editable Text", "編集可能なテキストに変換"),
|
||||
("layer.create", "Create Shapes from Text", "テキストからシェイプを作成"),
|
||||
("layer.create", "Create Masks from Text", "テキストからマスクを作成"),
|
||||
("layer.create", "Create Shapes from Vector Layer", "ベクトルレイヤーからシェイプを作成"),
|
||||
("layer.create", "Create Shapes from Vector Layer", "ベクターレイヤーからシェイプを作成"),
|
||||
("layer.create", "Create Keyframes from Data", "データからキーフレームを作成"),
|
||||
("layer.create", "Null Controllers for Positional Points", "位置のポイント用ヌルコントローラー"),
|
||||
("layer.create", "Null Controllers for Path Points", "パスのポイント用ヌルコントローラー"),
|
||||
@@ -2140,7 +2140,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[
|
||||
("layer.applyTextPreset", "Jitter", "ジッター"),
|
||||
("layer.applyTextPreset", "Drop In Lines", "行をドロップイン"),
|
||||
("anim.addKeyframe", "Add Keyframe", "キーフレームを追加"),
|
||||
("keys.toggleHold", "Toggle Hold Keyframe", "停止したキーフレームの切り替え"),
|
||||
("keys.toggleHold", "Toggle Hold Keyframe", "ホールドキーフレームの切り替え"),
|
||||
("keys.setMuted", "Mute Keyframes", "キーフレームをミュート"),
|
||||
("keys.setMuted", "Unmute Keyframes", "キーフレームのミュートを解除"),
|
||||
("keys.interpolation", "Keyframe Interpolation...", "キーフレーム補間法..."),
|
||||
@@ -2156,7 +2156,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[
|
||||
("layer.sequence", "Sequence Layers...", "シーケンスレイヤー..."),
|
||||
("keys.timeReverse", "Time-Reverse Keyframes", "時間反転キーフレーム"),
|
||||
("", "Animate Text", "テキストをアニメート"),
|
||||
("layer.enablePerChar3D", "Enable Per-character 3D", "文字ごとの3Dを使用可能にする"),
|
||||
("layer.enablePerChar3D", "Enable Per-character 3D", "文字ごとの3Dを有効化"),
|
||||
("layer.addTextAnimator", "Anchor Point", "アンカーポイント"),
|
||||
("layer.addTextAnimator", "Position", "位置"),
|
||||
("layer.addTextAnimator", "Scale", "スケール"),
|
||||
@@ -2168,7 +2168,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[
|
||||
("layer.addTextAnimator", "RGB", "RGB"),
|
||||
("layer.addTextAnimator", "Hue", "色相"),
|
||||
("layer.addTextAnimator", "Saturation", "彩度"),
|
||||
("layer.addTextAnimator", "Brightness", "明度"),
|
||||
("layer.addTextAnimator", "Brightness", "明るさ"),
|
||||
("", "Stroke Color", "線のカラー"),
|
||||
("layer.addTextAnimator", "Stroke Width", "線幅"),
|
||||
("", "Front", "前面"),
|
||||
@@ -2190,12 +2190,12 @@ const JAPANESE: &[(&str, &str, &str)] = &[
|
||||
("text.removeAllAnimators", "Remove All Text Animators", "すべてのテキストアニメーターを削除"),
|
||||
("prop.setExpression", "Add Expression", "エクスプレッションを追加"),
|
||||
("essential.addProperty", "Add Property to Essential Graphics", "エッセンシャルグラフィックスにプロパティを追加"),
|
||||
("prop.separateDimensions", "Separate Dimensions", "次元に分割"),
|
||||
("track.camera", "Track Camera", "カメラをトラック"),
|
||||
("prop.separateDimensions", "Separate Dimensions", "次元を分離"),
|
||||
("track.camera", "Track Camera", "カメラをトラッキング"),
|
||||
("track.warpStabilizer", "Warp Stabilizer VFX", "ワープスタビライザーVFX"),
|
||||
("track.motion", "Track Motion", "モーションをトラック"),
|
||||
("track.stabilize", "Stabilize Motion", "モーションをスタビライズ"),
|
||||
("track.property", "Track this Property", "このプロパティをトラック"),
|
||||
("track.motion", "Track Motion", "モーションをトラッキング"),
|
||||
("track.stabilize", "Stabilize Motion", "モーションを安定化"),
|
||||
("track.property", "Track this Property", "このプロパティをトラッキング"),
|
||||
("anim.reveal", "Reveal Properties with Keyframes", "キーフレームのあるプロパティを表示"),
|
||||
("anim.reveal", "Reveal Properties with Animation", "アニメーションのあるプロパティを表示"),
|
||||
("anim.reveal", "Reveal All Modified Properties", "変更されたすべてのプロパティを表示"),
|
||||
@@ -2275,7 +2275,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[
|
||||
("window.workspace", "Default", "初期設定"),
|
||||
("window.workspace", "Review", "レビュー"),
|
||||
("window.workspace", "Learn", "学習"),
|
||||
("window.workspace", "Small Screen", "小画面"),
|
||||
("window.workspace", "Small Screen", "小さい画面"),
|
||||
("window.workspace", "Standard", "標準"),
|
||||
("window.workspace", "All Panels", "すべてのパネル"),
|
||||
("window.workspace", "Animation", "アニメーション"),
|
||||
|
||||
@@ -173,6 +173,7 @@ pub(crate) fn tr_args(text: &str, args: &[&dyn Display]) -> String {
|
||||
|
||||
/// Japanese: the same rows, in the same order, as the Chinese catalogs below.
|
||||
const JAPANESE: &[(&str, &str)] = &[
|
||||
("Click to sample color", "クリックして色をサンプル"),
|
||||
("Appearance Settings…", "外観設定…"),
|
||||
("Add to favorites", "お気に入りに追加"),
|
||||
("Remove from favorites", "お気に入りから削除"),
|
||||
@@ -455,7 +456,7 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
("None", "なし"),
|
||||
("Not applied: tick it in the list to add it.", "未適用:リストでチェックを入れると追加されます。"),
|
||||
("Not assigned", "未割り当て"),
|
||||
("Note: Shadows are only cast from layers with Cast Shadows enabled.", "注意:シャドウは「シャドウを投影」が有効なレイヤーからのみ投影されます。"),
|
||||
("Note: Shadows are only cast from layers with Cast Shadows enabled.", "注意:「シャドウを落とす」が有効なレイヤーだけがシャドウを落とします。"),
|
||||
("Notify", "通知"),
|
||||
("OK", "OK"),
|
||||
("Offset:", "オフセット:"),
|
||||
@@ -537,7 +538,7 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
("Select a command to change its shortcut.", "ショートカットを変更するコマンドを選択してください。"),
|
||||
("Select a layer to see its effects", "レイヤーを選択するとエフェクトが表示されます"),
|
||||
("Select a layer to see its properties", "レイヤーを選択するとプロパティが表示されます"),
|
||||
("({}) Selected Objects", "({}) 個の選択されたオブジェクト"),
|
||||
("({}) Selected Objects", "選択したオブジェクト({})"),
|
||||
(
|
||||
"Select a layer with a mask or shape path (or the path property), or Puppet pins.",
|
||||
"マスクまたはシェイプパスのあるレイヤー(またはパスプロパティ)か、パペットピンを選択してください。",
|
||||
@@ -685,7 +686,7 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
("Import Image Sequence", "画像シーケンスを読み込み"),
|
||||
("Import PDF / Illustrator File", "PDF / Illustratorファイルを読み込み"),
|
||||
("Import Photoshop File", "Photoshopファイルを読み込み"),
|
||||
("Interpret Unlabeled Alpha As", "ラベルのないアルファを変換"),
|
||||
("Interpret Unlabeled Alpha As", "ラベルのないアルファの解釈"),
|
||||
("Join the ArtCraft Discord", "ArtCraftのDiscordに参加"),
|
||||
("Mercury GPU Acceleration ({})", "Mercury GPUアクセラレーション({})"),
|
||||
("Mercury Software Only", "Mercuryソフトウェアのみ"),
|
||||
@@ -803,7 +804,7 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
("Cycle Mask Colors", "新規マスクに別のカラーを使用"),
|
||||
("Use Gradients", "グラデーションを使用"),
|
||||
("Menu Bar", "メニューバー"),
|
||||
("Grids & Guides", "グリッド&ガイド"),
|
||||
("Grids & Guides", "グリッドとガイド"),
|
||||
("Gridline Every", "グリッドの間隔"),
|
||||
("Subdivisions", "分割数"),
|
||||
("Style", "スタイル"),
|
||||
@@ -835,7 +836,7 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
("Ask User", "ユーザーに確認"),
|
||||
("Guess", "推測"),
|
||||
("Ignore", "無視"),
|
||||
("Straight", "そのまま"),
|
||||
("Straight", "ストレート"),
|
||||
("Premultiplied", "プリマルチプライ"),
|
||||
("Default Drag Import As", "複数アイテムのドラッグ読み込みの初期設定"),
|
||||
("Footage", "フッテージ"),
|
||||
@@ -885,7 +886,7 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
("Scripting & Expressions", "スクリプトとエクスプレッション"),
|
||||
("Application Scripting", "アプリケーションのスクリプト"),
|
||||
("Allow Scripts to Write Files and Access Network", "スクリプトによるファイルへの書き込みとネットワークへのアクセスを許可"),
|
||||
("Warn User When Executing Files", "ファイルの実行時にカーソルに警告"),
|
||||
("Warn User When Executing Files", "ファイルの実行時にユーザーに警告"),
|
||||
("Enable JavaScript Debugger", "JavaScript デバッガーを使用"),
|
||||
("Expressions Editor", "エクスプレッションエディター"),
|
||||
("Font Size", "フォントサイズ"),
|
||||
@@ -894,7 +895,7 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
("Auto-complete", "オートコンプリート"),
|
||||
("Bracket Matching", "対応する括弧をハイライト"),
|
||||
("Word Wrap", "折り返し"),
|
||||
("Show Expression Error Banner", "式のエラーがある場合に警告バナーを表示"),
|
||||
("Show Expression Error Banner", "エクスプレッションのエラーがある場合に警告バナーを表示"),
|
||||
(
|
||||
"Expressions use the JavaScript engine. Script errors report their file and line in the Script Console; there is no step debugger.",
|
||||
"エクスプレッションはJavaScriptエンジンを使用します。スクリプトのエラーはファイルと行をスクリプトコンソールに報告します。ステップ実行デバッガーはありません。",
|
||||
@@ -1003,8 +1004,8 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
("Create Shapes from Vector Layer", "ベクターレイヤーからシェイプを作成"),
|
||||
("Create Shapes from Text", "テキストからシェイプを作成"),
|
||||
("Create Masks from Text", "テキストからマスクを作成"),
|
||||
("New Mask", "新しいマスク"),
|
||||
("Center In View", "ビュー内で中央揃え"),
|
||||
("New Mask", "新規マスク"),
|
||||
("Center In View", "ビューの中央に配置"),
|
||||
("Fit to Comp", "コンポジションに合わせる"),
|
||||
("Flip Horizontal", "水平方向に反転"),
|
||||
("Flip Vertical", "垂直方向に反転"),
|
||||
@@ -1033,8 +1034,8 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
("All Transform Properties", "すべてのトランスフォームプロパティ"),
|
||||
("Line Anchor", "行のアンカー"),
|
||||
("Line Spacing", "行間"),
|
||||
("Character Offset", "文字オフセット"),
|
||||
("Character Value", "文字値"),
|
||||
("Character Offset", "文字のオフセット"),
|
||||
("Character Value", "文字の値"),
|
||||
("Fill Color: RGB", "塗りの色: RGB"),
|
||||
("Fill Color: Hue", "塗りの色: 色相"),
|
||||
("Fill Color: Saturation", "塗りの色: 彩度"),
|
||||
@@ -1060,7 +1061,7 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
("ScriptUI Panel", "ScriptUI パネル"),
|
||||
("Lumetri Scopes", "Lumetri スコープ"),
|
||||
("Media Browser", "メディアブラウザー"),
|
||||
("Content-Aware Fill", "コンテンツに応じた塗り"),
|
||||
("Content-Aware Fill", "コンテンツに応じた塗りつぶし"),
|
||||
("Create Nulls From Paths", "パスからヌルを作成"),
|
||||
("VR Comp Editor", "VR コンポジションエディター"),
|
||||
("Media Duration", "メディアの長さ"),
|
||||
@@ -1095,7 +1096,7 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
("Background Color", "背景色"),
|
||||
("Basic", "基本"),
|
||||
("Shutter Angle", "シャッター角度"),
|
||||
("Shutter Phase", "シャッター位相"),
|
||||
("Shutter Phase", "シャッターフェーズ"),
|
||||
("Samples Per Frame", "フレームあたりのサンプル数"),
|
||||
("Adaptive Sample Limit", "適応サンプル数の上限"),
|
||||
("X and Y", "X と Y"),
|
||||
@@ -1222,6 +1223,7 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
];
|
||||
|
||||
const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
|
||||
("Click to sample color", "单击以取样颜色"),
|
||||
("Appearance Settings…", "外观设置…"),
|
||||
("Add to favorites", "添加到收藏"),
|
||||
("Remove from favorites", "从收藏中移除"),
|
||||
@@ -2330,6 +2332,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
|
||||
];
|
||||
|
||||
const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
|
||||
("Click to sample color", "按一下以取樣顏色"),
|
||||
("Appearance Settings…", "外觀設定…"),
|
||||
("Add to favorites", "加入最愛"),
|
||||
("Remove from favorites", "從最愛移除"),
|
||||
@@ -3386,6 +3389,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
|
||||
/// Ukrainian (uk): original translations of the same interface rows as the Chinese catalogs.
|
||||
/// Technical names, key names and substituted project content keep their original spelling.
|
||||
const UKRAINIAN: &[(&str, &str)] = &[
|
||||
("Click to sample color", "Клацніть, щоб узяти зразок кольору"),
|
||||
("Appearance Settings…", "Налаштування вигляду…"),
|
||||
("Add to favorites", "Додати до вибраного"),
|
||||
("Remove from favorites", "Видалити з вибраного"),
|
||||
@@ -3962,6 +3966,7 @@ const UKRAINIAN: &[(&str, &str)] = &[
|
||||
|
||||
// Brazilian Portuguese.
|
||||
const PORTUGUESE: &[(&str, &str)] = &[
|
||||
("Click to sample color", "Clique para obter uma amostra de cor"),
|
||||
("Export Scene to Blender...", "Exportar cena para o Blender..."),
|
||||
(" OK ", " OK "),
|
||||
(" Delete ", " Excluir "),
|
||||
@@ -4715,6 +4720,7 @@ const PORTUGUESE: &[(&str, &str)] = &[
|
||||
|
||||
/// Polish (pl): original translations covering every row in the Chinese panel catalogs.
|
||||
const POLISH: &[(&str, &str)] = &[
|
||||
("Click to sample color", "Kliknij, aby pobrać próbkę koloru"),
|
||||
("Appearance Settings…", "Ustawienia wyglądu…"),
|
||||
("Add to favorites", "Dodaj do ulubionych"),
|
||||
("Remove from favorites", "Usuń z ulubionych"),
|
||||
@@ -5462,6 +5468,7 @@ const POLISH: &[(&str, &str)] = &[
|
||||
|
||||
/// Czech (cs): original translations covering every row in the Chinese panel catalogs.
|
||||
const CZECH: &[(&str, &str)] = &[
|
||||
("Click to sample color", "Kliknutím naberete barvu"),
|
||||
(" OK ", " OK "),
|
||||
(" Delete ", " Odstranit "),
|
||||
(" Open Auto-Save ", " Otevřít automatickou zálohu "),
|
||||
@@ -6018,6 +6025,7 @@ const CZECH: &[(&str, &str)] = &[
|
||||
/// order. Terms follow the Spanish menu catalog (composición, material de archivo, cola de procesamiento,
|
||||
/// ajuste preestablecido, método abreviado). Written from the English source.
|
||||
const SPANISH: &[(&str, &str)] = &[
|
||||
("Click to sample color", "Haz clic para tomar una muestra de color"),
|
||||
("Copy Source Path", "Copiar ruta de origen"),
|
||||
("Appearance Settings…", "Ajustes de apariencia…"),
|
||||
("Add to favorites", "Añadir a favoritos"),
|
||||
@@ -6576,6 +6584,7 @@ const SPANISH: &[(&str, &str)] = &[
|
||||
|
||||
/// Georgian (ka): every row of the Chinese catalogs, in the same order (the tests keep them in step).
|
||||
const GEORGIAN: &[(&str, &str)] = &[
|
||||
("Click to sample color", "დააწკაპუნეთ ფერის ნიმუშის ასაღებად"),
|
||||
("Appearance Settings…", "იერსახის პარამეტრები…"),
|
||||
("Add to favorites", "რჩეულებში დამატება"),
|
||||
("Remove from favorites", "რჩეულებიდან ამოშლა"),
|
||||
|
||||
@@ -265,6 +265,8 @@ pub(crate) fn modal_fit_placed(ctx: &egui::Context, title: &str, size: egui::Vec
|
||||
ui.add_space(10.0);
|
||||
let title_h = ui.cursor().top() - ui.min_rect().top();
|
||||
body(ui, (size.y - 36.0 - title_h).max(40.0));
|
||||
// A colour picker's eyedropper samples the viewer under the dialog, but not the dialog itself.
|
||||
crate::color_picker::note_overlay(ui.ctx(), ui.min_rect().expand(18.0));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -119,6 +119,20 @@ fn fx_pick_session(
|
||||
ControlFlow::Continue(point_preview)
|
||||
}
|
||||
|
||||
/// Shared hint chip and sampled-color swatch for text and popup eyedroppers.
|
||||
fn color_pick_preview(app: &EffectcraftApp, painter: &egui::Painter, map: &crate::panels::viewer::ViewerMap, hint: &str, pointer: Option<egui::Pos2>) {
|
||||
let chip = Rect::from_min_size(map.area.min + vec2(10.0, 10.0), vec2(220.0, 22.0));
|
||||
painter.rect_filled(chip, 11.0, Color32::from_black_alpha(180));
|
||||
painter.text(chip.center(), Align2::CENTER_CENTER, hint, Tokens::ui(11.5), app.tokens.text);
|
||||
if let Some(pos) = pointer
|
||||
&& let Some(sample) = crate::panels::viewer_tools::sample_displayed(app, painter.ctx(), map.to_comp(pos))
|
||||
{
|
||||
let swatch = Rect::from_min_size(pos + vec2(14.0, 14.0), vec2(26.0, 18.0));
|
||||
painter.rect_filled(swatch, 2.0, Color32::from_rgb((sample[0] * 255.0) as u8, (sample[1] * 255.0) as u8, (sample[2] * 255.0) as u8));
|
||||
painter.rect_stroke(swatch, 2.0, Stroke::new(1.0, Color32::WHITE), egui::StrokeKind::Outside);
|
||||
}
|
||||
}
|
||||
|
||||
/// An armed text-colour pick: sample the shown frame on click, Esc cancels.
|
||||
fn text_color_pick(
|
||||
app: &mut EffectcraftApp,
|
||||
@@ -128,7 +142,6 @@ fn text_color_pick(
|
||||
ectx: &EvalCtx,
|
||||
actions: &mut Actions,
|
||||
) {
|
||||
let t = app.tokens;
|
||||
let ctx = ui.ctx().clone();
|
||||
let Some(pick) = app.ui.text_color_pick.clone() else {
|
||||
return;
|
||||
@@ -140,25 +153,15 @@ fn text_color_pick(
|
||||
app.viewer_pixels();
|
||||
let resp = ui.interact(map.area, egui::Id::unique("viewer-text-color-pick"), Sense::click());
|
||||
app.auto.add("viewer.textColorPick", map.area, &pick.field);
|
||||
let chip = Rect::from_min_size(map.area.min + vec2(10.0, 10.0), vec2(220.0, 22.0));
|
||||
painter.rect_filled(chip, 11.0, Color32::from_black_alpha(180));
|
||||
painter.text(chip.center(), Align2::CENTER_CENTER, crate::i18n::tr_args("Click to sample text {}", &[&pick.field]), Tokens::ui(11.5), t.text);
|
||||
color_pick_preview(app, painter, map, &crate::i18n::tr_args("Click to sample text {}", &[&pick.field]), resp.hover_pos());
|
||||
if let Some(pos) = resp.hover_pos() {
|
||||
ctx.set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
let c = map.to_comp(pos);
|
||||
app.pointer_comp = Some([c[0] as f32, c[1] as f32]);
|
||||
if let Some(img) = &app.viewer_image
|
||||
&& let Some(s) = fw::sample_frame(img, crate::panels::viewer_tools::shown_region(&ctx, map.comp), c)
|
||||
{
|
||||
let sw = Rect::from_min_size(pos + vec2(14.0, 14.0), vec2(26.0, 18.0));
|
||||
painter.rect_filled(sw, 2.0, Color32::from_rgb((s[0] * 255.0) as u8, (s[1] * 255.0) as u8, (s[2] * 255.0) as u8));
|
||||
painter.rect_stroke(sw, 2.0, Stroke::new(1.0, Color32::WHITE), egui::StrokeKind::Outside);
|
||||
}
|
||||
}
|
||||
if resp.clicked()
|
||||
&& let Some(pos) = resp.interact_pointer_pos()
|
||||
&& let Some(img) = &app.viewer_image
|
||||
&& let Some(s) = fw::sample_frame(img, crate::panels::viewer_tools::shown_region(&ctx, map.comp), map.to_comp(pos))
|
||||
&& let Some(s) = crate::panels::viewer_tools::sample_displayed(app, &ctx, map.to_comp(pos))
|
||||
{
|
||||
let mut params = json!({"layer": pick.layer});
|
||||
params[&pick.field] = json!([s[0], s[1], s[2]]);
|
||||
@@ -176,6 +179,27 @@ fn text_color_pick(
|
||||
}
|
||||
}
|
||||
|
||||
/// A colour picker's eyedropper is armed (its popup stays open): the viewer shows the colour under
|
||||
/// the pointer, and the next press on it samples the frame into the picker. The press is read from
|
||||
/// the pointer, since a modal dialog over the viewer takes its clicks.
|
||||
fn popup_eyedropper(app: &mut EffectcraftApp, ui: &mut egui::Ui, painter: &egui::Painter, map: &crate::panels::viewer::ViewerMap) {
|
||||
let ctx = ui.ctx().clone();
|
||||
if !crate::color_picker::eyedropper_armed(&ctx) {
|
||||
return;
|
||||
}
|
||||
let _ = ui.interact(map.area, egui::Id::unique("viewer-popup-eyedropper"), Sense::click());
|
||||
// The sampling surface, apart from the picker's button that arms it (`colorPicker.eyedropper`).
|
||||
app.auto.add("viewer.colorPickerEyedropper", map.area, "Sample color");
|
||||
ctx.set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
app.viewer_pixels();
|
||||
color_pick_preview(app, painter, map, crate::i18n::tr("Click to sample color"), crate::color_picker::eyedropper_point(&ctx));
|
||||
if let Some(pos) = crate::color_picker::sample_press(&ctx)
|
||||
&& let Some(s) = crate::panels::viewer_tools::sample_displayed(app, &ctx, map.to_comp(pos))
|
||||
{
|
||||
crate::color_picker::deliver_sample(&ctx, [s[0], s[1], s[2]]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Effect Controls' part of the Composition viewer, called by the viewer after its own
|
||||
/// interaction (so these controls sit on top):
|
||||
///
|
||||
@@ -195,12 +219,14 @@ pub fn viewer_hook(
|
||||
) {
|
||||
let t = app.tokens;
|
||||
let ctx = ui.ctx().clone();
|
||||
crate::color_picker::note_viewer(&ctx, map.area);
|
||||
let mut actions: Actions = vec![];
|
||||
let point_preview = match fx_pick_session(app, ui, painter, map, ectx, l2c, &mut actions) {
|
||||
ControlFlow::Break(()) => return,
|
||||
ControlFlow::Continue(p) => p,
|
||||
};
|
||||
text_color_pick(app, ui, painter, map, ectx, &mut actions);
|
||||
popup_eyedropper(app, ui, painter, map);
|
||||
let picking = app.ui.fx_pick.is_some() || app.ui.text_color_pick.is_some();
|
||||
let point_pick = app.ui.fx_pick.clone().filter(|p| p.kind == "point");
|
||||
if app.ui.viewer.show_layer_controls || point_pick.is_some() {
|
||||
|
||||
@@ -172,12 +172,7 @@ pub fn eyedropper_button(ui: &mut egui::Ui, rect: Rect, active: bool, id: egui::
|
||||
} else {
|
||||
t.text_dim
|
||||
};
|
||||
// A pipette: a diagonal glass tube with a bulb at the top right and a tip at the bottom left.
|
||||
let r = rect.shrink(rect.width() * 0.2);
|
||||
let at = |x: f32, y: f32| pos2(r.min.x + x * r.width(), r.min.y + y * r.height());
|
||||
p.line_segment([at(0.08, 0.92), at(0.62, 0.38)], Stroke::new(2.2, col));
|
||||
p.circle_filled(at(0.76, 0.24), r.width() * 0.2, col);
|
||||
p.line_segment([at(0.5, 0.3), at(0.7, 0.5)], Stroke::new(1.4, col));
|
||||
crate::color_picker::draw_pipette(p, rect, col);
|
||||
resp
|
||||
}
|
||||
|
||||
@@ -201,14 +196,21 @@ pub fn layer_to_comp(l2c: &Mat3, p: [f64; 2]) -> [f64; 2] {
|
||||
/// `region` ([x, y, w, h] in comp pixels: the whole comp, or the region of interest or Extended
|
||||
/// Viewer area it was rendered for), maybe at a reduced resolution. `None` outside it.
|
||||
pub fn frame_pixel(img: &egui::ColorImage, region: [f64; 4], pt: [f64; 2]) -> Option<Color32> {
|
||||
img.pixels.get(frame_pixel_index(img.size, region, pt)?).copied()
|
||||
}
|
||||
|
||||
pub(crate) fn frame_pixel_index(size: [usize; 2], region: [f64; 4], pt: [f64; 2]) -> Option<usize> {
|
||||
if size.contains(&0) {
|
||||
return None;
|
||||
}
|
||||
let [x, y, w, h] = region;
|
||||
let (fx, fy) = ((pt[0] - x) / w, (pt[1] - y) / h);
|
||||
if !(0.0..1.0).contains(&fx) || !(0.0..1.0).contains(&fy) {
|
||||
return None;
|
||||
}
|
||||
let sx = ((fx * img.size[0] as f64).floor() as usize).min(img.size[0].saturating_sub(1));
|
||||
let sy = ((fy * img.size[1] as f64).floor() as usize).min(img.size[1].saturating_sub(1));
|
||||
img.pixels.get(sy.checked_mul(img.size[0])?.checked_add(sx)?).copied()
|
||||
let sx = ((fx * size[0] as f64).floor() as usize).min(size[0].saturating_sub(1));
|
||||
let sy = ((fy * size[1] as f64).floor() as usize).min(size[1].saturating_sub(1));
|
||||
sy.checked_mul(size[0])?.checked_add(sx)
|
||||
}
|
||||
|
||||
/// Straight RGBA (0..1) of the displayed frame at comp point `pt` ([`frame_pixel`]).
|
||||
|
||||
@@ -296,21 +296,13 @@ fn marker(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, comp:
|
||||
hx += 5.0;
|
||||
}
|
||||
}
|
||||
p.line_segment([pos2(xe, y0), pos2(xe, y0 + h)], Stroke::new(1.0, col));
|
||||
}
|
||||
p.add(egui::Shape::convex_polygon(marker_shape(x, y0, h), col, Stroke::NONE));
|
||||
if !m.comment.is_empty() {
|
||||
// The name sits in a small outlined box beside the flag, as in After Effects. It is drawn in the theme's field
|
||||
// colours, so it follows Dark, Darker and Light.
|
||||
let galley = p.layout_no_wrap(m.comment.lines().next().unwrap_or_default().to_string(), Tokens::ui(10.0), t.text);
|
||||
let (box_w, mut box_x) = (galley.size().x + 8.0, x + 6.0);
|
||||
// A duration that ends under the box would be hidden by its fill: the box steps past the end cap instead.
|
||||
if let Some(xe) = end
|
||||
&& xe > box_x
|
||||
&& xe < box_x + box_w
|
||||
{
|
||||
box_x = xe + 3.0;
|
||||
}
|
||||
let box_w = galley.size().x + 8.0;
|
||||
let box_x = name_box_x(x, box_w, end, p.clip_rect());
|
||||
let box_rect = Rect::from_min_size(pos2(box_x, y0), vec2(box_w, h.max(galley.size().y + 2.0)));
|
||||
p.rect(box_rect, t.radius_sm, t.field_bg, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside);
|
||||
p.galley(pos2(box_rect.min.x + 4.0, box_rect.center().y - galley.size().y / 2.0), galley, t.text);
|
||||
@@ -385,6 +377,33 @@ fn marker(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, comp:
|
||||
});
|
||||
}
|
||||
|
||||
fn name_box_x(x: f32, width: f32, end: Option<f32>, clip: Rect) -> f32 {
|
||||
let start = x + 6.0;
|
||||
let right = match end {
|
||||
Some(end) if end > start && end < start + width => end + 3.0,
|
||||
_ => start,
|
||||
};
|
||||
if right + width <= clip.right() {
|
||||
return right;
|
||||
}
|
||||
let left = x - 6.0 - width;
|
||||
if left >= clip.left() {
|
||||
return left;
|
||||
}
|
||||
start
|
||||
}
|
||||
|
||||
/// Flags and duration handles sit above every name box in their marker strip.
|
||||
fn marker_glyphs(p: &egui::Painter, tokens: &Tokens, tm: TMap, marker: &Marker, time: f64, y0: f32, y1: f32) {
|
||||
let color = marker_color(tokens, marker.label);
|
||||
let h = (y1 - y0).min(10.0);
|
||||
p.add(egui::Shape::convex_polygon(marker_shape(tm.x(time), y0, h), color, Stroke::NONE));
|
||||
if marker.duration > Tick(0) {
|
||||
let end = tm.x(time + marker.duration.seconds());
|
||||
p.line_segment([pos2(end, y0), pos2(end, y0 + h)], Stroke::new(1.0, color));
|
||||
}
|
||||
}
|
||||
|
||||
/// Composition markers in the ruler strip `[y0, y1]`, protected regions over `rows`.
|
||||
pub(crate) fn comp_markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, comp: &Comp, tm: TMap, strip: Rect, rows: Rect) {
|
||||
let p = ui.painter().with_clip_rect(strip);
|
||||
@@ -398,6 +417,9 @@ pub(crate) fn comp_markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, comp: &C
|
||||
for (i, m) in comp.markers.iter().enumerate() {
|
||||
marker(app, ui, &p, comp, tm, m, m.time.seconds(), MarkerRef { layer: None, index: i }, strip.min.y, strip.max.y);
|
||||
}
|
||||
for m in &comp.markers {
|
||||
marker_glyphs(&p, &app.tokens, tm, m, m.time.seconds(), strip.min.y, strip.max.y);
|
||||
}
|
||||
}
|
||||
|
||||
/// The composition marker bin at the right end of the time ruler (`bin`), as in After Effects
|
||||
@@ -439,6 +461,9 @@ pub(crate) fn layer_markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, clip: R
|
||||
for (i, m) in layer.markers.iter().enumerate() {
|
||||
marker(app, ui, &p, comp, tm, m, layer.comp_time(m.time).seconds(), MarkerRef { layer: Some(layer.id.0), index: i }, r.min.y + 3.0, r.max.y - 3.0);
|
||||
}
|
||||
for m in &layer.markers {
|
||||
marker_glyphs(&p, &app.tokens, tm, m, layer.comp_time(m.time).seconds(), r.min.y + 3.0, r.max.y - 3.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// The nested comp's markers on a precomp layer's bar (`markers.nested`).
|
||||
@@ -488,6 +513,21 @@ fn nested_markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn duration_name_box_stays_visible_near_the_right_clip_edge() {
|
||||
let clip = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 20.0));
|
||||
let x = name_box_x(70.0, 20.0, Some(85.0), clip);
|
||||
assert!(x >= clip.left() && x + 20.0 <= clip.right());
|
||||
assert!(x + 20.0 < 70.0 - 4.0, "the left-hand box must not cover its own marker flag");
|
||||
assert!(85.0 < x || 85.0 > x + 20.0, "the end cap must remain outside the box");
|
||||
assert_eq!(name_box_x(20.0, 20.0, Some(35.0), clip), 38.0);
|
||||
for end in [None, Some(72.0), Some(110.0)] {
|
||||
let x = name_box_x(90.0, 20.0, end, clip);
|
||||
assert!(x >= clip.left() && x + 20.0 <= clip.right(), "all right-edge names must remain visible: {end:?}");
|
||||
assert!(x + 20.0 < 90.0 - 4.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_dialog_reads_and_writes_every_field() {
|
||||
let mut s = effectcraft_engine::Session::default();
|
||||
|
||||
@@ -173,21 +173,43 @@ fn kerning_field(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
|
||||
}
|
||||
}
|
||||
|
||||
/// Stroke Over Fill / Fill Over Stroke (grid row 2, right).
|
||||
fn stroke_order_field(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: Grid, doc: &TextDoc, actions: &mut Vec<serde_json::Value>) {
|
||||
/// Stroke order (grid row 2, right): per character (Fill Over Stroke / Stroke Over Fill), or for
|
||||
/// the whole layer, which is the layer's More Options > Fill & Stroke (`fill_stroke`, see
|
||||
/// [`layer_fill_stroke`]). Returns the layer order picked, if any.
|
||||
fn stroke_order_field(
|
||||
app: &mut EffectcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
g: Grid,
|
||||
doc: &TextDoc,
|
||||
fill_stroke: u32,
|
||||
actions: &mut Vec<serde_json::Value>,
|
||||
) -> Option<u32> {
|
||||
let t = app.tokens;
|
||||
let Grid { x0, y, w, enabled } = g;
|
||||
let fx = x0 + w / 2.0;
|
||||
let fy = y + 2.0 * 28.0;
|
||||
let dr = Rect::from_min_size(pos2(fx, fy), vec2(w / 2.0, 20.0));
|
||||
let labels = vec!["Fill Over Stroke".to_string(), "Stroke Over Fill".to_string()];
|
||||
let labels: Vec<String> =
|
||||
["Fill Over Stroke", "Stroke Over Fill", "-", "All Fills Over All Strokes", "All Strokes Over All Fills"].iter().map(|s| s.to_string()).collect();
|
||||
let current = match fill_stroke {
|
||||
1 => 3,
|
||||
2 => 4,
|
||||
_ => doc.stroke_over_fill as usize,
|
||||
};
|
||||
let pop = egui::Id::unique("char-stroke-order-pop");
|
||||
if widgets::dropdown(ui, dr, &labels[doc.stroke_over_fill as usize], &t, egui::Id::unique("char-stroke-order")).clicked() && enabled {
|
||||
if widgets::dropdown(ui, dr, &labels[current], &t, egui::Id::unique("char-stroke-order")).clicked() && enabled {
|
||||
widgets::open_popup(ui, pop);
|
||||
}
|
||||
app.auto.add("character.strokeOverFill", dr, "Stroke order");
|
||||
if let Some(i) = widgets::popup_menu(ui, pop, dr.left_bottom(), &labels, Some(doc.stroke_over_fill as usize)) {
|
||||
actions.push(json!({"strokeOverFill": i == 1}));
|
||||
match widgets::popup_menu(ui, pop, dr.left_bottom(), &labels, Some(current)) {
|
||||
Some(i @ (3 | 4)) => Some(i as u32 - 2),
|
||||
Some(i) => {
|
||||
actions.push(json!({"strokeOverFill": i == 1}));
|
||||
// A per-character order puts the layer back on the Per Character Palette, or it
|
||||
// would have no visible effect.
|
||||
(fill_stroke != 0).then_some(0)
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,6 +227,27 @@ fn line_join_field(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the layer's More Options > Fill & Stroke to `v` together with the per-character order
|
||||
/// it changes, as one undo step.
|
||||
fn apply_layer_fill_stroke(app: &mut EffectcraftApp, ctx: &egui::Context, tt: &TextTarget, v: u32, actions: Vec<serde_json::Value>) {
|
||||
let set = json!({"command": "prop.set", "params": {"layer": tt.layer, "path": "text/moreOptions/fillStroke", "value": v}});
|
||||
let steps: Vec<serde_json::Value> =
|
||||
std::iter::once(set).chain(actions.into_iter().map(|a| json!({"command": "layer.setText", "params": tt.params(a)}))).collect();
|
||||
if let Err(e) = crate::menus::invoke(app, ctx, "engine.batch", json!({"steps": steps, "label": "Stroke Order"})) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
|
||||
/// A text layer's More Options > Fill & Stroke in the active comp: 0 per character palette,
|
||||
/// 1 all fills over all strokes, 2 all strokes over all fills.
|
||||
fn layer_fill_stroke(app: &EffectcraftApp, layer: u64) -> u32 {
|
||||
let (Some(comp), Some(cid)) = (app.session.active_comp(), app.session.active_comp_id()) else { return 0 };
|
||||
let Some(l) = comp.layer(effectcraft_engine::project::LayerId(layer)) else { return 0 };
|
||||
let Some(more) = l.props.sub("text").and_then(|t| t.sub("moreOptions")) else { return 0 };
|
||||
let ectx = EvalCtx { expr: app.session.expr.as_deref(), ..EvalCtx::new(&app.session.project, cid, comp, app.session.time()) };
|
||||
ectx.e(l, more, "fillStroke")
|
||||
}
|
||||
|
||||
/// The Character panel for the text being edited (or the selected text layer): font family and
|
||||
/// style, size, leading, tracking, stroke, scale, baseline shift and other numeric fields, kerning,
|
||||
/// fill/stroke colours with eyedroppers, style toggles, OpenType features and variable-font axes.
|
||||
@@ -246,7 +289,8 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let grid = Grid { x0, y, w, enabled };
|
||||
numeric_fields(app, ui, &p, grid, &doc, &mut actions);
|
||||
kerning_field(app, ui, &p, grid, &doc, &mut actions);
|
||||
stroke_order_field(app, ui, grid, &doc, &mut actions);
|
||||
let fill_stroke = target.as_ref().map_or(0, |tt| layer_fill_stroke(app, tt.layer));
|
||||
let layer_fill_stroke_pick = stroke_order_field(app, ui, grid, &doc, fill_stroke, &mut actions);
|
||||
line_join_field(app, ui, &p, grid, &doc, &mut actions);
|
||||
y += 6.0 * 28.0 + 8.0;
|
||||
// Fill / stroke swatches with their enable boxes.
|
||||
@@ -269,7 +313,8 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
let er = Rect::from_min_size(pos2(fs.max.x + 4.0, y + 1.0), vec2(18.0, 18.0));
|
||||
let active = app.ui.text_color_pick.as_ref().is_some_and(|pick| pick.layer == target.as_ref().map_or(0, |tt| tt.layer) && pick.field == key);
|
||||
let eye = super::fx_widgets::eyedropper_button(ui, er, active, egui::Id::unique(("char-eye", key)), &t);
|
||||
let eye = super::fx_widgets::eyedropper_button(ui, er, active, egui::Id::unique(("char-eye", key)), &t)
|
||||
.on_hover_text(tr("Eyedropper: click, then click in the Composition panel"));
|
||||
app.auto.add(&format!("character.{key}.eyedropper"), er, &format!("{label} eyedropper"));
|
||||
if eye.clicked() {
|
||||
if active {
|
||||
@@ -383,6 +428,9 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
_ => {}
|
||||
}
|
||||
let Some(tt) = target else { return };
|
||||
if let Some(v) = layer_fill_stroke_pick {
|
||||
apply_layer_fill_stroke(app, &ctx, &tt, v, std::mem::take(&mut actions));
|
||||
}
|
||||
for a in actions {
|
||||
if let Err(e) = crate::menus::invoke(app, &ctx, "layer.setText", tt.params(a)) {
|
||||
app.ui.status = e;
|
||||
|
||||
@@ -5766,14 +5766,7 @@ fn enum_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>,
|
||||
let (p, layer, prop, at, uid) = (ve.p, ve.layer, ve.prop, ve.at, ve.uid);
|
||||
let t = app.tokens;
|
||||
let x = at.x;
|
||||
// Text Path Options ▸ Path lists the layer's masks.
|
||||
let mask_opts: Option<Vec<String>> = layer
|
||||
.props
|
||||
.prop("text/pathOptions/path")
|
||||
.filter(|q| q.uid == uid)
|
||||
.map(|_| std::iter::once("None".to_string()).chain(layer.masks().into_iter().flat_map(|m| m.groups().map(|g| g.name.clone()))).collect());
|
||||
let ui_opts = mask_opts.map(|options| ParamUi::Popup { options });
|
||||
if let ParamUi::Popup { options } = ui_opts.as_ref().unwrap_or(&prop.ui) {
|
||||
if let Some(options) = layer.popup_options(prop) {
|
||||
let r = Rect::from_min_size(pos2(x, at.y - 9.0), vec2(150.0, 18.0));
|
||||
let label = options.get(*i as usize).cloned().unwrap_or_default();
|
||||
let pop = egui::Id::unique(("epop", uid));
|
||||
@@ -5781,7 +5774,7 @@ fn enum_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>,
|
||||
widgets::open_popup(ui, pop);
|
||||
}
|
||||
app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
|
||||
if let Some(ni) = widgets::popup_menu(ui, pop, r.left_bottom(), options, Some(*i as usize)) {
|
||||
if let Some(ni) = widgets::popup_menu(ui, pop, r.left_bottom(), &options, Some(*i as usize)) {
|
||||
ve.set(actions, json!(ni));
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -366,6 +366,7 @@ pub(crate) fn display_key(app: &EffectcraftApp, has_conversion: bool) -> String
|
||||
/// is set), or the snapshot while Show Snapshot is on / F5 is held, through Show Channel and the
|
||||
/// exposure. Fast Previews ▸ Wireframe draws layer outlines instead.
|
||||
pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter: &egui::Painter, comp_rect: Rect, cid: ItemId, ectx: &EvalCtx) {
|
||||
clear_sample_source(ctx);
|
||||
let opts = app.session.state.viewer.clone();
|
||||
let snap = showing_snapshot(app, ctx);
|
||||
// Settings ▸ Video ▸ Mirror on Computer Monitor off: playback goes to Video Preview only.
|
||||
@@ -393,6 +394,8 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter:
|
||||
let dc = vw::DisplayColor::of(&app.session);
|
||||
let dc_key = display_key(app, dc.is_some());
|
||||
let plain = opts.channel == Channel::Rgb && opts.exposure == 0.0 && dc.is_none();
|
||||
let display = SampleDisplay { channel: opts.channel, colorized: opts.colorized, exposure: opts.exposure, conversion: dc.clone() };
|
||||
let region = shown_region(ctx, [ectx.comp.width as f32, ectx.comp.height as f32]);
|
||||
let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
|
||||
if snap {
|
||||
let Some(s) = app.session.snapshot.clone() else { return };
|
||||
@@ -415,6 +418,12 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter:
|
||||
}
|
||||
};
|
||||
painter.image(tex.id(), comp_rect, uv, Color32::WHITE);
|
||||
note_sample_source(
|
||||
ctx,
|
||||
SampleSource::Snapshot(s.image.clone()),
|
||||
[0.0, 0.0, ectx.comp.width as f64, ectx.comp.height as f64],
|
||||
(!plain).then_some(display),
|
||||
);
|
||||
painter.text(comp_rect.left_top() + vec2(6.0, 6.0), Align2::LEFT_TOP, crate::i18n::tr("Snapshot"), Tokens::ui(11.0), SNAP_COLOR);
|
||||
return;
|
||||
}
|
||||
@@ -428,6 +437,9 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter:
|
||||
ctx.request_repaint();
|
||||
}
|
||||
painter.image(id, comp_rect, uv, Color32::WHITE);
|
||||
if let Some((_, key)) = app.viewer_shown {
|
||||
note_sample_source(ctx, SampleSource::Gpu(key), region, None);
|
||||
}
|
||||
return;
|
||||
}
|
||||
comp_rect
|
||||
@@ -444,6 +456,9 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter:
|
||||
if plain {
|
||||
let uv = app.viewer_image.as_ref().map_or(uv, |src| fitted_uv(ctx, src.size, minify));
|
||||
painter.image(tex.id(), rect, uv, Color32::WHITE);
|
||||
if let Some(image) = app.viewer_image.clone() {
|
||||
note_sample_source(ctx, SampleSource::Cpu(image), region, None);
|
||||
}
|
||||
return;
|
||||
}
|
||||
rect
|
||||
@@ -470,6 +485,71 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter:
|
||||
}
|
||||
};
|
||||
painter.image(tex.id(), rect, fitted_uv(ctx, src.size, minify), Color32::WHITE);
|
||||
note_sample_source(ctx, SampleSource::Cpu(src), region, Some(display));
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum SampleSource {
|
||||
Cpu(std::sync::Arc<egui::ColorImage>),
|
||||
Gpu(crate::frames::FrameKey),
|
||||
Snapshot(std::sync::Arc<effectcraft_engine::render::Image>),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SampleDisplay {
|
||||
channel: Channel,
|
||||
colorized: bool,
|
||||
exposure: f32,
|
||||
conversion: Option<vw::DisplayColor>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct DisplayedSample {
|
||||
pass: u64,
|
||||
source: SampleSource,
|
||||
region: [f64; 4],
|
||||
display: Option<SampleDisplay>,
|
||||
}
|
||||
|
||||
fn sample_source_id() -> egui::Id {
|
||||
egui::Id::unique("viewer-displayed-sample-source")
|
||||
}
|
||||
|
||||
pub(crate) fn clear_sample_source(ctx: &egui::Context) {
|
||||
ctx.data_mut(|data| data.remove::<DisplayedSample>(sample_source_id()));
|
||||
}
|
||||
|
||||
fn note_sample_source(ctx: &egui::Context, source: SampleSource, region: [f64; 4], display: Option<SampleDisplay>) {
|
||||
let sample = DisplayedSample { pass: ctx.cumulative_pass_nr(), source, region, display };
|
||||
ctx.data_mut(|data| data.insert_temp(sample_source_id(), sample));
|
||||
}
|
||||
|
||||
/// Sample only a frame actually drawn this pass, with that draw's channel, exposure and
|
||||
/// display conversion. Hidden and wireframe views deliberately have no sample source.
|
||||
pub(crate) fn sample_displayed(app: &EffectcraftApp, ctx: &egui::Context, point: [f64; 2]) -> Option<[f32; 4]> {
|
||||
let sample = ctx.data(|data| data.get_temp::<DisplayedSample>(sample_source_id()))?;
|
||||
if sample.pass != ctx.cumulative_pass_nr() {
|
||||
return None;
|
||||
}
|
||||
let pixel = match sample.source {
|
||||
SampleSource::Cpu(image) => super::fx_widgets::frame_pixel(&image, sample.region, point)?,
|
||||
SampleSource::Gpu(key) => {
|
||||
if app.viewer_shown.map(|(_, shown)| shown) != Some(key) {
|
||||
return None;
|
||||
}
|
||||
super::fx_widgets::frame_pixel(app.viewer_image.as_ref()?, sample.region, point)?
|
||||
}
|
||||
SampleSource::Snapshot(image) => {
|
||||
let index = super::fx_widgets::frame_pixel_index([image.width as usize, image.height as usize], sample.region, point)?;
|
||||
crate::frames::display_pixel(*image.data.get(index)?)
|
||||
}
|
||||
};
|
||||
let image = egui::ColorImage::new([1, 1], vec![pixel]);
|
||||
let image = match sample.display {
|
||||
Some(display) => transformed(&image, display.channel, display.colorized, display.exposure, display.conversion),
|
||||
None => image,
|
||||
};
|
||||
super::fx_widgets::sample_frame(&image, [0.0, 0.0, 1.0, 1.0], [0.5, 0.5])
|
||||
}
|
||||
|
||||
/// The texture coordinates of a frame of `size` in its texture, fitted with at least `minify`
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
//! Shared device-creation gate for the integration-test executable.
|
||||
|
||||
static GPU_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
thread_local! {
|
||||
static HELD: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
}
|
||||
|
||||
pub(crate) struct Guard {
|
||||
held: Option<std::sync::MutexGuard<'static, ()>>,
|
||||
}
|
||||
|
||||
impl Drop for Guard {
|
||||
fn drop(&mut self) {
|
||||
if self.held.is_some() {
|
||||
HELD.with(|held| held.set(false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// GPU-backed harnesses hold this for their whole test; screenshots may enter it again.
|
||||
pub(crate) fn lock() -> Guard {
|
||||
let held = if HELD.with(std::cell::Cell::get) {
|
||||
None
|
||||
} else {
|
||||
let guard = GPU_TEST_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
HELD.with(|held| held.set(true));
|
||||
Some(guard)
|
||||
};
|
||||
Guard { held }
|
||||
}
|
||||
|
||||
/// Plain harnesses create their GPU renderer lazily on the first screenshot.
|
||||
pub(crate) trait RenderSerial {
|
||||
fn render_serialized(&mut self) -> Result<image::RgbaImage, String>;
|
||||
}
|
||||
|
||||
impl<State> RenderSerial for egui_kittest::Harness<'_, State> {
|
||||
fn render_serialized(&mut self) -> Result<image::RgbaImage, String> {
|
||||
let _device = lock();
|
||||
self.render().map_err(|error| format!("{error:?}"))
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! The crate's integration tests, one binary (each module is a former `tests/<name>.rs`).
|
||||
|
||||
mod gpu_test;
|
||||
mod native_menu_text_keys;
|
||||
mod render_queue_snapshot;
|
||||
mod shared_docking_ui;
|
||||
@@ -13,6 +14,7 @@ mod ui_batch_switches;
|
||||
mod ui_blank_context_menus;
|
||||
mod ui_camera_tracker;
|
||||
mod ui_character_eyedropper;
|
||||
mod ui_character_stroke_order;
|
||||
mod ui_color_hex;
|
||||
mod ui_comp_presets;
|
||||
mod ui_control_modifiers;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! adapter); run with
|
||||
//! `RQ_SNAPSHOT=/abs/out.png cargo test -p effectcraft-ui-egui --test it render_queue_snapshot:: -- --ignored`.
|
||||
|
||||
use crate::gpu_test::RenderSerial;
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::project::render_queue::RenderStatus;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
@@ -34,7 +35,7 @@ fn render_queue_panel_snapshot() {
|
||||
h.ctx.data_mut(|d| d.insert_temp(egui::Id::unique("rq-ui"), (ids, None::<u64>)));
|
||||
h.state_mut().show_panel(effectcraft_ui_egui::dock::PanelKind::RenderQueue);
|
||||
h.run_steps(4);
|
||||
let img = h.render().expect("render");
|
||||
let img = h.render_serialized().expect("render");
|
||||
let out = std::env::var("RQ_SNAPSHOT").unwrap_or_else(|_| concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/test-out/render_queue.png").into());
|
||||
if let Some(d) = std::path::Path::new(&out).parent() {
|
||||
std::fs::create_dir_all(d).unwrap();
|
||||
@@ -57,7 +58,7 @@ fn templates_dialog_snapshot() {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| app.take().expect("app"));
|
||||
h.run_steps(4);
|
||||
assert!(h.state().dialog.is_some(), "the Templates dialog is open");
|
||||
let img = h.render().expect("render");
|
||||
let img = h.render_serialized().expect("render");
|
||||
let out = std::env::var("RQ_TEMPLATES_SNAPSHOT").unwrap_or_else(|_| concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/test-out/rq_templates.png").into());
|
||||
if let Some(d) = std::path::Path::new(&out).parent() {
|
||||
std::fs::create_dir_all(d).unwrap();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! with wgpu and writes PNGs when `EC_SNAPSHOT_DIR` is set (`cargo test -p effectcraft-ui-egui
|
||||
//! --test it ui_3d:: -- --ignored`); the other test only runs the UI logic.
|
||||
|
||||
use crate::gpu_test::RenderSerial;
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::render::three_d::View3D;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
@@ -109,6 +110,7 @@ fn camera_and_light_dialogs_create_layers() {
|
||||
#[test]
|
||||
#[ignore = "renders with wgpu; run with --ignored (set EC_SNAPSHOT_DIR to keep PNGs)"]
|
||||
fn snapshot() {
|
||||
let _device = crate::gpu_test::lock();
|
||||
let dir = std::env::var("EC_SNAPSHOT_DIR").ok();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_pixels_per_point(1.0).wgpu().build_eframe(|_| app());
|
||||
let shots: [(&str, Option<&str>); 4] =
|
||||
@@ -118,7 +120,7 @@ fn snapshot() {
|
||||
h.state_mut().session.execute(c, json!({})).unwrap();
|
||||
}
|
||||
settle(&mut h);
|
||||
let img = h.render().expect("render");
|
||||
let img = h.render_serialized().expect("render");
|
||||
if let Some(d) = &dir {
|
||||
img.save(format!("{d}/ui3d_{name}.png")).unwrap();
|
||||
}
|
||||
@@ -126,14 +128,14 @@ fn snapshot() {
|
||||
let ctx = h.ctx.clone();
|
||||
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.newCamera", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
let img = h.render().expect("render");
|
||||
let img = h.render_serialized().expect("render");
|
||||
if let Some(d) = &dir {
|
||||
img.save(format!("{d}/ui3d_camera_dialog.png")).unwrap();
|
||||
}
|
||||
h.state_mut().dialog = None;
|
||||
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.newLight", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
let img = h.render().expect("render");
|
||||
let img = h.render_serialized().expect("render");
|
||||
if let Some(d) = &dir {
|
||||
img.save(format!("{d}/ui3d_light_dialog.png")).unwrap();
|
||||
}
|
||||
@@ -143,7 +145,7 @@ fn snapshot() {
|
||||
h.state_mut().session.execute("layer.select", json!({"layers": ["Card Blue"]})).unwrap();
|
||||
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.reveal.rotation", json!({})).unwrap();
|
||||
settle(&mut h);
|
||||
let img = h.render().expect("render");
|
||||
let img = h.render_serialized().expect("render");
|
||||
if let Some(d) = &dir {
|
||||
img.save(format!("{d}/ui3d_reveal_rotation.png")).unwrap();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Right-click actions in the blank Project and Timeline areas, and on Timeline property names.
|
||||
use crate::gpu_test::RenderSerial;
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::project::ItemKind;
|
||||
use effectcraft_ui_egui::dock::WorkspaceExt as _;
|
||||
@@ -20,7 +21,7 @@ fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect {
|
||||
}
|
||||
fn snapshot(h: &mut Harness<'_, EffectcraftApp>, name: &str) {
|
||||
if let Ok(dir) = std::env::var("BLANK_CONTEXT_MENU_SNAPSHOTS") {
|
||||
h.render().unwrap().save(format!("{dir}/{name}.png")).unwrap();
|
||||
h.render_serialized().unwrap().save(format!("{dir}/{name}.png")).unwrap();
|
||||
}
|
||||
}
|
||||
fn right_click(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! The solve is a synthetic one written into the effect (the analysis itself is tested in the
|
||||
//! engine and track crates).
|
||||
|
||||
use crate::gpu_test::RenderSerial;
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::effects::camera_tracker as ct;
|
||||
use effectcraft_engine::track::camtrack::{CameraSolve, ShotType, SolveMethod, SolvedFrame, SolvedPoint};
|
||||
@@ -186,6 +187,7 @@ fn blender_export_button_opens_python_dialog_and_writes_scene() {
|
||||
let shot = std::env::var("EFFECTCRAFT_BLENDER_UI_SNAPSHOT").ok();
|
||||
let builder = Harness::builder().with_size(egui::vec2(1600.0, 1000.0));
|
||||
let builder = if shot.is_some() { builder.wgpu() } else { builder };
|
||||
let _device = shot.is_some().then(crate::gpu_test::lock);
|
||||
let mut h = builder.build_eframe(|_| a);
|
||||
h.state_mut().show_panel(PanelKind::EffectControls);
|
||||
h.run_steps(5);
|
||||
@@ -272,6 +274,7 @@ fn settle(h: &mut Harness<'_, EffectcraftApp>) {
|
||||
#[test]
|
||||
#[ignore = "analyses the demo's 3D Showcase and renders with wgpu; run with --ignored (set EC_SNAPSHOT_DIR to keep PNGs)"]
|
||||
fn snapshot_showcase_track() {
|
||||
let _device = crate::gpu_test::lock();
|
||||
let dir = std::env::var("EC_SNAPSHOT_DIR").ok();
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
@@ -290,7 +293,7 @@ fn snapshot_showcase_track() {
|
||||
h.input_mut().events.push(egui::Event::PointerMoved(r.center() + egui::vec2(12.0, 12.0)));
|
||||
h.run_steps(2);
|
||||
if let Some(d) = &dir {
|
||||
h.render().expect("render").save(format!("{d}/camera_tracker_hover.png")).unwrap();
|
||||
h.render_serialized().expect("render").save(format!("{d}/camera_tracker_hover.png")).unwrap();
|
||||
}
|
||||
let sel: Vec<u64> = pts.iter().take(5).filter_map(|i| i.rsplit('.').next()?.parse().ok()).collect();
|
||||
h.state_mut().session.execute("camera.selectPoints", json!({"points": sel})).unwrap();
|
||||
@@ -298,6 +301,6 @@ fn snapshot_showcase_track() {
|
||||
h.state_mut().session.execute("layer.select", json!({"layers": ["#3"]})).unwrap();
|
||||
settle(&mut h);
|
||||
if let Some(d) = &dir {
|
||||
h.render().expect("render").save(format!("{d}/camera_tracker_solid.png")).unwrap();
|
||||
h.render_serialized().expect("render").save(format!("{d}/camera_tracker_solid.png")).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Character Fill and Stroke can sample a displayed Composition pixel.
|
||||
use crate::gpu_test::RenderSerial;
|
||||
use effectcraft_engine::{Session, commands::text_edit::layer_doc, project::LayerId};
|
||||
use effectcraft_ui_egui::{
|
||||
EffectcraftApp,
|
||||
@@ -37,6 +38,7 @@ fn settle(h: &mut Harness<'_, EffectcraftApp>) {
|
||||
|
||||
#[test]
|
||||
fn character_fill_eyedropper_samples_viewer_and_keeps_text_selection() {
|
||||
let _device = crate::gpu_test::lock();
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"width": 320, "height": 180, "duration": 2})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"name": "Background", "color": "#20c040"})).unwrap();
|
||||
@@ -64,6 +66,7 @@ fn character_fill_eyedropper_samples_viewer_and_keeps_text_selection() {
|
||||
|
||||
#[test]
|
||||
fn character_stroke_eyedropper_samples_viewer() {
|
||||
let _device = crate::gpu_test::lock();
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"width": 320, "height": 180, "duration": 2})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"name": "brat green", "color": "#8ACE00"})).unwrap();
|
||||
@@ -79,7 +82,7 @@ fn character_stroke_eyedropper_samples_viewer() {
|
||||
settle(&mut h);
|
||||
click_auto(&mut h, "character.stroke.eyedropper");
|
||||
if let Ok(dir) = std::env::var("EC_SNAPSHOT_DIR") {
|
||||
h.render().unwrap().save(format!("{dir}/character-eyedropper.png")).unwrap();
|
||||
h.render_serialized().unwrap().save(format!("{dir}/character-eyedropper.png")).unwrap();
|
||||
}
|
||||
let sample = comp_to_screen(&h.ctx, [20.0, 20.0]).unwrap();
|
||||
click(&mut h, sample);
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
//! The Character panel's stroke order menu: the two per-character orders and the two layer-wide
|
||||
//! ones (All Fills Over All Strokes, All Strokes Over All Fills), which set the text layer's
|
||||
//! More Options ▸ Fill & Stroke.
|
||||
use effectcraft_engine::{Session, commands::text_edit::layer_doc, project::LayerId};
|
||||
use effectcraft_ui_egui::{EffectcraftApp, dock::PanelKind};
|
||||
use egui::{
|
||||
Event, Modifiers, PointerButton, Pos2,
|
||||
accesskit::{Role, Toggled},
|
||||
pos2, vec2,
|
||||
};
|
||||
use egui_kittest::{
|
||||
Harness,
|
||||
kittest::{NodeT, Queryable},
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
fn click(h: &mut Harness<'_, EffectcraftApp>, pos: Pos2) {
|
||||
h.event(Event::PointerMoved(pos));
|
||||
h.step();
|
||||
for pressed in [true, false] {
|
||||
h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
}
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn open_menu(h: &mut Harness<'_, EffectcraftApp>) {
|
||||
let e = h.state().auto.find("character.strokeOverFill").unwrap_or_else(|| panic!("missing character.strokeOverFill")).clone();
|
||||
click(h, pos2(e.rect[0] + e.rect[2] * 0.5, e.rect[1] + e.rect[3] * 0.5));
|
||||
}
|
||||
|
||||
fn pick(h: &mut Harness<'_, EffectcraftApp>, label: &str) {
|
||||
open_menu(h);
|
||||
// A menu row: the dropdown shows the current order under the same label.
|
||||
h.get_by_role_and_label(Role::Button, label).click();
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn fill_stroke(s: &mut Session, layer: u64) -> serde_json::Value {
|
||||
s.execute("prop.get", json!({"layer": layer, "path": "text/moreOptions/fillStroke"})).unwrap()["value"].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_stroke_order_menu_sets_layer_fill_and_stroke() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"width": 320, "height": 180, "duration": 2})).unwrap();
|
||||
let text = s.execute("layer.newText", json!({"text": "Caption", "font": "Inter"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setText", json!({"layer": text, "applyStroke": true, "strokeWidth": 8.0, "strokeOverFill": false})).unwrap();
|
||||
let mut app = EffectcraftApp::new(s);
|
||||
app.set_workspace("Text");
|
||||
let builder = Harness::builder().with_size(vec2(1600.0, 1400.0));
|
||||
let mut h = if std::env::var_os("EC_SNAPSHOT_DIR").is_some() { builder.wgpu().build_eframe(|_| app) } else { builder.build_eframe(|_| app) };
|
||||
h.state_mut().show_panel(PanelKind::Character);
|
||||
h.run_steps(4);
|
||||
|
||||
pick(&mut h, "All Fills Over All Strokes");
|
||||
assert_eq!(fill_stroke(&mut h.state_mut().session, text), json!(1));
|
||||
pick(&mut h, "All Strokes Over All Fills");
|
||||
assert_eq!(fill_stroke(&mut h.state_mut().session, text), json!(2));
|
||||
// The menu shows the layer's order as the current one.
|
||||
open_menu(&mut h);
|
||||
assert_eq!(h.get_by_role_and_label(Role::Button, "All Strokes Over All Fills").accesskit_node().toggled(), Some(Toggled::True));
|
||||
assert_eq!(h.get_by_role_and_label(Role::Button, "Fill Over Stroke").accesskit_node().toggled(), Some(Toggled::False));
|
||||
if let Ok(dir) = std::env::var("EC_SNAPSHOT_DIR") {
|
||||
h.render().unwrap().save(format!("{dir}/character-stroke-order.png")).unwrap();
|
||||
}
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(2);
|
||||
|
||||
// A per-character order goes back to the Per Character Palette, in one undo step.
|
||||
pick(&mut h, "Stroke Over Fill");
|
||||
assert_eq!(fill_stroke(&mut h.state_mut().session, text), json!(0));
|
||||
assert!(layer_doc(&h.state().session, LayerId(text)).unwrap().stroke_over_fill);
|
||||
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(fill_stroke(&mut h.state_mut().session, text), json!(2));
|
||||
assert!(!layer_doc(&h.state().session, LayerId(text)).unwrap().stroke_over_fill);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Shared pane resizing in the full application, including application shortcut ownership.
|
||||
|
||||
use crate::gpu_test::RenderSerial;
|
||||
use craft_ui::docking::{Divider, DockArea, Hit};
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
@@ -16,7 +17,7 @@ fn snapshots(h: &mut Harness<'_, EffectcraftApp>, directory: Option<&std::path::
|
||||
let ctx = h.ctx.clone();
|
||||
h.state_mut().set_theme(&ctx, theme);
|
||||
h.run_steps(3);
|
||||
h.render().unwrap().save(directory.join(format!("effectcraft-{phase}-{name}.png"))).unwrap();
|
||||
h.render_serialized().unwrap().save(directory.join(format!("effectcraft-{phase}-{name}.png"))).unwrap();
|
||||
}
|
||||
let ctx = h.ctx.clone();
|
||||
h.state_mut().set_theme(&ctx, ThemeKind::Dark);
|
||||
@@ -32,6 +33,7 @@ fn first_size(h: &Harness<'_, EffectcraftApp>) -> f32 {
|
||||
|
||||
#[test]
|
||||
fn focused_dock_resize_owns_arrows_and_keeps_pointer_dragging() {
|
||||
let _device = crate::gpu_test::lock();
|
||||
let mut session = Session::default();
|
||||
session.execute("comp.new", json!({"name": "Split test", "width": 160, "height": 90, "duration": 1})).unwrap();
|
||||
session.execute("layer.newSolid", json!({"name": "Selected", "color": [1.0, 0.0, 0.0, 1.0]})).unwrap();
|
||||
|
||||
@@ -44,6 +44,7 @@ fn same_adapter(name: String) -> eframe::egui_wgpu::WgpuSetup {
|
||||
/// viewer's GPU frames change when the light is added.
|
||||
#[test]
|
||||
fn environment_light_lights_the_scene_on_the_gpu() {
|
||||
let _device = crate::gpu_test::lock();
|
||||
let Some(probe) = effectcraft_gpu::Gpu::headless() else {
|
||||
eprintln!("no GPU adapter: skipping");
|
||||
return;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Real keyboard/pointer coverage for the font menus in both text panels.
|
||||
use crate::gpu_test::RenderSerial;
|
||||
use effectcraft_engine::{Session, commands::text_edit::layer_doc, project::LayerId};
|
||||
use effectcraft_ui_egui::{EffectcraftApp, dock::PanelKind};
|
||||
use egui::{Event, Key, Modifiers, PointerButton};
|
||||
@@ -29,6 +30,7 @@ fn type_text(h: &mut Harness<'_, EffectcraftApp>, text: &str) {
|
||||
|
||||
#[test]
|
||||
fn font_search_filters_selects_and_undoes_in_both_panels() {
|
||||
let _device = crate::gpu_test::lock();
|
||||
for (panel, prefix) in [(PanelKind::Character, "character.font"), (PanelKind::Properties, "properties.text.font")] {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"width": 320, "height": 180})).unwrap();
|
||||
@@ -56,7 +58,7 @@ fn font_search_filters_selects_and_undoes_in_both_panels() {
|
||||
assert!(!rows.is_empty());
|
||||
assert!(rows.iter().all(|r| r.label.to_lowercase().contains("jetbrains")));
|
||||
if let Ok(dir) = std::env::var("EC_SNAPSHOT_DIR") {
|
||||
h.render().unwrap().save(format!("{dir}/{prefix}-search.png")).unwrap();
|
||||
h.render_serialized().unwrap().save(format!("{dir}/{prefix}-search.png")).unwrap();
|
||||
}
|
||||
assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "Inter", "typing only filters");
|
||||
key(&mut h, Key::Enter);
|
||||
@@ -76,7 +78,7 @@ fn font_search_filters_selects_and_undoes_in_both_panels() {
|
||||
// Reopening clears the previous query, and mouse selection still works.
|
||||
h.run_steps(2);
|
||||
if let Ok(dir) = std::env::var("EC_SNAPSHOT_DIR") {
|
||||
h.render().unwrap().save(format!("{dir}/{prefix}-reopened.png")).unwrap();
|
||||
h.render_serialized().unwrap().save(format!("{dir}/{prefix}-reopened.png")).unwrap();
|
||||
}
|
||||
assert!(h.state().auto.query(&format!("{prefix}.option.")).len() > 1);
|
||||
type_text(&mut h, "JetBrains");
|
||||
|
||||
@@ -53,6 +53,7 @@ fn small_session() -> Session {
|
||||
/// composites on the CPU. The worker is held on a gate so the "preparing" state is observable.
|
||||
#[test]
|
||||
fn the_interface_draws_and_composites_on_the_cpu_while_the_gpu_compositor_is_built() {
|
||||
let _device = crate::gpu_test::lock();
|
||||
if !any_adapter() {
|
||||
eprintln!("no wgpu adapter: skipping");
|
||||
return;
|
||||
@@ -111,6 +112,7 @@ fn the_interface_draws_and_composites_on_the_cpu_while_the_gpu_compositor_is_bui
|
||||
/// thread, not under the UI: the window keeps drawing on the CPU and says why.
|
||||
#[test]
|
||||
fn a_compositor_build_that_panics_leaves_the_viewer_on_the_cpu() {
|
||||
let _device = crate::gpu_test::lock();
|
||||
if !any_adapter() {
|
||||
eprintln!("no wgpu adapter: skipping");
|
||||
return;
|
||||
@@ -153,6 +155,7 @@ fn same_adapter(name: String) -> eframe::egui_wgpu::WgpuSetup {
|
||||
|
||||
#[test]
|
||||
fn viewer_draws_gpu_frames_that_match_the_cpu() {
|
||||
let _device = crate::gpu_test::lock();
|
||||
let Some(probe) = effectcraft_gpu::Gpu::headless() else {
|
||||
eprintln!("no GPU adapter: skipping");
|
||||
return;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Guide Layer in the layer context menus (Timeline rows and the Composition viewer), a plain item
|
||||
//! (no check mark) that toggles the switch on the selection, and the guide mark by a guide layer's
|
||||
//! name.
|
||||
use crate::gpu_test::RenderSerial;
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::project::LayerId;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
@@ -45,7 +46,7 @@ fn guide(h: &Harness<'_, EffectcraftApp>, id: LayerId) -> bool {
|
||||
|
||||
fn snapshot(h: &mut Harness<'_, EffectcraftApp>, name: &str) {
|
||||
if let Ok(dir) = std::env::var("GUIDE_LAYER_SNAPSHOTS") {
|
||||
h.render().unwrap().save(format!("{dir}/{name}.png")).unwrap();
|
||||
h.render_serialized().unwrap().save(format!("{dir}/{name}.png")).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! Effect Controls' layer parameters (a Displacement Map's layer) pick through the same widget,
|
||||
//! and both write an undoable `prop.set`.
|
||||
|
||||
use crate::gpu_test::RenderSerial;
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::keyframe::Value;
|
||||
use effectcraft_engine::project::LayerId;
|
||||
@@ -183,6 +184,7 @@ fn effect_controls_layer_param_picks_through_the_shared_widget() {
|
||||
#[test]
|
||||
#[ignore = "renders with wgpu; run with --ignored (set EC_SNAPSHOT_DIR to keep PNGs)"]
|
||||
fn snapshot_source_dropdown_open_and_chosen() {
|
||||
let _device = crate::gpu_test::lock();
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 1280, "height": 720, "frameRate": 30, "duration": 5})).unwrap();
|
||||
s.execute("comp.settings", json!({"renderer": "advanced3D"})).unwrap();
|
||||
@@ -200,7 +202,7 @@ fn snapshot_source_dropdown_open_and_chosen() {
|
||||
h.run_steps(4);
|
||||
let save = |h: &mut Harness<'_, EffectcraftApp>, name: &str| {
|
||||
if let Ok(dir) = std::env::var("EC_SNAPSHOT_DIR") {
|
||||
h.render().expect("render").save(format!("{dir}/{name}")).expect("save");
|
||||
h.render_serialized().expect("render").save(format!("{dir}/{name}")).expect("save");
|
||||
}
|
||||
};
|
||||
save(&mut h, "source_unset.png");
|
||||
|
||||
@@ -8,16 +8,13 @@ use effectcraft_ui_egui::theme::ThemeKind;
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
|
||||
/// One render at a time: two tests creating a GPU device together crash some Vulkan loaders.
|
||||
static RENDER: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
fn close(got: [u8; 4], want: egui::Color32) -> bool {
|
||||
got[..3].iter().zip(want.to_array()).all(|(a, b)| a.abs_diff(b) <= 3)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_name_box_follows_the_theme() {
|
||||
let _one_at_a_time = RENDER.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let _one_at_a_time = crate::gpu_test::lock();
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Song", "width": 640, "height": 360, "duration": 4})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"name": "Bed", "color": "#204060", "width": 640, "height": 360})).unwrap();
|
||||
@@ -58,7 +55,7 @@ fn the_name_box_follows_the_theme() {
|
||||
/// stays visible: the cap is drawn, the bar shows left of it, and the box starts after it.
|
||||
#[test]
|
||||
fn a_short_duration_stays_visible_beside_the_name_box() {
|
||||
let _one_at_a_time = RENDER.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let _one_at_a_time = crate::gpu_test::lock();
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Song", "width": 640, "height": 360, "duration": 4})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"name": "Bed", "color": "#204060", "width": 640, "height": 360})).unwrap();
|
||||
@@ -95,3 +92,29 @@ fn a_short_duration_stays_visible_beside_the_name_box() {
|
||||
let fill = at(xe + 3.0 + 2.5, mid);
|
||||
assert!(close(fill, tokens.field_bg), "the box begins after the end cap: {fill:?}, field_bg {:?}", tokens.field_bg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_later_name_box_does_not_hide_an_earlier_duration_cap() {
|
||||
let _one_at_a_time = crate::gpu_test::lock();
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"width": 640, "height": 360, "duration": 4})).unwrap();
|
||||
s.execute("markers.set", json!({"new": true, "time": 1.0, "duration": 0.5, "label": "Red"})).unwrap();
|
||||
s.execute("markers.set", json!({"new": true, "time": 1.3, "comment": "This name overlaps the earlier endpoint", "label": "Blue"})).unwrap();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(move |_| EffectcraftApp::new(s));
|
||||
h.run_steps(5);
|
||||
let a = h.state().auto.find("timeline.marker.0").unwrap().rect;
|
||||
let b = h.state().auto.find("timeline.marker.1").unwrap().rect;
|
||||
let x = |r: [f32; 4]| r[0] + r[2] / 2.0;
|
||||
let end = x(a) + (x(b) - x(a)) * 0.5 / 0.3;
|
||||
let y = a[1] + 1.0 + (a[3] - 1.0).min(10.0) / 2.0;
|
||||
let image = h.render().unwrap();
|
||||
if let Some(dir) = std::env::var_os("MARKER_NAMES_SNAPSHOT") {
|
||||
image.save(std::path::Path::new(&dir).join("marker-overlapping-cap.png")).unwrap();
|
||||
}
|
||||
let scale = image.width() as f32 / 1600.0;
|
||||
let cap = (-1..=1)
|
||||
.map(|offset| image.get_pixel(((end + offset as f32) * scale) as u32, (y * scale) as u32).0)
|
||||
.max_by_key(|pixel| i32::from(pixel[0]) - i32::from(pixel[1]))
|
||||
.unwrap();
|
||||
assert!(i32::from(cap[0]) - i32::from(cap[1]) > 40, "the earlier red duration cap stays above the later name: {cap:?}");
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::gpu_test::RenderSerial;
|
||||
use std::sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
@@ -140,6 +141,6 @@ fn browser_cache_refreshes_filters_navigates_and_recovers_from_errors() {
|
||||
assert_eq!(names(&listing(&h, false).1), ["Sub", "Added.csv", "Clip.csv", "From action.csv"]);
|
||||
assert!(h.state().auto.find("mediaBrowser.entry.Clip.csv").is_some());
|
||||
if let Ok(path) = std::env::var("MEDIA_BROWSER_SNAPSHOT") {
|
||||
h.render().unwrap().save(path).unwrap();
|
||||
h.render_serialized().unwrap().save(path).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::gpu_test::RenderSerial;
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_engine::{
|
||||
@@ -74,7 +75,7 @@ fn file_folder_and_virtual_paths_copy_from_the_context_menu_without_document_cha
|
||||
if name == "作品 clip.csv"
|
||||
&& let Ok(file) = std::env::var("MEDIA_BROWSER_COPY_SNAPSHOT")
|
||||
{
|
||||
h.render().unwrap().save(file).unwrap();
|
||||
h.render_serialized().unwrap().save(file).unwrap();
|
||||
}
|
||||
assert_eq!(click(&mut h, &id, PointerButton::Primary), [path]);
|
||||
h.run_steps(3);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! The menu bar from the keyboard, and the app's shortcuts waiting while a menu is open (#279)
|
||||
//! (egui_kittest, UI logic only).
|
||||
|
||||
use crate::gpu_test::RenderSerial;
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use egui::{Event, Key, Modifiers, Pos2, Rect, pos2, vec2};
|
||||
@@ -226,7 +227,7 @@ fn relink_is_reachable_through_the_dependencies_menu() {
|
||||
h.run_steps(8);
|
||||
let relink = h.query_by_label_contains("Relink Missing Footage").unwrap().rect();
|
||||
if let Some(path) = std::env::var_os("EFFECTCRAFT_RELINK_MENU_SNAPSHOT") {
|
||||
h.render().unwrap().save(path).unwrap();
|
||||
h.render_serialized().unwrap().save(path).unwrap();
|
||||
}
|
||||
click_at(&mut h, relink.center());
|
||||
assert!(picked.get());
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! registers its automation ids; the Footage panel's buttons edit into the comp; the Progress
|
||||
//! panel lists and cancels a job; Lumetri Scopes switch scope through their dropdown.
|
||||
|
||||
use crate::gpu_test::RenderSerial;
|
||||
use effectcraft_ui_egui::dock::WorkspaceExt as _;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -448,7 +449,7 @@ fn panels_snapshot() {
|
||||
}
|
||||
open(&mut h, panel);
|
||||
h.run_steps(3);
|
||||
let img = h.render().expect("render");
|
||||
let img = h.render_serialized().expect("render");
|
||||
img.save(format!("{dir}/{panel}{scope}.png")).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Effects & Presets: copy the configured folder path through the contents menu.
|
||||
|
||||
use crate::gpu_test::RenderSerial;
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_ui_egui::panels::effects_presets::PresetFile;
|
||||
use effectcraft_ui_egui::{ControlRequest, EffectcraftApp, control, dock::PanelKind};
|
||||
@@ -52,7 +53,7 @@ fn presets_menu_copies_the_configured_folder_without_editing_the_project() {
|
||||
click(&mut h, "effects.menu");
|
||||
h.run_steps(3);
|
||||
if let Ok(path) = std::env::var("PRESETS_FOLDER_SNAPSHOT") {
|
||||
h.render().unwrap().save(path).unwrap();
|
||||
h.render_serialized().unwrap().save(path).unwrap();
|
||||
}
|
||||
assert_eq!(click(&mut h, "effects.copyPresetsFolderPath"), [expected.as_str()]);
|
||||
h.run_steps(2);
|
||||
@@ -156,7 +157,7 @@ fn shared_preset_rows_search_apply_and_undo_the_selected_definition() {
|
||||
assert_eq!(h.state().session.active_comp().unwrap().layers[0].props.prop("transform/opacity").unwrap().value.as_f64(), expected);
|
||||
assert_eq!(h.state().session.history.undo.len(), history + 1);
|
||||
if let Ok(dir) = std::env::var("PRESET_CACHE_SNAPSHOTS") {
|
||||
h.render().unwrap().save(format!("{dir}/{name}.png")).unwrap();
|
||||
h.render_serialized().unwrap().save(format!("{dir}/{name}.png")).unwrap();
|
||||
}
|
||||
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Window ▸ Script Console: the panel shows, runs JavaScript through `script.run` (persistent
|
||||
//! context) and logs output, results and errors (egui_kittest, UI logic only).
|
||||
|
||||
use crate::gpu_test::RenderSerial;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use effectcraft_ui_egui::dock::PanelKind;
|
||||
use egui_kittest::Harness;
|
||||
@@ -59,7 +60,7 @@ fn script_console_snapshot() {
|
||||
}
|
||||
h.state_mut().ui.script_console.input = "app.project.numItems".into();
|
||||
h.run_steps(3);
|
||||
let img = h.render().expect("render");
|
||||
let img = h.render_serialized().expect("render");
|
||||
let out = std::env::var("SC_SNAPSHOT").unwrap_or_else(|_| concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/test-out/script_console.png").into());
|
||||
if let Some(d) = std::path::Path::new(&out).parent() {
|
||||
std::fs::create_dir_all(d).unwrap();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! windows (clicks reach the script's handlers), the branching History panel, and the Window /
|
||||
//! File ▸ Scripts menus listing scripts (egui_kittest, UI logic only).
|
||||
|
||||
use crate::gpu_test::RenderSerial;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use effectcraft_ui_egui::dock::PanelKind;
|
||||
use effectcraft_ui_egui::dock::WorkspaceExt as _;
|
||||
@@ -210,6 +211,7 @@ fn history_panel_jumps_between_branches() {
|
||||
/// onDraw fills of concave paths and real images (drawImage, image controls), rendered.
|
||||
#[test]
|
||||
fn on_draw_concave_fills_and_images_render() {
|
||||
let _device = crate::gpu_test::lock();
|
||||
let dir = std::env::temp_dir().join(format!("effectcraft-scriptui-img-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let png = dir.join("green.png");
|
||||
@@ -244,7 +246,7 @@ fn on_draw_concave_fills_and_images_render() {
|
||||
let g = &win.root.children[0];
|
||||
assert!(matches!(&g.draw[1], effectcraft_engine::scriptui::DrawOp::Image { image: Some(i), .. } if i.src.as_deref() == Some(png.as_str())));
|
||||
assert_eq!(win.root.children[1].image.as_ref().and_then(|i| i.src.clone()).as_deref(), Some(png.as_str()));
|
||||
let img = h.render().expect("render");
|
||||
let img = h.render_serialized().expect("render");
|
||||
if let Ok(d) = std::env::var("EC_SNAPSHOT_DIR") {
|
||||
img.save(format!("{d}/scriptui_paint.png")).unwrap();
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! as its own Timeline tab (not a rename, and not in place of the comp shown), the tabs switch and
|
||||
//! close comps, and a dragged tab lands where its insertion mark shows.
|
||||
|
||||
use crate::gpu_test::RenderSerial;
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::project::ItemId;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
@@ -135,7 +136,7 @@ fn tab_drag_snapshot() {
|
||||
}
|
||||
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/test-out");
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
h.render().expect("render").save(format!("{dir}/tab-drag.png")).unwrap();
|
||||
h.render_serialized().expect("render").save(format!("{dir}/tab-drag.png")).unwrap();
|
||||
}
|
||||
|
||||
/// A precomp layer's bar shows its comp's markers; a double-click opens that comp at the marker.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::gpu_test::RenderSerial;
|
||||
use effectcraft_engine::{Session, config::MemoryConfig, prefs::Prefs};
|
||||
use effectcraft_ui_egui::{EffectcraftApp, menus};
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2};
|
||||
@@ -49,7 +50,7 @@ fn star_controls_save_and_favorite_menu_selects_the_family() {
|
||||
assert_eq!(h.state().session.prefs.appearance.favorite_themes, vec!["light"]);
|
||||
if let Some(path) = std::env::var_os("FAVORITES_SCREENSHOT_DIR") {
|
||||
std::fs::create_dir_all(&path).unwrap();
|
||||
h.render().unwrap().save(std::path::Path::new(&path).join("effectcraft-favorites.png")).unwrap();
|
||||
h.render_serialized().unwrap().save(std::path::Path::new(&path).join("effectcraft-favorites.png")).unwrap();
|
||||
}
|
||||
assert_eq!(h.state().session.prefs.appearance.appearance_mode, "dark");
|
||||
let p = at(&h, "settings.ok");
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! interest drawing, rulers and guides, snapping, the shape Pen and motion-path key drags
|
||||
//! (egui_kittest, UI logic only).
|
||||
|
||||
use crate::gpu_test::RenderSerial;
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::commands::shape_tool::PaintKind;
|
||||
use effectcraft_engine::project::LayerId;
|
||||
@@ -448,7 +449,7 @@ fn mid_drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, mods: egu
|
||||
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 8.0)));
|
||||
h.step();
|
||||
}
|
||||
h.render().expect("the mid-drag frame")
|
||||
h.render_serialized().expect("the mid-drag frame")
|
||||
}
|
||||
|
||||
/// Release the button where the drag left it.
|
||||
@@ -490,7 +491,7 @@ fn shape_drag_ghost_shows_the_tools_own_shape_while_dragging() {
|
||||
h.state_mut().ui.tool = tool;
|
||||
settle_viewer(&mut h);
|
||||
let (a, b) = (screen(&h, from), screen(&h, to));
|
||||
let before = h.render().expect("the frame before the drag");
|
||||
let before = h.render_serialized().expect("the frame before the drag");
|
||||
let mid = mid_drag(&mut h, a, b, egui::Modifiers::default());
|
||||
let fill = fill_rgb(&h);
|
||||
|
||||
@@ -526,7 +527,7 @@ fn shape_drag_ghost_normalises_the_same_way_as_the_release() {
|
||||
h.state_mut().ui.tool = Tool::Ellipse;
|
||||
settle_viewer(&mut h);
|
||||
let (a, b) = (screen(&h, from.map(|v| v as f32)), screen(&h, to.map(|v| v as f32)));
|
||||
let before = h.render().expect("the frame before the drag");
|
||||
let before = h.render_serialized().expect("the frame before the drag");
|
||||
// Measured with the button down, so this is the ghost and not the committed shape.
|
||||
let mid = mid_drag(&mut h, a, b, mods);
|
||||
let on = [centre[0] as f32 + size[0] as f32 / 4.0, centre[1] as f32 + size[1] as f32 / 4.0];
|
||||
@@ -595,7 +596,7 @@ fn a_layer_drag_moves_the_picture_while_the_button_is_down() {
|
||||
h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
|
||||
h.step();
|
||||
// Where the layer is not (yet): the plate behind it.
|
||||
let plate = px_at(&h.render().unwrap(), &h, [440.0, 200.0]);
|
||||
let plate = px_at(&h.render_serialized().unwrap(), &h, [440.0, 200.0]);
|
||||
|
||||
// Drag in small steps, still holding the button, and give the renders a chance to land.
|
||||
let mut moved = false;
|
||||
@@ -606,7 +607,7 @@ fn a_layer_drag_moves_the_picture_while_the_button_is_down() {
|
||||
std::thread::sleep(std::time::Duration::from_millis(15));
|
||||
// 30 comp px behind the pointer: inside the layer, well away from its outline, its anchor
|
||||
// icon and where it started.
|
||||
let at = px_at(&h.render().expect("mid-drag frame"), &h, [320.0 + 200.0 * (i as f32 / 40.0) - 30.0, 200.0]);
|
||||
let at = px_at(&h.render_serialized().expect("mid-drag frame"), &h, [320.0 + 200.0 * (i as f32 / 40.0) - 30.0, 200.0]);
|
||||
if i >= 30 && delta(at, plate) > 30 {
|
||||
moved = true;
|
||||
break;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! comp goes to an unlocked viewer (a new one when every viewer is locked); a click on another
|
||||
//! viewer makes it the active one; closing a viewer forgets it.
|
||||
|
||||
use crate::gpu_test::RenderSerial;
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::project::ItemId;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
@@ -171,5 +172,5 @@ fn viewers_snapshot() {
|
||||
h.run_steps(4);
|
||||
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/test-out");
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
h.render().unwrap().save(format!("{dir}/viewers.png")).unwrap();
|
||||
h.render_serialized().unwrap().save(format!("{dir}/viewers.png")).unwrap();
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Opt-in visual review of every built-in panel, workspaces and common dialogs.
|
||||
//! Original fixture; captures are review outputs, not shipped assets.
|
||||
use crate::gpu_test::RenderSerial;
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_ui_egui::{Dialog, EffectcraftApp, dock::PanelKind};
|
||||
use egui_kittest::Harness;
|
||||
@@ -19,18 +20,18 @@ fn all_ui_review_captures() {
|
||||
std::sync::Arc::make_mut(&mut s.project).settings.gpu_acceleration = false;
|
||||
let mut h = Harness::builder().with_size(size).build_eframe(|_| EffectcraftApp::new(s));
|
||||
h.run_steps(4);
|
||||
h.render().unwrap().save(out.join(format!("{label}-workspace.png"))).unwrap();
|
||||
h.render_serialized().unwrap().save(out.join(format!("{label}-workspace.png"))).unwrap();
|
||||
let ctx = h.ctx.clone();
|
||||
h.state_mut().set_theme(&ctx, effectcraft_ui_egui::theme::ThemeKind::Light);
|
||||
h.run_steps(4);
|
||||
h.render().unwrap().save(out.join(format!("{label}-workspace-light.png"))).unwrap();
|
||||
h.render_serialized().unwrap().save(out.join(format!("{label}-workspace-light.png"))).unwrap();
|
||||
h.state_mut().set_theme(&ctx, effectcraft_ui_egui::theme::ThemeKind::Dark);
|
||||
for p in PanelKind::ALL {
|
||||
h.state_mut().show_panel(p);
|
||||
h.state_mut().ui.maximized = Some(p);
|
||||
h.run_steps(4);
|
||||
assert!(h.state().auto.find(&format!("panel.{}", p.id())).is_some(), "{} opens", p.title());
|
||||
h.render().unwrap().save(out.join(format!("{label}-{}.png", p.id()))).unwrap();
|
||||
h.render_serialized().unwrap().save(out.join(format!("{label}-{}.png", p.id()))).unwrap();
|
||||
}
|
||||
h.state_mut().ui.maximized = None;
|
||||
for (name, dialog) in [
|
||||
@@ -42,7 +43,7 @@ fn all_ui_review_captures() {
|
||||
] {
|
||||
h.state_mut().dialog = Some(dialog);
|
||||
h.run_steps(4);
|
||||
h.render().unwrap().save(out.join(format!("{label}-dialog-{name}.png"))).unwrap();
|
||||
h.render_serialized().unwrap().save(out.join(format!("{label}-dialog-{name}.png"))).unwrap();
|
||||
h.state_mut().dialog = None;
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
@@ -122,6 +122,7 @@ fn cpu_pixels(f: &FrameImage) -> Vec<egui::Color32> {
|
||||
|
||||
#[test]
|
||||
fn gpu_worker_renders_effect_frames_and_the_disk_cache_survives_a_reload() {
|
||||
let _device = crate::gpu_test::lock();
|
||||
let (Some(page_gpu), Some(worker_gpu)) = (Gpu::headless(), Gpu::headless_deferred()) else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
|
||||
@@ -10,10 +10,6 @@ use effectcraft_engine::offload::{Inbox, Offload, Post, WorkerReply, WorkerReque
|
||||
use effectcraft_gpu::Gpu;
|
||||
use serde_json::json;
|
||||
|
||||
/// One device at a time in this test binary: wgpu's OpenGL backend (Mesa llvmpipe on FreeBSD,
|
||||
/// Linux without Vulkan) panics or crashes when tests create and drive devices concurrently.
|
||||
static GPU_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Holds offloaded jobs until the test runs them.
|
||||
#[derive(Default)]
|
||||
struct Manual(Mutex<Vec<(WorkerRequest, Arc<Inbox>)>>);
|
||||
@@ -63,7 +59,7 @@ fn page() -> (Session, Arc<Manual>) {
|
||||
|
||||
#[test]
|
||||
fn render_queue_jobs_render_on_the_worker_gpu() {
|
||||
let _gpu = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let _gpu = crate::gpu_test::lock();
|
||||
let Some(g) = Gpu::headless_deferred() else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
@@ -102,7 +98,7 @@ fn render_queue_jobs_render_on_the_worker_gpu() {
|
||||
|
||||
#[test]
|
||||
fn analyses_render_their_input_on_the_worker_gpu() {
|
||||
let _gpu = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let _gpu = crate::gpu_test::lock();
|
||||
let Some(g) = Gpu::headless_deferred() else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! User-reported selection, Project deletion, and render-queue drop regressions.
|
||||
use crate::gpu_test::RenderSerial;
|
||||
use effectcraft_engine::{Session, project::LayerId};
|
||||
use effectcraft_ui_egui::{Dialog, EffectcraftApp, dock::PanelKind};
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2};
|
||||
@@ -273,7 +274,7 @@ fn viewer_right_click_targets_hit_and_duplicate_preserves_group() {
|
||||
}
|
||||
assert!(h.state().frames.is_cached(&key));
|
||||
h.run_steps(3);
|
||||
h.render().expect("offscreen context menu render").save(out).unwrap();
|
||||
h.render_serialized().expect("offscreen context menu render").save(out).unwrap();
|
||||
}
|
||||
h.get_by_label("Duplicate").click();
|
||||
h.run_steps(3);
|
||||
@@ -433,7 +434,7 @@ fn workflow_snapshot() {
|
||||
}
|
||||
assert!(h.state().frames.is_cached(&key), "current composition rendered before capture");
|
||||
h.run_steps(3);
|
||||
h.render().expect("offscreen UI render").save(out).unwrap();
|
||||
h.render_serialized().expect("offscreen UI render").save(out).unwrap();
|
||||
}
|
||||
|
||||
/// Filled rects painted this frame (flattening nested shape lists).
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Inspect the actual viewer's painted colors and pointer-driven control geometry.
|
||||
|
||||
use crate::gpu_test::RenderSerial;
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_ui_egui::{
|
||||
EffectcraftApp,
|
||||
@@ -269,10 +270,10 @@ fn viewer_theme_and_width_visual_matrix() {
|
||||
}
|
||||
|
||||
fn save_stable_snapshot(h: &mut Harness<'_, EffectcraftApp>, path: &str, width: f32) {
|
||||
let image = h.render().unwrap();
|
||||
let image = h.render_serialized().unwrap();
|
||||
image.save(path).unwrap();
|
||||
h.run_steps(3);
|
||||
let repeated = h.render().unwrap();
|
||||
let repeated = h.render_serialized().unwrap();
|
||||
assert!(image == repeated, "settled synthetic viewer pixels must repeat exactly: {path}");
|
||||
if width == 900.0 {
|
||||
let times: Vec<_> = h
|
||||
|
||||
+5
-1
@@ -4,6 +4,10 @@ Everything in EffectCraft is an engine command with a stable id and JSON params.
|
||||
panel gestures, the CLI, the control channel and MCP all go through the same commands, so anything a
|
||||
user can do, an agent can do too. There are three ways in:
|
||||
|
||||
Render-queue outputs cannot replace footage, proxies or the project file. See
|
||||
[render source protection](file-format-parity.md#render-source-protection) for aliases, default names and sequence
|
||||
limits. This guard does not change ordinary-file overwrite defaults or add another confirmation API.
|
||||
|
||||
| Interface | Best for | Needs a window |
|
||||
|---|---|---|
|
||||
| **MCP** (`effectcraft-cli mcp`) | Claude Code and other MCP clients | no (headless), or yes with `--bridge` |
|
||||
@@ -108,7 +112,7 @@ See [MCP conventions](mcp.md) for core tools, resources, strict arguments, and r
|
||||
| `run_script {code, name?}` | Run JavaScript with the After Effects-style scripting object model (`app.project`, `comp.layers.addText(…)`, `layer.property("ADBE Transform Group").property("ADBE Position").setValueAtTime(…)`…). Returns `{ok, result, output, error: {message, line, column}}`; edits are undoable. |
|
||||
| `get_project` / `get_comp {comp?}` | Project items, comp settings and layers. |
|
||||
| `get_layer {layer, comp?, time?, depth?, flat?}` | A layer's property tree (`depth` limits how many group levels expand). Every node has a `path`. |
|
||||
| `get_property {layer, path, comp?, time?}` | A leaf property's value at a time, keyframes and expression. Group paths (for example `geometryOptions`) contain children: inspect them with `get_layer`, then query a leaf such as `geometryOptions/extrusionDepth`. |
|
||||
| `get_property {layer, path, comp?, time?}` | A leaf property's value at a time, keyframes and expression; a popup (enum) property adds `options` (`[{value, label}]`, zero-based values, also in `get_layer`). Group paths (for example `geometryOptions`) contain children: inspect them with `get_layer`, then query a leaf such as `geometryOptions/extrusionDepth`. |
|
||||
| `set_property {layer, path, value?, time?, timeBase?, expression?, comp?}` | Sets a static value. With `time` it sets a keyframe (`timeBase: "comp"` for comp seconds); with `expression` it sets an expression. |
|
||||
| `add_keyframe {layer, path, time+value \| keys:[...], timeBase?, interpolation?, comp?}` | Adds keys (layer seconds, or comp seconds with `timeBase: "comp"`), then optionally applies linear/bezier/hold/easyEase. |
|
||||
| `list_effects {filter?, compact?}` | Effect ids, names, categories, GPU / 32-bpc support and parameters; `compact` lists only ids, names and categories. |
|
||||
|
||||
@@ -45,7 +45,7 @@ printf '{"id":1,"method":"engine.execute","params":{"command":"comp.new","params
|
||||
|
||||
Useful query commands for `engine.execute` (they aren't journaled or undoable): `project.summary`,
|
||||
`comp.info {comp?}`, `layer.tree {layer, comp?, depth?, time?}` (every node has a `path`),
|
||||
`prop.get {layer, path, comp?, time?}`, `editor.state`, `command.list {filter?, enabledOnly?, schemas?}`,
|
||||
`prop.get {layer, path, comp?, time?}` (a popup (enum) property also lists its `options`, `[{value, label}]`, as `layer.tree` does), `editor.state`, `command.list {filter?, enabledOnly?, schemas?}`,
|
||||
`command.describe {command}` (params doc and JSON Schema), `app.capabilities` (version, counts,
|
||||
export formats, parity summary), `jobs.list` (Progress panel jobs, e.g. the background
|
||||
`footage.check` after File ▸ Open).
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@
|
||||
<!-- Generated from the effect registry (crates/effects/src/catalog.rs); do not edit by hand.
|
||||
Regenerate: UPDATE_DOCS=1 cargo test -p effectcraft-effects --lib effects_doc_is_current -->
|
||||
|
||||
EffectCraft ships 308 effects with 3033 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over.
|
||||
EffectCraft ships 308 effects with 3034 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over.
|
||||
|
||||
- **GPU**: 280 effects also run on the GPU compositor with identical results.
|
||||
- **32**: 308 effects process 32-bit float (HDR, overbright) pixels without clamping.
|
||||
@@ -325,7 +325,7 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
|
||||
| CC Hair | `ec.sim.cchair` | 20 | GPU | 32 | Implemented |
|
||||
| CC Mr. Mercury | `ec.sim.ccmrmercury` | 23 | GPU | 32 | Implemented |
|
||||
| CC Particle Systems II | `ec.sim.ccparticlesystems2` | 21 | GPU | 32 | Implemented |
|
||||
| CC Particle World | `ec.sim.ccparticleworld` | 35 | GPU | 32 | Implemented |
|
||||
| CC Particle World | `ec.sim.ccparticleworld` | 36 | GPU | 32 | Implemented |
|
||||
| CC Pixel Polly | `ec.sim.ccpixelpolly` | 10 | GPU | 32 | Implemented |
|
||||
| CC Rainfall | `ec.sim.ccrainfall` | 14 | GPU | 32 | Implemented |
|
||||
| CC Scatterize | `ec.sim.ccscatterize` | 4 | GPU | 32 | Implemented |
|
||||
|
||||
@@ -105,6 +105,37 @@ single-file video (Animation / PNG codecs), DPX / Photoshop sequences, MXF, DNxH
|
||||
encoders ([hardware-parity.md](hardware-parity.md)), and rendering safely over an existing file
|
||||
(#325).
|
||||
|
||||
### Render source protection
|
||||
|
||||
The render queue refuses an output that would replace footage, an attached proxy, or the
|
||||
project file. It checks every output module and segment before dispatching the item. A refused
|
||||
item is marked Failed with its reason; other valid queued items can still render. Render logs
|
||||
are checked too. No render parameter overrides this protection.
|
||||
|
||||
Checks compare normalized paths and native file identities, so an existing symbolic link,
|
||||
hard link, junction or Windows short name cannot disguise a source as another output file.
|
||||
Source identities are indexed once per check and matching identities are verified. The index
|
||||
does not keep file handles open for the lifetime of a long sequence.
|
||||
|
||||
Default output names move aside when they would replace a protected file. This also applies to
|
||||
Create Proxy: recreating a proxy chooses a new default name and attaches the new proxy, leaving
|
||||
the previous proxy file intact. An explicit output path is kept as entered and refused if it
|
||||
would replace a protected file.
|
||||
|
||||
After Pre-render imports its result, that file is footage too. Re-queueing the same item
|
||||
with the same output is refused; run Pre-render again for a fresh default output, or choose
|
||||
another Output To. An item's `last_output` is not permission to replace imported footage.
|
||||
|
||||
Image-sequence checks inspect existing frames in the requested range. Skip Existing Files leaves
|
||||
existing frames alone. If a frame number appears in a folder name, the checker probes each
|
||||
frame separately and refuses ranges over 100,000 frames instead of silently checking a prefix.
|
||||
Choose a fixed output folder or a shorter range for those exports.
|
||||
|
||||
This guard does not introduce an overwrite flag, confirmation dialog, or changed replacement
|
||||
defaults for the CLI, scripts or MCP. Replacement consent and staging ordinary output files
|
||||
belong to the exporter. The source check runs before export; filesystem changes between that
|
||||
check and a write still require exporter-side revalidation.
|
||||
|
||||
## Estimate
|
||||
|
||||
**≈ 130–230 h**: `.aep` / `.aepx` import 80–160 h (after the owner decision), wiring TGA / DPX /
|
||||
@@ -115,5 +146,6 @@ relinking 6–12 h, `.mogrt`-compatible templates 15–30 h (partly parallel wit
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | Render-queue source-protection behavior and limits consolidated here from the standalone page |
|
||||
| 2026-10-12 | minor | #324 and #514 marked closed; open `.aep` PRs #251 and #860 noted |
|
||||
| 2026-10-10 | major | First file format parity document, from the After Effects 26.5 bundle and this repository's import/export registries |
|
||||
Reference in new issue
Block a user