mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 13:08:07 -06:00
Merge upstream/main into cs-ui
Keep every entry in contributors/people.toml, and translate the new menu item (Copy Frame to Clipboard) and the five gradient-editor strings into Czech. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,15 +2,19 @@
|
||||
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
|
||||
name: FreeBSD
|
||||
|
||||
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every push
|
||||
# or dependency change: a drifted dependency still shows up within a day, and always before a
|
||||
# release ships. PRs that change the FreeBSD packaging itself still run it.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "47 2 * * *"
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [release]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- .github/workflows/freebsd.yml
|
||||
- packaging/freebsd/**
|
||||
- packaging/env.sh
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -4,19 +4,17 @@
|
||||
# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.)
|
||||
name: Windows ARM64
|
||||
|
||||
# Nightly on main and on pushes to `release` (dependency and runner-image drift shows up within a
|
||||
# day, and always before a release ships), and on PRs that change the Windows packaging itself.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "13 5 * * *"
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [release]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- "**/build.rs"
|
||||
- .github/workflows/windows-arm64.yml
|
||||
- packaging/windows/**
|
||||
schedule:
|
||||
- cron: "41 4 * * 1" # weekly, so drift in the runner images shows up
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -65,6 +65,8 @@ Screenshots of EffectCraft itself, rendered headlessly, showing only procedurall
|
||||
`crates/engine/src/templates.rs`.
|
||||
- Text animator presets (Typewriter, Fade Up Characters, Bounce In Words, Tracking In, Scramble, Blur In, Jitter,
|
||||
Drop In Lines): `crates/engine/presets/text_animators.json` (sidecar `.attribution`).
|
||||
- Transition animation presets (23: wipes, clock and fan reveals, blinds, dissolves, card flips, CC wipes, fades):
|
||||
`crates/engine/presets/transitions.json` (sidecar `.attribution`).
|
||||
- Sample scripts (File ▸ Scripts) and ScriptUI panel: `crates/engine/scripts/Rename Layers.jsx`,
|
||||
`crates/engine/scripts/Sort Layers by In Point.jsx`, `crates/engine/scripts/Create Null at Selected Layers.jsx`,
|
||||
`crates/engine/scripts/Render Queue Batch.jsx`, `crates/engine/scripts/ScriptUI Panels/Layer Tools.jsx`
|
||||
|
||||
Generated
+1
@@ -1560,6 +1560,7 @@ dependencies = [
|
||||
"effectcraft-render",
|
||||
"effectcraft-time",
|
||||
"serde_json",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -55,5 +55,9 @@ real_name = "Vikram Bhaskaran"
|
||||
display_name = "Vikram Bhaskaran"
|
||||
sync_github_name = false
|
||||
|
||||
[people.vangel76]
|
||||
real_name = "Valentin Klein"
|
||||
display_name = "Valentin Klein"
|
||||
|
||||
[people.Losminek]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -681,6 +681,12 @@ fn list_fonts_lists_families_with_styles() {
|
||||
assert!(matched.len() < fams.len(), "the query filters: {one}");
|
||||
let mono = matched.iter().find(|f| f["family"] == "JetBrains Mono").unwrap_or_else(|| panic!("{one}"));
|
||||
assert!(mono["styles"].as_array().unwrap().contains(&json!("Regular")), "{one}");
|
||||
assert_eq!(mono["variable"], false, "{one}");
|
||||
// `variable` keeps only the variable fonts (the font menu's filter); the static bundled
|
||||
// families drop out.
|
||||
let var = call_json(&mut s, "list_fonts", json!({"variable": true}));
|
||||
let listed = var["families"].as_array().unwrap();
|
||||
assert!(listed.iter().all(|f| f["variable"] == true && f["family"] != "Inter"), "{var}");
|
||||
}
|
||||
|
||||
/// Trained models over MCP (Roto Brush and face tracking): listed with their authors and
|
||||
|
||||
@@ -210,7 +210,7 @@ fn list_effects(b: &mut Backend, a: &Value) -> Result<Reply> {
|
||||
}
|
||||
|
||||
fn list_fonts(b: &mut Backend, a: &Value) -> Result<Reply> {
|
||||
json_reply(b.exec("text.fonts", obj(&[("query", get(a, "query")), ("rescan", get(a, "rescan"))]))?)
|
||||
json_reply(b.exec("text.fonts", obj(&[("query", get(a, "query")), ("variable", get(a, "variable")), ("rescan", get(a, "rescan"))]))?)
|
||||
}
|
||||
|
||||
/// `effect.apply` on one layer, then set parameters on the new instance by param id: one batch,
|
||||
@@ -703,12 +703,13 @@ static TOOLS: &[ToolDef] = &[
|
||||
},
|
||||
ToolDef {
|
||||
name: "list_fonts",
|
||||
description: "The font families text layers can use, bundled and installed on this machine: family, styles, origin (bundled / system / user) and the name in the font's own language. Narrow with `query`; `rescan` picks up fonts installed since launch. Set one with execute_command `layer.setText {\"layer\", \"font\", \"style\"}`.",
|
||||
description: "The font families text layers can use, bundled and installed on this machine: family, styles, origin (bundled / system / user), whether it is a variable font and the name in the font's own language. Narrow with `query` and `variable`; `rescan` picks up fonts installed since launch. Set one with execute_command `layer.setText {\"layer\", \"font\", \"style\"}`.",
|
||||
bridge_only: false,
|
||||
schema: || {
|
||||
schema(
|
||||
json!({
|
||||
"query": {"type": "string", "description": "Only families whose name (English or own-language) contains this text."},
|
||||
"variable": {"type": "boolean", "description": "Only variable fonts (families with variation axes)."},
|
||||
"rescan": {"type": "boolean", "description": "Look for fonts installed since launch first."}
|
||||
}),
|
||||
&[],
|
||||
|
||||
@@ -323,11 +323,40 @@ fn wild(pat: &str, name: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a selection: comma-separated names (optionally quoted) or `<hex>` hashes / wildcards.
|
||||
/// Split a selection at its commas. A name in double or single quotes stays whole, commas
|
||||
/// included (Blender allows `Green, Glossy`), and a backslash takes the next character
|
||||
/// literally (`Green\, Glossy`, `\"`). Unquoted text is trimmed; the quotes are dropped.
|
||||
fn selection_names(sel: &str) -> Vec<String> {
|
||||
let mut out = vec![];
|
||||
let mut cur = String::new();
|
||||
let mut quote: Option<char> = None;
|
||||
let mut chars = sel.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
match (c, quote) {
|
||||
('\\', _) => {
|
||||
if let Some(next) = chars.next() {
|
||||
cur.push(next);
|
||||
}
|
||||
}
|
||||
(c, Some(q)) if c == q => quote = None,
|
||||
('"' | '\'', None) if cur.trim().is_empty() => {
|
||||
cur.clear();
|
||||
quote = Some(c);
|
||||
}
|
||||
(',', None) => out.push(std::mem::take(&mut cur).trim().to_string()),
|
||||
(c, _) => cur.push(c),
|
||||
}
|
||||
}
|
||||
out.push(cur.trim().to_string());
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse a selection: comma-separated names (optionally quoted, see [`selection_names`]) or
|
||||
/// `<hex>` hashes / wildcards.
|
||||
pub fn selection_hashes(sel: &str, manifest: Option<&[(String, u32)]>) -> Vec<u32> {
|
||||
let mut out = vec![];
|
||||
for tok in sel.split(',') {
|
||||
let t = tok.trim().trim_matches('"').trim_matches('\'');
|
||||
for t in selection_names(sel) {
|
||||
let t = t.as_str();
|
||||
if t.is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -677,6 +706,25 @@ mod tests {
|
||||
assert_eq!(selection_hashes(&hex, None), vec![crypto_hash("left")]);
|
||||
}
|
||||
|
||||
/// #473: a name with a comma in it (Blender allows `Green, Glossy`) is selected when it is
|
||||
/// quoted or its comma escaped, instead of being cut into two names that match nothing.
|
||||
#[test]
|
||||
fn cryptomatte_selection_keeps_quoted_names_with_commas_whole() {
|
||||
let manifest = vec![("Green, Glossy".to_string(), 7), ("Green".to_string(), 1), ("Glossy".to_string(), 2), ("ring".to_string(), 3)];
|
||||
let m = Some(manifest.as_slice());
|
||||
assert_eq!(selection_hashes(r#""Green, Glossy""#, m), vec![7]);
|
||||
assert_eq!(selection_hashes("'Green, Glossy', ring", m), vec![7, 3]);
|
||||
assert_eq!(selection_hashes(r"Green\, Glossy", m), vec![7]);
|
||||
assert_eq!(selection_hashes(r#"ring , "Green, Glossy""#, m), vec![3, 7]);
|
||||
// Without a manifest the whole name is hashed.
|
||||
assert_eq!(selection_hashes(r#""Green, Glossy""#, None), vec![crypto_hash("Green, Glossy")]);
|
||||
// Plain lists, apostrophes inside names and an unclosed quote still parse.
|
||||
assert_eq!(selection_hashes("Green, Glossy", m), vec![1, 2]);
|
||||
assert_eq!(selection_names("Bob's mat, ring"), vec!["Bob's mat", "ring"]);
|
||||
assert_eq!(selection_names(r#""Green, Glossy"#), vec!["Green, Glossy"]);
|
||||
assert_eq!(selection_names(r#"say \"hi\""#), vec![r#"say "hi""#]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fog_and_depth_of_field_follow_depth() {
|
||||
let h = scene();
|
||||
|
||||
@@ -43,16 +43,27 @@ fn tritone(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
||||
/// Brightness & Contrast without Use Legacy: tone curves that keep black and white fixed (no
|
||||
/// clipping). Brightness bends the curve with a power (`b` in -1.5..1.5), contrast is an S
|
||||
/// curve around mid grey (`c` in -1..1). Values above 1 pass through. The GPU kernel mirrors it.
|
||||
/// Brightness & Contrast without Use Legacy: pin black and white, then a mild S-curve.
|
||||
///
|
||||
/// Fitted to After Effects non-legacy grey ramps (#681, kasper9n oracles). Brightness is a
|
||||
/// power toward white (`1-(1-v)^e`) or black (`v^e`); contrast uses `g = 1 + 0.53 c` instead of
|
||||
/// the old `2^(2c)` (that crushed 0.25 to ~8/255 at Contrast 100; AE is ~40/255). Values above 1
|
||||
/// pass through. The GPU kernel mirrors it.
|
||||
pub fn bc_modern(v: f32, b: f32, c: f32) -> f32 {
|
||||
if v >= 1.0 {
|
||||
return v;
|
||||
}
|
||||
let v = v.max(0.0).powf(2f32.powf(-b));
|
||||
let g = 2f32.powf(2.0 * c);
|
||||
if v < 0.5 { 0.5 * (2.0 * v).powf(g) } else { 1.0 - 0.5 * (2.0 - 2.0 * v).max(0.0).powf(g) }
|
||||
let mut v = v.max(0.0);
|
||||
if b > 0.0 {
|
||||
v = 1.0 - (1.0 - v).powf(2f32.powf(1.24 * b));
|
||||
} else if b < 0.0 {
|
||||
v = v.powf(2f32.powf(-1.32 * b));
|
||||
}
|
||||
if c != 0.0 {
|
||||
let g = (1.0 + 0.53 * c).max(0.05);
|
||||
v = if v < 0.5 { 0.5 * (2.0 * v).powf(g) } else { 1.0 - 0.5 * (2.0 - 2.0 * v).max(0.0).powf(g) };
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn brightness_contrast(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
@@ -1181,6 +1192,34 @@ mod tests {
|
||||
assert!((bc(&legacy, 0.0) - 0.5).abs() < 1e-5);
|
||||
}
|
||||
|
||||
/// After Effects non-legacy ramps (kasper9n, #681). B0 C-100 matched B0 C-50 and is omitted.
|
||||
#[test]
|
||||
fn brightness_contrast_modern_matches_ae_ramp_samples() {
|
||||
let u8 = |v: f32| (v * 255.0).round() as i32;
|
||||
let go = |b: f64, c: f64, x: u8| {
|
||||
u8(px("ec.color.brightnesscontrast", &[("brightness", num(b)), ("contrast", num(c))], [x as f32 / 255.0, x as f32 / 255.0, x as f32 / 255.0, 1.0])
|
||||
[0])
|
||||
};
|
||||
// (brightness, contrast, input, AE 8-bit)
|
||||
let samples: &[(f64, f64, u8, u8)] = &[
|
||||
(0.0, 100.0, 64, 40),
|
||||
(0.0, 100.0, 128, 129),
|
||||
(0.0, 100.0, 192, 217),
|
||||
(0.0, 50.0, 64, 53),
|
||||
(0.0, 50.0, 192, 205),
|
||||
(0.0, -50.0, 64, 77),
|
||||
(100.0, 0.0, 128, 209),
|
||||
(50.0, 0.0, 128, 171),
|
||||
(106.0, -18.0, 128, 214),
|
||||
];
|
||||
for &(b, c, x, ae) in samples {
|
||||
let got = go(b, c, x);
|
||||
assert!((got - ae as i32).abs() <= 10, "B{b} C{c} in {x}: got {got} AE {ae}");
|
||||
}
|
||||
// Old g=4 S-curve put Contrast 100 at ~8/255 here.
|
||||
assert!(go(0.0, 100.0, 64) >= 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levels_clips_black_and_white_independently() {
|
||||
let lv = |vals: &[(&str, Value)], v: f32| px("ec.color.levels", vals, [v, v, v, 1.0])[0];
|
||||
|
||||
@@ -105,7 +105,7 @@ pub use noise::remove_grain_level;
|
||||
pub use noise3::{FractalGpu, fractal_gpu};
|
||||
pub use stylize3::{BumpLight, bump_layer_id};
|
||||
pub use textfx::{TextLayer, text_layer};
|
||||
pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, posterized_time, time_frames, timewarp_crop, timewarp_plan};
|
||||
pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, pmb_plan, posterized_time, time_frames, timewarp_crop, timewarp_plan};
|
||||
// effectcraft-gpu fx_tone.
|
||||
pub use ocio::color_stabilizer_maps;
|
||||
// GPU effects, part B (3D Channel, Immersive Video, colour management, simulation render passes).
|
||||
|
||||
@@ -219,28 +219,63 @@ fn cylinder(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let (_, _, w, h) = rect;
|
||||
let r = (ctx.params.f("radius") / 100.0 * w / std::f64::consts::TAU).max(0.5);
|
||||
let c = b.to_px(ctx.params.v2("position"));
|
||||
// Keep the pre-existing Rotation control as an additional texture spin.
|
||||
let spin = ctx.params.f("rotation").to_radians();
|
||||
let (rx, ry, rz) = (ctx.params.f("rotationX"), ctx.params.f("rotationY"), ctx.params.f("rotationZ"));
|
||||
let tilted = rx != 0.0 || ry != 0.0 || rz != 0.0;
|
||||
let m = rot_xyz(rx, ry, rz);
|
||||
// The cylinder's local Y axis after rotation, in viewer coordinates.
|
||||
let axis = [m[0][1], m[1][1], m[2][1]];
|
||||
let a = 1.0 - axis[2] * axis[2];
|
||||
let mode = ctx.params.e("render");
|
||||
let sh = Shading::from(ctx);
|
||||
let src = b.img.clone();
|
||||
b.img.rows_mut().for_each(|(y, row)| {
|
||||
let v = (y as f64 + 0.5 - c.1) / h + 0.5;
|
||||
let dy = y as f64 + 0.5 - c.1;
|
||||
for (x, px) in row.iter_mut().enumerate() {
|
||||
let s = (x as f64 + 0.5 - c.0) / r;
|
||||
if s.abs() >= 1.0 {
|
||||
let dx = x as f64 + 0.5 - c.0;
|
||||
if !tilted {
|
||||
// Preserve exactly the mapping of projects saved before axis rotations.
|
||||
let v = dy / h + 0.5;
|
||||
let s = dx / r;
|
||||
if s.abs() >= 1.0 {
|
||||
*px = [0.0; 4];
|
||||
continue;
|
||||
}
|
||||
let th = s.asin();
|
||||
let cz = th.cos();
|
||||
let front = || sh.shade(texel(&src, rect, (th + spin) / std::f64::consts::TAU + 0.5, v), [s, 0.0, cz]);
|
||||
let back = || sh.shade(texel(&src, rect, (std::f64::consts::PI - th + spin) / std::f64::consts::TAU + 0.5, v), [-s, 0.0, cz]);
|
||||
*px = match mode {
|
||||
1 => front(),
|
||||
2 => back(),
|
||||
_ => over(front(), back()),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
// Intersect the viewing ray (dx,dy,z) with the rotated cylindrical
|
||||
// side wall: |q - dot(q,axis)*axis|^2 = radius^2.
|
||||
let da = dx * axis[0] + dy * axis[1];
|
||||
let bq = -da * axis[2];
|
||||
let discriminant = bq * bq - a * (dx * dx + dy * dy - da * da - r * r);
|
||||
if a <= 1e-10 || !discriminant.is_finite() || discriminant <= 0.0 {
|
||||
*px = [0.0; 4];
|
||||
continue;
|
||||
}
|
||||
let th = s.asin();
|
||||
let cz = th.cos();
|
||||
let front = || {
|
||||
let u = (th + spin) / std::f64::consts::TAU + 0.5;
|
||||
sh.shade(texel(&src, rect, u, v), [s, 0.0, cz])
|
||||
};
|
||||
let back = || {
|
||||
let u = (std::f64::consts::PI - th + spin) / std::f64::consts::TAU + 0.5;
|
||||
sh.shade(texel(&src, rect, u, v), [-s, 0.0, cz])
|
||||
let root = discriminant.sqrt();
|
||||
let face = |z: f64, back: bool| {
|
||||
// Rᵀ turns the intersection into cylinder-local coordinates.
|
||||
let local = [m[0][0] * dx + m[1][0] * dy + m[2][0] * z, m[0][1] * dx + m[1][1] * dy + m[2][1] * z, m[0][2] * dx + m[1][2] * dy + m[2][2] * z];
|
||||
let u = (local[0].atan2(local[2]) + spin) / std::f64::consts::TAU + 0.5;
|
||||
let v = local[1] / h + 0.5;
|
||||
let sign = if back { -1.0 } else { 1.0 };
|
||||
let nx = local[0] / r * sign;
|
||||
let nz = local[2] / r * sign;
|
||||
let normal = [m[0][0] * nx + m[0][2] * nz, m[1][0] * nx + m[1][2] * nz, m[2][0] * nx + m[2][2] * nz];
|
||||
sh.shade(texel(&src, rect, u, v), normal)
|
||||
};
|
||||
let front = || face((-bq + root) / a, false);
|
||||
let back = || face((-bq - root) / a, true);
|
||||
*px = match mode {
|
||||
1 => front(),
|
||||
2 => back(),
|
||||
@@ -362,6 +397,9 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
p("radius", "Radius", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)),
|
||||
p("position", "Position", pt(0.5, 0.5), ParamUi::Point),
|
||||
p("rotation", "Rotation", num(0.0), ParamUi::Angle),
|
||||
p("rotationX", "Rotation X", num(0.0), ParamUi::Angle),
|
||||
p("rotationY", "Rotation Y", num(0.0), ParamUi::Angle),
|
||||
p("rotationZ", "Rotation Z", num(0.0), ParamUi::Angle),
|
||||
p("render", "Render", Value::Enum(0), render()),
|
||||
];
|
||||
cyl.extend(shading());
|
||||
@@ -527,6 +565,31 @@ mod tests {
|
||||
assert_eq!(out.get(50 + 18, 10)[3], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_axis_rotations_affect_geometry_and_texture() {
|
||||
let img = Image::filled(100, 100, [0.5, 0.5, 0.5, 1.0]);
|
||||
let default = run("ec.perspective.cccylinder", &img, &[]).img;
|
||||
let x_tilt = run("ec.perspective.cccylinder", &img, &[("rotationX", num(70.0))]).img;
|
||||
// Tilting the finite cylinder shortens its vertical projection.
|
||||
assert!(default.get(50, 10)[3] > 0.99);
|
||||
assert_eq!(x_tilt.get(50, 10)[3], 0.0);
|
||||
assert!(x_tilt.get(50, 50)[3] > 0.99);
|
||||
let z_roll = run("ec.perspective.cccylinder", &img, &[("rotationZ", num(90.0))]).img;
|
||||
// A quarter turn moves the side wall from vertical to horizontal.
|
||||
assert_eq!(default.get(5, 50)[3], 0.0);
|
||||
assert!(z_roll.get(5, 50)[3] > 0.99);
|
||||
|
||||
let mut striped = Image::filled(100, 100, [0.0, 0.0, 0.0, 1.0]);
|
||||
for y in 0..100 {
|
||||
for x in 0..100 {
|
||||
striped.set(x, y, [x as f32 / 100.0, 0.2, 0.3, 1.0]);
|
||||
}
|
||||
}
|
||||
let baseline = run("ec.perspective.cccylinder", &striped, &[]).img;
|
||||
let spun = run("ec.perspective.cccylinder", &striped, &[("rotationY", num(90.0))]).img;
|
||||
assert!((baseline.get(50, 50)[0] - spun.get(50, 50)[0]).abs() > 0.05);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spotlight_darkens_outside_cone() {
|
||||
let img = Image::filled(80, 80, [1.0, 1.0, 1.0, 1.0]);
|
||||
|
||||
@@ -583,6 +583,13 @@ fn smooth_flow(flow: &mut Flow, pm: &PixelMotion) {
|
||||
|
||||
/// The motion field from frame `a` to frame `b` (or from the warp layer's frames).
|
||||
pub fn timewarp_flow(a: &Image, b: &Image, pm: &PixelMotion) -> Flow {
|
||||
timewarp_flow_search(a, b, pm, 4)
|
||||
}
|
||||
|
||||
/// [`timewarp_flow`] with an explicit coarse search radius: Timewarp's own resampling needs
|
||||
/// little (motion between adjacent source frames at near-100% speed), but Pixel Motion Blur
|
||||
/// must find fast full-frame displacements (#535).
|
||||
fn timewarp_flow_search(a: &Image, b: &Image, pm: &PixelMotion, radius: i32) -> Flow {
|
||||
let ga = motion_grey(&crop_edges(a, pm.crops), pm.weights, 1.0);
|
||||
let gain = if pm.correct_luminance {
|
||||
let (ma, mb) = (mean_grey(&ga), mean_grey(&motion_grey(b, pm.weights, 1.0)));
|
||||
@@ -591,7 +598,7 @@ pub fn timewarp_flow(a: &Image, b: &Image, pm: &PixelMotion) -> Flow {
|
||||
1.0
|
||||
};
|
||||
let gb = motion_grey(&crop_edges(b, pm.crops), pm.weights, gain);
|
||||
let mut flow = effectcraft_raster::flow::block_flow_window(&ga, &gb, pm.tile, pm.window, 4);
|
||||
let mut flow = effectcraft_raster::flow::block_flow_window(&ga, &gb, pm.tile, pm.window, radius);
|
||||
smooth_flow(&mut flow, pm);
|
||||
flow
|
||||
}
|
||||
@@ -923,12 +930,71 @@ fn wide_time(ctx: &EffectCtx, b: Buf) -> Buf {
|
||||
mean_of(ctx, b, ×)
|
||||
}
|
||||
|
||||
fn pixel_motion_blur(ctx: &EffectCtx, b: Buf) -> Buf {
|
||||
fn shutter_times(ctx: &EffectCtx) -> Vec<f64> {
|
||||
let angle = if ctx.params.e("shutterControl") == 1 { ctx.params.f("shutterAngle") } else { 180.0 };
|
||||
let n = ctx.params.f("shutterSamples").round().clamp(1.0, 64.0) as usize;
|
||||
let span = angle.clamp(0.0, 720.0) / 360.0 / ctx.fps();
|
||||
let times: Vec<f64> = (0..n).map(|i| ctx.time - span * 0.5 + if n > 1 { span * i as f64 / (n - 1) as f64 } else { span * 0.5 }).collect();
|
||||
mean_of(ctx, b, ×)
|
||||
(0..n).map(|i| ctx.time - span * 0.5 + if n > 1 { span * i as f64 / (n - 1) as f64 } else { span * 0.5 }).collect()
|
||||
}
|
||||
|
||||
fn pixel_motion_blur(ctx: &EffectCtx, b: Buf) -> Buf {
|
||||
match pmb_plan(ctx).as_ref().map(TimewarpPlan::render_cpu) {
|
||||
Some(blur) => blur,
|
||||
None => mean_of(ctx, b, &shutter_times(ctx)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pixel Motion Blur as a [`TimewarpPlan`]: each shutter instant is decoded between the two
|
||||
/// source frames it falls between along a measured motion field (the same synthesis Timewarp
|
||||
/// uses), and the instants average into the blur. Vector Detail sets the spacing of the motion
|
||||
/// vectors, so a coarse grid follows one big motion and a fine grid separates neighbouring
|
||||
/// ones (#535). `None` without a host — the caller then falls back. Shared by the CPU effect
|
||||
/// and the GPU compositor (`fx_time`), which renders the plan like Timewarp's.
|
||||
pub fn pmb_plan(ctx: &EffectCtx) -> Option<TimewarpPlan> {
|
||||
ctx.env.host?;
|
||||
let fps = ctx.fps();
|
||||
let samples = shutter_times(ctx);
|
||||
let n = samples.len();
|
||||
let mut times = Vec::new();
|
||||
for &s in &samples {
|
||||
let (f0, _) = tw_split(s, fps);
|
||||
times.push(f0 / fps);
|
||||
times.push((f0 + 1.0) / fps);
|
||||
}
|
||||
let fr = fetch(ctx, ×)?;
|
||||
let idx = |t: f64| times.iter().position(|q| (q - t).abs() < 1e-9).unwrap_or(0);
|
||||
let mut pm = PixelMotion::from(ctx, fr.grid.scale);
|
||||
// PMB's own Vector Detail: 0 → one vector per 32 px, 100 → one per 2 px.
|
||||
let detail = (ctx.params.f("vectorDetail") / 100.0).clamp(0.0, 1.0);
|
||||
pm.tile = (32.0 * (1.0f64 / 16.0).powf(detail) * fr.grid.scale).round().max(2.0) as u32;
|
||||
let mut flows: Vec<FlowEntry> = vec![];
|
||||
let mut steps = vec![];
|
||||
let w = 1.0 / n as f32;
|
||||
for &s in &samples {
|
||||
let (f0, frac) = tw_split(s, fps);
|
||||
let (i0, i1) = (idx(f0 / fps), idx((f0 + 1.0) / fps));
|
||||
let step = if frac < 1e-4 {
|
||||
TwStep::Whole(i0)
|
||||
} else {
|
||||
let flow = match flows.iter().position(|fl| fl.key == f0 as i64) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
let reach = (fr.grid.img.width.max(fr.grid.img.height) / 4).clamp(8, 64);
|
||||
flows.push(FlowEntry { key: f0 as i64, flow: timewarp_flow_search(&fr.imgs[i0], &fr.imgs[i1], &pm, reach as i32) });
|
||||
flows.len() - 1
|
||||
}
|
||||
};
|
||||
TwStep::Motion { a: i0, b: i1, w: frac, flow }
|
||||
};
|
||||
steps.push((step, w));
|
||||
}
|
||||
Some(TimewarpPlan { grid: fr.grid, frames: fr.imgs, steps, flows: flows.into_iter().map(|f| f.flow).collect(), pm })
|
||||
}
|
||||
|
||||
/// A measured flow with the source frame pair it came from (plans cache one per pair).
|
||||
struct FlowEntry {
|
||||
key: i64,
|
||||
flow: Flow,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
{
|
||||
"format": "effectcraft.animationPresets/1",
|
||||
"notes": "Original EffectCraft transition presets (MIT OR Apache-2.0), written from scratch on EffectCraft's own Transition effects. Key times are seconds from the current time (layer time); effect paths are the effect's parameter ids; layer paths are property paths such as transform/opacity.",
|
||||
"presets": [
|
||||
{
|
||||
"id": "fadeIn",
|
||||
"name": "Fade In",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "The layer fades up from transparent over half a second.",
|
||||
"layer": { "keys": { "transform/opacity": [[0, 0], [0.5, 100]] }, "ease": true }
|
||||
},
|
||||
{
|
||||
"id": "fadeOut",
|
||||
"name": "Fade Out",
|
||||
"category": "Transitions",
|
||||
"direction": "out",
|
||||
"description": "The layer fades down to transparent over half a second.",
|
||||
"layer": { "keys": { "transform/opacity": [[0, 100], [0.5, 0]] }, "ease": true }
|
||||
},
|
||||
{
|
||||
"id": "wipeInFromLeft",
|
||||
"name": "Wipe In from Left",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "A soft edge sweeps across from the left and reveals the layer.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.linearwipe", "set": { "angle": 270, "feather": 40 }, "keys": { "completion": [[0, 100], [1, 0]] }, "ease": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wipeInFromRight",
|
||||
"name": "Wipe In from Right",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "A soft edge sweeps across from the right and reveals the layer.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.linearwipe", "set": { "angle": 90, "feather": 40 }, "keys": { "completion": [[0, 100], [1, 0]] }, "ease": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wipeInFromTop",
|
||||
"name": "Wipe In from Top",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "The layer is revealed from the top edge downwards.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.linearwipe", "set": { "angle": 0, "feather": 30 }, "keys": { "completion": [[0, 100], [0.9, 0]] }, "ease": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wipeInFromBottom",
|
||||
"name": "Wipe In from Bottom",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "The layer is revealed from the bottom edge upwards.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.linearwipe", "set": { "angle": 180, "feather": 30 }, "keys": { "completion": [[0, 100], [0.9, 0]] }, "ease": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "diagonalSweepIn",
|
||||
"name": "Diagonal Sweep In",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "A wide, very soft edge sweeps in from the bottom-left corner.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.linearwipe", "set": { "angle": 45, "feather": 160 }, "keys": { "completion": [[0, 100], [1.2, 0]] }, "ease": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wipeOutToRight",
|
||||
"name": "Wipe Out to Right",
|
||||
"category": "Transitions",
|
||||
"direction": "out",
|
||||
"description": "A soft edge travels left to right and takes the layer away.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.linearwipe", "set": { "angle": 90, "feather": 40 }, "keys": { "completion": [[0, 0], [1, 100]] }, "ease": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "clockReveal",
|
||||
"name": "Clock Reveal",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "A clock hand sweeps once around the centre and reveals the layer.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.radialwipe", "set": { "wipe": 1, "feather": 6 }, "keys": { "completion": [[0, 100], [1.2, 0]] }, "ease": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "fanOpen",
|
||||
"name": "Fan Open",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "The layer opens like a fan, from the top down both sides.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.radialwipe", "set": { "wipe": 2, "startAngle": 180, "feather": 10 }, "keys": { "completion": [[0, 100], [1, 0]] }, "ease": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "clockWipeOut",
|
||||
"name": "Clock Wipe Out",
|
||||
"category": "Transitions",
|
||||
"direction": "out",
|
||||
"description": "A clock hand sweeps once around the centre and removes the layer.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.radialwipe", "set": { "feather": 6 }, "keys": { "completion": [[0, 0], [1.2, 100]] }, "ease": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "blindsOpen",
|
||||
"name": "Blinds Open",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "Vertical slats widen until the layer is whole.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.venetian", "set": { "direction": 0, "width": 60, "feather": 4 }, "keys": { "completion": [[0, 100], [0.9, 0]] }, "ease": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "slatsDown",
|
||||
"name": "Slats Down",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "Horizontal slats thicken until the layer is whole.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.venetian", "set": { "direction": 90, "width": 36, "feather": 3 }, "keys": { "completion": [[0, 100], [0.8, 0]] } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "blindsClose",
|
||||
"name": "Blinds Close",
|
||||
"category": "Transitions",
|
||||
"direction": "out",
|
||||
"description": "Vertical slats narrow until the layer is gone.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.venetian", "set": { "direction": 0, "width": 60, "feather": 4 }, "keys": { "completion": [[0, 0], [0.9, 100]] }, "ease": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pixelDissolveIn",
|
||||
"name": "Pixel Dissolve In",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "Small hard-edged blocks pop in at random until the layer is whole.",
|
||||
"effects": [
|
||||
{
|
||||
"effect": "ec.transition.blockdissolve",
|
||||
"set": { "blockWidth": 6, "blockHeight": 6, "softEdges": false },
|
||||
"keys": { "completion": [[0, 100], [1.2, 0]] }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mosaicFadeIn",
|
||||
"name": "Mosaic Fade In",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "Large soft tiles fade in at random.",
|
||||
"effects": [
|
||||
{
|
||||
"effect": "ec.transition.blockdissolve",
|
||||
"set": { "blockWidth": 48, "blockHeight": 48, "feather": 16 },
|
||||
"keys": { "completion": [[0, 100], [1.2, 0]] },
|
||||
"ease": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pixelDissolveOut",
|
||||
"name": "Pixel Dissolve Out",
|
||||
"category": "Transitions",
|
||||
"direction": "out",
|
||||
"description": "Small hard-edged blocks drop out at random until the layer is gone.",
|
||||
"effects": [
|
||||
{
|
||||
"effect": "ec.transition.blockdissolve",
|
||||
"set": { "blockWidth": 6, "blockHeight": 6, "softEdges": false },
|
||||
"keys": { "completion": [[0, 0], [1.2, 100]] }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cardFlipIn",
|
||||
"name": "Card Flip In",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "A grid of cards turns over, left to right, to show the layer.",
|
||||
"effects": [
|
||||
{
|
||||
"effect": "ec.transition.cardwipe",
|
||||
"set": { "rows": 4, "columns": 8, "flipAxis": 1, "flipOrder": 1, "transitionWidth": 70 },
|
||||
"keys": { "completion": [[0, 100], [1.4, 0]] },
|
||||
"ease": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cardFlipOut",
|
||||
"name": "Card Flip Out",
|
||||
"category": "Transitions",
|
||||
"direction": "out",
|
||||
"description": "A grid of cards turns over, left to right, and the layer is gone.",
|
||||
"effects": [
|
||||
{
|
||||
"effect": "ec.transition.cardwipe",
|
||||
"set": { "rows": 4, "columns": 8, "flipAxis": 1, "transitionWidth": 70 },
|
||||
"keys": { "completion": [[0, 0], [1.4, 100]] },
|
||||
"ease": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "gridDoorsIn",
|
||||
"name": "Grid Doors In",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "Tiles open like small doors from the centre outwards.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.ccgridwipe", "set": { "shape": 0, "tiles": 12, "reverse": true }, "keys": { "completion": [[0, 100], [1, 0]] }, "ease": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "lensPopIn",
|
||||
"name": "Lens Pop In",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "The layer grows out of a circle that stretches the picture outwards.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.ccradialscalewipe", "set": { "reverse": true }, "keys": { "completion": [[0, 100], [1, 0]] }, "ease": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "lineSweepIn",
|
||||
"name": "Line Sweep In",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "A stepped, slanted edge sweeps in from the lower left.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.cclinesweep", "set": { "direction": 90, "thickness": 30, "slant": 30 }, "keys": { "completion": [[0, 100], [1, 0]] } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "jawsCloseIn",
|
||||
"name": "Jaws Close In",
|
||||
"category": "Transitions",
|
||||
"direction": "in",
|
||||
"description": "Toothed edges close in from the top and bottom until the layer is whole.",
|
||||
"effects": [
|
||||
{ "effect": "ec.transition.ccjaws", "set": { "shape": 2, "height": 30, "width": 40 }, "keys": { "completion": [[0, 100], [1, 0]] }, "ease": true }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
asset: transitions.json
|
||||
title: EffectCraft Transitions animation presets (Fade In/Out, Wipe In from Left/Right/Top/Bottom, Diagonal Sweep In, Wipe Out to Right, Clock Reveal, Fan Open, Clock Wipe Out, Blinds Open/Close, Slats Down, Pixel Dissolve In/Out, Mosaic Fade In, Card Flip In/Out, Grid Doors In, Lens Pop In, Line Sweep In, Jaws Close In)
|
||||
author: EffectCraft contributors
|
||||
source: original work
|
||||
license: MIT OR Apache-2.0
|
||||
added: 2026-10-11 by vangel76 (Claude Code agent, #757)
|
||||
notes: Effect choices, values and key timings written from scratch for EffectCraft on its own Transition effects; not derived from any Adobe preset (.ffx) or its values.
|
||||
@@ -0,0 +1,177 @@
|
||||
//! EffectCraft's own animation preset library (Effects & Presets ▸ * Animation Presets): the
|
||||
//! Transitions category, original presets built on our Transition effects and layer opacity.
|
||||
//!
|
||||
//! Each preset adds effects (with values and keyframes) and/or keys layer properties, starting
|
||||
//! at the current time, on every selected layer. The library is data
|
||||
//! (`presets/transitions.json`), written from scratch; nothing comes from After Effects' `.ffx`
|
||||
//! presets.
|
||||
|
||||
use effectcraft_keyframe::Keyframe;
|
||||
use effectcraft_project::build::Ids;
|
||||
use effectcraft_project::{Node, Property};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::text_anim::json_value;
|
||||
use super::{CommandSpec, bad, has_layers, layer_mut, layers_p, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd, query};
|
||||
|
||||
/// The original transition presets (`presets/transitions.json`).
|
||||
pub const TRANSITIONS_JSON: &str = include_str!("../../presets/transitions.json");
|
||||
|
||||
/// Keys per animated property, so a hostile inline preset can't allocate without bound.
|
||||
const MAX_KEYS: usize = 256;
|
||||
|
||||
fn presets() -> Vec<Value> {
|
||||
serde_json::from_str::<Value>(TRANSITIONS_JSON).ok().and_then(|v| v.get("presets").and_then(Value::as_array).cloned()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn text<'a>(v: &'a Value, key: &str) -> Option<&'a str> {
|
||||
v.get(key).and_then(Value::as_str)
|
||||
}
|
||||
|
||||
/// (id, name, category) of every built-in animation preset, in library order.
|
||||
pub fn preset_names() -> Vec<(String, String, String)> {
|
||||
presets()
|
||||
.iter()
|
||||
.filter_map(|p| Some((text(p, "id")?.to_string(), text(p, "name")?.to_string(), text(p, "category").unwrap_or("Transitions").to_string())))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn find_preset(want: &str) -> Option<Value> {
|
||||
presets().into_iter().find(|x| text(x, "id") == Some(want) || text(x, "name").is_some_and(|n| n.eq_ignore_ascii_case(want)))
|
||||
}
|
||||
|
||||
/// Set `pr` to `v` (when given) and replace its keys with `keys` (`[[seconds, value], …]`,
|
||||
/// seconds from `at`). Returns false when nothing in the request fits the property.
|
||||
fn set_prop(pr: &mut Property, v: Option<&Value>, keys: Option<&Value>, at: Tick, ease: bool) -> bool {
|
||||
let mut ok = true;
|
||||
if let Some(v) = v {
|
||||
match json_value(v, &pr.value) {
|
||||
Some(val) => pr.value = val,
|
||||
None => ok = false,
|
||||
}
|
||||
}
|
||||
let Some(list) = keys else { return ok };
|
||||
let Some(list) = list.as_array() else { return false };
|
||||
let mut ks: Vec<Keyframe> = Vec::new();
|
||||
for k in list.iter().take(MAX_KEYS) {
|
||||
let t = k.get(0).and_then(Value::as_f64).filter(|t| t.is_finite() && t.abs() <= 86_400.0);
|
||||
let (Some(t), Some(val)) = (t, k.get(1).and_then(|x| json_value(x, &pr.value))) else { return false };
|
||||
let mut key = Keyframe::new(at + Tick::from_seconds_f64(t), val);
|
||||
if pr.hold_only {
|
||||
key = key.hold();
|
||||
} else if ease {
|
||||
key = key.eased();
|
||||
}
|
||||
ks.push(key);
|
||||
}
|
||||
ks.sort_by_key(|k| k.time);
|
||||
ks.dedup_by_key(|k| k.time);
|
||||
let Some(first) = ks.first() else { return false };
|
||||
pr.value = first.value.clone();
|
||||
pr.keys = ks;
|
||||
ok
|
||||
}
|
||||
|
||||
/// Apply `set` / `keys` maps to the properties under `root`; returns the paths that don't exist
|
||||
/// or don't take the given values.
|
||||
fn apply_maps(root: &mut effectcraft_project::PropGroup, spec: &Value, at: Tick) -> Vec<String> {
|
||||
let ease = spec.get("ease").and_then(Value::as_bool).unwrap_or(false);
|
||||
let empty = Map::new();
|
||||
let set = spec.get("set").and_then(Value::as_object).unwrap_or(&empty);
|
||||
let keys = spec.get("keys").and_then(Value::as_object).unwrap_or(&empty);
|
||||
let mut skipped = Vec::new();
|
||||
for (path, v) in set {
|
||||
if !root.prop_mut(path).is_some_and(|pr| set_prop(pr, Some(v), None, at, false)) {
|
||||
skipped.push(path.clone());
|
||||
}
|
||||
}
|
||||
for (path, list) in keys {
|
||||
if !root.prop_mut(path).is_some_and(|pr| !pr.static_only && set_prop(pr, None, Some(list), at, ease)) {
|
||||
skipped.push(path.clone());
|
||||
}
|
||||
}
|
||||
skipped
|
||||
}
|
||||
|
||||
/// `anim.applyBuiltinPreset`: add a built-in preset's effects and keyframes to the layers.
|
||||
fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "anim.applyBuiltinPreset";
|
||||
let want = str_p(p, "preset").ok_or_else(|| bad(C, "`preset` id or name required"))?;
|
||||
let preset = find_preset(want).ok_or_else(|| bad(C, format!("no animation preset `{want}`")))?;
|
||||
let name = text(&preset, "name").unwrap_or("Preset").to_string();
|
||||
let effects: Vec<(&'static effectcraft_effects::EffectSpec, Value)> = preset
|
||||
.get("effects")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|e| {
|
||||
let id = text(e, "effect").unwrap_or_default();
|
||||
effectcraft_effects::lookup(id).map(|spec| (spec, e.clone())).ok_or_else(|| bad(C, format!("preset `{name}` uses unknown effect `{id}`")))
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
let layer_spec = preset.get("layer").cloned();
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "select a layer"));
|
||||
}
|
||||
let sizes: Vec<_> = {
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
ids.iter().filter_map(|id| comp.layer(*id)).map(|l| (l.id, effectcraft_render::effect_bounds(&s.project, comp, l).0)).collect()
|
||||
};
|
||||
let now = s.time();
|
||||
let (added, skipped) = s.edit(&format!("Apply Animation Preset {name}"), None, |proj, _| {
|
||||
let mut added: Vec<u64> = Vec::new();
|
||||
let mut skipped: Vec<String> = Vec::new();
|
||||
for (lid, size) in &sizes {
|
||||
let mut next = proj.next_id;
|
||||
let l = layer_mut(proj, cid, *lid)?;
|
||||
let at = l.layer_time(now);
|
||||
if !effects.is_empty() {
|
||||
let fx = l.props.sub_mut("effects").ok_or_else(|| bad(C, "this layer type has no effects"))?;
|
||||
for (spec, ej) in &effects {
|
||||
let same = fx.groups().filter(|g| g.match_id == spec.id).count();
|
||||
let inst = if same == 0 { spec.name.to_string() } else { format!("{} {}", spec.name, same.saturating_add(1)) };
|
||||
let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), &inst, *size);
|
||||
skipped.extend(apply_maps(&mut g, ej, at).into_iter().map(|path| format!("{}/{path}", spec.id)));
|
||||
added.push(g.uid);
|
||||
fx.children.push(Node::Group(g));
|
||||
}
|
||||
}
|
||||
if let Some(ls) = &layer_spec {
|
||||
skipped.extend(apply_maps(&mut l.props, ls, at));
|
||||
}
|
||||
proj.next_id = next;
|
||||
}
|
||||
Ok((added, skipped))
|
||||
})?;
|
||||
Ok(json!({"preset": text(&preset, "id"), "effects": added, "skipped": skipped}))
|
||||
}
|
||||
|
||||
/// `anim.builtinPresets`: the library, optionally one category.
|
||||
fn list(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cat = str_p(p, "category");
|
||||
Ok(Value::Array(
|
||||
presets()
|
||||
.iter()
|
||||
.filter(|x| cat.is_none_or(|c| text(x, "category").is_some_and(|k| k.eq_ignore_ascii_case(c))))
|
||||
.map(|x| {
|
||||
json!({
|
||||
"id": x.get("id"),
|
||||
"name": x.get("name"),
|
||||
"category": x.get("category"),
|
||||
"direction": x.get("direction"),
|
||||
"description": x.get("description"),
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("anim.applyBuiltinPreset", "Apply Built-in Animation Preset", [], None, "{preset: id|name (see anim.builtinPresets), layers?}", has_layers, apply),
|
||||
query!("anim.builtinPresets", "Built-in Animation Presets", "{category?}", list),
|
||||
]
|
||||
}
|
||||
@@ -316,6 +316,7 @@ pub const IMPORT_FORMATS: &[&str] = &[
|
||||
"Opus",
|
||||
"glTF/GLB",
|
||||
"OBJ",
|
||||
"STL",
|
||||
"Lottie JSON",
|
||||
];
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Composition menu, part two: crop to region of interest / layer bounds, Save Frame As ▸ File,
|
||||
//! Responsive Design — Time.
|
||||
//! Copy Frame to Clipboard, Responsive Design — Time.
|
||||
|
||||
use effectcraft_project::Marker;
|
||||
use effectcraft_time::Tick;
|
||||
@@ -120,6 +120,26 @@ fn save_frame(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"path": path, "width": img.width, "height": img.height, "time": t.seconds()}))
|
||||
}
|
||||
|
||||
/// Composition ▸ Copy Frame to Clipboard (After Effects 26.3): render the frame as Save Frame As
|
||||
/// ▸ File does and hand it to the frontend, which puts it on the system clipboard.
|
||||
fn copy_frame(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let t = f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(s.time());
|
||||
let scale = f_p(p, "scale").unwrap_or(1.0).clamp(0.01, 4.0);
|
||||
let bg = comp.background;
|
||||
let img = s.render(cid, t, effectcraft_render::RenderOpts { scale, backend: effectcraft_render::Backend::Auto, ..Default::default() });
|
||||
let (width, height) = (img.width, img.height);
|
||||
let rgba = img.to_rgba8_over(bg);
|
||||
let expected = (width as usize).checked_mul(height as usize).and_then(|n| n.checked_mul(4));
|
||||
if width == 0 || height == 0 || expected != Some(rgba.len()) {
|
||||
return Err(EngineError::Other(format!("cannot copy an empty or malformed {width}×{height} frame")));
|
||||
}
|
||||
s.events.push(crate::Event::CopyImage { width, height, rgba });
|
||||
s.toast("Copied frame to the clipboard");
|
||||
Ok(json!({"width": width, "height": height, "time": t.seconds()}))
|
||||
}
|
||||
|
||||
/// Responsive Design — Time: protected comp markers for an intro, an outro or the work area.
|
||||
fn responsive_time(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
@@ -156,6 +176,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_comp,
|
||||
save_frame
|
||||
),
|
||||
cmd!("comp.copyFrame", "Copy Frame to Clipboard", ["Composition"], None, "{comp?, time?, scale?}", has_comp, copy_frame),
|
||||
cmd!("comp.responsiveTime", "Responsive Design — Time", [], None, "{op: intro|outro|workArea}", has_comp, responsive_time),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -531,6 +531,42 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!(new.iter().map(|l| l.0).collect::<Vec<_>>()))
|
||||
}
|
||||
|
||||
/// Largest SVG text `edit.pasteSvg` reads (a clipboard can hold anything).
|
||||
const MAX_SVG_PASTE: usize = 64 << 20;
|
||||
|
||||
/// `edit.pasteSvg`: SVG markup (the system clipboard's, copied from a browser, Illustrator or a
|
||||
/// design tool) as a new shape layer above the selected layer, its canvas centred in the comp:
|
||||
/// After Effects 26.3 pastes copied vector artwork as shape layers. Edit ▸ Paste runs it when
|
||||
/// the system clipboard holds SVG copied after anything copied in the app.
|
||||
fn paste_svg(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "edit.pasteSvg";
|
||||
let text = str_p(p, "svg").ok_or_else(|| super::bad(c, "missing `svg` (SVG markup)"))?;
|
||||
if text.len() > MAX_SVG_PASTE {
|
||||
return Err(super::bad(c, format!("the SVG is larger than {} MB", MAX_SVG_PASTE >> 20)));
|
||||
}
|
||||
if !crate::vector::is_svg_text(text) {
|
||||
return Err(super::bad(c, "the text is not SVG markup"));
|
||||
}
|
||||
let doc = effectcraft_svg::parse(text.as_bytes()).map_err(|e| super::bad(c, format!("cannot read the SVG: {e}")))?;
|
||||
if doc.flatten().is_empty() {
|
||||
return Err(super::bad(c, "the SVG has no shapes"));
|
||||
}
|
||||
let cid = super::comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
let name = match str_p(p, "name") {
|
||||
Some(n) => n.to_string(),
|
||||
None => {
|
||||
let count = comp.layers.iter().filter(|l| matches!(l.source, LayerSource::Shape)).count();
|
||||
comp.unique_layer_name(&format!("Shape Layer {}", count.saturating_add(1)))
|
||||
}
|
||||
};
|
||||
let id = s.edit("Paste", None, |proj, st| {
|
||||
let l = crate::vector::shapes_from_svg(proj, &comp, &doc, &name);
|
||||
super::layer::insert_layer(proj, st, cid, l)
|
||||
})?;
|
||||
Ok(json!({"layer": id.0, "skipped": doc.skipped}))
|
||||
}
|
||||
|
||||
fn split(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let t = s.time();
|
||||
@@ -818,6 +854,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_clip,
|
||||
paste
|
||||
),
|
||||
cmd!(
|
||||
"edit.pasteSvg",
|
||||
"Paste SVG as Shapes",
|
||||
[],
|
||||
None,
|
||||
"{svg: SVG markup, name?, comp?} → {layer, skipped} (Edit ▸ Paste runs it for SVG on the system clipboard)",
|
||||
has_comp,
|
||||
paste_svg
|
||||
),
|
||||
cmd!("edit.pasteReversedKeyframes", "Paste Reversed Keyframes", ["Edit"], None, "{layers?, prop?|path?, time?}", has_key_clip, paste_reversed),
|
||||
cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{layers?}", layers_or_keys, delete),
|
||||
cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+D"), "{layers?} (effects or shape items when they are selected)", has_layers, duplicate),
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
//! control channel and MCP all see them); running one emits [`crate::Event::Frontend`] and the UI
|
||||
//! performs it. Headless sessions accept and ignore them.
|
||||
|
||||
use super::{CommandSpec, always, frontend, has_comp, has_layers};
|
||||
use crate::cmd;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, frontend, has_comp, has_layers};
|
||||
use crate::{Result, Session, cmd};
|
||||
|
||||
macro_rules! fe {
|
||||
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr) => {
|
||||
@@ -116,5 +118,28 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"{} — the Project panel's selection, with the layers that use it; one undo step",
|
||||
always
|
||||
),
|
||||
// A gradient property's "Edit Gradient…" (the Timeline's Colors value): opens the
|
||||
// Gradient Editor on it at the current time. OK sets it with prop.set (one undo step).
|
||||
cmd!(
|
||||
"prop.editGradient",
|
||||
"Edit Gradient...",
|
||||
[],
|
||||
None,
|
||||
"{layer?, path|prop, comp?} — a gradient property; opens the Gradient Editor at the current time",
|
||||
has_layers,
|
||||
edit_gradient
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// `prop.editGradient`: check the target is a gradient property before the UI opens the editor,
|
||||
/// so agents get an error (not success) for a wrong one, and hand the UI the resolved ids.
|
||||
fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "prop.editGradient";
|
||||
let (cid, lid, uid) = super::prop::prop_ref(s, p, c)?;
|
||||
let pr = s.project.comp(cid).and_then(|comp| comp.layer(lid)).and_then(|l| l.props.find(uid)).ok_or_else(|| bad(c, format!("no property @{uid}")))?;
|
||||
if !matches!(pr.value, effectcraft_keyframe::Value::Gradient(_)) {
|
||||
return Err(bad(c, format!("{} is not a gradient property", pr.name)));
|
||||
}
|
||||
frontend(s, c, &json!({"comp": cid.0, "layer": lid.0, "prop": uid}))
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
mod align;
|
||||
mod align_data;
|
||||
mod anim;
|
||||
mod anim_presets;
|
||||
pub mod anim_tools;
|
||||
mod animation;
|
||||
pub(crate) mod app_more;
|
||||
@@ -162,6 +163,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(layer_menu::specs());
|
||||
v.extend(create::specs());
|
||||
v.extend(animation::specs());
|
||||
v.extend(anim_presets::specs());
|
||||
v.extend(view::specs());
|
||||
v.extend(viewer_cmds::specs());
|
||||
v.extend(shape_stroke::specs());
|
||||
@@ -221,6 +223,11 @@ pub fn text_preset_list() -> Vec<(String, String)> {
|
||||
text_anim::preset_names()
|
||||
}
|
||||
|
||||
/// (id, name, category) of the built-in animation presets (Transitions…).
|
||||
pub fn builtin_preset_list() -> Vec<(String, String, String)> {
|
||||
anim_presets::preset_names()
|
||||
}
|
||||
|
||||
pub fn find(id: &str) -> Option<&'static CommandSpec> {
|
||||
// Every command execution looks its spec up: index the ~2,000 ids once.
|
||||
static INDEX: OnceLock<std::collections::HashMap<&'static str, usize>> = OnceLock::new();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Advanced 3D: model layers (File ▸ Import of glTF/OBJ, Layer ▸ New ▸ 3D primitives), the
|
||||
//! Advanced 3D: model layers (File ▸ Import of glTF/OBJ/STL, Layer ▸ New ▸ 3D primitives), the
|
||||
//! comp renderer switch, Layer ▸ Environment Layer, Geometry Options for extruded text and
|
||||
//! shapes, and Layer ▸ Material commands.
|
||||
|
||||
@@ -376,7 +376,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"3D Model Layer",
|
||||
[],
|
||||
None,
|
||||
"{item?: id or name of a 3D model footage item, path?: a .gltf, .glb or .obj file to import, name?, time? (s), index? (1-based stack position), position? ([x, y] comp px)}; switches a Classic 3D comp to Advanced 3D",
|
||||
"{item?: id or name of a 3D model footage item, path?: a .gltf, .glb, .obj or .stl file to import, name?, time? (s), index? (1-based stack position), position? ([x, y] comp px)}; switches a Classic 3D comp to Advanced 3D",
|
||||
has_comp,
|
||||
new_model
|
||||
),
|
||||
|
||||
@@ -384,6 +384,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always,
|
||||
settings_save
|
||||
),
|
||||
cmd!(
|
||||
"script.runTasks",
|
||||
"Run Scheduled Script Tasks",
|
||||
[],
|
||||
None,
|
||||
"{} → runs the app.scheduleTask tasks that are due (frontends do it every frame) → {ran, output, errors, next (ms until the next task, or null)}",
|
||||
always,
|
||||
scriptui::run_tasks
|
||||
),
|
||||
crate::query!("scriptui.list", "List Script Windows", "{} → [{window, title, kind: dialog|palette|window|panel, script, modal, size}]", scriptui::list),
|
||||
crate::query!(
|
||||
"scriptui.get",
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
//! commands here set several of them in one undo step and add the groups to strokes saved before
|
||||
//! they existed. Also the toolbar's Fill / Stroke options on selected shape layers.
|
||||
|
||||
use effectcraft_keyframe::Value as KV;
|
||||
use effectcraft_keyframe::{Gradient, Value as KV};
|
||||
use effectcraft_project::build::{self, EXTRA_DASHES, Ids};
|
||||
use effectcraft_project::{ItemId, Layer, LayerId, LayerSource, Node, PropGroup, Uid};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::layer::color_p;
|
||||
use super::shape_tool::{self, PaintKind};
|
||||
use super::{CommandSpec, bad, f_p, has_layers, layer_mut, layer_p, layers_p, merge_p};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
@@ -224,15 +225,157 @@ pub fn first_paint(layer: &Layer, lt: Tick) -> (Option<[f64; 4]>, Option<[f64; 4
|
||||
(fill, stroke, width)
|
||||
}
|
||||
|
||||
/// Paint one contents group and its sub-groups: Fills take `fill`, Strokes `stroke`, Strokes and
|
||||
/// Gradient Strokes `width`. With `add_stroke`, a group with a path and no stroke gets one (before
|
||||
/// its fills, as new shapes have it).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn paint(c: &mut PropGroup, ids: &mut Ids, lt: Tick, fill: Option<[f64; 4]>, stroke: Option<[f64; 4]>, width: Option<f64>, add_stroke: bool, depth: usize) {
|
||||
/// What the toolbar's Fill and Stroke set on the selected shape layers.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
struct PaintEdit {
|
||||
fill: Option<[f64; 4]>,
|
||||
stroke: Option<[f64; 4]>,
|
||||
width: Option<f64>,
|
||||
/// A group with a path and no stroke gets one.
|
||||
add_stroke: bool,
|
||||
fill_kind: Option<PaintKind>,
|
||||
stroke_kind: Option<PaintKind>,
|
||||
}
|
||||
|
||||
/// The kind of the first Fill and the first Stroke in `layer`'s contents at layer time `lt`: a
|
||||
/// switched-off or missing one is None (what the toolbar shows for a selected shape layer).
|
||||
pub fn first_paint_kinds(layer: &Layer, lt: Tick) -> (PaintKind, PaintKind) {
|
||||
let (mut fill, mut stroke) = (None, None);
|
||||
let kind = |g: &PropGroup| match g.match_id.as_str() {
|
||||
_ if !g.enabled => PaintKind::None,
|
||||
"fill" | "stroke" => PaintKind::Solid,
|
||||
_ if g.get("type").is_some_and(|p| p.value_at(lt).as_enum() == 1) => PaintKind::Radial,
|
||||
_ => PaintKind::Linear,
|
||||
};
|
||||
if let Some(c) = layer.props.sub("contents") {
|
||||
walk_groups(c, &mut |g| match g.match_id.as_str() {
|
||||
"fill" | "gfill" if fill.is_none() => fill = Some(kind(g)),
|
||||
"stroke" | "gstroke" if stroke.is_none() => stroke = Some(kind(g)),
|
||||
_ => {}
|
||||
});
|
||||
}
|
||||
(fill.unwrap_or(PaintKind::None), stroke.unwrap_or(PaintKind::None))
|
||||
}
|
||||
|
||||
/// The bounds (`[x, y, w, h]`) of the path items of one contents level at layer time `lt`, where
|
||||
/// a new gradient's Start and End Points go; a 100 px square at the origin without paths.
|
||||
fn path_bounds(c: &PropGroup, lt: Tick) -> [f64; 4] {
|
||||
let mut acc: Option<[f64; 4]> = None;
|
||||
for g in c.groups() {
|
||||
let at = |id: &str| g.get(id).map(|p| p.value_at(lt));
|
||||
let b = match g.match_id.as_str() {
|
||||
"rect" | "ellipse" => {
|
||||
let [w, h] = at("size").map(|v| v.as_vec2()).unwrap_or([100.0; 2]);
|
||||
let [x, y] = at("position").map(|v| v.as_vec2()).unwrap_or([0.0; 2]);
|
||||
Some([x - w.abs() / 2.0, y - h.abs() / 2.0, x + w.abs() / 2.0, y + h.abs() / 2.0])
|
||||
}
|
||||
"star" => {
|
||||
let r = at("outerRadius").map(|v| v.as_f64().abs()).unwrap_or(100.0);
|
||||
let [x, y] = at("position").map(|v| v.as_vec2()).unwrap_or([0.0; 2]);
|
||||
Some([x - r, y - r, x + r, y + r])
|
||||
}
|
||||
"path" => {
|
||||
let v = at("path");
|
||||
let bez = v.as_ref().and_then(|v| v.as_path()).map(effectcraft_path::to_kurbo);
|
||||
bez.and_then(|b| effectcraft_path::bounds(&[b])).map(|r| [r.x0, r.y0, r.x1, r.y1])
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let Some(b) = b.filter(|b| b.iter().all(|v| v.is_finite())) else { continue };
|
||||
acc = Some(match acc {
|
||||
None => b,
|
||||
Some(a) => [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])],
|
||||
});
|
||||
}
|
||||
acc.map(|[x0, y0, x1, y1]| [x0, y0, x1 - x0, y1 - y0]).unwrap_or([-50.0, -50.0, 100.0, 100.0])
|
||||
}
|
||||
|
||||
/// Fill Options / Stroke Options on one contents level (as in After Effects, with shape layers
|
||||
/// selected they change the layers' paint): None switches its Fills and Gradient Fills (or
|
||||
/// Strokes and Gradient Strokes) off; another kind switches them on, turns them into that kind
|
||||
/// (keeping the properties both kinds have: blend mode, opacity, width, dashes…) and gives a
|
||||
/// level with paths and no such item one.
|
||||
fn set_kind(c: &mut PropGroup, ids: &mut Ids, lt: Tick, stroke: bool, kind: PaintKind, e: &PaintEdit) {
|
||||
let (solid, grad) = if stroke { ("stroke", "gstroke") } else { ("fill", "gfill") };
|
||||
let is_item = |g: &PropGroup| g.match_id == solid || g.match_id == grad;
|
||||
if kind == PaintKind::None {
|
||||
for n in &mut c.children {
|
||||
if let Node::Group(g) = n
|
||||
&& is_item(g)
|
||||
{
|
||||
g.enabled = false;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let radial = kind == PaintKind::Radial;
|
||||
let color = (if stroke { e.stroke } else { e.fill }).unwrap_or([1.0; 4]);
|
||||
let width = e.width.filter(|w| *w > 0.0).unwrap_or(2.0);
|
||||
let (start, end) = shape_tool::gradient_ends(radial, path_bounds(c, lt));
|
||||
let fresh = |ids: &mut Ids| match (kind, stroke) {
|
||||
(PaintKind::Solid, false) => build::shape_fill(ids, color),
|
||||
(PaintKind::Solid, true) => build::shape_stroke(ids, color, width),
|
||||
(_, false) => build::shape_gradient_fill(ids, radial, start, end, Gradient::default()),
|
||||
(_, true) => build::shape_gradient_stroke(ids, radial, start, end, Gradient::default(), width),
|
||||
};
|
||||
let want = if kind == PaintKind::Solid { solid } else { grad };
|
||||
let mut found = false;
|
||||
for i in 0..c.children.len() {
|
||||
let Some(Node::Group(g)) = c.children.get(i) else { continue };
|
||||
if !is_item(g) {
|
||||
continue;
|
||||
}
|
||||
found = true;
|
||||
let replacement = (g.match_id != want).then(|| {
|
||||
let mut new = fresh(ids);
|
||||
new.uid = g.uid;
|
||||
new.name = super::effect::unique_name(c, &new.name);
|
||||
for n in &mut new.children {
|
||||
if let Some(old) = g.children.iter().find(|o| o.match_id() == n.match_id()) {
|
||||
*n = old.clone();
|
||||
}
|
||||
}
|
||||
new
|
||||
});
|
||||
let Some(Node::Group(g)) = c.children.get_mut(i) else { continue };
|
||||
match replacement {
|
||||
Some(new) => *g = new,
|
||||
None => {
|
||||
g.enabled = true;
|
||||
if let Some(t) = g.get_mut("type").filter(|_| want == grad) {
|
||||
t.set_value_at(lt, KV::Enum(radial as u32));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found && c.groups().any(|g| PATHS.contains(&g.match_id.as_str())) {
|
||||
let mut new = fresh(ids);
|
||||
new.name = super::effect::unique_name(c, &new.name);
|
||||
// Paths, strokes, then fills, as new shapes have them.
|
||||
let at = if stroke {
|
||||
c.children.iter().position(|n| matches!(n.match_id(), "fill" | "gfill")).unwrap_or(c.children.len())
|
||||
} else {
|
||||
c.children.iter().rposition(|n| PATHS.contains(&n.match_id()) || matches!(n.match_id(), "stroke" | "gstroke")).map_or(c.children.len(), |i| i + 1)
|
||||
};
|
||||
c.children.insert(at, new.into());
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint one contents group and its sub-groups: the kinds first, then Fills take `fill`, Strokes
|
||||
/// `stroke`, Strokes and Gradient Strokes `width`. With `add_stroke`, a group with a path and no
|
||||
/// stroke gets one (before its fills, as new shapes have it).
|
||||
fn paint(c: &mut PropGroup, ids: &mut Ids, lt: Tick, e: &PaintEdit, depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
if add_stroke && c.groups().any(|g| PATHS.contains(&g.match_id.as_str())) && !c.groups().any(is_stroke) {
|
||||
if let Some(k) = e.stroke_kind {
|
||||
set_kind(c, ids, lt, true, k, e);
|
||||
}
|
||||
if let Some(k) = e.fill_kind {
|
||||
set_kind(c, ids, lt, false, k, e);
|
||||
}
|
||||
let (fill, stroke, width) = (e.fill, e.stroke, e.width);
|
||||
if e.add_stroke && c.groups().any(|g| PATHS.contains(&g.match_id.as_str())) && !c.groups().any(is_stroke) {
|
||||
let at = c.children.iter().position(|n| matches!(n, Node::Group(g) if matches!(g.match_id.as_str(), "fill" | "gfill"))).unwrap_or(c.children.len());
|
||||
c.children.insert(at, build::shape_stroke(ids, stroke.unwrap_or([1.0; 4]), width.unwrap_or(2.0)).into());
|
||||
}
|
||||
@@ -252,7 +395,7 @@ fn paint(c: &mut PropGroup, ids: &mut Ids, lt: Tick, fill: Option<[f64; 4]>, str
|
||||
"gstroke" => set(g, "width", width.map(KV::Scalar)),
|
||||
"group" => {
|
||||
if let Some(sub) = g.sub_mut("contents") {
|
||||
paint(sub, ids, lt, fill, stroke, width, add_stroke, depth + 1);
|
||||
paint(sub, ids, lt, e, depth + 1);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -260,10 +403,20 @@ fn paint(c: &mut PropGroup, ids: &mut Ids, lt: Tick, fill: Option<[f64; 4]>, str
|
||||
}
|
||||
}
|
||||
|
||||
/// `p`'s `fillType` / `strokeType` (none|solid|linear|radial).
|
||||
fn kind_p(p: &Value, key: &str, c: &str) -> Result<Option<PaintKind>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v.as_str().and_then(PaintKind::from_name).map(Some).ok_or_else(|| bad(c, format!("`{key}`: none|solid|linear|radial"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The toolbar's Fill and Stroke options with shape layers selected (as in After Effects, they
|
||||
/// change the layers' fills and strokes): `fill` sets every Fill's colour, `stroke` every Stroke's,
|
||||
/// `strokeWidth` every stroke's width. A stroke colour or a width above 0 gives the paths that
|
||||
/// have no stroke one. Without values, reports the first layer's paint.
|
||||
/// change the layers' fills and strokes): `fillType` / `strokeType` switch every Fill / Stroke
|
||||
/// off (none) or turn them into Solid Color, Linear or Radial Gradient ones (adding one where a
|
||||
/// path has none), `fill` sets every Fill's colour, `stroke` every Stroke's, `strokeWidth` every
|
||||
/// stroke's width. A stroke colour or a width above 0 gives the paths that have no stroke one.
|
||||
/// Without values, reports the first layer's paint.
|
||||
fn fill_stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "shape.fillStroke";
|
||||
let (cid, lids) = layers_p(s, p)?;
|
||||
@@ -275,8 +428,10 @@ fn fill_stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let rgba = |k: &str| color_p(p, k).map(|c| [c[0] as f64, c[1] as f64, c[2] as f64, 1.0]);
|
||||
let (fill, stroke) = (rgba("fill"), rgba("stroke"));
|
||||
let width = f_p(p, "strokeWidth").filter(|w| w.is_finite()).map(|w| w.max(0.0));
|
||||
if fill.is_some() || stroke.is_some() || width.is_some() {
|
||||
let add_stroke = width.map_or(stroke.is_some(), |w| w > 0.0);
|
||||
let (fill_kind, stroke_kind) = (kind_p(p, "fillType", c)?, kind_p(p, "strokeType", c)?);
|
||||
let add_stroke = stroke_kind != Some(PaintKind::None) && width.map_or(stroke.is_some(), |w| w > 0.0);
|
||||
let e = PaintEdit { fill, stroke, width, add_stroke, fill_kind, stroke_kind };
|
||||
if fill.is_some() || stroke.is_some() || width.is_some() || fill_kind.is_some() || stroke_kind.is_some() {
|
||||
let t = s.time();
|
||||
s.edit("Fill and Stroke", merge_p(p), |proj, _| {
|
||||
let mut next = proj.next_id;
|
||||
@@ -284,7 +439,7 @@ fn fill_stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let l = layer_mut(proj, cid, *lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
if let Some(contents) = l.props.sub_mut("contents") {
|
||||
paint(contents, &mut Ids(&mut next), lt, fill, stroke, width, add_stroke, 0);
|
||||
paint(contents, &mut Ids(&mut next), lt, &e, 0);
|
||||
}
|
||||
}
|
||||
proj.next_id = next;
|
||||
@@ -292,8 +447,10 @@ fn fill_stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})?;
|
||||
}
|
||||
let l = shapes.first().and_then(|id| s.project.comp(cid)?.layer(*id)).ok_or(EngineError::NoComp)?;
|
||||
let (fill, stroke, width) = first_paint(l, l.layer_time(s.time()));
|
||||
Ok(json!({"fill": fill, "stroke": stroke, "strokeWidth": width}))
|
||||
let lt = l.layer_time(s.time());
|
||||
let (fill, stroke, width) = first_paint(l, lt);
|
||||
let (fill_kind, stroke_kind) = first_paint_kinds(l, lt);
|
||||
Ok(json!({"fill": fill, "stroke": stroke, "strokeWidth": width, "fillType": fill_kind.id(), "strokeType": stroke_kind.id()}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -303,7 +460,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Fill and Stroke",
|
||||
[],
|
||||
None,
|
||||
"{layers?, fill?: color, stroke?: color, strokeWidth?, merge?} → {fill, stroke, strokeWidth} (first layer)",
|
||||
"{layers?, fillType?: none|solid|linear|radial, strokeType?, fill?: color, stroke?: color, strokeWidth?, merge?} → {fill, stroke, strokeWidth, fillType, strokeType} (first layer)",
|
||||
has_layers,
|
||||
fill_stroke
|
||||
),
|
||||
|
||||
@@ -139,12 +139,18 @@ fn blended(mut g: PropGroup, paint: &ToolPaint) -> PropGroup {
|
||||
g
|
||||
}
|
||||
|
||||
/// A gradient's Start and End Points across `bounds` (`[x, y, w, h]`): left to right, or out
|
||||
/// from the centre.
|
||||
pub(crate) fn gradient_ends(radial: bool, bounds: [f64; 4]) -> ([f64; 2], [f64; 2]) {
|
||||
let [x, y, w, h] = bounds;
|
||||
let mid = [x + w / 2.0, y + h / 2.0];
|
||||
if radial { (mid, [mid[0] + w.max(h).max(2.0) / 2.0, mid[1]]) } else { ([x, mid[1]], [x + w.max(1.0), mid[1]]) }
|
||||
}
|
||||
|
||||
/// The Stroke and Fill a new shape gets from `paint`; gradients run across `bounds` (`[x, y, w,
|
||||
/// h]` in the group's space): left to right, or out from the centre.
|
||||
pub(crate) fn paint_items(ids: &mut Ids, paint: &ShapeTool, bounds: [f64; 4]) -> Vec<PropGroup> {
|
||||
let [x, y, w, h] = bounds;
|
||||
let mid = [x + w / 2.0, y + h / 2.0];
|
||||
let ends = |radial: bool| if radial { (mid, [mid[0] + w.max(h).max(2.0) / 2.0, mid[1]]) } else { ([x, mid[1]], [x + w.max(1.0), mid[1]]) };
|
||||
let ends = |radial: bool| gradient_ends(radial, bounds);
|
||||
let mut items = vec![];
|
||||
let (s, width) = (&paint.stroke, paint.stroke_width);
|
||||
let stroke = match s.kind {
|
||||
|
||||
@@ -246,7 +246,7 @@ fn enable_per_char(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// JSON → property value shaped like `like`.
|
||||
fn json_value(v: &Value, like: &KV) -> Option<KV> {
|
||||
pub(super) fn json_value(v: &Value, like: &KV) -> Option<KV> {
|
||||
Some(match v {
|
||||
Value::Bool(b) => match like {
|
||||
KV::Bool(_) => KV::Bool(*b),
|
||||
|
||||
@@ -506,7 +506,9 @@ pub fn fonts(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use effectcraft_text::fonts;
|
||||
let added = if p.get("rescan").and_then(Value::as_bool).unwrap_or(false) { fonts::rescan_system() } else { 0 };
|
||||
let query = p.get("query").and_then(Value::as_str).map(str::to_lowercase).unwrap_or_default();
|
||||
let only_variable = p.get("variable").and_then(Value::as_bool).unwrap_or(false);
|
||||
let native = fonts::native_families();
|
||||
let variable = fonts::variable_families();
|
||||
let mut origin: std::collections::BTreeMap<String, &'static str> = Default::default();
|
||||
for f in fonts::all_faces() {
|
||||
origin.entry(f.info.family.clone()).or_insert(f.info.origin);
|
||||
@@ -514,8 +516,9 @@ pub fn fonts(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let list: Vec<Value> = effectcraft_text::families()
|
||||
.into_iter()
|
||||
.filter(|(f, _)| query.is_empty() || f.to_lowercase().contains(&query) || native.get(f).is_some_and(|n| n.to_lowercase().contains(&query)))
|
||||
.filter(|(f, _)| !only_variable || variable.contains(f))
|
||||
.map(|(f, styles)| {
|
||||
let mut v = json!({"family": f, "styles": styles, "origin": origin.get(&f).copied().unwrap_or("system")});
|
||||
let mut v = json!({"family": f, "styles": styles, "origin": origin.get(&f).copied().unwrap_or("system"), "variable": variable.contains(&f)});
|
||||
if let Some(n) = native.get(&f) {
|
||||
v["nativeName"] = json!(n);
|
||||
}
|
||||
@@ -559,7 +562,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"List Fonts",
|
||||
[],
|
||||
None,
|
||||
"{query?, rescan?} → {count, families: [{family, styles, origin: bundled|system|user, nativeName?}], added} (rescan picks up fonts installed since launch)",
|
||||
"{query?, variable?, rescan?} → {count, families: [{family, styles, origin: bundled|system|user, variable, nativeName?}], added} (variable: only variable fonts; rescan picks up fonts installed since launch)",
|
||||
always_ok,
|
||||
fonts
|
||||
),
|
||||
|
||||
@@ -210,7 +210,13 @@ mod tests {
|
||||
let mut paths: Vec<String> =
|
||||
o.project.items.values().filter_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.path.clone()) } else { None }).collect();
|
||||
paths.sort();
|
||||
assert_eq!(paths, ["/nonexistent/ec/abs.png", clip.to_str().unwrap(), "media/ec-relative-gone.png"]);
|
||||
// Windows joins the stored "media/..." onto the project's folder and leaves a path with mixed
|
||||
// separators; `Path` equality compares components, so both spellings are the same file.
|
||||
let expected = ["/nonexistent/ec/abs.png", clip.to_str().unwrap(), "media/ec-relative-gone.png"];
|
||||
assert_eq!(paths.len(), expected.len(), "{paths:?}");
|
||||
for (got, want) in paths.iter().zip(expected) {
|
||||
assert_eq!(std::path::Path::new(got), std::path::Path::new(want), "{paths:?}");
|
||||
}
|
||||
assert!(!o.is_dirty(), "finding the files is no edit");
|
||||
let r = o.execute("footage.check", json!({"wait": true})).unwrap();
|
||||
assert_eq!(r["missing"], 2);
|
||||
|
||||
@@ -350,6 +350,15 @@ pub enum Event {
|
||||
},
|
||||
/// Drop cached frames / renders (Edit ▸ Purge).
|
||||
PurgeCaches,
|
||||
/// Put an image on the system clipboard (Composition ▸ Copy Frame to Clipboard): `rgba` is
|
||||
/// `width` × `height` straight-alpha RGBA8 rows. Headless sessions ignore it; the pixels are
|
||||
/// not serialized.
|
||||
CopyImage {
|
||||
width: u32,
|
||||
height: u32,
|
||||
#[serde(skip)]
|
||||
rgba: Vec<u8>,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct Session {
|
||||
@@ -980,6 +989,7 @@ impl Session {
|
||||
self.roto_job = None;
|
||||
self.roto_pending.clear();
|
||||
self.drop_tasks();
|
||||
self.script_ui.project_epoch = self.script_ui.project_epoch.wrapping_add(1);
|
||||
p.fix_next_id();
|
||||
upgrade_effects(&mut p);
|
||||
self.project = Arc::new(p);
|
||||
@@ -1046,6 +1056,8 @@ mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_3d;
|
||||
#[cfg(test)]
|
||||
mod tests_anim_presets;
|
||||
#[cfg(test)]
|
||||
mod tests_anim_tools;
|
||||
#[cfg(test)]
|
||||
mod tests_camera_track;
|
||||
@@ -1124,6 +1136,11 @@ pub fn text_presets() -> Vec<(String, String)> {
|
||||
commands::text_preset_list()
|
||||
}
|
||||
|
||||
/// Built-in animation presets (Effects & Presets ▸ * Animation Presets): (id, name, category).
|
||||
pub fn animation_presets() -> Vec<(String, String, String)> {
|
||||
commands::builtin_preset_list()
|
||||
}
|
||||
|
||||
pub fn text_families() -> Vec<String> {
|
||||
effectcraft_text::families().into_iter().map(|(f, _)| f).collect()
|
||||
}
|
||||
@@ -1153,6 +1170,8 @@ pub struct FontRow {
|
||||
/// Settings ▸ Type ▸ Show Font Names in English is off.
|
||||
pub display: String,
|
||||
pub recent: bool,
|
||||
/// A variable font (the menu's Variable Fonts filter keeps only these).
|
||||
pub variable: bool,
|
||||
}
|
||||
|
||||
/// Outline polylines of "Sample" set in `family` at `size` px (baseline at y = 0, y down), for
|
||||
@@ -1193,14 +1212,21 @@ pub fn font_preview(family: &str, size: f64) -> Vec<Vec<[f32; 2]>> {
|
||||
}
|
||||
|
||||
/// The Character panel's font menu: the recent fonts (Settings ▸ Type ▸ Number of Recent Fonts
|
||||
/// to Display), a separator, then every family.
|
||||
/// to Display), a separator, then every family. Each row says whether the family is variable,
|
||||
/// for the menu's Variable Fonts filter.
|
||||
pub fn font_menu(prefs: &prefs::Prefs) -> Vec<FontRow> {
|
||||
let all = text_families();
|
||||
let native = if prefs.type_.font_names_in_english { Default::default() } else { effectcraft_text::fonts::native_families() };
|
||||
let row = |f: &String, recent: bool| FontRow { family: f.clone(), display: native.get(f).cloned().unwrap_or_else(|| f.clone()), recent };
|
||||
let variable = effectcraft_text::fonts::variable_families();
|
||||
let row = |f: &String, recent: bool| FontRow {
|
||||
family: f.clone(),
|
||||
display: native.get(f).cloned().unwrap_or_else(|| f.clone()),
|
||||
recent,
|
||||
variable: variable.contains(f),
|
||||
};
|
||||
let mut out: Vec<FontRow> = prefs.recent_fonts_shown().iter().filter(|f| all.contains(f)).map(|f| row(f, true)).collect();
|
||||
if !out.is_empty() {
|
||||
out.push(FontRow { family: String::new(), display: "-".into(), recent: false });
|
||||
out.push(FontRow { family: String::new(), display: "-".into(), recent: false, variable: false });
|
||||
}
|
||||
out.extend(all.iter().map(|f| row(f, false)));
|
||||
out
|
||||
|
||||
@@ -197,7 +197,7 @@ const VIDEO: &[&str] = &["mp4", "m4v", "mov", "webm", "mkv", "avi", "mxf", "gif"
|
||||
const IMAGE: &[&str] = &["png", "jpg", "jpeg", "tif", "tiff", "exr", "bmp", "webp", "psd", "psb", "svg", "tga", "dpx", "hdr"];
|
||||
const AUDIO: &[&str] = &["wav", "aif", "aiff", "mp3", "m4a", "aac", "flac", "ogg", "opus"];
|
||||
const DATA: &[&str] = &["json", "csv", "tsv", "mgjson"];
|
||||
const MODEL: &[&str] = &["gltf", "glb", "obj"];
|
||||
const MODEL: &[&str] = &["gltf", "glb", "obj", "stl"];
|
||||
|
||||
/// Kind of file from its extension.
|
||||
pub fn kind_of(path: &str) -> Option<&'static str> {
|
||||
|
||||
@@ -693,6 +693,7 @@ Composition
|
||||
File... | comp.saveFrameAs
|
||||
Photoshop Layers... | comp.saveFrameAsPsd
|
||||
ProEXR... | comp.saveFrameAsExr
|
||||
Copy Frame to Clipboard | comp.copyFrame
|
||||
Pre-render... | render.preRender
|
||||
Save Current Preview... | render.saveCurrentPreview
|
||||
---
|
||||
|
||||
@@ -35,9 +35,6 @@ pub const LANGUAGES: &[(&str, &str)] = &[
|
||||
("Čeština", "cs"),
|
||||
];
|
||||
|
||||
/// Settings ▸ Appearance ▸ UI Scale: (label, `appearance.uiScale` percent).
|
||||
pub const UI_SCALES: &[(&str, &str)] = &[("75%", "75"), ("100%", "100"), ("125%", "125"), ("150%", "150"), ("175%", "175"), ("200%", "200")];
|
||||
|
||||
/// Settings ▸ Startup & Repair ▸ Window Graphics: (label, `startup.windowGraphics` value).
|
||||
pub const WINDOW_GRAPHICS: &[(&str, &str)] = &[("Automatic", "auto"), ("OpenGL (compatibility)", "gl")];
|
||||
|
||||
@@ -1171,7 +1168,7 @@ pub fn pages() -> Vec<Page> {
|
||||
s("appearance.lightTheme", "Light Theme", Kind::Choice(LIGHT_THEMES), true),
|
||||
s("appearance.darkTheme", "Dark Theme", Kind::Choice(DARK_THEMES), true),
|
||||
s("appearance.brightness", "Brightness", Kind::Slider(-1.0, 1.0), true),
|
||||
s("appearance.uiScale", "UI Scale", Kind::Choice(UI_SCALES), true),
|
||||
s("appearance.uiScale", "UI Scale", Kind::Int(75, 200, "%"), true),
|
||||
Section("Labels and Colors"),
|
||||
s("appearance.useLabelColorForHandles", "Use Label Color for Layer Handles and Paths", B, true),
|
||||
s("appearance.useLabelColorForTabs", "Use Label Color for Related Tabs", B, true),
|
||||
|
||||
@@ -67,11 +67,11 @@ fn add_configure_render() {
|
||||
let r = s.execute("renderQueue.setOutputModule", json!({"item": id, "format": "png", "channels": "rgba"})).unwrap();
|
||||
assert!(r["outputPath"].as_str().unwrap().ends_with("_[#####].png"), "{r}");
|
||||
assert!(s.execute("renderQueue.setOutputModule", json!({"item": id, "format": "jpeg", "channels": "rgba"})).is_err());
|
||||
let r = s.execute("renderQueue.setOutput", json!({"item": id, "path": "/tmp/rq/out.mov"})).unwrap();
|
||||
let r = s.execute("renderQueue.setOutput", json!({"item": id, "path": crate::tests_stubs::tmp_out("rq/out.mov")})).unwrap();
|
||||
assert_eq!(r["output"]["format"], "ProRes", "Output To with .mov switches to ProRes");
|
||||
assert_eq!(std::path::Path::new(r["outputPath"].as_str().unwrap()), std::path::absolute("/tmp/rq/out.mov").unwrap());
|
||||
assert_eq!(std::path::Path::new(r["outputPath"].as_str().unwrap()), std::path::PathBuf::from(crate::tests_stubs::tmp_out("rq/out.mov")));
|
||||
// A second item that fails, a third that is unqueued.
|
||||
let b = s.execute("renderQueue.add", json!({"format": "gif", "output": "/tmp/rq/fail.gif"})).unwrap();
|
||||
let b = s.execute("renderQueue.add", json!({"format": "gif", "output": crate::tests_stubs::tmp_out("rq/fail.gif")})).unwrap();
|
||||
let c = s.execute("renderQueue.add", json!({"output": "/tmp/rq/skip.mp4"})).unwrap();
|
||||
s.execute("renderQueue.setRender", json!({"item": c["item"], "render": false})).unwrap();
|
||||
assert_eq!(s.project.render_queue[2].status.label(), "Unqueued");
|
||||
@@ -79,12 +79,12 @@ fn add_configure_render() {
|
||||
assert_eq!(r["items"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(
|
||||
ex.log.lock().unwrap().iter().map(std::path::PathBuf::from).collect::<Vec<_>>(),
|
||||
[std::path::absolute("/tmp/rq/out.mov").unwrap(), std::path::absolute("/tmp/rq/fail.gif").unwrap()]
|
||||
[std::path::PathBuf::from(crate::tests_stubs::tmp_out("rq/out.mov")), std::path::PathBuf::from(crate::tests_stubs::tmp_out("rq/fail.gif"))]
|
||||
);
|
||||
let q = &s.project.render_queue;
|
||||
assert_eq!(q[0].status.label(), "Done");
|
||||
assert!(q[0].started.is_some() && q[0].render_time.is_some());
|
||||
assert_eq!(std::path::Path::new(q[0].last_output.as_deref().unwrap()), std::path::absolute("/tmp/rq/out.mov").unwrap());
|
||||
assert_eq!(std::path::Path::new(q[0].last_output.as_deref().unwrap()), std::path::PathBuf::from(crate::tests_stubs::tmp_out("rq/out.mov")));
|
||||
assert_eq!(q[1].status, RenderStatus::Failed("boom".into()));
|
||||
assert_eq!(q[2].status.label(), "Unqueued");
|
||||
assert!(s.drain_events().iter().any(|e| matches!(e, Event::Toast { error: true, .. })));
|
||||
|
||||
@@ -374,6 +374,11 @@ pub struct ScriptUiEvent {
|
||||
/// (`{ok, output, error}`) and updates [`Session::script_ui`].
|
||||
pub type ScriptUiDispatch = fn(&mut Session, &ScriptUiEvent) -> std::result::Result<Value, String>;
|
||||
|
||||
/// Runs the scripts' `app.scheduleTask` tasks that are due; returns
|
||||
/// `{ran, output, errors: [{message, line, file}], next (ms until the next task is due, or null)}`
|
||||
/// and updates [`Session::script_ui`]. Frontends call it every frame (see [`ScriptUi::run_tasks`]).
|
||||
pub type ScriptTaskRunner = fn(&mut Session) -> Value;
|
||||
|
||||
/// The open script windows.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
@@ -384,6 +389,16 @@ pub struct ScriptUi {
|
||||
/// The scripting engine's event hand-off (set with [`Session::script`]).
|
||||
#[serde(skip)]
|
||||
pub dispatch: Option<ScriptUiDispatch>,
|
||||
/// The scripting engine's scheduled-task runner (set with [`Session::script`]).
|
||||
#[serde(skip)]
|
||||
pub tasks: Option<ScriptTaskRunner>,
|
||||
/// Bumped whenever the project is closed or replaced (New / Open / Close Project): repeating
|
||||
/// tasks of scripts without an open window stop then.
|
||||
#[serde(skip)]
|
||||
pub project_epoch: u64,
|
||||
/// The script contexts of this session with scheduled tasks (kept by the scripting engine).
|
||||
#[serde(skip)]
|
||||
pub task_hosts: Vec<u32>,
|
||||
}
|
||||
|
||||
impl ScriptUi {
|
||||
@@ -391,6 +406,17 @@ impl ScriptUi {
|
||||
self.windows.iter().find(|w| w.id == id)
|
||||
}
|
||||
|
||||
/// Run the scripts' due `app.scheduleTask` tasks ([`ScriptTaskRunner`]); `None` without a
|
||||
/// scripting engine.
|
||||
pub fn run_tasks(s: &mut Session) -> Option<Value> {
|
||||
let f = s.script_ui.tasks?;
|
||||
// A task may close its ScriptUI panel: forget it then.
|
||||
let open = crate::commands::scripts::open_panel_names(s);
|
||||
let r = f(s);
|
||||
crate::commands::scripts::forget_closed_panels(s, &open);
|
||||
Some(r)
|
||||
}
|
||||
|
||||
/// Replace the windows a script host published (laying them out).
|
||||
pub fn publish(&mut self, host: u32, mut windows: Vec<ScriptWindow>) {
|
||||
for w in &mut windows {
|
||||
@@ -687,6 +713,12 @@ pub(crate) fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
dispatch(s, ScriptUiEvent { window, widget, kind: kind.into(), value })
|
||||
}
|
||||
|
||||
/// `script.runTasks`: run the scripts' `app.scheduleTask` tasks that are due now (frontends do
|
||||
/// it every frame; headless hosts and agents call this).
|
||||
pub(crate) fn run_tasks(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
ScriptUi::run_tasks(s).ok_or_else(|| EngineError::Other("scripting is not available in this build".into()))
|
||||
}
|
||||
|
||||
/// `scriptui.close`: close a window (a dialog's `show()` returns `result`, default 2 = Cancel).
|
||||
pub(crate) fn close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let window = window_p(s, p, "scriptui.close")?;
|
||||
|
||||
@@ -190,6 +190,25 @@ pub fn cpu_cores() -> usize {
|
||||
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1)
|
||||
}
|
||||
|
||||
/// The graphics lines of Settings ▸ GPU Information and Help ▸ System Report: whether the GPU
|
||||
/// compositor runs (and on which adapter), else why compositing is on the CPU, and the adapter
|
||||
/// the window itself got. Without this a card the compositor couldn't use looked undetected (#596).
|
||||
pub fn gpu_summary(s: &crate::Session) -> String {
|
||||
let mut text = match s.accel.as_ref().map(|a| a.name()) {
|
||||
Some(name) => format!("Compositing: GPU, {name}.\n"),
|
||||
None => {
|
||||
let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
|
||||
let why = s.accel_note.as_deref().unwrap_or("no reason was recorded");
|
||||
format!("Compositing: CPU, {threads} threads (pure Rust, rayon).\nGPU compositing is off: {why}\n")
|
||||
}
|
||||
};
|
||||
match &s.window_adapter {
|
||||
Some(adapter) => text.push_str(&format!("Window graphics: {adapter}\n")),
|
||||
None => text.push_str("Window graphics: not known yet (the window has not drawn its first frame).\n"),
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -280,6 +280,57 @@ fn toolbar_fill_and_stroke_paint_selected_shape_layers() {
|
||||
let _ = s.render(cid, s.time(), Default::default());
|
||||
}
|
||||
|
||||
/// The toolbar's Fill Options / Stroke Options change a selected shape layer's paint kind (#732):
|
||||
/// None switches the fill off, a gradient turns the Fill into a Gradient Fill that keeps its
|
||||
/// opacity, Solid Color turns it back, and a Stroke kind adds a stroke where there is none.
|
||||
#[test]
|
||||
fn toolbar_fill_and_stroke_kinds_change_selected_shape_layers() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"width": 400, "height": 300, "duration": 2})).unwrap();
|
||||
let sh = s.execute("layer.newShape", json!({"kind": "rect", "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let items = |s: &Session| -> Vec<(String, bool)> {
|
||||
let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(sh)).unwrap();
|
||||
l.props.group("contents/group/contents").unwrap().groups().map(|g| (g.match_id.clone(), g.enabled)).collect()
|
||||
};
|
||||
let kinds = |s: &mut Session| {
|
||||
let p = s.execute("shape.fillStroke", json!({})).unwrap();
|
||||
(p["fillType"].as_str().unwrap().to_string(), p["strokeType"].as_str().unwrap().to_string())
|
||||
};
|
||||
assert_eq!(kinds(&mut s), ("solid".into(), "none".into()));
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let centre = |s: &Session| s.render(cid, s.time(), Default::default()).get(200, 150);
|
||||
let red = centre(&s);
|
||||
// None switches the fill off: nothing is drawn, and the item stays.
|
||||
s.execute("shape.fillStroke", json!({"fillType": "none"})).unwrap();
|
||||
assert_eq!(items(&s), [("rect".into(), true), ("fill".into(), false)]);
|
||||
assert_eq!(kinds(&mut s).0, "none");
|
||||
assert_ne!(centre(&s), red, "the fill is off");
|
||||
// A radial gradient: the Fill becomes a Gradient Fill (switched on) that keeps its opacity.
|
||||
s.execute("shape.fillStroke", json!({"fillType": "solid", "fill": "#ff0000"})).unwrap();
|
||||
assert_eq!(centre(&s), red);
|
||||
let opacity = |s: &Session, m: &str| {
|
||||
let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(sh)).unwrap();
|
||||
l.props.group(&format!("contents/group/contents/{m}")).unwrap().get("opacity").unwrap().value.as_f64()
|
||||
};
|
||||
s.execute("prop.set", json!({"layer": sh, "path": "contents/group/contents/fill/opacity", "value": 40})).unwrap();
|
||||
s.execute("shape.fillStroke", json!({"fillType": "radial"})).unwrap();
|
||||
assert_eq!(items(&s), [("rect".into(), true), ("gfill".into(), true)]);
|
||||
assert_eq!((kinds(&mut s).0.as_str(), opacity(&s, "gfill")), ("radial", 40.0));
|
||||
s.execute("shape.fillStroke", json!({"fillType": "linear"})).unwrap();
|
||||
assert_eq!(kinds(&mut s).0, "linear");
|
||||
s.execute("shape.fillStroke", json!({"fillType": "solid", "fill": "#00ff00"})).unwrap();
|
||||
assert_eq!(items(&s), [("rect".into(), true), ("fill".into(), true)]);
|
||||
assert_eq!(opacity(&s, "fill"), 40.0);
|
||||
// A Stroke kind adds a stroke (before the fill); None switches it off again; undo is per step.
|
||||
s.execute("shape.fillStroke", json!({"strokeType": "linear"})).unwrap();
|
||||
assert_eq!(items(&s), [("rect".into(), true), ("gstroke".into(), true), ("fill".into(), true)]);
|
||||
s.execute("shape.fillStroke", json!({"strokeType": "none"})).unwrap();
|
||||
assert_eq!(kinds(&mut s), ("solid".into(), "none".into()));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(kinds(&mut s), ("solid".into(), "linear".into()));
|
||||
assert!(s.execute("shape.fillStroke", json!({"fillType": "plaid"})).is_err());
|
||||
}
|
||||
|
||||
/// Selecting a mask in the Timeline (its row or its Mask Path) selects all of its points, so the
|
||||
/// viewer drags the whole mask (#203); a shape's Path item works the same way.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Built-in animation presets (Effects & Presets ▸ * Animation Presets ▸ Transitions).
|
||||
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::render::RenderOpts;
|
||||
|
||||
/// A 160×90 comp with one white full-frame solid; returns (session, solid).
|
||||
fn session() -> (Session, u64) {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "T", "width": 160, "height": 90, "frameRate": 30, "duration": 5})).unwrap();
|
||||
let solid = s.execute("layer.newSolid", json!({"name": "S", "color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
|
||||
(s, solid)
|
||||
}
|
||||
|
||||
/// Share of the comp's pixels that are mostly opaque at `secs`.
|
||||
fn coverage(s: &Session, secs: f64) -> f64 {
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let img = s.render(cid, Tick::from_seconds_f64(secs), RenderOpts { scale: 1.0, ..Default::default() });
|
||||
img.data.iter().filter(|p| p[3] > 0.5).count() as f64 / img.data.len().max(1) as f64
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_transition_preset_applies_and_transitions() {
|
||||
let (mut s, solid) = session();
|
||||
let list = s.execute("anim.builtinPresets", json!({"category": "Transitions"})).unwrap();
|
||||
let list = list.as_array().unwrap().clone();
|
||||
assert!(list.len() >= 20, "{}", list.len());
|
||||
assert_eq!(crate::animation_presets().len(), list.len());
|
||||
let start = 0.5;
|
||||
for p in &list {
|
||||
let id = p["id"].as_str().unwrap();
|
||||
let incoming = match p["direction"].as_str() {
|
||||
Some("in") => true,
|
||||
Some("out") => false,
|
||||
d => panic!("{id}: direction {d:?}"),
|
||||
};
|
||||
s.execute("time.set", json!({"time": start})).unwrap();
|
||||
let r = s.execute("anim.applyBuiltinPreset", json!({"layers": [solid], "preset": id})).unwrap();
|
||||
assert_eq!(r["skipped"], json!([]), "{id}: every path in the preset exists and takes its values");
|
||||
let (a, b) = (coverage(&s, start), coverage(&s, start + 2.0));
|
||||
let (hidden, shown) = if incoming { (a, b) } else { (b, a) };
|
||||
assert!(hidden < 0.15, "{id}: hidden end covers {hidden:.3}");
|
||||
assert!(shown > 0.97, "{id}: shown end covers {shown:.3}");
|
||||
// Keys start at the current time: before it the layer holds the first key's state.
|
||||
assert!((coverage(&s, 0.0) - a).abs() < 0.02, "{id}");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let l = &s.active_comp().unwrap().layers[0];
|
||||
assert_eq!(l.effects().map_or(0, |fx| fx.groups().count()), 0, "{id}: undo removes the effects");
|
||||
assert!(l.props.prop("transform/opacity").unwrap().keys.is_empty(), "{id}: undo removes the keys");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transition_preset_by_name_and_bad_input() {
|
||||
let (mut s, solid) = session();
|
||||
// By name; a second application numbers the effect like Effect ▸ apply does.
|
||||
s.execute("anim.applyBuiltinPreset", json!({"layers": [solid], "preset": "Wipe In from Left"})).unwrap();
|
||||
let r = s.execute("anim.applyBuiltinPreset", json!({"layers": [solid], "preset": "wipeInFromLeft"})).unwrap();
|
||||
assert_eq!(r["effects"].as_array().unwrap().len(), 1);
|
||||
let names: Vec<String> = s.active_comp().unwrap().layers[0].effects().unwrap().groups().map(|g| g.name.clone()).collect();
|
||||
assert_eq!(names, ["Linear Wipe", "Linear Wipe 2"]);
|
||||
let fx = s.active_comp().unwrap().layers[0].effects().unwrap().groups().next().unwrap().clone();
|
||||
let completion = fx.prop("completion").unwrap();
|
||||
assert_eq!(completion.keys.len(), 2);
|
||||
assert_eq!(completion.keys[0].time, Tick::ZERO);
|
||||
// Unknown presets and missing params are errors, not panics.
|
||||
assert!(s.execute("anim.applyBuiltinPreset", json!({"layers": [solid], "preset": "nope"})).is_err());
|
||||
assert!(s.execute("anim.applyBuiltinPreset", json!({"layers": [solid]})).is_err());
|
||||
// A layer without effects (a camera) refuses effect presets.
|
||||
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
assert!(s.execute("anim.applyBuiltinPreset", json!({"layers": [cam], "preset": "clockReveal"})).is_err());
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Composition ▸ Save Frame As ▸ Photoshop Layers… / ProEXR….
|
||||
//! Composition ▸ Save Frame As ▸ Photoshop Layers… / ProEXR…, and Copy Frame to Clipboard.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
@@ -121,3 +121,33 @@ fn proexr_values_at_or_below_one_are_unchanged() {
|
||||
assert!((got[0] - 1.0).abs() < 1e-4 && (got[1] - 0.1329).abs() < 1e-3 && got[2].abs() < 1e-6, "{got:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Composition ▸ Copy Frame to Clipboard (After Effects 26.3): the frame, as Save Frame As
|
||||
/// renders it, goes to the frontend as an image for the system clipboard.
|
||||
#[test]
|
||||
fn copy_frame_hands_the_rendered_frame_to_the_frontend() {
|
||||
let mut s = frame();
|
||||
s.drain_events();
|
||||
let r = s.execute("comp.copyFrame", json!({})).unwrap();
|
||||
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(32), Some(32)));
|
||||
let events = s.drain_events();
|
||||
let Some((width, height, rgba)) = events.iter().find_map(|e| match e {
|
||||
crate::Event::CopyImage { width, height, rgba } => Some((*width, *height, rgba)),
|
||||
_ => None,
|
||||
}) else {
|
||||
panic!("no CopyImage event in {events:?}")
|
||||
};
|
||||
assert_eq!((width, height, rgba.len()), (32, 32, 32 * 32 * 4));
|
||||
// Red in the corner; red × (50% blue multiply) in the middle; opaque everywhere.
|
||||
assert_eq!(&rgba[..4], &[255, 0, 0, 255]);
|
||||
let mid = &rgba[(16 * 32 + 16) * 4..][..4];
|
||||
assert!(mid[0] > 100 && mid[0] < 160 && mid[2] < 13 && mid[3] == 255, "{mid:?}");
|
||||
assert!(rgba.chunks(4).all(|p| p[3] == 255));
|
||||
// The pixels are not serialized into the event stream.
|
||||
let json = serde_json::to_value(events.iter().find(|e| matches!(e, crate::Event::CopyImage { .. })).unwrap()).unwrap();
|
||||
assert!(json["CopyImage"].get("rgba").is_none(), "{json}");
|
||||
// Scaled, and refused without a composition.
|
||||
assert_eq!(s.execute("comp.copyFrame", json!({"scale": 0.5})).unwrap()["width"], 16);
|
||||
assert!(Session::default().execute("comp.copyFrame", json!({})).is_err());
|
||||
assert!(crate::menus::entries().iter().any(|(path, e)| e.command == "comp.copyFrame" && path.first().map(String::as_str) == Some("Composition")));
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ fn prefs_set_validates_keys_types_and_ranges() {
|
||||
}
|
||||
|
||||
/// Settings ▸ Appearance ▸ UI Scale (#284): a percentage, 100 by default, kept within 75–200;
|
||||
/// the dialog's choices set it as a number.
|
||||
/// the dialog types or drags any whole percentage in that range (#682).
|
||||
#[test]
|
||||
fn ui_scale_is_a_percentage_from_75_to_200() {
|
||||
let mut p = Prefs::default();
|
||||
@@ -101,10 +101,8 @@ fn ui_scale_is_a_percentage_from_75_to_200() {
|
||||
p.set("appearance.uiScale", json!(10)).unwrap();
|
||||
assert_eq!(p.appearance.ui_scale, 75);
|
||||
let page = crate::prefs::pages().into_iter().find(|pg| pg.id == "appearance").unwrap();
|
||||
let listed = page.items.iter().any(|i| {
|
||||
matches!(i, crate::prefs::Item::Setting { key: "appearance.uiScale", kind: crate::prefs::Kind::Choice(c), live: true, .. } if c.contains(&("200%", "200")))
|
||||
});
|
||||
assert!(listed, "Settings ▸ Appearance offers it");
|
||||
let field = crate::prefs::Item::Setting { key: "appearance.uiScale", label: "UI Scale", kind: crate::prefs::Kind::Int(75, 200, "%"), live: true };
|
||||
assert!(page.items.contains(&field), "Settings ▸ Appearance offers it");
|
||||
// Older settings files without it get the default.
|
||||
assert_eq!(Prefs::from_json(r#"{"version": 2, "appearance": {"theme": "light"}}"#).appearance.ui_scale, 100);
|
||||
}
|
||||
@@ -658,3 +656,44 @@ fn render_backend_says_why_it_renders_on_the_cpu() {
|
||||
assert_eq!(why(&mut s), json!("headless: renders on the CPU unless started with --gpu"));
|
||||
assert_eq!(s.execute("render.backend", json!({"backend": "cpu"})).unwrap()["why"], json!("the project's renderer is Mercury Software Only"));
|
||||
}
|
||||
|
||||
/// Settings ▸ GPU Information names what the GPU compositor runs on, or why it is off, and the
|
||||
/// adapter the window got, so a card the compositor couldn't use doesn't look undetected (#596).
|
||||
#[test]
|
||||
fn gpu_summary_names_the_adapter_or_says_why_there_is_none() {
|
||||
use crate::sysinfo::gpu_summary;
|
||||
use effectcraft_render::{Accelerator, FxStep, Renderer};
|
||||
|
||||
struct Named;
|
||||
impl Accelerator for Named {
|
||||
fn name(&self) -> String {
|
||||
"NVIDIA GeForce RTX 4070 (Vulkan)".into()
|
||||
}
|
||||
fn comp_frame(&self, _: &Renderer, _: crate::project::ItemId, _: effectcraft_time::Tick) -> Option<effectcraft_raster::Image> {
|
||||
None
|
||||
}
|
||||
fn supports_effect(&self, _: &str) -> bool {
|
||||
false
|
||||
}
|
||||
fn effects(&self, _: &[FxStep], _: &effectcraft_effects::Buf, _: Option<f32>) -> Option<effectcraft_effects::Buf> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
let mut s = Session::default();
|
||||
let off = gpu_summary(&s);
|
||||
assert!(off.contains("Compositing: CPU"), "{off}");
|
||||
assert!(off.contains("GPU compositing is off: no reason was recorded"), "{off}");
|
||||
assert!(off.contains("Window graphics: not known yet"), "{off}");
|
||||
|
||||
s.accel_note = Some("GPU compositor unavailable: no compute shaders".into());
|
||||
s.window_adapter = Some("NVIDIA GeForce RTX 4070 (Dx12, DiscreteGpu)".into());
|
||||
let declined = gpu_summary(&s);
|
||||
assert!(declined.contains("GPU compositing is off: GPU compositor unavailable: no compute shaders"), "{declined}");
|
||||
assert!(declined.contains("Window graphics: NVIDIA GeForce RTX 4070 (Dx12, DiscreteGpu)"), "the card the window got is listed: {declined}");
|
||||
|
||||
s.accel = Some(std::sync::Arc::new(Named));
|
||||
let on = gpu_summary(&s);
|
||||
assert!(on.starts_with("Compositing: GPU, NVIDIA GeForce RTX 4070 (Vulkan)."), "{on}");
|
||||
assert!(!on.contains("compositing is off"), "{on}");
|
||||
}
|
||||
|
||||
@@ -270,18 +270,26 @@ fn rq() -> (Session, Arc<MockExporter>) {
|
||||
(s, ex)
|
||||
}
|
||||
|
||||
/// An output path that is absolute on every platform. `/tmp/...` has no drive letter on Windows,
|
||||
/// where it means "the current drive" to the OS but resolves against the home folder's drive in
|
||||
/// the render queue, so a test cannot know which drive to expect.
|
||||
pub(crate) fn tmp_out(rel: &str) -> String {
|
||||
let base = std::env::temp_dir().join("effectcraft-rq-tests");
|
||||
rel.split('/').fold(base, |p, part| p.join(part)).to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_output_module_encodes_twice() {
|
||||
let (mut s, ex) = rq();
|
||||
assert!(!s.is_enabled("render.addOutputModule"));
|
||||
s.execute("renderQueue.add", json!({"output": "/tmp/om/main.mp4"})).unwrap();
|
||||
s.execute("renderQueue.add", json!({"output": tmp_out("om/main.mp4")})).unwrap();
|
||||
let r = s.execute("render.addOutputModule", json!({"format": "png"})).unwrap();
|
||||
assert_eq!(r["module"], 2);
|
||||
assert_eq!(r["outputModules"], 2);
|
||||
let extra = r["extraOutputs"][0]["outputPath"].as_str().unwrap().to_string();
|
||||
assert!(extra.ends_with(".png"), "{extra}");
|
||||
// Module 2 is editable on its own.
|
||||
s.execute("renderQueue.setOutput", json!({"index": 1, "module": 2, "path": "/tmp/om/second.mov"})).unwrap();
|
||||
s.execute("renderQueue.setOutput", json!({"index": 1, "module": 2, "path": tmp_out("om/second.mov")})).unwrap();
|
||||
assert!(s.execute("renderQueue.setOutput", json!({"index": 1, "module": 3, "path": "/tmp/x.mov"})).is_err());
|
||||
s.execute("renderQueue.render", json!({})).unwrap();
|
||||
assert_eq!(
|
||||
@@ -294,10 +302,10 @@ fn add_output_module_encodes_twice() {
|
||||
(format.to_string(), std::path::PathBuf::from(path))
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
[("H264".to_string(), std::path::absolute("/tmp/om/main.mp4").unwrap()), ("ProRes".to_string(), std::path::absolute("/tmp/om/second.mov").unwrap())]
|
||||
[("H264".to_string(), std::path::PathBuf::from(tmp_out("om/main.mp4"))), ("ProRes".to_string(), std::path::PathBuf::from(tmp_out("om/second.mov")))]
|
||||
);
|
||||
assert_eq!(s.project.render_queue[0].status.label(), "Done");
|
||||
assert_eq!(std::path::Path::new(s.project.render_queue[0].last_output.as_deref().unwrap()), std::path::absolute("/tmp/om/main.mp4").unwrap());
|
||||
assert_eq!(std::path::Path::new(s.project.render_queue[0].last_output.as_deref().unwrap()), std::path::PathBuf::from(tmp_out("om/main.mp4")));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.render_queue[0].extra_outputs.len(), 1, "undo the Output To edit");
|
||||
}
|
||||
@@ -311,7 +319,7 @@ fn pre_render_imports_and_replaces() {
|
||||
let outer = s.active_comp_id().unwrap();
|
||||
s.execute("layer.addItem", json!({"item": inner.0})).unwrap();
|
||||
s.open_comp(inner);
|
||||
let r = s.execute("render.preRender", json!({"output": "/tmp/pre/inner.mov"})).unwrap();
|
||||
let r = s.execute("render.preRender", json!({"output": tmp_out("pre/inner.mov")})).unwrap();
|
||||
assert_eq!(r["postRenderAction"], "Import & Replace Usage");
|
||||
assert_eq!(s.project.render_queue[0].post_render, PostRenderAction::ImportAndReplace);
|
||||
s.execute("renderQueue.render", json!({})).unwrap();
|
||||
@@ -319,7 +327,7 @@ fn pre_render_imports_and_replaces() {
|
||||
let l = &s.project.comp(outer).unwrap().layers[0];
|
||||
let LayerSource::Footage { item } = l.source else { panic!("not replaced: {:?}", l.source) };
|
||||
assert!(
|
||||
matches!(&s.project.item(item).unwrap().kind, effectcraft_project::ItemKind::Footage(f) if std::path::Path::new(&f.path) == std::path::absolute("/tmp/pre/inner.mov").unwrap())
|
||||
matches!(&s.project.item(item).unwrap().kind, effectcraft_project::ItemKind::Footage(f) if std::path::Path::new(&f.path) == std::path::PathBuf::from(tmp_out("pre/inner.mov")))
|
||||
);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(matches!(s.project.comp(outer).unwrap().layers[0].source, LayerSource::Comp { .. }));
|
||||
@@ -331,23 +339,23 @@ fn post_render_actions_import_and_set_proxy() {
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let items_before = s.project.items.len();
|
||||
// Import: the rendered file becomes a footage item.
|
||||
let a = s.execute("renderQueue.add", json!({"output": "/tmp/post/a.mov"})).unwrap();
|
||||
let a = s.execute("renderQueue.add", json!({"output": tmp_out("post/a.mov")})).unwrap();
|
||||
s.execute("renderQueue.setOutputModule", json!({"item": a["item"], "postRenderAction": "import"})).unwrap();
|
||||
assert_eq!(s.project.render_queue[0].post_render, PostRenderAction::Import);
|
||||
s.execute("renderQueue.render", json!({})).unwrap();
|
||||
assert_eq!(s.project.items.len(), items_before + 1);
|
||||
assert!(s.project.items.values().any(
|
||||
|i| matches!(&i.kind, effectcraft_project::ItemKind::Footage(f) if std::path::Path::new(&f.path) == std::path::absolute("/tmp/post/a.mov").unwrap())
|
||||
|i| matches!(&i.kind, effectcraft_project::ItemKind::Footage(f) if std::path::Path::new(&f.path) == std::path::PathBuf::from(tmp_out("post/a.mov")))
|
||||
));
|
||||
// Set Proxy: the comp gets the render as its proxy.
|
||||
let b = s.execute("renderQueue.add", json!({"output": "/tmp/post/b.mov"})).unwrap();
|
||||
let b = s.execute("renderQueue.add", json!({"output": tmp_out("post/b.mov")})).unwrap();
|
||||
s.execute("renderQueue.setOutputModule", json!({"item": b["item"], "postRenderAction": "Set Proxy"})).unwrap();
|
||||
s.execute("renderQueue.render", json!({})).unwrap();
|
||||
let px = s.project.item(cid).unwrap().proxy.clone().expect("proxy set");
|
||||
assert_eq!(std::path::Path::new(&px.footage.path), std::path::absolute("/tmp/post/b.mov").unwrap());
|
||||
assert_eq!(std::path::Path::new(&px.footage.path), std::path::PathBuf::from(tmp_out("post/b.mov")));
|
||||
// None: nothing happens.
|
||||
let n = s.project.items.len();
|
||||
let c = s.execute("renderQueue.add", json!({"output": "/tmp/post/c.mov"})).unwrap();
|
||||
let c = s.execute("renderQueue.add", json!({"output": tmp_out("post/c.mov")})).unwrap();
|
||||
s.execute("renderQueue.setOutputModule", json!({"item": c["item"], "postRenderAction": "none"})).unwrap();
|
||||
s.execute("renderQueue.render", json!({})).unwrap();
|
||||
assert_eq!(s.project.items.len(), n);
|
||||
@@ -426,9 +434,9 @@ fn save_frame_as_queues_with_the_frame_defaults() {
|
||||
#[test]
|
||||
fn log_notify_and_overflow_settings() {
|
||||
let (mut s, _) = rq();
|
||||
let a = s.execute("renderQueue.add", json!({"output": "/tmp/n/a.mp4", "log": "plusPerFrameInfo"})).unwrap();
|
||||
let a = s.execute("renderQueue.add", json!({"output": tmp_out("n/a.mp4"), "log": "plusPerFrameInfo"})).unwrap();
|
||||
assert_eq!(a["logLabel"], "Plus Per Frame Info");
|
||||
assert_eq!(std::path::Path::new(a["logPath"].as_str().unwrap()), std::path::absolute("/tmp/n/a_RenderLog.txt").unwrap());
|
||||
assert_eq!(std::path::Path::new(a["logPath"].as_str().unwrap()), std::path::PathBuf::from(tmp_out("n/a_RenderLog.txt")));
|
||||
s.execute("renderQueue.setLog", json!({"item": a["item"], "log": "plusSettings"})).unwrap();
|
||||
assert_eq!(s.project.render_queue[0].log, effectcraft_project::render_queue::RenderLog::PlusSettings);
|
||||
s.execute("renderQueue.setOverflowFolders", json!({"folders": ["/tmp/o1", " ", "/tmp/o2"]})).unwrap();
|
||||
|
||||
@@ -260,6 +260,63 @@ fn fonts_lists_bundled_and_installed_families() {
|
||||
assert_eq!(crate::font_styles("No Such Family").len(), 5);
|
||||
}
|
||||
|
||||
/// A font file of just `name` (the family, Regular) and an `fvar` table with one axis pinned at
|
||||
/// 400 (so the helpers that look for an installed font with a real axis range never pick it).
|
||||
fn pinned_variable_font(family: &str) -> Vec<u8> {
|
||||
let u16s = |v: &[u16]| -> Vec<u8> { v.iter().flat_map(|x| x.to_be_bytes()).collect() };
|
||||
let strings: Vec<Vec<u8>> = [family, "Regular"].iter().map(|s| s.encode_utf16().flat_map(|u| u.to_be_bytes()).collect()).collect();
|
||||
let mut name = u16s(&[0, 2, 30]);
|
||||
let mut off = 0;
|
||||
for (id, st) in strings.iter().enumerate() {
|
||||
name.extend(u16s(&[3, 1, 0x409, id as u16 + 1, st.len() as u16, off]));
|
||||
off += st.len() as u16;
|
||||
}
|
||||
strings.iter().for_each(|st| name.extend(st));
|
||||
let mut fvar = u16s(&[1, 0, 16, 2, 1, 20, 0, 8]);
|
||||
fvar.extend(b"wght".iter().copied().chain(u16s(&[0x190, 0, 0x190, 0, 0x190, 0, 0, 256])));
|
||||
let mut out = u16s(&[1, 0, 2, 0, 0, 0]);
|
||||
let mut body: Vec<u8> = vec![];
|
||||
for (tag, data) in [(b"fvar", fvar), (b"name", name)] {
|
||||
out.extend(tag);
|
||||
out.extend([0u8; 4]);
|
||||
out.extend(((12 + 32 + body.len()) as u32).to_be_bytes());
|
||||
out.extend((data.len() as u32).to_be_bytes());
|
||||
body.extend(data);
|
||||
}
|
||||
out.extend(body);
|
||||
out
|
||||
}
|
||||
|
||||
/// The font menu's Variable Fonts filter (After Effects 26.3): `text.fonts {variable: true}`
|
||||
/// lists only families with variation axes, every listing says which families are variable, and
|
||||
/// the font menu's rows carry the flag the filter uses.
|
||||
#[test]
|
||||
fn fonts_filter_variable_families() {
|
||||
const FAMILY: &str = "EffectCraft Variable Filter Test";
|
||||
assert_eq!(effectcraft_text::fonts::add_font_data(pinned_variable_font(FAMILY)).len(), 1);
|
||||
let mut s = Session::default();
|
||||
let all = s.execute("text.fonts", json!({})).unwrap();
|
||||
let fams = all["families"].as_array().unwrap();
|
||||
let flag = |name: &str| fams.iter().find(|f| f["family"] == name).map(|f| f["variable"].clone());
|
||||
assert_eq!(flag("Inter"), Some(json!(false)), "the bundled fonts are static");
|
||||
assert_eq!(flag(FAMILY), Some(json!(true)));
|
||||
let var = s.execute("text.fonts", json!({"variable": true})).unwrap();
|
||||
let listed = var["families"].as_array().unwrap();
|
||||
assert_eq!(var["count"].as_u64(), Some(listed.len() as u64));
|
||||
assert!(listed.iter().all(|f| f["variable"] == true), "{var}");
|
||||
assert!(listed.iter().any(|f| f["family"] == FAMILY), "{var}");
|
||||
assert!(listed.len() < fams.len(), "the filter drops the static families");
|
||||
// Combined with the name query.
|
||||
let both = s.execute("text.fonts", json!({"variable": true, "query": "variable filter"})).unwrap();
|
||||
assert_eq!(both["families"].as_array().map(Vec::len), Some(1), "{both}");
|
||||
let none = s.execute("text.fonts", json!({"variable": true, "query": "jetbrains mono"})).unwrap();
|
||||
assert!(!none["families"].as_array().unwrap().iter().any(|f| f["family"] == "JetBrains Mono"), "{none}");
|
||||
// The font menu's rows.
|
||||
let menu = crate::font_menu(&s.prefs);
|
||||
assert!(menu.iter().any(|r| r.family == FAMILY && r.variable));
|
||||
assert!(menu.iter().any(|r| r.family == "Inter" && !r.variable));
|
||||
}
|
||||
|
||||
/// `layer.setText`'s schema lists every OpenType key as optional, so checked execution (control
|
||||
/// channel, MCP) accepts them and requires none (#520).
|
||||
#[test]
|
||||
|
||||
@@ -235,3 +235,51 @@ fn create_masks_from_text() {
|
||||
assert!(s.execute_checked("layer.create", json!({"op": "shapesFromVector", "layers": [lid]})).is_err());
|
||||
assert!(s.execute_checked("layer.create", json!({"op": "nullTracing", "layers": [lid]})).is_err());
|
||||
}
|
||||
|
||||
/// Edit ▸ Paste of SVG markup (After Effects 26.3): a shape layer with the SVG's shapes, its
|
||||
/// canvas centred in the comp, above the selected layer, one undo step.
|
||||
#[test]
|
||||
fn paste_svg_markup_as_a_shape_layer() {
|
||||
let (mut s, lid) = text_session();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
s.execute("layer.select", json!({"layers": [lid]})).unwrap();
|
||||
let svg = r##"<?xml version="1.0"?>
|
||||
<!-- copied from a design tool -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20" viewBox="0 0 40 20">
|
||||
<rect x="0" y="0" width="20" height="20" fill="#ff0000"/>
|
||||
<rect x="20" y="0" width="20" height="20" fill="#0000ff"/>
|
||||
</svg>"##;
|
||||
let r = s.execute_checked("edit.pasteSvg", json!({"svg": svg})).unwrap();
|
||||
let sid = r["layer"].as_u64().unwrap();
|
||||
let c = s.active_comp().unwrap();
|
||||
assert_eq!(c.layers.len(), 2);
|
||||
assert_eq!(c.layers[0].id.0, sid, "above the selected layer");
|
||||
assert!(matches!(c.layers[0].source, LayerSource::Shape));
|
||||
assert_eq!(c.layers[0].name, "Shape Layer 1");
|
||||
assert_eq!(s.state.selected_layers, [effectcraft_project::LayerId(sid)]);
|
||||
assert_eq!(c.layers[0].props.sub("contents").unwrap().groups().count(), 2);
|
||||
// Hide the text so only the pasted shapes draw: the 40×20 canvas is centred in the
|
||||
// 320×120 comp (x 140–180, y 50–70), red on the left, blue on the right.
|
||||
s.execute("layer.setSwitch", json!({"layers": [lid], "switch": "video", "value": false})).unwrap();
|
||||
let img = s.render(cid, s.time(), RenderOpts::default());
|
||||
let px = |x: usize, y: usize| img.data[y * img.width as usize + x];
|
||||
let red = px(150, 60);
|
||||
assert!(red[3] > 0.99 && red[0] > 0.99 && red[2] < 0.01, "{red:?}");
|
||||
let blue = px(170, 60);
|
||||
assert!(blue[3] > 0.99 && blue[2] > 0.99 && blue[0] < 0.01, "{blue:?}");
|
||||
assert!(px(130, 60)[3] < 0.01 && px(160, 45)[3] < 0.01, "nothing outside the canvas");
|
||||
// One undo step removes it.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().layers.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_svg_rejects_text_that_is_not_usable_svg() {
|
||||
let (mut s, _) = text_session();
|
||||
for bad in ["hello", "<svg", "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"10\" height=\"10\"></svg>", "<html><svg></html>"] {
|
||||
assert!(s.execute_checked("edit.pasteSvg", json!({"svg": bad})).is_err(), "{bad}");
|
||||
}
|
||||
assert!(s.execute_checked("edit.pasteSvg", json!({})).is_err());
|
||||
assert_eq!(s.active_comp().unwrap().layers.len(), 1, "nothing pasted");
|
||||
}
|
||||
|
||||
@@ -250,6 +250,12 @@ pub fn vector_clip_masks(doc: &Doc) -> (Vec<BezPath>, Doc) {
|
||||
(masks, out)
|
||||
}
|
||||
|
||||
/// Whether text (the system clipboard's, say) is SVG markup: Edit ▸ Paste makes it a shape
|
||||
/// layer (`edit.pasteSvg`).
|
||||
pub fn is_svg_text(text: &str) -> bool {
|
||||
effectcraft_svg::looks_like_svg(text.as_bytes())
|
||||
}
|
||||
|
||||
/// Whether footage is a vector file (SVG / PDF / AI / EPS).
|
||||
pub fn is_vector(f: &effectcraft_project::Footage) -> bool {
|
||||
effectcraft_render::is_vector_footage(f) || f.path.to_ascii_lowercase().ends_with(".svg")
|
||||
@@ -471,6 +477,32 @@ pub fn image_layer(proj: &mut Project, comp: &Comp, item: ItemId, img: &VectorIm
|
||||
/// are kept with Merge Paths (see [`svg_contents`]).
|
||||
pub fn shapes_from_vector(proj: &mut Project, comp: &Comp, src: &Layer, doc: &Doc) -> Layer {
|
||||
let mut l = build::layer(proj, comp, &format!("{} Outlines", src.name), LayerSource::Shape, (0, 0), None);
|
||||
add_doc_shapes(proj, &mut l, doc);
|
||||
copy_transform(proj, src, &mut l);
|
||||
like_source(&mut l, src);
|
||||
l.name = comp.unique_layer_name(&format!("{} Outlines", src.name));
|
||||
l
|
||||
}
|
||||
|
||||
/// Edit ▸ Paste of SVG markup (After Effects 26.3 pastes copied vector artwork as shape layers):
|
||||
/// a shape layer named `name` with the document's shapes, its Anchor Point at the centre of the
|
||||
/// SVG canvas and its Position at the comp centre, so the canvas lands centred in the comp.
|
||||
pub fn shapes_from_svg(proj: &mut Project, comp: &Comp, doc: &Doc, name: &str) -> Layer {
|
||||
let mut l = build::layer(proj, comp, name, LayerSource::Shape, (0, 0), None);
|
||||
add_doc_shapes(proj, &mut l, doc);
|
||||
let (w, h) = (doc.width, doc.height);
|
||||
if w.is_finite()
|
||||
&& h.is_finite()
|
||||
&& let Some(a) = l.props.sub_mut("transform").and_then(|t| t.get_mut("anchor"))
|
||||
{
|
||||
a.value = Value::Vec3([w / 2.0, h / 2.0, 0.0]);
|
||||
}
|
||||
l
|
||||
}
|
||||
|
||||
/// The shapes of `doc` into shape layer `l`'s Contents; clips enclosing the whole document
|
||||
/// become its masks (Add, then Intersect).
|
||||
fn add_doc_shapes(proj: &mut Project, l: &mut Layer, doc: &Doc) {
|
||||
let (clips, doc) = vector_clip_masks(doc);
|
||||
let mut ids = Ids(&mut proj.next_id);
|
||||
let items = svg_contents(&mut ids, &doc);
|
||||
@@ -488,10 +520,6 @@ pub fn shapes_from_vector(proj: &mut Project, comp: &Comp, src: &Layer, doc: &Do
|
||||
}
|
||||
}
|
||||
}
|
||||
copy_transform(proj, src, &mut l);
|
||||
like_source(&mut l, src);
|
||||
l.name = comp.unique_layer_name(&format!("{} Outlines", src.name));
|
||||
l
|
||||
}
|
||||
|
||||
/// Layer ▸ Create ▸ Create Shapes from Text: one group per character with its outline, fill and
|
||||
|
||||
@@ -43,6 +43,9 @@ const UPLOAD_BUDGET: usize = 1536 << 20;
|
||||
/// Working textures kept for reuse once their last image is dropped (bytes).
|
||||
const POOL_BUDGET: usize = 768 << 20;
|
||||
|
||||
/// Readback staging buffers kept for reuse across sizes (bytes).
|
||||
const STAGING_BUDGET: u64 = 256 << 20;
|
||||
|
||||
/// A GPU image: premultiplied RGBA f32 in a texture (cheap to clone; immutable once written).
|
||||
/// Working images come from the context's texture pool and go back to it when the last clone
|
||||
/// is dropped.
|
||||
@@ -774,6 +777,12 @@ impl GpuContext {
|
||||
self.unavailable.push((entry, "dropped by a test".into()));
|
||||
}
|
||||
|
||||
/// Bytes of readback staging buffers kept for reuse (tests).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn staging_bytes(&self) -> u64 {
|
||||
self.staging.lock().map(|s| s.iter().map(|(k, v)| k * v.len() as u64).sum()).unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn can_readback(&self) -> bool {
|
||||
self.readbacks.check().is_ok() && (self.can_wait() || self.deferred.is_some())
|
||||
}
|
||||
@@ -1153,9 +1162,15 @@ impl<'g> Enc<'g> {
|
||||
}
|
||||
out.copy_from_slice(&bytes);
|
||||
// Keep a few staging buffers per size (frames read back at the same size every time).
|
||||
// Layers whose bounds change every frame read back at a new size each time: drop the
|
||||
// other sizes once over budget, or mapped host memory grows for the whole render (#2081).
|
||||
if let Ok(mut s) = self.g.staging.lock() {
|
||||
let held: u64 = s.iter().map(|(k, v)| k.saturating_mul(v.len() as u64)).sum();
|
||||
if held.saturating_add(size) > STAGING_BUDGET {
|
||||
s.retain(|k, _| *k == size);
|
||||
}
|
||||
let v = s.entry(size).or_default();
|
||||
if v.len() < 2 && size <= 256 << 20 {
|
||||
if v.len() < 2 && size <= STAGING_BUDGET {
|
||||
v.push(buf);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,13 +45,12 @@ pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<G
|
||||
let fd = 1.0 / ctx.fps();
|
||||
mean_of(e, ctx, b, &(-back..=fwd).map(|k| ctx.time + k as f64 * fd).collect::<Vec<_>>())
|
||||
}
|
||||
"ec.time.pixelmotionblur" => {
|
||||
let angle = if ctx.params.e("shutterControl") == 1 { ctx.params.f("shutterAngle") } else { 180.0 };
|
||||
let n = ctx.params.f("shutterSamples").round().clamp(1.0, 64.0) as usize;
|
||||
let span = angle.clamp(0.0, 720.0) / 360.0 / ctx.fps();
|
||||
let times: Vec<f64> = (0..n).map(|i| ctx.time - span * 0.5 + if n > 1 { span * i as f64 / (n - 1) as f64 } else { span * 0.5 }).collect();
|
||||
mean_of(e, ctx, b, ×)
|
||||
}
|
||||
// Pixel Motion Blur synthesizes along measured motion vectors (#535); a plan that
|
||||
// cannot be built (no host) declines to the CPU effect.
|
||||
"ec.time.pixelmotionblur" => match effectcraft_effects::pmb_plan(ctx) {
|
||||
Some(plan) => render_plan(e, &plan),
|
||||
None => None,
|
||||
},
|
||||
"ec.time.timewarp" => timewarp(e, ctx, b),
|
||||
_ => None,
|
||||
}
|
||||
@@ -179,6 +178,12 @@ fn timewarp(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
return Some(b);
|
||||
}
|
||||
let Some(plan) = effectcraft_effects::timewarp_plan(ctx) else { return Some(b) };
|
||||
render_plan(e, &plan)
|
||||
}
|
||||
|
||||
/// Render a prepared Timewarp plan (also Pixel Motion Blur's, `effectcraft_effects::pmb_plan`):
|
||||
/// every step to an image on the GPU, then the weighted average.
|
||||
fn render_plan(e: &mut Enc, plan: &effectcraft_effects::TimewarpPlan) -> Option<GBuf> {
|
||||
// Pixel Motion's in-between frame of frames a, b along motion field `flow`.
|
||||
let motion = |e: &mut Enc, a: usize, bi: usize, w: f32, flow: usize| -> Option<GpuImage> {
|
||||
let pa = e.g.upload_image(&effectcraft_effects::timewarp_crop(&plan.frames[a], &plan.pm))?;
|
||||
|
||||
@@ -448,6 +448,13 @@ fn cylinder(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
p.u[0][0] = pr.e("render");
|
||||
p.f[0] = f4([c.0, c.1, r, h]);
|
||||
p.f[2][0] = pr.f("rotation").to_radians() as f32;
|
||||
let (rx, ry, rz) = (pr.f("rotationX"), pr.f("rotationY"), pr.f("rotationZ"));
|
||||
p.u[0][1] = (rx != 0.0 || ry != 0.0 || rz != 0.0) as u32;
|
||||
let m = rot_xyz(rx, ry, rz);
|
||||
// Matrix rows, as for CC Sphere; the kernel uses Rᵀ for mapping.
|
||||
for (i, row) in m.iter().enumerate() {
|
||||
p.f[7 + i] = f4([row[0], row[1], row[2], 0.0]);
|
||||
}
|
||||
run(e, "ftr_cylinder", &p, b, None, None)
|
||||
}
|
||||
|
||||
|
||||
@@ -666,7 +666,24 @@ fn ftr_faces(mode: u32, front: vec4<f32>, back: vec4<f32>) -> vec4<f32> {
|
||||
return ftr_over(front, back);
|
||||
}
|
||||
|
||||
// Cylinder: u[0].x = render; f[0] = (cx, cy, radius, layer height); f[2].x = rotation.
|
||||
// Cylinder: u[0].x = render, u[0].y = axis rotation enabled;
|
||||
// f[0] = (cx, cy, radius, layer height); f[2].x = legacy texture spin;
|
||||
// f[7..10] = rows of the local-to-view rotation matrix.
|
||||
fn ftr_cylinder_face(d: vec2<f32>, z: f32, back: bool) -> vec4<f32> {
|
||||
let m0 = P.f[7].xyz;
|
||||
let m1 = P.f[8].xyz;
|
||||
let m2 = P.f[9].xyz;
|
||||
let q = vec3<f32>(d, z);
|
||||
let local = vec3<f32>(m0.x * q.x + m1.x * q.y + m2.x * q.z,
|
||||
m0.y * q.x + m1.y * q.y + m2.y * q.z,
|
||||
m0.z * q.x + m1.z * q.y + m2.z * q.z);
|
||||
let u = (atan2(local.x, local.z) + P.f[2].x) / 6.283185307179586 + 0.5;
|
||||
let v = local.y / P.f[0].w + 0.5;
|
||||
let flip = select(1.0, -1.0, back);
|
||||
let n = vec3<f32>(local.x / P.f[0].z * flip, 0.0, local.z / P.f[0].z * flip);
|
||||
return ftr_shade(ftr_texel(u, v), vec3<f32>(dot(m0, n), dot(m1, n), dot(m2, n)));
|
||||
}
|
||||
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn ftr_cylinder(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let p = vec2<i32>(gid.xy);
|
||||
@@ -674,18 +691,35 @@ fn ftr_cylinder(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
return;
|
||||
}
|
||||
let d = ftr_centre(p) - P.f[0].xy;
|
||||
let v = d.y / P.f[0].w + 0.5;
|
||||
let s = d.x / P.f[0].z;
|
||||
if (abs(s) >= 1.0) {
|
||||
if (P.u[0].y == 0u) {
|
||||
// Original path is unchanged for old projects and default parameters.
|
||||
let v = d.y / P.f[0].w + 0.5;
|
||||
let s = d.x / P.f[0].z;
|
||||
if (abs(s) >= 1.0) {
|
||||
textureStore(out, p, vec4<f32>(0.0));
|
||||
return;
|
||||
}
|
||||
let tau = 6.283185307179586;
|
||||
let th = asin_p(s);
|
||||
let cz = cos(th);
|
||||
let spin = P.f[2].x;
|
||||
let front = ftr_shade(ftr_texel((th + spin) / tau + 0.5, v), vec3<f32>(s, 0.0, cz));
|
||||
let back = ftr_shade(ftr_texel((3.141592653589793 - th + spin) / tau + 0.5, v), vec3<f32>(-s, 0.0, cz));
|
||||
textureStore(out, p, ftr_faces(P.u[0].x, front, back));
|
||||
return;
|
||||
}
|
||||
let axis = vec3<f32>(P.f[7].y, P.f[8].y, P.f[9].y);
|
||||
let a = 1.0 - axis.z * axis.z;
|
||||
let da = dot(d, axis.xy);
|
||||
let bq = -da * axis.z;
|
||||
let disc = bq * bq - a * (dot(d, d) - da * da - P.f[0].z * P.f[0].z);
|
||||
if (a <= 1e-10 || !(disc > 0.0)) {
|
||||
textureStore(out, p, vec4<f32>(0.0));
|
||||
return;
|
||||
}
|
||||
let tau = 6.283185307179586;
|
||||
let th = asin_p(s);
|
||||
let cz = cos(th);
|
||||
let spin = P.f[2].x;
|
||||
let front = ftr_shade(ftr_texel((th + spin) / tau + 0.5, v), vec3<f32>(s, 0.0, cz));
|
||||
let back = ftr_shade(ftr_texel((3.141592653589793 - th + spin) / tau + 0.5, v), vec3<f32>(-s, 0.0, cz));
|
||||
let root = sqrt(disc);
|
||||
let front = ftr_cylinder_face(d, (-bq + root) / a, false);
|
||||
let back = ftr_cylinder_face(d, (-bq - root) / a, true);
|
||||
textureStore(out, p, ftr_faces(P.u[0].x, front, back));
|
||||
}
|
||||
|
||||
|
||||
@@ -621,18 +621,25 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
if (P.u[0].y == 1u) {
|
||||
px = vec4<f32>(max((c + br - 0.5) * k + 0.5, vec3<f32>(0.0)) * a, a);
|
||||
} else if (P.u[0].y == 2u) {
|
||||
// Brightness & Contrast tone curves (effects crate bc_modern).
|
||||
// Brightness & Contrast tone curves (effects crate bc_modern, AE-fitted #681).
|
||||
var o = c;
|
||||
let pb = exp2(-br);
|
||||
let g = exp2(2.0 * ct);
|
||||
let g = max(1.0 + 0.53 * ct, 0.05);
|
||||
for (var i = 0; i < 3; i++) {
|
||||
if (c[i] < 1.0) {
|
||||
let v = powz(max(c[i], 0.0), pb);
|
||||
if (v < 0.5) {
|
||||
o[i] = 0.5 * powz(2.0 * v, g);
|
||||
} else {
|
||||
o[i] = 1.0 - 0.5 * powz(max(2.0 - 2.0 * v, 0.0), g);
|
||||
var v = max(c[i], 0.0);
|
||||
if (br > 0.0) {
|
||||
v = 1.0 - powz(1.0 - v, exp2(1.24 * br));
|
||||
} else if (br < 0.0) {
|
||||
v = powz(v, exp2(-1.32 * br));
|
||||
}
|
||||
if (ct != 0.0) {
|
||||
if (v < 0.5) {
|
||||
v = 0.5 * powz(2.0 * v, g);
|
||||
} else {
|
||||
v = 1.0 - 0.5 * powz(max(2.0 - 2.0 * v, 0.0), g);
|
||||
}
|
||||
}
|
||||
o[i] = v;
|
||||
}
|
||||
}
|
||||
px = vec4<f32>(o * a, a);
|
||||
|
||||
@@ -839,3 +839,21 @@ fn effects_whose_kernels_failed_render_on_the_cpu() {
|
||||
check(&format!("missing kernels {depth:?}"), Some(d), 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// A layer whose bounds change every frame reads back at a new size each time: the staging
|
||||
/// buffers kept for reuse stay within budget instead of growing for the whole render (#2081).
|
||||
#[test]
|
||||
fn readback_staging_stays_bounded_across_sizes() {
|
||||
let Some(g) = gpu() else { return };
|
||||
if !g.ctx.can_wait() {
|
||||
return;
|
||||
}
|
||||
let mut e = crate::context::Enc::new(&g.ctx);
|
||||
// 1024 × (256 + i) RGBA f32: ~4 MiB each, ~480 MiB over all sizes.
|
||||
for i in 0..120 {
|
||||
let img = g.ctx.image(1024, 256 + i);
|
||||
assert!(e.download(&img).is_some(), "readback {i}");
|
||||
}
|
||||
let held = g.ctx.staging_bytes();
|
||||
assert!(held <= (256 << 20) + 2 * 1024 * 16 * 376, "staging buffers hold {held} bytes");
|
||||
}
|
||||
|
||||
@@ -89,6 +89,11 @@ fn perspective() {
|
||||
effect_case("ec.perspective.cccylinder", &[("position", pt(35.0, 22.0)), ("rotation", n(30.0))]);
|
||||
effect_case("ec.perspective.cccylinder", &[("radius", n(60.0)), ("render", e(1)), ("lightDirection", n(70.0)), ("roughness", n(0.2))]);
|
||||
effect_case("ec.perspective.cccylinder", &[("render", e(2)), ("lightHeight", n(20.0)), ("specular", n(60.0))]);
|
||||
// All three axes: independently and combined, against the CPU reference.
|
||||
effect_case("ec.perspective.cccylinder", &[("rotationX", n(35.0)), ("render", e(1))]);
|
||||
effect_case("ec.perspective.cccylinder", &[("rotationY", n(65.0)), ("rotation", n(15.0))]);
|
||||
effect_case("ec.perspective.cccylinder", &[("rotationZ", n(-25.0)), ("render", e(2))]);
|
||||
effect_case("ec.perspective.cccylinder", &[("rotationX", n(30.0)), ("rotationY", n(20.0)), ("rotationZ", n(15.0))]);
|
||||
effect_case("ec.perspective.ccsphere", &[("offset", pt(35.0, 22.0)), ("radius", n(18.0)), ("rotationX", n(20.0)), ("rotationY", n(35.0))]);
|
||||
effect_case("ec.perspective.ccsphere", &[("radius", n(20.0)), ("rotationZ", n(50.0)), ("render", e(2))]);
|
||||
effect_case("ec.perspective.ccspotlight", &[("from", pt(15.0, 8.0)), ("to", pt(35.0, 22.0))]);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
//! Animated GIF footage through the wired session (#651): the imported item is a movie whose
|
||||
//! frames follow the file's own delays in preview rendering, not one frozen still.
|
||||
|
||||
use effectcraft_engine::render::RenderOpts;
|
||||
use effectcraft_project::{ItemId, ItemKind};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::json;
|
||||
|
||||
fn tmp(name: &str) -> String {
|
||||
let d = std::env::temp_dir().join(format!("effectcraft-host-gif-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d.join(name).to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
/// A 3-frame 16×16 GIF: red, green, blue, one tenth of a second each.
|
||||
fn animated_gif() -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
{
|
||||
let mut enc = gif::Encoder::new(&mut out, 16, 16, &[]).unwrap();
|
||||
for rgb in [[255u8, 0, 0], [0, 255, 0], [0, 0, 255]] {
|
||||
let mut f = gif::Frame::from_rgb(16, 16, &rgb.repeat(256));
|
||||
f.delay = 10;
|
||||
enc.write_frame(&f).unwrap();
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn centre(s: &effectcraft_engine::Session, comp: ItemId, t: f64) -> [f32; 3] {
|
||||
let img = s.render(comp, Tick::from_seconds_f64(t), RenderOpts::default());
|
||||
let p = img.data[8 * 16 + 8];
|
||||
[p[0], p[1], p[2]]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_imported_animated_gif_plays_its_frames() {
|
||||
let mut s = effectcraft_host::session();
|
||||
let path = tmp("anim.gif");
|
||||
std::fs::write(&path, animated_gif()).unwrap();
|
||||
s.execute("comp.new", json!({"name": "Gif", "width": 16, "height": 16, "frameRate": 10, "duration": 1})).unwrap();
|
||||
let before = s.project.items.len();
|
||||
s.execute("file.import", json!({"paths": [path.clone()], "addToComp": true})).unwrap();
|
||||
let f = match s.project.items.values().find_map(|i| match &i.kind {
|
||||
ItemKind::Footage(f) if f.path == path => Some(f),
|
||||
_ => None,
|
||||
}) {
|
||||
Some(f) => f.clone(),
|
||||
None => panic!("no footage item for the GIF"),
|
||||
};
|
||||
assert_eq!(s.project.items.len(), before + 1, "one footage item, not a comp");
|
||||
assert_eq!((f.codec.as_str(), f.has_video), ("GIF", true), "probed as an animated movie");
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let (r, g, b) = (centre(&s, cid, 0.05), centre(&s, cid, 0.15), centre(&s, cid, 0.25));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert!(r[0] > 0.9 && r[1] < 0.1, "frame at 0.05 s is red, got {r:?}");
|
||||
assert!(g[1] > 0.9 && g[0] < 0.1, "frame at 0.15 s is green, got {g:?}");
|
||||
assert!(b[2] > 0.9 && b[0] < 0.1, "frame at 0.25 s is blue, got {b:?}");
|
||||
}
|
||||
@@ -751,6 +751,38 @@ mod tests {
|
||||
assert_eq!(g.coerce_json(&serde_json::json!(5)), None);
|
||||
}
|
||||
|
||||
/// #612: the gradient editor samples a new stop from its own track only. `sample` multiplies
|
||||
/// in the opacity stops, so feeding its alpha into a colour stop would apply 50% twice (25%).
|
||||
#[test]
|
||||
fn gradient_tracks_sample_separately() {
|
||||
let g = Gradient { colors: vec![(0.0, [1.0, 0.0, 0.0, 0.8]), (1.0, [0.0, 0.0, 1.0, 0.4])], opacities: vec![(0.0, 0.5), (1.0, 0.5)] };
|
||||
let c = g.color_at(0.5);
|
||||
assert!((c[0] - 0.5).abs() < 1e-6 && (c[2] - 0.5).abs() < 1e-6, "{c:?}");
|
||||
assert!((c[3] - 0.6).abs() < 1e-6, "the colour track's own alpha, without opacity: {c:?}");
|
||||
assert!((g.opacity_at(0.5) - 0.5).abs() < 1e-6);
|
||||
assert!((g.sample(0.5)[3] - 0.3).abs() < 1e-6, "sample still applies opacity once");
|
||||
// A stop added at 50% from the opacity track keeps 50%: sampled again, it is still 50%.
|
||||
let mut with_stop = g.clone();
|
||||
with_stop.opacities.insert(1, (0.5, g.opacity_at(0.5)));
|
||||
assert!((with_stop.opacity_at(0.5) - 0.5).abs() < 1e-6);
|
||||
assert!((with_stop.sample(0.5)[3] - 0.3).abs() < 1e-6, "adding a stop doesn't change the look");
|
||||
// Outside the stops, and with none: the ends hold, and an empty track is opaque white.
|
||||
assert_eq!(g.color_at(-1.0), [1.0, 0.0, 0.0, 0.8]);
|
||||
assert_eq!(g.color_at(2.0), [0.0, 0.0, 1.0, 0.4]);
|
||||
let empty = Gradient { colors: vec![], opacities: vec![] };
|
||||
assert_eq!((empty.color_at(0.3), empty.opacity_at(0.3)), ([1.0; 4], 1.0));
|
||||
}
|
||||
|
||||
/// #612: the editor saves the typed form, so one colour stop (which the plain JSON parser
|
||||
/// rejects) and each stop's RGBA come back exactly.
|
||||
#[test]
|
||||
fn typed_gradient_round_trips_one_stop_and_rgba() {
|
||||
let one = Value::Gradient(Gradient { colors: vec![(0.25, [0.1, 0.2, 0.3, 0.7])], opacities: vec![(0.0, 1.0), (0.6, 0.35)] });
|
||||
let cur = Value::Gradient(Gradient::default());
|
||||
assert_eq!(cur.coerce_json(&one.to_json()), Some(one.clone()));
|
||||
assert_eq!(cur.coerce_json(&serde_json::json!({"colors": [[0.25, [0.1, 0.2, 0.3, 0.7]]]})), None, "plain JSON needs two colours");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linear_and_hold() {
|
||||
let keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(2.0), Value::Scalar(100.0))];
|
||||
|
||||
@@ -169,6 +169,24 @@ impl Gradient {
|
||||
let a = sample_stops(&self.opacities, t, |a, b, f| a + (b - a) * f as f32).unwrap_or(1.0);
|
||||
[col[0], col[1], col[2], col[3] * a]
|
||||
}
|
||||
|
||||
/// The colour stops alone at `t` (0..1), RGBA as stored: unlike [`Gradient::sample`], the
|
||||
/// opacity stops are not applied. A new colour stop takes this value.
|
||||
pub fn color_at(&self, t: f64) -> [f32; 4] {
|
||||
sample_stops(&self.colors, t.clamp(0.0, 1.0), |a, b, f| {
|
||||
let mut o = [0.0; 4];
|
||||
for i in 0..4 {
|
||||
o[i] = a[i] + (b[i] - a[i]) * f as f32;
|
||||
}
|
||||
o
|
||||
})
|
||||
.unwrap_or([1.0; 4])
|
||||
}
|
||||
|
||||
/// The opacity stops alone at `t` (0..1). A new opacity stop takes this value.
|
||||
pub fn opacity_at(&self, t: f64) -> f32 {
|
||||
sample_stops(&self.opacities, t.clamp(0.0, 1.0), |a, b, f| a + (b - a) * f as f32).unwrap_or(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_stops<T: Copy>(stops: &[(f64, T)], t: f64, lerp: impl Fn(T, T, f64) -> T) -> Option<T> {
|
||||
|
||||
+20
-10
@@ -8,6 +8,7 @@
|
||||
|
||||
use effectcraft_project::{AlphaMode, Footage};
|
||||
use effectcraft_raster::{Image, Px};
|
||||
use effectcraft_render::FloatCurve;
|
||||
use filmcraft_color::{Matrix, Range};
|
||||
use filmcraft_frame::{Chroma, PixelData, VideoFrame};
|
||||
use rayon::prelude::*;
|
||||
@@ -17,24 +18,33 @@ use rayon::prelude::*;
|
||||
pub(crate) struct AlphaOp {
|
||||
mode: AlphaMode,
|
||||
matte: [f32; 3],
|
||||
/// Interpret Footage ▸ Preserve RGB: float values stay as the file stores them (not
|
||||
/// sRGB-encoded).
|
||||
preserve_rgb: bool,
|
||||
/// How float values (linear light) are encoded: sRGB by default, a pure 2.4 power curve
|
||||
/// (Working Gamma 2.4), or not at all (Preserve RGB, Interpret As Linear Light).
|
||||
float: FloatCurve,
|
||||
}
|
||||
|
||||
impl AlphaOp {
|
||||
pub(crate) fn new(mode: AlphaMode, matte: [f32; 3]) -> Self {
|
||||
Self { mode, matte, preserve_rgb: false }
|
||||
Self { mode, matte, float: FloatCurve::Srgb }
|
||||
}
|
||||
/// The interpretation of footage `f`.
|
||||
pub(crate) fn of(f: &Footage) -> Self {
|
||||
Self { preserve_rgb: f.preserve_rgb, ..Self::new(f.alpha, f.premul_color) }
|
||||
/// The interpretation of footage `f`, float values handed over as `float` says (as stored
|
||||
/// under Preserve RGB, whatever it says).
|
||||
pub(crate) fn of(f: &Footage, float: FloatCurve) -> Self {
|
||||
let float = if f.preserve_rgb { FloatCurve::Linear } else { float };
|
||||
Self { float, ..Self::new(f.alpha, f.premul_color) }
|
||||
}
|
||||
/// A linear float value as the compositor takes it: sRGB-encoded, or the file's own value
|
||||
/// under Preserve RGB.
|
||||
/// How float values are encoded.
|
||||
pub(crate) fn float_curve(self) -> FloatCurve {
|
||||
self.float
|
||||
}
|
||||
/// A linear float value as the compositor takes it.
|
||||
#[inline(always)]
|
||||
fn float(self, v: f32) -> f32 {
|
||||
if self.preserve_rgb { v } else { encode(v) }
|
||||
match self.float {
|
||||
FloatCurve::Srgb => encode(v),
|
||||
FloatCurve::Gamma24 => v.abs().powf(1.0 / 2.4).copysign(v),
|
||||
FloatCurve::Linear => v,
|
||||
}
|
||||
}
|
||||
/// `c` is the file's colour (straight or premultiplied as the mode says), `a` its alpha.
|
||||
#[inline(always)]
|
||||
|
||||
@@ -75,34 +75,21 @@ pub fn read_exr_channels(bytes: &[u8]) -> Option<AuxChannels> {
|
||||
}
|
||||
};
|
||||
collect_meta(&img.attributes.other);
|
||||
for layer in img.layer_data.iter() {
|
||||
let n = w.checked_mul(h)?;
|
||||
// The decoded image is consumed channel by channel, so each channel's samples are freed (or
|
||||
// moved) as its plane is made instead of the whole file being held twice (#474).
|
||||
for layer in img.layer_data {
|
||||
collect_meta(&layer.attributes.other);
|
||||
let prefix = layer.attributes.layer_name.as_ref().map(|t| t.to_string()).filter(|s| !s.is_empty());
|
||||
let (lw, lh) = (layer.size.0, layer.size.1);
|
||||
let size = (layer.size.0, layer.size.1);
|
||||
let pos = layer.attributes.layer_position;
|
||||
let (ox, oy) = (pos.0 as i64 - dw.position.0 as i64, pos.1 as i64 - dw.position.1 as i64);
|
||||
for ch in layer.channel_data.list.iter() {
|
||||
let offset = (i64::from(pos.0) - i64::from(dw.position.0), i64::from(pos.1) - i64::from(dw.position.1));
|
||||
for ch in layer.channel_data.list {
|
||||
let name = channel_name(prefix.as_deref(), &ch.name.to_string());
|
||||
let fill = if is_depth(&name) { BACKGROUND_DEPTH } else { 0.0 };
|
||||
let mut plane = vec![fill; w * h];
|
||||
let vals: Vec<f32> = ch.sample_data.values_as_f32().collect();
|
||||
if vals.len() < lw * lh {
|
||||
continue;
|
||||
if let Some(plane) = place(ch.sample_data, size, offset, (w, h), n, fill) {
|
||||
aux.channels.push((name, plane));
|
||||
}
|
||||
for y in 0..lh {
|
||||
let ty = y as i64 + oy;
|
||||
if ty < 0 || ty >= h as i64 {
|
||||
continue;
|
||||
}
|
||||
for x in 0..lw {
|
||||
let tx = x as i64 + ox;
|
||||
if tx < 0 || tx >= w as i64 {
|
||||
continue;
|
||||
}
|
||||
plane[ty as usize * w + tx as usize] = vals[y * lw + x];
|
||||
}
|
||||
}
|
||||
aux.channels.push((name, plane));
|
||||
}
|
||||
}
|
||||
for (_, (name, manifest)) in crypto {
|
||||
@@ -114,6 +101,45 @@ pub fn read_exr_channels(bytes: &[u8]) -> Option<AuxChannels> {
|
||||
Some(aux)
|
||||
}
|
||||
|
||||
/// One channel's samples (`size`, at `offset` in the `display` window of `n` pixels) as a
|
||||
/// display-window plane, `fill` outside its data window. A channel covering exactly the display
|
||||
/// window is converted in one pass, and an f32 one is moved without a copy. `None` when the
|
||||
/// channel has fewer samples than its size.
|
||||
fn place(samples: exr::image::FlatSamples, size: (usize, usize), offset: (i64, i64), display: (usize, usize), n: usize, fill: f32) -> Option<Vec<f32>> {
|
||||
use exr::image::FlatSamples;
|
||||
let (lw, lh) = size;
|
||||
if samples.len() < lw.checked_mul(lh)? {
|
||||
return None;
|
||||
}
|
||||
if size == display && offset == (0, 0) {
|
||||
return Some(match samples {
|
||||
FlatSamples::F32(mut v) => {
|
||||
v.truncate(n);
|
||||
v
|
||||
}
|
||||
FlatSamples::F16(v) => v.iter().take(n).map(|s| s.to_f32()).collect(),
|
||||
FlatSamples::U32(v) => v.iter().take(n).map(|s| *s as f32).collect(),
|
||||
});
|
||||
}
|
||||
let value = |i: usize| match &samples {
|
||||
FlatSamples::F32(v) => v.get(i).copied(),
|
||||
FlatSamples::F16(v) => v.get(i).map(|s| s.to_f32()),
|
||||
FlatSamples::U32(v) => v.get(i).map(|s| *s as f32),
|
||||
};
|
||||
let (w, h) = display;
|
||||
let mut plane = vec![fill; n];
|
||||
for y in 0..lh {
|
||||
let Some(ty) = usize::try_from(y as i64 + offset.1).ok().filter(|ty| *ty < h) else { continue };
|
||||
for x in 0..lw {
|
||||
let Some(tx) = usize::try_from(x as i64 + offset.0).ok().filter(|tx| *tx < w) else { continue };
|
||||
if let (Some(v), Some(p)) = (value(y * lw + x), plane.get_mut(ty * w + tx)) {
|
||||
*p = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(plane)
|
||||
}
|
||||
|
||||
/// Names of the beauty pass, most preferred first, matched against the last part of a layer's
|
||||
/// name (Blender's `ViewLayer.Combined` is `Combined`): Blender's Combined pass and its
|
||||
/// compositor's File Output default `Image`, the beauty / rgba / rgb / color of Nuke, Arnold and
|
||||
@@ -262,6 +288,31 @@ mod tests {
|
||||
assert!(read_exr_channels(b"not an exr").is_none());
|
||||
}
|
||||
|
||||
/// #474: a full-window f32 channel (Cryptomatte ranks, …) becomes its plane without a copy,
|
||||
/// so a decode no longer holds the file's samples twice; other channels are placed in the
|
||||
/// display window as before.
|
||||
#[test]
|
||||
fn channels_become_planes_without_an_extra_copy() {
|
||||
use exr::image::FlatSamples;
|
||||
let v = vec![0.25f32; 6];
|
||||
let ptr = v.as_ptr();
|
||||
let plane = place(FlatSamples::F32(v), (3, 2), (0, 0), (3, 2), 6, 0.0).unwrap();
|
||||
assert_eq!(plane.as_ptr(), ptr, "moved, not copied");
|
||||
assert_eq!(plane, [0.25; 6]);
|
||||
let half = |x: f32| exr::prelude::f16::from_f32(x);
|
||||
let full = place(FlatSamples::F16(vec![half(0.5); 6]), (3, 2), (0, 0), (3, 2), 6, 0.0).unwrap();
|
||||
assert_eq!(full, [0.5; 6]);
|
||||
// A 2×1 data window at (1, 1) of a 3×2 display window; the rest is the fill.
|
||||
let part = place(FlatSamples::F16(vec![half(1.0), half(2.0)]), (2, 1), (1, 1), (3, 2), 6, 9.0).unwrap();
|
||||
assert_eq!(part, [9.0, 9.0, 9.0, 9.0, 1.0, 2.0]);
|
||||
// Partly outside the display window: clipped, never out of bounds.
|
||||
let off = place(FlatSamples::U32(vec![7, 8]), (2, 1), (2, -1), (3, 2), 6, 0.0).unwrap();
|
||||
assert_eq!(off, [0.0; 6]);
|
||||
let edge = place(FlatSamples::U32(vec![7, 8]), (2, 1), (2, 0), (3, 2), 6, 0.0).unwrap();
|
||||
assert_eq!(edge, [0.0, 0.0, 7.0, 0.0, 0.0, 0.0]);
|
||||
assert!(place(FlatSamples::F32(vec![1.0; 5]), (3, 2), (0, 0), (3, 2), 6, 0.0).is_none(), "too few samples");
|
||||
}
|
||||
|
||||
/// #295: a multi-layer EXR whose colour is only in named layers (`diffuse.R`, as Blender and
|
||||
/// Nuke write them) failed to import ("does not contain non-deep rgb channels"); it now
|
||||
/// shows its colour layer and keeps every channel for EXtractoR.
|
||||
|
||||
@@ -58,7 +58,7 @@ pub const AUDIO_EXTENSIONS: &[&str] = filmcraft_media::AUDIO_EXTENSIONS;
|
||||
pub const VIDEO_EXTENSIONS: &[&str] = &["mp4", "m4v", "mov", "mkv", "webm"];
|
||||
|
||||
/// File extensions recognised as 3D models (Advanced 3D model layers).
|
||||
pub const MODEL_EXTENSIONS: &[&str] = &["gltf", "glb", "obj"];
|
||||
pub const MODEL_EXTENSIONS: &[&str] = &["gltf", "glb", "obj", "stl"];
|
||||
|
||||
/// Whether `path` has an extension we can import.
|
||||
pub fn is_importable(path: &std::path::Path) -> bool {
|
||||
|
||||
+67
-25
@@ -6,7 +6,7 @@ use std::sync::{Arc, Condvar, Mutex, OnceLock};
|
||||
|
||||
use effectcraft_project::{AlphaMode, Footage, FootageKind, ItemId};
|
||||
use effectcraft_raster::{Image, Px};
|
||||
use effectcraft_render::FootageSource;
|
||||
use effectcraft_render::{FloatCurve, FootageSource};
|
||||
use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
|
||||
use filmcraft_media::{FrameRequest, SharedSource};
|
||||
|
||||
@@ -38,8 +38,8 @@ fn colour_bars(w: u32, h: u32) -> Image {
|
||||
struct Key {
|
||||
path: Arc<str>,
|
||||
frame: i64,
|
||||
/// The alpha interpretation (bits 0–1) and Preserve RGB (bit 2): they change the decoded
|
||||
/// pixels.
|
||||
/// The alpha interpretation (bits 0–1) and how float values are encoded (bits 2–3: sRGB,
|
||||
/// as stored, a 2.4 power curve): they change the decoded pixels.
|
||||
interp: u8,
|
||||
matte: [u32; 3],
|
||||
/// A movie frame made at this size for a reduced-resolution render ((0, 0): as decoded).
|
||||
@@ -233,6 +233,8 @@ struct Loc {
|
||||
key: Key,
|
||||
/// Media time for movies (`None`: an image file, `key.path`).
|
||||
media_t: Option<Tick>,
|
||||
/// How float values are encoded (also in `key.interp`).
|
||||
float: FloatCurve,
|
||||
}
|
||||
|
||||
/// Removes an in-flight key and wakes waiters, also when decoding panics.
|
||||
@@ -345,15 +347,15 @@ impl MediaPool {
|
||||
|
||||
/// Decode (or fetch from cache) the frame of `footage` at source time `t`.
|
||||
pub fn frame_at(&self, footage: &Footage, t: Tick) -> Result<Arc<Image>> {
|
||||
Inner::frame_at(&self.inner, footage, t, true, None)
|
||||
Inner::frame_at(&self.inner, footage, t, true, None, FloatCurve::Srgb)
|
||||
}
|
||||
|
||||
/// [`FootageSource::frame`] / [`FootageSource::frame_at_size`]: `None` (logged) on errors.
|
||||
fn frame_sized(&self, _item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>) -> Option<Arc<Image>> {
|
||||
fn frame_sized(&self, _item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) -> Option<Arc<Image>> {
|
||||
if footage.missing {
|
||||
return None;
|
||||
}
|
||||
match Inner::frame_at(&self.inner, footage, t, true, size) {
|
||||
match Inner::frame_at(&self.inner, footage, t, true, size, float) {
|
||||
Ok(img) => Some(img),
|
||||
Err(e) => {
|
||||
log::warn!("media: frame of {} at {:?}: {e}", footage.path, t);
|
||||
@@ -520,7 +522,7 @@ impl MediaPool {
|
||||
|
||||
/// A thumbnail of `footage` (its first frame) fitting in `max_side` × `max_side`, aspect kept.
|
||||
pub fn thumbnail(&self, footage: &Footage, max_side: u32) -> Result<Image> {
|
||||
let img = Inner::frame_at(&self.inner, footage, Tick::ZERO, false, None)?;
|
||||
let img = Inner::frame_at(&self.inner, footage, Tick::ZERO, false, None, FloatCurve::Srgb)?;
|
||||
let (w, h) = (img.width.max(1), img.height.max(1));
|
||||
let s = (max_side.max(1) as f64 / w.max(h) as f64).min(1.0);
|
||||
if s >= 1.0 {
|
||||
@@ -618,22 +620,28 @@ impl Inner {
|
||||
((d.0 as i128 * rate.num as i128 + den / 2) / den) as i64
|
||||
}
|
||||
|
||||
fn locate(footage: &Footage, t: Tick) -> Loc {
|
||||
fn locate(footage: &Footage, t: Tick, float: FloatCurve) -> Loc {
|
||||
let alpha = match footage.alpha {
|
||||
AlphaMode::Straight => 0,
|
||||
AlphaMode::Premultiplied => 1,
|
||||
AlphaMode::Ignore => 2,
|
||||
};
|
||||
let interp = alpha | (u8::from(footage.preserve_rgb) << 2);
|
||||
let float = AlphaOp::of(footage, float).float_curve();
|
||||
let interp = alpha
|
||||
| (match float {
|
||||
FloatCurve::Srgb => 0,
|
||||
FloatCurve::Linear => 1,
|
||||
FloatCurve::Gamma24 => 2,
|
||||
} << 2);
|
||||
let matte = if footage.alpha == AlphaMode::Premultiplied { footage.premul_color.map(f32::to_bits) } else { [0; 3] };
|
||||
let key = |path: &str, frame| Key { path: path.into(), frame, interp, matte, size: (0, 0) };
|
||||
match footage.kind {
|
||||
FootageKind::Sequence if !footage.sequence.is_empty() => {
|
||||
let i = Self::frame_index(footage.frame_rate, t, footage.sequence_frames(), footage.loop_count);
|
||||
match footage.sequence_file(i) {
|
||||
Some(path) => Loc { key: key(path, 0), media_t: None },
|
||||
Some(path) => Loc { key: key(path, 0), media_t: None, float },
|
||||
// A gap in the numbering: colour bars, as in After Effects.
|
||||
None => Loc { key: key(PLACEHOLDER, ((footage.width as i64) << 32) | footage.height as i64), media_t: None },
|
||||
None => Loc { key: key(PLACEHOLDER, ((footage.width as i64) << 32) | footage.height as i64), media_t: None, float },
|
||||
}
|
||||
}
|
||||
// A layer of a layered still is keyed by its layer index and size mode (smart objects
|
||||
@@ -641,9 +649,9 @@ impl Inner {
|
||||
FootageKind::Still if footage.layer.is_some() || footage.page != 0 => {
|
||||
let l = footage.layer.as_ref().map_or(0, |l| 1 + l.index as i64 * 2 + l.layer_size as i64 + if l.embedded.is_some() { 1 << 40 } else { 0 })
|
||||
+ ((footage.page as i64) << 42);
|
||||
Loc { key: key(&footage.path, l), media_t: None }
|
||||
Loc { key: key(&footage.path, l), media_t: None, float }
|
||||
}
|
||||
FootageKind::Still | FootageKind::Sequence | FootageKind::Model | FootageKind::Data => Loc { key: key(&footage.path, 0), media_t: None },
|
||||
FootageKind::Still | FootageKind::Sequence | FootageKind::Model | FootageKind::Data => Loc { key: key(&footage.path, 0), media_t: None, float },
|
||||
FootageKind::Video | FootageKind::Audio => {
|
||||
let rate = footage.frame_rate;
|
||||
let n = Self::frame_count(rate, footage.duration);
|
||||
@@ -651,18 +659,18 @@ impl Inner {
|
||||
// Middle of the frame in the file's own timing (robust to timestamp rounding).
|
||||
let native = footage.native_rate.unwrap_or(rate);
|
||||
let mt = native.tick_of(i) + Tick(native.frame_duration().0 / 2);
|
||||
Loc { key: key(&footage.path, i), media_t: Some(mt) }
|
||||
Loc { key: key(&footage.path, i), media_t: Some(mt), float }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame of `footage` at `t`; a movie's resampled to `size` when it can be made at that
|
||||
/// size directly (see [`FootageSource::frame_at_size`]).
|
||||
fn frame_at(self: &Arc<Self>, footage: &Footage, t: Tick, playback: bool, size: Option<(u32, u32)>) -> Result<Arc<Image>> {
|
||||
fn frame_at(self: &Arc<Self>, footage: &Footage, t: Tick, playback: bool, size: Option<(u32, u32)>, float: FloatCurve) -> Result<Arc<Image>> {
|
||||
if !footage.has_video {
|
||||
return Err(MediaError::Unsupported(format!("{}: no video", footage.path)));
|
||||
}
|
||||
let mut loc = Self::locate(footage, t);
|
||||
let mut loc = Self::locate(footage, t, float);
|
||||
let movie = loc.media_t.is_some();
|
||||
if movie && let Some(size) = size {
|
||||
loc.key.size = size;
|
||||
@@ -673,10 +681,10 @@ impl Inner {
|
||||
let prev = c.last.insert(loc.key.path.clone(), loc.key.frame);
|
||||
if prev == Some(loc.key.frame - 1) && self.read_ahead.load(Ordering::Relaxed) {
|
||||
let next_t = t + footage.frame_rate.frame_duration();
|
||||
let mut next = Self::locate(footage, next_t);
|
||||
let mut next = Self::locate(footage, next_t, float);
|
||||
next.key.size = loc.key.size;
|
||||
if next.key != loc.key && !c.lru.map.contains_key(&next.key) && !c.inflight.contains(&next.key) {
|
||||
self.prefetch(footage, next_t, size);
|
||||
self.prefetch(footage, next_t, size, float);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -709,20 +717,20 @@ impl Inner {
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn prefetch(self: &Arc<Self>, footage: &Footage, t: Tick, size: Option<(u32, u32)>) {
|
||||
fn prefetch(self: &Arc<Self>, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) {
|
||||
let (me, f) = (self.clone(), footage.clone());
|
||||
self.prefetched.fetch_add(1, Ordering::Relaxed);
|
||||
// A plain thread, not a rayon job: the decode below parallelises with rayon itself.
|
||||
std::thread::spawn(move || {
|
||||
let _ = me.frame_at(&f, t, false, size);
|
||||
let _ = me.frame_at(&f, t, false, size, float);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn prefetch(self: &Arc<Self>, _footage: &Footage, _t: Tick, _size: Option<(u32, u32)>) {}
|
||||
fn prefetch(self: &Arc<Self>, _footage: &Footage, _t: Tick, _size: Option<(u32, u32)>, _float: FloatCurve) {}
|
||||
|
||||
fn decode(&self, loc: &Loc, footage: &Footage) -> Result<Image> {
|
||||
let op = AlphaOp::of(footage);
|
||||
let op = AlphaOp::of(footage, loc.float);
|
||||
let path = &loc.key.path;
|
||||
match loc.media_t {
|
||||
None if &**path == PLACEHOLDER => Ok(colour_bars(footage.width, footage.height)),
|
||||
@@ -792,10 +800,13 @@ impl FootageSource for MediaPool {
|
||||
Some(self.budget())
|
||||
}
|
||||
fn frame(&self, item: ItemId, footage: &Footage, t: Tick) -> Option<Arc<Image>> {
|
||||
self.frame_sized(item, footage, t, None)
|
||||
self.frame_sized(item, footage, t, None, FloatCurve::Srgb)
|
||||
}
|
||||
fn frame_at_size(&self, item: ItemId, footage: &Footage, t: Tick, width: u32, height: u32) -> Option<Arc<Image>> {
|
||||
self.frame_sized(item, footage, t, Some((width, height)))
|
||||
self.frame_sized(item, footage, t, Some((width, height)), FloatCurve::Srgb)
|
||||
}
|
||||
fn frame_with(&self, item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) -> Option<Arc<Image>> {
|
||||
self.frame_sized(item, footage, t, size, float)
|
||||
}
|
||||
|
||||
fn vector_frame(&self, _item: ItemId, footage: &Footage, scale: f64) -> Option<Arc<Image>> {
|
||||
@@ -810,7 +821,7 @@ impl FootageSource for MediaPool {
|
||||
if footage.missing || !footage.has_video {
|
||||
return None;
|
||||
}
|
||||
let loc = Inner::locate(footage, t);
|
||||
let loc = Inner::locate(footage, t, FloatCurve::Srgb);
|
||||
if loc.media_t.is_some() || !loc.key.path.to_ascii_lowercase().ends_with(".exr") {
|
||||
return None;
|
||||
}
|
||||
@@ -1018,4 +1029,35 @@ mod tests {
|
||||
assert_eq!(raw, [0.18, 0.5, 2.0, 1.0]);
|
||||
assert_eq!(pool.frame_at(&f, Tick::ZERO).unwrap().get(0, 0), encoded, "the default interpretation is cached apart");
|
||||
}
|
||||
|
||||
/// #750: float footage is handed over as the renderer asks: sRGB-encoded, with Working
|
||||
/// Gamma 2.4's pure power curve, or as stored (Interpret As Linear Light encodes it then);
|
||||
/// Preserve RGB reads it as stored whatever is asked. Each decodes and caches apart.
|
||||
#[test]
|
||||
fn float_exr_is_encoded_with_the_requested_curve() {
|
||||
use exr::prelude::*;
|
||||
let (w, h) = (2usize, 2usize);
|
||||
let ch = |n: &str, v: f32| AnyChannel::new(n, FlatSamples::F32(vec![v; w * h]));
|
||||
let channels = AnyChannels::sort(vec![ch("R", 0.051), ch("G", 0.18), ch("B", 2.0), ch("A", 1.0)].into());
|
||||
let image = exr::image::Image::from_layer(Layer::new((w, h), LayerAttributes::default(), Encoding::FAST_LOSSLESS, channels));
|
||||
let mut bytes = std::io::Cursor::new(Vec::new());
|
||||
image.write().to_buffered(&mut bytes).unwrap();
|
||||
let b: Arc<[u8]> = bytes.into_inner().into();
|
||||
let pool = MediaPool::new();
|
||||
pool.add_bytes("/linear.exr", b.clone());
|
||||
let f = crate::probe_bytes("/linear.exr", b).unwrap();
|
||||
let px = |f: &Footage, float| pool.frame_with(ItemId(1), f, Tick::ZERO, None, float).unwrap().get(0, 0);
|
||||
let near = |o: [f32; 4], want: [f32; 3]| (0..3).all(|i| (o[i] - want[i]).abs() < 1e-3);
|
||||
let srgb = px(&f, FloatCurve::Srgb);
|
||||
assert!(near(srgb, [0.2503, 0.4614, 1.3532]), "sRGB: {srgb:?}");
|
||||
assert_eq!(pool.frame(ItemId(1), &f, Tick::ZERO).unwrap().get(0, 0), srgb, "frame() is sRGB");
|
||||
let g24 = px(&f, FloatCurve::Gamma24);
|
||||
assert!(near(g24, [0.2898, 0.4898, 2.0f32.powf(1.0 / 2.4)]), "2.4: {g24:?}");
|
||||
assert_eq!(px(&f, FloatCurve::Linear), [0.051, 0.18, 2.0, 1.0]);
|
||||
let preserve = Footage { preserve_rgb: true, ..f.clone() };
|
||||
assert_eq!(px(&preserve, FloatCurve::Gamma24), [0.051, 0.18, 2.0, 1.0]);
|
||||
// Each curve is cached apart: asking again returns the same pixels.
|
||||
assert_eq!(px(&f, FloatCurve::Srgb), srgb);
|
||||
assert_eq!(px(&f, FloatCurve::Gamma24), g24);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ pub fn probe(path: impl AsRef<Path>) -> Result<Footage> {
|
||||
probe_bytes(&path.to_string_lossy(), bytes.into())
|
||||
}
|
||||
|
||||
/// Probe a 3D model (glTF 2.0 / OBJ): a [`FootageKind::Model`] item whose duration is its
|
||||
/// Probe a 3D model (glTF 2.0 / OBJ / STL): a [`FootageKind::Model`] item whose duration is its
|
||||
/// longest animation. `resolve` reads sibling resources.
|
||||
pub fn probe_model(path: &str, bytes: &[u8], resolve: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<Footage> {
|
||||
let m = effectcraft_model::load(path, bytes, resolve).map_err(|e| MediaError::Decode(format!("{path}: {e}")))?;
|
||||
@@ -31,6 +31,7 @@ pub fn probe_model(path: &str, bytes: &[u8], resolve: &dyn Fn(&str) -> Option<Ve
|
||||
let dur = s.animations.iter().map(|a| a.1).fold(0.0f64, f64::max);
|
||||
let codec = match effectcraft_model::format_of(path) {
|
||||
Some("obj") => "OBJ",
|
||||
Some("stl") => "STL",
|
||||
Some("glb") => "GLB",
|
||||
_ => "glTF",
|
||||
};
|
||||
|
||||
@@ -674,3 +674,13 @@ fn conformed_flac_audio_reads_back_like_the_decoder() {
|
||||
assert_eq!(pool.audio_samples(&f, at, 4800, 48_000), decoded);
|
||||
let _ = std::fs::remove_dir_all(&folder);
|
||||
}
|
||||
|
||||
/// File ▸ Import of an STL part: importable by extension and probed as a 3D model item.
|
||||
#[test]
|
||||
fn stl_imports_as_a_model() {
|
||||
assert!(effectcraft_media::is_importable(Path::new("part.stl")));
|
||||
let ascii = "solid part\nfacet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid part\n";
|
||||
let f = effectcraft_media::probe_bytes("part.stl", Arc::from(ascii.as_bytes())).expect("probe");
|
||||
assert_eq!(f.kind, FootageKind::Model);
|
||||
assert_eq!(f.codec, "STL (1 meshes, 1 triangles)");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "effectcraft-model"
|
||||
description = "3D models: glTF 2.0 (.gltf/.glb) and Wavefront OBJ/MTL import, PBR metallic-roughness materials, node hierarchy, skinning and animation, parametric primitives, and extruded/bevelled outline meshes"
|
||||
description = "3D models: glTF 2.0 (.gltf/.glb), Wavefront OBJ/MTL and STL import, PBR metallic-roughness materials, node hierarchy, skinning and animation, parametric primitives, and extruded/bevelled outline meshes"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! metallic-roughness, normal, occlusion and emissive maps), the node hierarchy, skins and
|
||||
//! animations.
|
||||
//! - [`obj`]: Wavefront OBJ geometry with MTL materials (mapped onto the metallic-roughness model).
|
||||
//! - [`stl`]: STL meshes (binary and ASCII, with the binary per-facet and part colours).
|
||||
//! - [`prim`]: parametric primitives (cube, sphere, plane, torus, cone, cylinder).
|
||||
//! - [`extrude`]: extruded, bevelled meshes from 2D outlines (text and shape layers), with
|
||||
//! [`triangulate`] for the caps.
|
||||
@@ -18,6 +19,7 @@ pub mod extrude;
|
||||
pub mod gltf;
|
||||
pub mod obj;
|
||||
pub mod prim;
|
||||
pub mod stl;
|
||||
pub mod triangulate;
|
||||
|
||||
use std::sync::Arc;
|
||||
@@ -720,13 +722,14 @@ pub fn skin_point(inst: &Instance, p: &Primitive, k: usize, v: [f32; 3]) -> [f64
|
||||
[q.x, q.y, q.z]
|
||||
}
|
||||
|
||||
/// Which loader handles a file name (`gltf`, `glb`, `obj`), by extension.
|
||||
/// Which loader handles a file name (`gltf`, `glb`, `obj`, `stl`), by extension.
|
||||
pub fn format_of(path: &str) -> Option<&'static str> {
|
||||
let ext = path.rsplit('.').next()?.to_ascii_lowercase();
|
||||
match ext.as_str() {
|
||||
"gltf" => Some("gltf"),
|
||||
"glb" => Some("glb"),
|
||||
"obj" => Some("obj"),
|
||||
"stl" => Some("stl"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -736,9 +739,10 @@ pub fn format_of(path: &str) -> Option<&'static str> {
|
||||
pub fn load(path: &str, bytes: &[u8], resolve: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<Model> {
|
||||
match format_of(path) {
|
||||
Some("obj") => obj::parse(bytes, resolve),
|
||||
Some("stl") => stl::parse(bytes),
|
||||
Some(_) => gltf::parse(bytes, resolve),
|
||||
None if bytes.starts_with(b"glTF") => gltf::parse(bytes, resolve),
|
||||
None => Err(ModelError::Unsupported(format!("{path}: not a glTF or OBJ file"))),
|
||||
None => Err(ModelError::Unsupported(format!("{path}: not a glTF, OBJ or STL file"))),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
//! STL (stereolithography) meshes, binary and ASCII.
|
||||
//!
|
||||
//! Written from the public description of the format (3D Systems' StereoLithography Interface
|
||||
//! Specification, 1989, and its widely documented colour extensions):
|
||||
//!
|
||||
//! - Binary: an 80-byte header, a little-endian `u32` facet count, then 50 bytes per facet: a
|
||||
//! normal and three vertices (12 `f32`) and a 16-bit attribute word. Bytes after the last
|
||||
//! facet are ignored.
|
||||
//! - ASCII: `solid name`, then `facet normal nx ny nz` / `outer loop` / `vertex x y z`… /
|
||||
//! `endloop` / `endfacet`, closed by `endsolid`; several solids may follow each other.
|
||||
//! Loops with more than three vertices are fan-triangulated.
|
||||
//! - Colour (binary only): with `COLOR=r g b a` (four bytes) in the header (Materialise Magics),
|
||||
//! that is the part's colour and a facet whose attribute has bit 15 clear has its own colour
|
||||
//! (red in bits 0–4, green 5–9, blue 10–14); otherwise (VisCAM / SolidView) a facet with bit 15
|
||||
//! set has its own colour (blue in bits 0–4, green 5–9, red 10–14). Each colour becomes a
|
||||
//! plain dielectric material; uncoloured files get none (the renderer's default material).
|
||||
//!
|
||||
//! STL has no units, no UVs and no shared vertices: every facet keeps its own three vertices
|
||||
//! with the face normal from its counter-clockwise winding (the stored normal is only used when
|
||||
//! the triangle is degenerate), which gives the faceted look of an STL part. STL files are
|
||||
//! Z-up (3D printing and CAD); they are turned to the +Y-up convention of [`crate::Model`]
|
||||
//! (`(x, y, z)` → `(x, z, −y)`, a proper rotation, so winding is kept). Facets with non-finite
|
||||
//! coordinates are skipped.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::{Material, Mesh, Model, Primitive, Result, cross, normalize, obj::srgb_to_linear, perr, sub};
|
||||
|
||||
/// Header (80) plus facet count (4).
|
||||
const HEADER: usize = 84;
|
||||
/// Bytes per binary facet.
|
||||
const FACET: usize = 50;
|
||||
|
||||
/// Parse an STL file (binary or ASCII, detected from the contents).
|
||||
pub fn parse(bytes: &[u8]) -> Result<Model> {
|
||||
let count = bytes.get(80..HEADER).and_then(|b| <[u8; 4]>::try_from(b).ok()).map(u32::from_le_bytes);
|
||||
let needed = count.and_then(|n| (n as usize).checked_mul(FACET)).and_then(|n| n.checked_add(HEADER));
|
||||
// A binary file's size is exact; many binary headers still begin with `solid`, so the size
|
||||
// decides first, then the ASCII keyword.
|
||||
if needed.is_some_and(|n| n == bytes.len()) {
|
||||
return binary(bytes);
|
||||
}
|
||||
if looks_ascii(bytes) {
|
||||
return ascii(bytes);
|
||||
}
|
||||
if needed.is_some_and(|n| n <= bytes.len()) {
|
||||
return binary(bytes);
|
||||
}
|
||||
Err(perr("not an STL file (truncated binary facets or no `solid` keyword)"))
|
||||
}
|
||||
|
||||
fn looks_ascii(bytes: &[u8]) -> bool {
|
||||
let head = bytes.get(..bytes.len().min(512)).unwrap_or(&[]);
|
||||
let text = String::from_utf8_lossy(head);
|
||||
let t = text.trim_start();
|
||||
t.get(..5).is_some_and(|k| k.eq_ignore_ascii_case("solid")) && (t.contains("facet") || t.contains("endsolid"))
|
||||
}
|
||||
|
||||
/// The Z-up → Y-up turn.
|
||||
fn y_up(v: [f32; 3]) -> [f32; 3] {
|
||||
[v[0], v[2], -v[1]]
|
||||
}
|
||||
|
||||
/// Facets grouped into primitives by colour, in order of first use.
|
||||
#[derive(Default)]
|
||||
struct Builder {
|
||||
/// (colour key, primitive); `None` = uncoloured.
|
||||
prims: Vec<(Option<[u8; 3]>, Primitive)>,
|
||||
/// Colour → index into `prims` (up to 32 768 colours, so not a linear search).
|
||||
slots: HashMap<Option<[u8; 3]>, usize>,
|
||||
facets: usize,
|
||||
}
|
||||
|
||||
impl Builder {
|
||||
/// Add one triangle (file coordinates); `stored` is the file's facet normal.
|
||||
fn tri(&mut self, color: Option<[u8; 3]>, stored: [f32; 3], v: [[f32; 3]; 3]) {
|
||||
if v.iter().flatten().any(|c| !c.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let v = v.map(y_up);
|
||||
let n = normalize(cross(sub(v[1], v[0]), sub(v[2], v[0])))
|
||||
.or_else(|| stored.iter().all(|c| c.is_finite()).then(|| normalize(y_up(stored))).flatten())
|
||||
.unwrap_or([0.0, 1.0, 0.0]);
|
||||
let slot = *self.slots.entry(color).or_insert_with(|| {
|
||||
self.prims.push((color, Primitive::default()));
|
||||
self.prims.len() - 1
|
||||
});
|
||||
let Some((_, p)) = self.prims.get_mut(slot) else { return };
|
||||
for c in v {
|
||||
p.indices.push(p.positions.len() as u32);
|
||||
p.positions.push(c);
|
||||
p.normals.push(n);
|
||||
}
|
||||
self.facets += 1;
|
||||
}
|
||||
|
||||
fn finish(self, name: String) -> Result<Model> {
|
||||
if self.facets == 0 {
|
||||
return Err(perr("STL file without facets"));
|
||||
}
|
||||
let mut materials = vec![];
|
||||
let mut mesh = Mesh { name, primitives: vec![] };
|
||||
for (color, mut p) in self.prims {
|
||||
if let Some(c) = color {
|
||||
let lin = c.map(|x| srgb_to_linear(x as f32 / 255.0));
|
||||
let name = format!("#{:02X}{:02X}{:02X}", c[0], c[1], c[2]);
|
||||
materials.push(Material { name, ..Material::plain([lin[0], lin[1], lin[2], 1.0], 0.0, 0.5) });
|
||||
p.material = Some(materials.len() - 1);
|
||||
}
|
||||
mesh.primitives.push(p);
|
||||
}
|
||||
// Uncoloured facets next to coloured ones: a neutral grey of their own.
|
||||
if !materials.is_empty() {
|
||||
for p in mesh.primitives.iter_mut().filter(|p| p.material.is_none()) {
|
||||
materials.push(Material { name: "STL".into(), ..Material::plain([0.6, 0.6, 0.6, 1.0], 0.0, 0.5) });
|
||||
p.material = Some(materials.len() - 1);
|
||||
}
|
||||
}
|
||||
Ok(Model::single(mesh, materials))
|
||||
}
|
||||
}
|
||||
|
||||
fn f32_at(b: &[u8], at: usize) -> f32 {
|
||||
b.get(at..at + 4).and_then(|s| <[u8; 4]>::try_from(s).ok()).map_or(f32::NAN, f32::from_le_bytes)
|
||||
}
|
||||
|
||||
/// A 5-bit channel expanded to 8 bits.
|
||||
fn five(x: u16) -> u8 {
|
||||
let x = (x & 31) as u8;
|
||||
(x << 3) | (x >> 2)
|
||||
}
|
||||
|
||||
/// The Magics `COLOR=` part colour in the header (RGB; alpha is ignored).
|
||||
fn header_color(header: &[u8]) -> Option<[u8; 3]> {
|
||||
let at = header.windows(6).position(|w| w == b"COLOR=")?;
|
||||
let c = header.get(at + 6..at + 10)?;
|
||||
Some([*c.first()?, *c.get(1)?, *c.get(2)?])
|
||||
}
|
||||
|
||||
fn binary(bytes: &[u8]) -> Result<Model> {
|
||||
let header = bytes.get(..80).ok_or_else(|| perr("STL header truncated"))?;
|
||||
let magics = header_color(header);
|
||||
let body = bytes.get(HEADER..).unwrap_or(&[]);
|
||||
let count = bytes.get(80..HEADER).and_then(|b| <[u8; 4]>::try_from(b).ok()).map_or(0, u32::from_le_bytes) as usize;
|
||||
let mut b = Builder::default();
|
||||
for f in body.as_chunks::<FACET>().0.iter().take(count) {
|
||||
let n = [f32_at(f, 0), f32_at(f, 4), f32_at(f, 8)];
|
||||
let v = [0usize, 1, 2].map(|k| [f32_at(f, 12 + 12 * k), f32_at(f, 16 + 12 * k), f32_at(f, 20 + 12 * k)]);
|
||||
let attr = f.get(48..50).and_then(|a| <[u8; 2]>::try_from(a).ok()).map_or(0, u16::from_le_bytes);
|
||||
let color = match magics {
|
||||
Some(part) => Some(if attr & 0x8000 == 0 { [five(attr), five(attr >> 5), five(attr >> 10)] } else { part }),
|
||||
None => (attr & 0x8000 != 0).then(|| [five(attr >> 10), five(attr >> 5), five(attr)]),
|
||||
};
|
||||
b.tri(color, n, v);
|
||||
}
|
||||
let text = String::from_utf8_lossy(header);
|
||||
let name = text.trim_matches(|c: char| c == '\0' || c.is_whitespace());
|
||||
b.finish(name.strip_prefix("solid").unwrap_or(name).trim().to_string())
|
||||
}
|
||||
|
||||
/// Three numbers from the rest of an ASCII line.
|
||||
fn three<'a>(it: &mut impl Iterator<Item = &'a str>) -> Option<[f32; 3]> {
|
||||
Some([it.next()?.parse().ok()?, it.next()?.parse().ok()?, it.next()?.parse().ok()?])
|
||||
}
|
||||
|
||||
fn ascii(bytes: &[u8]) -> Result<Model> {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
let mut b = Builder::default();
|
||||
let mut name: Option<String> = None;
|
||||
let mut normal = [0.0f32; 3];
|
||||
let mut loop_v: Vec<[f32; 3]> = vec![];
|
||||
for (ln, line) in text.lines().enumerate() {
|
||||
let line = line.trim();
|
||||
let mut it = line.split_whitespace();
|
||||
let Some(key) = it.next() else { continue };
|
||||
let bad = || perr(format!("STL line {}: expected three numbers", ln + 1));
|
||||
match key.to_ascii_lowercase().as_str() {
|
||||
"solid" => {
|
||||
if name.is_none() {
|
||||
name = Some(line.get(5..).unwrap_or("").trim().to_string());
|
||||
}
|
||||
}
|
||||
"facet" => {
|
||||
// `facet normal nx ny nz`
|
||||
normal = it.next().and_then(|_| three(&mut it)).unwrap_or([0.0; 3]);
|
||||
loop_v.clear();
|
||||
}
|
||||
"vertex" => loop_v.push(three(&mut it).ok_or_else(bad)?),
|
||||
"endloop" | "endfacet" => {
|
||||
if let Some((&first, rest)) = loop_v.split_first() {
|
||||
for w in rest.windows(2) {
|
||||
if let [a, c] = w {
|
||||
b.tri(None, normal, [first, *a, *c]);
|
||||
}
|
||||
}
|
||||
}
|
||||
loop_v.clear();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
b.finish(name.unwrap_or_default())
|
||||
}
|
||||
@@ -373,3 +373,93 @@ fn stacked_extrusions_move_towards_the_camera() {
|
||||
assert_eq!((a[0], a[1]), (b[0], b[1]));
|
||||
}
|
||||
}
|
||||
|
||||
/// A binary STL: `header` (padded to 80 bytes) and facets of (normal, vertices, attribute).
|
||||
fn binary_stl(header: &[u8], facets: &[([f32; 3], [[f32; 3]; 3], u16)]) -> Vec<u8> {
|
||||
let mut b = header.to_vec();
|
||||
b.resize(80, 0);
|
||||
b.extend((facets.len() as u32).to_le_bytes());
|
||||
for (n, v, attr) in facets {
|
||||
for c in n.iter().chain(v.iter().flatten()) {
|
||||
b.extend(c.to_le_bytes());
|
||||
}
|
||||
b.extend(attr.to_le_bytes());
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
/// The unit square in the XY plane (Z-up file), facing +Z, as two facets.
|
||||
const SQUARE: [[[f32; 3]; 3]; 2] = [[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0]], [[0.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]]];
|
||||
|
||||
#[test]
|
||||
fn stl_binary_is_faceted_and_turned_y_up() {
|
||||
// A header that begins with `solid`, as many binary exporters write it.
|
||||
let bytes = binary_stl(b"solid part", &[([0.0, 0.0, 1.0], SQUARE[0], 0), ([0.0, 0.0, 1.0], SQUARE[1], 0)]);
|
||||
let m = load("part.STL", &bytes, &|_| None).unwrap();
|
||||
assert_eq!(format_of("part.STL"), Some("stl"));
|
||||
assert_eq!(m.meshes[0].name, "part");
|
||||
assert!(m.materials.is_empty());
|
||||
let p = &m.meshes[0].primitives[0];
|
||||
assert_eq!((p.triangle_count(), p.positions.len()), (2, 6));
|
||||
assert!(p.uvs.is_empty());
|
||||
// Z-up +Z becomes Y-up +Y; file Y becomes −Z.
|
||||
assert_eq!(p.normals, vec![[0.0, 1.0, 0.0]; 6]);
|
||||
assert_eq!(p.positions[2], [1.0, 0.0, -1.0]);
|
||||
// Counter-clockwise from outside after the turn.
|
||||
let (a, b, c) = (p.positions[0], p.positions[1], p.positions[2]);
|
||||
assert!(dot(cross(sub(b, a), sub(c, a)), p.normals[0]) > 0.0);
|
||||
// Trailing bytes after the last facet are ignored.
|
||||
let mut longer = bytes.clone();
|
||||
longer.extend([0u8; 7]);
|
||||
assert_eq!(stl::parse(&longer).unwrap().meshes, m.meshes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stl_ascii_with_polygon_loops_and_several_solids() {
|
||||
let src = "solid Bracket\n facet normal 0 0 1\n outer loop\n vertex 0 0 0\n vertex 1 0 0\n vertex 1 1 0\n vertex 0 1 0\n endloop\n endfacet\nendsolid Bracket\n\
|
||||
solid second\nfacet normal 0 0 0\nouter loop\nvertex 0 0 1\nvertex 1 0 1\nvertex 1E0 1 1\nendloop\nendfacet\nendsolid\n";
|
||||
let m = load("bracket.stl", src.as_bytes(), &|_| None).unwrap();
|
||||
assert_eq!(m.meshes[0].name, "Bracket");
|
||||
// The quad fans into two triangles; the second solid adds one.
|
||||
assert_eq!(m.triangle_count(), 3);
|
||||
let (lo, hi) = m.bounds().unwrap();
|
||||
assert_eq!((lo, hi), ([0.0, 0.0, -1.0], [1.0, 1.0, 0.0]));
|
||||
assert!(stl::parse(b"solid x\nfacet normal 0 0 1\nouter loop\nvertex 0 zero 0\n").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stl_binary_colours() {
|
||||
// VisCAM / SolidView: bit 15 set, red in bits 10–14.
|
||||
let red = 0x8000 | (31 << 10);
|
||||
let m = stl::parse(&binary_stl(b"", &[([0.0; 3], SQUARE[0], red), ([0.0; 3], SQUARE[1], 0)])).unwrap();
|
||||
assert_eq!(m.meshes[0].primitives.len(), 2);
|
||||
assert_eq!(m.materials.len(), 2);
|
||||
assert!(m.materials[0].base_color.iter().zip([1.0, 0.0, 0.0, 1.0]).all(|(x, y)| (x - y).abs() < 1e-5));
|
||||
assert_eq!(m.materials[0].metallic, 0.0);
|
||||
assert_eq!(m.meshes[0].primitives[1].material, Some(1));
|
||||
// Materialise Magics: a part colour in the header; bit 15 clear = the facet's own colour
|
||||
// with red in bits 0–4.
|
||||
let mut header = b"COLOR=".to_vec();
|
||||
header.extend([0, 255, 0, 255]);
|
||||
let m = stl::parse(&binary_stl(&header, &[([0.0; 3], SQUARE[0], 31), ([0.0; 3], SQUARE[1], 0x8000)])).unwrap();
|
||||
let close = |a: [f32; 4], b: [f32; 4]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5);
|
||||
assert!(close(m.materials[0].base_color, [1.0, 0.0, 0.0, 1.0]), "{:?}", m.materials[0]);
|
||||
assert!(close(m.materials[1].base_color, [0.0, 1.0, 0.0, 1.0]), "{:?}", m.materials[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stl_hostile_input_is_an_error() {
|
||||
// Claims four billion facets but has one: truncated.
|
||||
let mut bytes = binary_stl(b"", &[([0.0; 3], SQUARE[0], 0)]);
|
||||
bytes.splice(80..84, u32::MAX.to_le_bytes());
|
||||
assert!(stl::parse(&bytes).is_err());
|
||||
assert!(stl::parse(b"").is_err());
|
||||
assert!(stl::parse(&[0u8; 83]).is_err());
|
||||
// No facets, or only non-finite ones.
|
||||
assert!(stl::parse(&binary_stl(b"", &[])).is_err());
|
||||
assert!(stl::parse(&binary_stl(b"", &[([0.0; 3], [[f32::NAN, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], 0)])).is_err());
|
||||
assert!(stl::parse(b"solid empty\nendsolid empty\n").is_err());
|
||||
// A degenerate facet keeps its stored normal.
|
||||
let m = stl::parse(&binary_stl(b"", &[([1.0, 0.0, 0.0], [[0.0; 3]; 3], 0)])).unwrap();
|
||||
assert_eq!(m.meshes[0].primitives[0].normals[0], [1.0, 0.0, 0.0]);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
//! are linearised just before they are transformed and blended, the comp accumulates in linear
|
||||
//! and is encoded back to the working space when the comp is done (so precomps hand encoded
|
||||
//! pixels to their parent). Without a working space the Working Gamma's curve is used (sRGB
|
||||
//! at 2.2, a pure 2.4 power curve at 2.4); so is it for Interpret As Linear Light footage.
|
||||
//! at 2.2, a pure 2.4 power curve at 2.4); so is it for Interpret As Linear Light footage
|
||||
//! and for float footage (OpenEXR, float TIFF), whose linear values are encoded once
|
||||
//! ([`Pipe::float_curve`]).
|
||||
|
||||
use effectcraft_color::{ColorSpace, Conversion};
|
||||
use effectcraft_project::{Footage, HdrMode, ProjectSettings};
|
||||
@@ -68,6 +70,20 @@ impl Pipe {
|
||||
f.color_profile.unwrap_or(if self.space.is_some() { ColorSpace::Srgb } else { self.curve() })
|
||||
}
|
||||
|
||||
/// How float footage `f` (OpenEXR, float TIFF; linear light) is decoded: as stored when
|
||||
/// Interpret As Linear Light encodes it afterwards (once, with
|
||||
/// [`Pipe::linear_footage_space`]), else with the Working Gamma's curve when unmanaged and
|
||||
/// the sRGB curve otherwise (the working space converts from there).
|
||||
pub fn float_curve(&self, f: &Footage) -> crate::FloatCurve {
|
||||
if f.linear_light_applies() { crate::FloatCurve::Linear } else { self.float_encoding() }
|
||||
}
|
||||
|
||||
/// The curve float footage is encoded with when nothing interprets it afterwards: the
|
||||
/// Working Gamma's when unmanaged (sRGB at 2.2, a pure 2.4 power curve at 2.4), else sRGB.
|
||||
pub fn float_encoding(&self) -> crate::FloatCurve {
|
||||
if self.space.is_none() && self.gamma24 { crate::FloatCurve::Gamma24 } else { crate::FloatCurve::Srgb }
|
||||
}
|
||||
|
||||
/// Footage `f` from its colour profile (`None` = sRGB) → working space; nothing for
|
||||
/// Preserve RGB, whose values enter the working space as the file stores them.
|
||||
pub fn media_in(&self, f: &Footage) -> Option<Conversion> {
|
||||
|
||||
@@ -36,6 +36,21 @@ use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
|
||||
pub use eval::{EvalCtx, ExprHost, effect_bounds, source_size};
|
||||
use rayon::prelude::*;
|
||||
|
||||
/// How a [`FootageSource`] hands over float footage (OpenEXR, float TIFF), whose values are
|
||||
/// linear light: encoded with a curve, as the compositor takes footage, or as stored. Other
|
||||
/// footage is decoded the same way whatever it says. Interpret Footage ▸ Preserve RGB always
|
||||
/// reads the values as stored.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||||
pub enum FloatCurve {
|
||||
/// The sRGB curve (managed projects, and Working Gamma 2.2).
|
||||
#[default]
|
||||
Srgb,
|
||||
/// A pure 2.4 power curve (no working space, Working Gamma 2.4).
|
||||
Gamma24,
|
||||
/// The linear values as stored (Interpret As Linear Light encodes them afterwards).
|
||||
Linear,
|
||||
}
|
||||
|
||||
/// Supplies decoded footage frames (implemented by the media layer).
|
||||
pub trait FootageSource: Send + Sync {
|
||||
/// The frame of `item` at source time `t`, straight from the file (any size).
|
||||
@@ -47,6 +62,16 @@ pub trait FootageSource: Send + Sync {
|
||||
fn frame_at_size(&self, item: ItemId, footage: &Footage, t: Tick, _width: u32, _height: u32) -> Option<Arc<Image>> {
|
||||
self.frame(item, footage, t)
|
||||
}
|
||||
/// [`FootageSource::frame`] (`size: None`) or [`FootageSource::frame_at_size`] with float
|
||||
/// footage handed over as `float` says ([`FootageSource::frame`] uses [`FloatCurve::Srgb`]).
|
||||
/// Sources without float footage can leave the default, which ignores `float`.
|
||||
fn frame_with(&self, item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) -> Option<Arc<Image>> {
|
||||
let _ = float;
|
||||
match size {
|
||||
Some((w, h)) => self.frame_at_size(item, footage, t, w, h),
|
||||
None => self.frame(item, footage, t),
|
||||
}
|
||||
}
|
||||
/// `frames` interleaved stereo sample frames of `item`'s audio from source time `t` at
|
||||
/// `rate` Hz (`None` when unavailable). The first sample is `t.to_units_floor(rate)`;
|
||||
/// a sample boundary uses the earliest representable tick that selects that sample.
|
||||
@@ -1109,7 +1134,7 @@ impl<'a> Renderer<'a> {
|
||||
if let Some(pf) = self.proxy_for(*item)
|
||||
&& let Some(nc) = self.project.comp(*item)
|
||||
{
|
||||
let img = self.footage.frame(*item, pf, lt)?;
|
||||
let img = self.footage.frame_with(*item, pf, lt, None, self.pipe.float_encoding())?;
|
||||
return Some(self.footage_buf(&img, pf, nc.width, nc.height));
|
||||
}
|
||||
// Essential Properties overrides render the nested comp with this instance's
|
||||
@@ -1220,7 +1245,7 @@ impl<'a> Renderer<'a> {
|
||||
if f.fields != effectcraft_project::FieldOrder::Off && matches!(f.kind, FootageKind::Video | FootageKind::Sequence) {
|
||||
let field_rate = FrameRate::new(f.frame_rate.num * 2, f.frame_rate.den);
|
||||
let i = field_rate.frame_at(t).max(0);
|
||||
let img = self.footage.frame(item, f, f.frame_rate.tick_of(i / 2))?;
|
||||
let img = self.footage.frame_with(item, f, f.frame_rate.tick_of(i / 2), None, self.pipe.float_curve(f))?;
|
||||
let dominant_upper = f.fields == effectcraft_project::FieldOrder::UpperFirst;
|
||||
// Upper field = even lines (0, 2, …).
|
||||
let parity = if (i % 2 == 0) == dominant_upper { 0 } else { 1 };
|
||||
@@ -1234,6 +1259,7 @@ impl<'a> Renderer<'a> {
|
||||
|
||||
fn footage_frame_raw(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick, size: Option<(u32, u32)>) -> Option<Arc<Image>> {
|
||||
let mode = frame_blend_mode(ctx, layer);
|
||||
let float = self.pipe.float_curve(f);
|
||||
if mode != FrameBlend::Off && matches!(f.kind, FootageKind::Video | FootageKind::Sequence) {
|
||||
let (i, w) = frame_position(t, f.frame_rate);
|
||||
if w > 1e-6 {
|
||||
@@ -1241,7 +1267,7 @@ impl<'a> Renderer<'a> {
|
||||
let key = self.cache.map(|_| {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
(0x00f8_a3e5_u64, item, i, (w as f32).to_bits(), mode as u8, f.frame_rate.num, f.frame_rate.den, &f.path).hash(&mut h);
|
||||
(0x00f8_a3e5_u64, item, i, (w as f32).to_bits(), mode as u8, f.frame_rate.num, f.frame_rate.den, &f.path, float).hash(&mut h);
|
||||
h.finish()
|
||||
});
|
||||
if let (Some(c), Some(k)) = (self.cache, key)
|
||||
@@ -1249,8 +1275,8 @@ impl<'a> Renderer<'a> {
|
||||
{
|
||||
return Some(Arc::new(b.img.clone()));
|
||||
}
|
||||
let a = self.footage.frame(item, f, f.frame_rate.tick_of(i));
|
||||
let b = self.footage.frame(item, f, f.frame_rate.tick_of(i + 1));
|
||||
let a = self.footage.frame_with(item, f, f.frame_rate.tick_of(i), None, float);
|
||||
let b = self.footage.frame_with(item, f, f.frame_rate.tick_of(i + 1), None, float);
|
||||
if let (Some(a), Some(b)) = (a, b) {
|
||||
let img = blend_frames(mode, &a, &b, w as f32);
|
||||
if let (Some(c), Some(k)) = (self.cache, key) {
|
||||
@@ -1260,10 +1286,7 @@ impl<'a> Renderer<'a> {
|
||||
}
|
||||
}
|
||||
}
|
||||
match size {
|
||||
Some((w, h)) => self.footage.frame_at_size(item, f, t, w, h),
|
||||
None => self.footage.frame(item, f, t),
|
||||
}
|
||||
self.footage.frame_with(item, f, t, size, float)
|
||||
}
|
||||
|
||||
/// Source → masks, clamped/quantised to the project depth.
|
||||
|
||||
@@ -7,7 +7,7 @@ use effectcraft_project::build;
|
||||
use effectcraft_project::{AlphaMode, BitDepth, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
use crate::{FootageSource, Image, LayerCache, RenderOpts, Renderer, render_frame};
|
||||
use crate::{FloatCurve, FootageSource, Image, LayerCache, RenderOpts, Renderer, render_frame};
|
||||
|
||||
fn setup(depth: BitDepth) -> (Project, ItemId, Comp) {
|
||||
let mut p = Project::default();
|
||||
@@ -294,3 +294,48 @@ fn working_gamma_24_encodes_linear_footage_with_a_power_curve() {
|
||||
crate::color::Pipe::of(&s).key()
|
||||
});
|
||||
}
|
||||
|
||||
/// Float footage (an OpenEXR) the way `effectcraft-media` decodes it: linear values handed over
|
||||
/// as the renderer asks ([`FloatCurve`]).
|
||||
struct FloatFile([f32; 3]);
|
||||
impl FootageSource for FloatFile {
|
||||
fn frame(&self, item: ItemId, f: &Footage, t: Tick) -> Option<Arc<Image>> {
|
||||
self.frame_with(item, f, t, None, FloatCurve::Srgb)
|
||||
}
|
||||
fn frame_with(&self, _: ItemId, f: &Footage, _: Tick, _: Option<(u32, u32)>, float: FloatCurve) -> Option<Arc<Image>> {
|
||||
let c = self.0.map(|v| match float {
|
||||
FloatCurve::Srgb => ColorSpace::Srgb.encode(v),
|
||||
FloatCurve::Gamma24 => v.powf(1.0 / 2.4),
|
||||
FloatCurve::Linear => v,
|
||||
});
|
||||
Some(Arc::new(Image::filled(f.width, f.height, [c[0], c[1], c[2], 1.0])))
|
||||
}
|
||||
}
|
||||
|
||||
/// #750: with no working space, float footage is encoded with the Working Gamma's curve (as
|
||||
/// After Effects converts a linear EXR), and Interpret As Linear Light encodes it once, not on
|
||||
/// top of the decode's own encoding. A working space ignores Working Gamma.
|
||||
#[test]
|
||||
fn float_footage_is_encoded_once_with_the_working_gamma() {
|
||||
let render = |gamma: f64, ws: Option<ColorSpace>, linear_light: bool| {
|
||||
let (mut p, cid, comp) = setup(BitDepth::Bpc32);
|
||||
p.settings.working_gamma = gamma;
|
||||
p.settings.working_space = ws;
|
||||
let f = Footage { path: "x.exr".into(), linear_light, ..footage(None) };
|
||||
let fid = p.add_item("F", Label::Aqua, None, ItemKind::Footage(f));
|
||||
let l = build::layer(&mut p, &comp, "F", LayerSource::Footage { item: fid }, (40, 20), None);
|
||||
p.comp_mut(cid).unwrap().layers.push(l);
|
||||
let src = FloatFile([0.051, 0.18, 0.5]);
|
||||
Renderer::new(&p, &src, RenderOpts::default()).comp_frame(cid, Tick::ZERO).get(5, 5)
|
||||
};
|
||||
let near = |o: [f32; 4], want: [f32; 3]| (0..3).all(|i| (o[i] - want[i]).abs() < 2e-3);
|
||||
for linear_light in [false, true] {
|
||||
let o = render(2.4, None, linear_light);
|
||||
assert!(near(o, [0.290, 0.490, 0.749]), "2.4, linear light {linear_light}: {o:?}");
|
||||
let o = render(2.2, None, linear_light);
|
||||
assert!(near(o, [0.250, 0.461, 0.735]), "2.2, linear light {linear_light}: {o:?}");
|
||||
let srgb = render(2.2, Some(ColorSpace::Srgb), linear_light);
|
||||
assert!(near(srgb, [0.250, 0.461, 0.735]), "sRGB working space, linear light {linear_light}: {srgb:?}");
|
||||
assert_eq!(render(2.4, Some(ColorSpace::Srgb), linear_light), srgb);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
//! Pixel Motion Blur must synthesize blur from motion vectors, not average decoded frames
|
||||
//! (#535). Two bars move in opposite directions between discrete source frames; the gap
|
||||
//! pixels between their old and new positions must fill in, and Vector Detail must matter.
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{AlphaMode, BitDepth, Comp, Footage, FootageKind, FrameBlend, ItemId, ItemKind, LayerSource, Project};
|
||||
use effectcraft_render::{FootageSource, Image, RenderOpts, Renderer};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use std::sync::Arc;
|
||||
|
||||
// A 12×8 block with a horizontal brightness gradient slides right by 12 px between source
|
||||
// frames 9 and 10. The gradient gives the block matcher real texture, so Pixel Motion can find
|
||||
// the +12 vector and smear the block across the gap — unlike a flat bar, whose motion is
|
||||
// undefined (the aperture problem) and which no synthesizer can invent coverage for.
|
||||
struct Clip;
|
||||
impl FootageSource for Clip {
|
||||
fn frame(&self, _: ItemId, f: &Footage, t: Tick) -> Option<Arc<Image>> {
|
||||
let k = f.frame_rate.frame_at(t).clamp(0, 19);
|
||||
let dx = 12 * (k - 10);
|
||||
let mut image = Image::filled(64, 48, [0.0, 0.0, 0.0, 1.0]);
|
||||
for y in 0..48 {
|
||||
for x in 0..64 {
|
||||
if (16..32).contains(&y) && (20 + dx..32 + dx).contains(&x) {
|
||||
let g = (x - (20 + dx)) as f32 / 12.0;
|
||||
image.set(x as u32, y, [0.4 + 0.6 * g, 0.3, 0.2, 1.0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Arc::new(image))
|
||||
}
|
||||
}
|
||||
|
||||
fn scene(detail: Option<f64>) -> (Project, ItemId) {
|
||||
let mut p = Project::default();
|
||||
p.settings.bit_depth = BitDepth::Bpc32;
|
||||
let comp = Comp::new(64, 48, FrameRate::FPS_25, Tick::from_seconds_f64(0.8));
|
||||
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
|
||||
let footage = Footage {
|
||||
path: "generated.mov".into(),
|
||||
kind: FootageKind::Video,
|
||||
width: 64,
|
||||
height: 48,
|
||||
pixel_aspect: 1.0,
|
||||
frame_rate: FrameRate::FPS_25,
|
||||
duration: Tick::from_seconds_f64(0.8),
|
||||
has_video: true,
|
||||
has_audio: false,
|
||||
alpha: AlphaMode::Ignore,
|
||||
missing: false,
|
||||
..Default::default()
|
||||
};
|
||||
let fid = p.add_item("Clip", Label::Aqua, None, ItemKind::Footage(footage));
|
||||
let mut layer = build::layer(&mut p, &comp, "Clip", LayerSource::Footage { item: fid }, (64, 48), None);
|
||||
layer.switches.frame_blend = FrameBlend::Off;
|
||||
layer.switches.motion_blur = false;
|
||||
if let Some(detail) = detail {
|
||||
let spec = effectcraft_effects::find("ec.time.pixelmotionblur").unwrap();
|
||||
let mut next = p.next_id;
|
||||
let mut effect = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [64.0, 48.0]);
|
||||
for (name, value) in [
|
||||
("shutterControl", Value::Enum(1)),
|
||||
("shutterAngle", Value::Scalar(180.0)),
|
||||
("shutterSamples", Value::Scalar(16.0)),
|
||||
("vectorDetail", Value::Scalar(detail)),
|
||||
] {
|
||||
effect.prop_mut(name).unwrap().value = value;
|
||||
}
|
||||
p.next_id = next;
|
||||
layer.props.sub_mut("effects").unwrap().children.push(effect.into());
|
||||
}
|
||||
p.comp_mut(cid).unwrap().layers.push(layer);
|
||||
(p, cid)
|
||||
}
|
||||
|
||||
fn render(p: &Project, cid: ItemId, time: f64) -> Image {
|
||||
Renderer::new(p, &Clip, RenderOpts::default()).comp_frame(cid, Tick::from_seconds_f64(time))
|
||||
}
|
||||
|
||||
fn difference(a: &Image, b: &Image) -> f64 {
|
||||
a.data.iter().zip(&b.data).flat_map(|(p, q)| (0..4).map(move |c| (p[c] - q[c]).abs() as f64)).sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_motion_blur_synthesizes_from_motion_vectors() {
|
||||
let (plain, cid) = scene(None);
|
||||
let still = render(&plain, cid, 0.4);
|
||||
let average = average_of(&plain, cid);
|
||||
let (low, low_id) = scene(Some(0.0));
|
||||
let (high, high_id) = scene(Some(100.0));
|
||||
let a = render(&low, low_id, 0.4);
|
||||
let b = render(&high, high_id, 0.4);
|
||||
assert!(difference(&b, &still) > 1.0, "the blur equals the still frame");
|
||||
assert!(difference(&b, &average) > 1.0, "the blur is just the plain overlay of the two decoded positions");
|
||||
assert!((24..32).any(|x| b.get(x, 24)[0] > 0.2), "no motion trail behind the moving block: {:?}", (18..46).map(|x| b.get(x, 24)[0]).collect::<Vec<f32>>());
|
||||
assert!(difference(&a, &b) > 0.5, "Vector Detail is inert: the motion field does not change with it");
|
||||
}
|
||||
|
||||
fn average_of(p: &Project, cid: ItemId) -> Image {
|
||||
let mut average = Image::new(64, 48);
|
||||
for i in 0..16 {
|
||||
let time = ((0.39 + 0.02 * i as f64 / 15.0) * 1e6).round() / 1e6;
|
||||
let frame = render(p, cid, time);
|
||||
for (p, q) in average.data.iter_mut().zip(&frame.data) {
|
||||
for c in 0..4 {
|
||||
p[c] += q[c] / 16.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
average
|
||||
}
|
||||
@@ -18,6 +18,7 @@ effectcraft-render = { workspace = true }
|
||||
effectcraft-path = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
boa_engine = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
|
||||
# On the web, boa's clock and Math.random need the browser (getrandom `wasm_js`, web-time).
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
//! ExtendScript's preprocessor directives, from the JavaScript Tools Guide's description of their
|
||||
//! behaviour: `#include "file"` (and `//@include "file"`) inlines a file in place of the line,
|
||||
//! resolved against the including file's folder, then the folders the latest `#includepath "a;b"`
|
||||
//! (or `//@includepath`) named; `#target`, `#targetengine`, `#strict`, `#script` and `#engine`
|
||||
//! are dropped. Each file is inlined once (which also breaks include cycles), nesting is limited
|
||||
//! to [`MAX_DEPTH`], files and their total size are capped, and reads follow the scripts' file
|
||||
//! gate ([`crate::scriptfile::check_read`]). Directives are recognised on lines of their own.
|
||||
//!
|
||||
//! Errors in the expanded code are mapped back to the file and line they came from.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
|
||||
use crate::runtime::ScriptError;
|
||||
use crate::scriptfile::{self, MAX_DEPTH, MAX_FILE_BYTES};
|
||||
|
||||
/// The most text a script and the files it includes may add up to.
|
||||
const MAX_TOTAL_BYTES: usize = 4 * MAX_FILE_BYTES;
|
||||
|
||||
/// A script with its includes inlined.
|
||||
pub(crate) struct Expanded {
|
||||
pub(crate) code: String,
|
||||
/// Per line of `code`: the file (index into `files`) and 1-based line it came from.
|
||||
lines: Vec<(usize, u32)>,
|
||||
/// The script itself, then each included file.
|
||||
files: Vec<String>,
|
||||
}
|
||||
|
||||
impl Expanded {
|
||||
/// Point an error in the expanded code at the file and line it came from.
|
||||
pub(crate) fn locate(&self, e: &mut ScriptError) {
|
||||
if self.files.first() != Some(&e.file) {
|
||||
return;
|
||||
}
|
||||
let Some(&(file, line)) = e.line.and_then(|l| l.checked_sub(1)).and_then(|l| self.lines.get(l as usize)) else { return };
|
||||
if let Some(name) = self.files.get(file) {
|
||||
e.file = name.clone();
|
||||
e.line = Some(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Directive<'a> {
|
||||
Include(&'a str),
|
||||
IncludePath(&'a str),
|
||||
Ignored,
|
||||
}
|
||||
|
||||
/// The preprocessor directive on `line`, if it is one.
|
||||
fn directive(line: &str) -> Option<Directive<'_>> {
|
||||
let t = line.trim();
|
||||
let (rest, comment) = match t.strip_prefix("//@") {
|
||||
Some(r) => (r, true),
|
||||
None => (t.strip_prefix('#')?, false),
|
||||
};
|
||||
let end = rest.find(|c: char| !c.is_ascii_alphabetic()).unwrap_or(rest.len());
|
||||
let (word, arg) = rest.split_at(end);
|
||||
if !(arg.is_empty() || arg.starts_with(char::is_whitespace) || arg.starts_with(['"', '\''])) {
|
||||
return None;
|
||||
}
|
||||
let arg = unquote(arg);
|
||||
match word {
|
||||
"include" => Some(Directive::Include(arg)),
|
||||
"includepath" => Some(Directive::IncludePath(arg)),
|
||||
// `//@target aftereffects` and friends are plain comments already.
|
||||
"target" | "targetengine" | "strict" | "script" | "engine" if !comment => Some(Directive::Ignored),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A directive's argument: `"x"`, `'x'` or `x`, with an optional trailing `;`.
|
||||
fn unquote(arg: &str) -> &str {
|
||||
let a = arg.trim();
|
||||
let a = a.strip_suffix(';').map_or(a, str::trim_end);
|
||||
for q in ['"', '\''] {
|
||||
if let Some(inner) = a.strip_prefix(q).and_then(|r| r.split(q).next()) {
|
||||
return inner;
|
||||
}
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
struct Walk<'a> {
|
||||
s: &'a Session,
|
||||
/// The running script's folder: the file gate lets it load files below it.
|
||||
root: Option<PathBuf>,
|
||||
/// The latest `#includepath`.
|
||||
search: Vec<PathBuf>,
|
||||
seen: HashSet<PathBuf>,
|
||||
total: usize,
|
||||
out: Expanded,
|
||||
}
|
||||
|
||||
impl Walk<'_> {
|
||||
fn push(&mut self, line: &str, file: usize, n: usize) {
|
||||
if !self.out.lines.is_empty() {
|
||||
self.out.code.push('\n');
|
||||
}
|
||||
self.out.code.push_str(line);
|
||||
self.out.lines.push((file, u32::try_from(n + 1).unwrap_or(u32::MAX)));
|
||||
}
|
||||
|
||||
/// Inline `code` (file `file`, in folder `dir`), `depth` includes deep.
|
||||
fn walk(&mut self, code: &str, file: usize, dir: Option<&Path>, depth: usize) -> Result<(), ScriptError> {
|
||||
for (n, line) in code.split('\n').enumerate() {
|
||||
let err = |w: &Self, message: String| ScriptError {
|
||||
message,
|
||||
line: Some(u32::try_from(n + 1).unwrap_or(u32::MAX)),
|
||||
column: None,
|
||||
file: w.out.files.get(file).cloned().unwrap_or_default(),
|
||||
};
|
||||
match directive(line) {
|
||||
None => self.push(line, file, n),
|
||||
Some(Directive::Ignored) => self.push("", file, n),
|
||||
Some(Directive::IncludePath(paths)) => {
|
||||
self.search =
|
||||
paths.split(';').map(str::trim).filter(|p| !p.is_empty()).filter_map(|p| scriptfile::resolve(self.s, p, dir, &[]).ok()).collect();
|
||||
self.push("", file, n);
|
||||
}
|
||||
Some(Directive::Include(name)) => {
|
||||
if name.is_empty() {
|
||||
return Err(err(self, "#include needs a file name".into()));
|
||||
}
|
||||
let path = scriptfile::resolve(self.s, name, dir, &self.search).map_err(|e| err(self, format!("#include {e}")))?;
|
||||
if !self.seen.insert(path.clone()) {
|
||||
// Already inlined (or the file including it): once is enough.
|
||||
self.push("", file, n);
|
||||
continue;
|
||||
}
|
||||
if depth >= MAX_DEPTH {
|
||||
return Err(err(self, format!("#include \"{name}\": includes are nested too deeply (at most {MAX_DEPTH})")));
|
||||
}
|
||||
scriptfile::check_read(self.s, &path, self.root.as_deref()).map_err(|e| err(self, e))?;
|
||||
let text = scriptfile::read(self.s, &path).map_err(|e| err(self, e))?;
|
||||
self.total = self.total.saturating_add(text.len());
|
||||
if self.total > MAX_TOTAL_BYTES {
|
||||
return Err(err(self, format!("#include \"{name}\": the included files exceed {} MiB", MAX_TOTAL_BYTES >> 20)));
|
||||
}
|
||||
// The included lines take the directive's place.
|
||||
self.out.files.push(path.to_string_lossy().to_string());
|
||||
let idx = self.out.files.len().saturating_sub(1);
|
||||
self.walk(&text, idx, path.parent(), depth + 1)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// `code` (the script `name`) with its directives applied, or `None` when it has none.
|
||||
pub(crate) fn expand(s: &Session, code: &str, name: &str) -> Result<Option<Expanded>, ScriptError> {
|
||||
if !code.split('\n').any(|l| directive(l).is_some()) {
|
||||
return Ok(None);
|
||||
}
|
||||
let root = scriptfile::script_dir(s, name);
|
||||
let mut w = Walk {
|
||||
s,
|
||||
root: root.clone(),
|
||||
search: vec![],
|
||||
seen: HashSet::new(),
|
||||
total: code.len(),
|
||||
out: Expanded { code: String::with_capacity(code.len()), lines: vec![], files: vec![if name.is_empty() { "script" } else { name }.to_string()] },
|
||||
};
|
||||
if root.is_some() {
|
||||
w.seen.insert(crate::runtime::normalize(name));
|
||||
}
|
||||
// A script without a folder (the Script Console, code run by name) looks in the project's.
|
||||
let dir = root.or_else(|| crate::runtime::project_dir(s).map(|d| crate::runtime::normalize(&d)));
|
||||
w.walk(code, 0, dir.as_deref(), 0)?;
|
||||
Ok(Some(w.out))
|
||||
}
|
||||
@@ -14,7 +14,8 @@
|
||||
//! ```
|
||||
//!
|
||||
//! * **Object model** (`prelude.js`): `app` (project, open/newProject, undo groups,
|
||||
//! `executeCommand`/`findMenuCommandId`, `scheduleTask`, `settings` kept in the settings
|
||||
//! `executeCommand`/`findMenuCommandId`, `scheduleTask` (one-shot and repeating, see
|
||||
//! [`run_tasks`]), `settings` kept in the settings
|
||||
//! folder), `Project`, `ItemCollection`,
|
||||
//! `CompItem`/`FootageItem`/`FolderItem`, `LayerCollection`, `AVLayer`/`TextLayer`/
|
||||
//! `ShapeLayer`/`CameraLayer`/`LightLayer`, `PropertyGroup`/`Property` (values, keyframes,
|
||||
@@ -53,15 +54,19 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![recursion_limit = "256"]
|
||||
|
||||
mod include;
|
||||
pub mod matchnames;
|
||||
mod model;
|
||||
mod runtime;
|
||||
mod scriptfile;
|
||||
mod socket;
|
||||
mod tasks;
|
||||
mod ui;
|
||||
|
||||
use effectcraft_engine::{ScriptRequest, Session};
|
||||
|
||||
pub use runtime::{Outcome, ScriptError, run};
|
||||
pub use tasks::{MAX_TASKS, MIN_REPEAT_MS, run_tasks};
|
||||
pub use ui::dispatch_ui;
|
||||
|
||||
/// The [`effectcraft_engine::ScriptRunner`] this crate provides.
|
||||
@@ -73,6 +78,7 @@ pub fn runner(s: &mut Session, req: &ScriptRequest) -> serde_json::Value {
|
||||
pub fn install(s: &mut Session) {
|
||||
s.script = Some(runner);
|
||||
s.script_ui.dispatch = Some(dispatch_ui);
|
||||
s.script_ui.tasks = Some(run_tasks);
|
||||
}
|
||||
|
||||
/// Run `code` as a one-off script named `name`.
|
||||
@@ -83,10 +89,16 @@ pub fn run_code(s: &mut Session, code: &str, name: &str) -> Outcome {
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_callable;
|
||||
#[cfg(test)]
|
||||
mod tests_ease_presets;
|
||||
#[cfg(test)]
|
||||
mod tests_include;
|
||||
#[cfg(test)]
|
||||
mod tests_panels;
|
||||
#[cfg(test)]
|
||||
mod tests_tasks;
|
||||
#[cfg(test)]
|
||||
mod tests_ui;
|
||||
#[cfg(test)]
|
||||
mod tests_ui_more;
|
||||
|
||||
@@ -340,6 +340,25 @@ var __attr = {
|
||||
},
|
||||
};
|
||||
|
||||
// Layers, property groups and properties are callable, like After Effects: `layer("Effects")`,
|
||||
// `fx(1)` and `effect("Slider Control")("Slider")` are `.property(...)`. A Proxy is callable
|
||||
// only around a function, so the object's state moves onto an arrow function (no `prototype`;
|
||||
// its own `name` and `length` are removed so the object's getters show) with the object's
|
||||
// prototype; `instanceof` and attribute access are unchanged.
|
||||
function __callable(o) {
|
||||
var f = () => undefined;
|
||||
delete f.name;
|
||||
delete f.length;
|
||||
Object.setPrototypeOf(f, Object.getPrototypeOf(o));
|
||||
var keys = Object.keys(o);
|
||||
for (var i = 0; i < keys.length; i++) f[keys[i]] = o[keys[i]];
|
||||
var self = new Proxy(f, {
|
||||
get: __attr.get,
|
||||
apply: function (t, thisArg, args) { return self.property(args[0]); },
|
||||
});
|
||||
return self;
|
||||
}
|
||||
|
||||
// 1-based collections with `[n]` access.
|
||||
var __coll = {
|
||||
get: function (t, k, r) {
|
||||
@@ -604,7 +623,7 @@ function PropertyGroup(layer, uid) {
|
||||
this.__layer = layer;
|
||||
this.__uid = uid;
|
||||
this.__kind = "PropertyGroup";
|
||||
return new Proxy(this, __attr);
|
||||
return __callable(this);
|
||||
}
|
||||
PropertyGroup.prototype = Object.create(PropertyBase.prototype);
|
||||
PropertyGroup.prototype.constructor = PropertyGroup;
|
||||
@@ -647,7 +666,7 @@ function MaskPropertyGroup(layer, uid) {
|
||||
this.__layer = layer;
|
||||
this.__uid = uid;
|
||||
this.__kind = "MaskPropertyGroup";
|
||||
return new Proxy(this, __attr);
|
||||
return __callable(this);
|
||||
}
|
||||
MaskPropertyGroup.prototype = Object.create(PropertyGroup.prototype);
|
||||
MaskPropertyGroup.prototype.constructor = MaskPropertyGroup;
|
||||
@@ -679,7 +698,7 @@ function Property(layer, uid) {
|
||||
this.__layer = layer;
|
||||
this.__uid = uid;
|
||||
this.__kind = "Property";
|
||||
return new Proxy(this, __attr);
|
||||
return __callable(this);
|
||||
}
|
||||
Property.prototype = Object.create(PropertyBase.prototype);
|
||||
Property.prototype.constructor = Property;
|
||||
@@ -981,7 +1000,7 @@ Layer.prototype = {
|
||||
this.__comp = comp;
|
||||
this.__id = id;
|
||||
this.__kind = kind;
|
||||
return new Proxy(this, __attr);
|
||||
return __callable(this);
|
||||
},
|
||||
__info: function () { return __get("layer", { comp: this.__comp, layer: this.__id }); },
|
||||
__p: function (o) {
|
||||
@@ -1804,7 +1823,7 @@ var app = {
|
||||
if (!cmd) throw __err("Unknown command id " + id);
|
||||
return __call(cmd, {});
|
||||
},
|
||||
scheduleTask: function (code, delay, repeat) { return __task("schedule", String(code), !!repeat); },
|
||||
scheduleTask: function (code, delay, repeat) { return __task("schedule", String(code), Number(delay), !!repeat); },
|
||||
cancelTask: function (id) { __task("cancel", id); },
|
||||
setMemoryUsageLimits: function () {},
|
||||
setMultiFrameRenderingConfig: function () {},
|
||||
|
||||
@@ -65,18 +65,18 @@ pub(crate) struct Active {
|
||||
pub(crate) output: Vec<String>,
|
||||
/// Open undo groups: (name, project snapshot when the outermost group began).
|
||||
pub(crate) groups: Vec<(String, Arc<Project>)>,
|
||||
/// `app.scheduleTask` queue: (id, code, repeat).
|
||||
pub(crate) tasks: Vec<(u32, String, bool)>,
|
||||
pub(crate) next_task: u32,
|
||||
pub(crate) name: String,
|
||||
/// The script context running (its `app.scheduleTask` tasks are kept under this id, see
|
||||
/// [`crate::tasks`]).
|
||||
pub(crate) host: u32,
|
||||
/// Running on a script-host thread: modal dialogs hand the session back to the caller
|
||||
/// through this link while they wait.
|
||||
pub(crate) link: Option<crate::ui::Link>,
|
||||
}
|
||||
|
||||
impl Active {
|
||||
pub(crate) fn new(session: Session, name: &str) -> Active {
|
||||
Active { session, output: vec![], groups: vec![], tasks: vec![], next_task: 0, name: name.to_string(), link: None }
|
||||
pub(crate) fn new(session: Session, name: &str, host: u32) -> Active {
|
||||
Active { session, output: vec![], groups: vec![], name: name.to_string(), host, link: None }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,36 +268,42 @@ pub(crate) fn collapse(s: &mut Session, name: &str, snap: &Arc<Project>) {
|
||||
h.merge_key = None;
|
||||
}
|
||||
|
||||
/// `__task("schedule", code, repeat)` → id; `__task("cancel", id)`.
|
||||
/// `__task("schedule", code, delayMs, repeat)` → id; `__task("cancel", id)` (see
|
||||
/// [`crate::tasks`]).
|
||||
fn native_task(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let op = arg_string(args, 0, ctx)?;
|
||||
if op == "cancel" {
|
||||
let id = args.get(1).cloned().unwrap_or_default().to_number(ctx)? as u32;
|
||||
let _ = with_active(|a| {
|
||||
a.tasks.retain(|t| t.0 != id);
|
||||
Ok(())
|
||||
});
|
||||
let id = args.get(1).cloned().unwrap_or_default().to_number(ctx)?;
|
||||
if id.is_finite() && id >= 0.0 && id <= f64::from(u32::MAX) {
|
||||
let _ = with_active(|a| {
|
||||
crate::tasks::cancel(a.host, id as u32);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
return Ok(JsValue::undefined());
|
||||
}
|
||||
let code = arg_string(args, 1, ctx)?;
|
||||
let repeat = args.get(2).cloned().unwrap_or_default().to_boolean();
|
||||
let delay = args.get(2).cloned().unwrap_or_default().to_number(ctx)?;
|
||||
let repeat = args.get(3).cloned().unwrap_or_default().to_boolean();
|
||||
let now = web_time::Instant::now();
|
||||
let id = with_active(|a| {
|
||||
a.next_task += 1;
|
||||
let id = a.next_task;
|
||||
a.tasks.push((id, code, repeat));
|
||||
let id = crate::tasks::schedule(a.host, &a.name, a.session.script_ui.project_epoch, code, delay, repeat, now)?;
|
||||
if !a.session.script_ui.task_hosts.contains(&a.host) {
|
||||
a.session.script_ui.task_hosts.push(a.host);
|
||||
}
|
||||
Ok(id)
|
||||
})
|
||||
.map_err(throw)?;
|
||||
Ok(JsValue::from(id))
|
||||
}
|
||||
|
||||
fn project_dir(s: &Session) -> Option<String> {
|
||||
pub(crate) fn project_dir(s: &Session) -> Option<String> {
|
||||
let p = s.path.as_deref()?;
|
||||
std::path::Path::new(p).parent().map(|d| d.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// Lexically normalized absolute path (`a/./b/../c` → `a/c`).
|
||||
fn normalize(p: &str) -> std::path::PathBuf {
|
||||
pub(crate) fn normalize(p: &str) -> std::path::PathBuf {
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
let path = Path::new(p);
|
||||
let abs = if path.is_absolute() { path.to_path_buf() } else { std::env::current_dir().unwrap_or_default().join(path) };
|
||||
@@ -415,7 +421,7 @@ pub(crate) fn new_context() -> Result<Context, String> {
|
||||
(js_string!("__query"), 2, native_query),
|
||||
(js_string!("__print"), 1, native_print),
|
||||
(js_string!("__undo"), 2, native_undo),
|
||||
(js_string!("__task"), 3, native_task),
|
||||
(js_string!("__task"), 4, native_task),
|
||||
(js_string!("__file"), 3, native_file),
|
||||
(js_string!("__sock"), 3, crate::socket::native_sock),
|
||||
(js_string!("__uiNewId"), 0, crate::ui::native_new_id),
|
||||
@@ -553,14 +559,11 @@ pub(crate) fn settle() -> Vec<String> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Evaluate a script file's code, then the one-shot tasks it scheduled (`app.scheduleTask`).
|
||||
pub(crate) fn eval_with_tasks(ctx: &mut Context, code: &str, file: &str) -> Result<J, ScriptError> {
|
||||
/// Evaluate a script file's code, then the one-shot tasks it scheduled (`app.scheduleTask`;
|
||||
/// repeating ones run later, see [`crate::tasks`]).
|
||||
pub(crate) fn eval_with_tasks(ctx: &mut Context, code: &str, file: &str, host: u32) -> Result<J, ScriptError> {
|
||||
let mut r = eval_in(ctx, code, file);
|
||||
let tasks = with_active(|a| Ok(std::mem::take(&mut a.tasks))).unwrap_or_default();
|
||||
for (_, code, repeat) in tasks {
|
||||
if repeat {
|
||||
continue;
|
||||
}
|
||||
for code in crate::tasks::take_one_shots(host) {
|
||||
if let Err(e) = eval_in(ctx, &code, "scheduled task")
|
||||
&& r.is_ok()
|
||||
{
|
||||
@@ -587,7 +590,23 @@ pub(crate) fn outcome(output: Vec<String>, result: Result<J, ScriptError>) -> Ou
|
||||
/// events reach the host through [`crate::dispatch_ui`]); palettes and panels keep their host
|
||||
/// alive for their handlers. The Script Console (and the web build) run inline, where dialogs
|
||||
/// don't block.
|
||||
///
|
||||
/// ExtendScript's `#include` / `#includepath` are inlined first, and errors point at the file
|
||||
/// and line they came from ([`crate::include`]).
|
||||
pub fn run(session: &mut Session, req: &ScriptRequest) -> Outcome {
|
||||
let expanded = match crate::include::expand(session, req.code, req.name) {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Outcome { error: Some(e), ..Default::default() },
|
||||
};
|
||||
let Some(x) = expanded else { return run_expanded(session, req) };
|
||||
let mut out = run_expanded(session, &ScriptRequest { code: &x.code, name: req.name, console: req.console });
|
||||
if let Some(e) = out.error.as_mut() {
|
||||
x.locate(e);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn run_expanded(session: &mut Session, req: &ScriptRequest) -> Outcome {
|
||||
if let Err(message) = check_nesting(req.code) {
|
||||
return Outcome { error: Some(ScriptError { message, file: req.name.into(), ..Default::default() }), ..Default::default() };
|
||||
}
|
||||
@@ -660,7 +679,7 @@ pub fn check_nesting(code: &str) -> Result<(), String> {
|
||||
pub(crate) fn run_inline(session: &mut Session, req: &ScriptRequest) -> Outcome {
|
||||
let host = if req.console { crate::ui::CONSOLE_HOST } else { crate::ui::new_host_id() };
|
||||
let s = std::mem::take(session);
|
||||
ACTIVE.with(|a| *a.borrow_mut() = Some(Active::new(s, req.name)));
|
||||
ACTIVE.with(|a| *a.borrow_mut() = Some(Active::new(s, req.name, host)));
|
||||
let guard = Restore { target: session };
|
||||
let file = if req.name.is_empty() { "script" } else { req.name };
|
||||
let (result, windows) = if req.console {
|
||||
@@ -681,10 +700,11 @@ pub(crate) fn run_inline(session: &mut Session, req: &ScriptRequest) -> Outcome
|
||||
} else {
|
||||
match new_context() {
|
||||
Ok(mut ctx) => {
|
||||
let r = eval_with_tasks(&mut ctx, req.code, file);
|
||||
let r = eval_with_tasks(&mut ctx, req.code, file, host);
|
||||
let windows = crate::ui::snapshot(&mut ctx);
|
||||
if windows.iter().any(|w| w.visible) {
|
||||
// Palettes and panels outlive the run: keep their context for the handlers.
|
||||
if windows.iter().any(|w| w.visible) || crate::tasks::has(host) {
|
||||
// Palettes, panels and repeating tasks outlive the run: keep their context
|
||||
// for the handlers and tasks.
|
||||
crate::ui::keep_inline(host, ctx);
|
||||
}
|
||||
(r, windows)
|
||||
@@ -694,6 +714,7 @@ pub(crate) fn run_inline(session: &mut Session, req: &ScriptRequest) -> Outcome
|
||||
};
|
||||
let output = settle();
|
||||
drop(guard);
|
||||
crate::tasks::set_epoch(host, session.script_ui.project_epoch);
|
||||
crate::ui::publish(session, host, req.name, windows);
|
||||
outcome(output, result)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
//! Script files a running script loads (`#include`, `$.evalFile`): where a name resolves, whether
|
||||
//! the script may read it, and the limits that keep hostile, huge or cyclic files from running
|
||||
//! away.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
|
||||
use crate::runtime::{GATE, normalize, project_dir};
|
||||
|
||||
/// How deep loaded files may nest (a loaded file loading another…).
|
||||
pub(crate) const MAX_DEPTH: usize = 32;
|
||||
/// The largest single file a script may load.
|
||||
pub(crate) const MAX_FILE_BYTES: usize = 16 << 20;
|
||||
|
||||
/// The folder of the script file `name` runs from: `None` for the Script Console and for scripts
|
||||
/// run by name or from code, which have no file.
|
||||
pub(crate) fn script_dir(s: &Session, name: &str) -> Option<PathBuf> {
|
||||
let parent = Path::new(name).parent().filter(|p| !p.as_os_str().is_empty())?;
|
||||
s.services.exists(name).then(|| normalize(&parent.to_string_lossy()))
|
||||
}
|
||||
|
||||
/// Resolve `name`: absolute paths as they are; relative ones against `dir` (the loading script's
|
||||
/// folder), then each of `search`. The first existing file wins; when none exists, the first
|
||||
/// candidate, so the read error names it.
|
||||
pub(crate) fn resolve(s: &Session, name: &str, dir: Option<&Path>, search: &[PathBuf]) -> Result<PathBuf, String> {
|
||||
if Path::new(name).is_absolute() {
|
||||
return Ok(normalize(name));
|
||||
}
|
||||
let candidates: Vec<PathBuf> = dir.into_iter().chain(search.iter().map(PathBuf::as_path)).map(|d| normalize(&d.join(name).to_string_lossy())).collect();
|
||||
candidates
|
||||
.iter()
|
||||
.find(|c| s.services.exists(&c.to_string_lossy()))
|
||||
.or(candidates.first())
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("cannot find \"{name}\": the script has no folder to look in (use an absolute path or #includepath)"))
|
||||
}
|
||||
|
||||
/// The read gate for loaded script files: files under the running script's own folder (`root`) or
|
||||
/// the project's folder; anywhere with the Allow Scripts to Write Files preference.
|
||||
pub(crate) fn check_read(s: &Session, path: &Path, root: Option<&Path>) -> Result<(), String> {
|
||||
if s.prefs.scripting.allow_scripts_write_files {
|
||||
return Ok(());
|
||||
}
|
||||
let project = project_dir(s).map(|d| normalize(&d));
|
||||
if root.into_iter().chain(project.as_deref()).any(|d| path.starts_with(d)) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(format!("Unable to read {}: scripts can only load files from their own or the project's folder unless {GATE} is on", path.display()))
|
||||
}
|
||||
|
||||
/// Read a script file as text (a UTF-8 byte order mark dropped), refusing files over
|
||||
/// [`MAX_FILE_BYTES`].
|
||||
pub(crate) fn read(s: &Session, path: &Path) -> Result<String, String> {
|
||||
let too_big = || format!("Unable to read {}: script files are limited to {} MiB", path.display(), MAX_FILE_BYTES >> 20);
|
||||
if std::fs::metadata(path).is_ok_and(|m| m.len() > MAX_FILE_BYTES as u64) {
|
||||
return Err(too_big());
|
||||
}
|
||||
let bytes = s.services.read_file(&path.to_string_lossy()).map_err(|e| format!("Unable to read {}: {e}", path.display()))?;
|
||||
if bytes.len() > MAX_FILE_BYTES {
|
||||
return Err(too_big());
|
||||
}
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
Ok(text.strip_prefix('\u{feff}').unwrap_or(&text).to_string())
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
//! `app.scheduleTask(code, delay, repeat)` / `app.cancelTask(id)`.
|
||||
//!
|
||||
//! Tasks belong to the script context (host, see [`crate::ui`]) that scheduled them and run in
|
||||
//! it. A script file's one-shot tasks run right after the script; everything else (repeating
|
||||
//! tasks, and one-shot tasks scheduled later by handlers or other tasks) runs when it is due, as
|
||||
//! frontends call [`run_tasks`] every frame (`ScriptUi::run_tasks`, `script.runTasks`).
|
||||
//!
|
||||
//! Lifetime: a repeating task runs every `delay` ms until `app.cancelTask(id)`, until it throws
|
||||
//! (the error is reported once and the task cancelled), or until its owner goes away: a script
|
||||
//! whose windows (ScriptUI panel, palette) were open stops when the last one closes; a script
|
||||
//! without windows (and the Script Console) keeps its tasks until the project is closed or
|
||||
//! replaced. Bounds: repeating tasks run at most every [`MIN_REPEAT_MS`], a script has at most
|
||||
//! [`MAX_TASKS`] pending tasks, every run has the script engine's usual limits, and a due task
|
||||
//! runs once per pass (no catch-up bursts after a long frame).
|
||||
//!
|
||||
//! Each thread keeps the tasks of the hosts it runs: a script-host thread its own, the caller's
|
||||
//! thread those of inline hosts (the Script Console, web runs).
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use boa_engine::Context;
|
||||
use effectcraft_engine::Session;
|
||||
use serde_json::{Value as J, json};
|
||||
use web_time::{Duration, Instant};
|
||||
|
||||
use crate::runtime::{ScriptError, eval_in};
|
||||
|
||||
/// The shortest interval of a repeating task (milliseconds).
|
||||
pub const MIN_REPEAT_MS: u64 = 50;
|
||||
/// The most tasks one script may have pending.
|
||||
pub const MAX_TASKS: usize = 64;
|
||||
/// The longest delay (milliseconds), as in browsers' `setTimeout`.
|
||||
const MAX_DELAY_MS: f64 = 2_147_483_647.0;
|
||||
|
||||
struct Task {
|
||||
id: u32,
|
||||
code: String,
|
||||
interval: Duration,
|
||||
due: Instant,
|
||||
repeat: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct HostTasks {
|
||||
next_id: u32,
|
||||
list: Vec<Task>,
|
||||
/// The script's name (inline hosts without windows run under it).
|
||||
name: String,
|
||||
/// [`effectcraft_engine::scriptui::ScriptUi::project_epoch`] when the host last ran (inline
|
||||
/// hosts; the caller tracks script-host threads).
|
||||
epoch: u64,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static TASKS: RefCell<BTreeMap<u32, HostTasks>> = const { RefCell::new(BTreeMap::new()) };
|
||||
}
|
||||
|
||||
/// Queue a task for `host`; its id.
|
||||
pub(crate) fn schedule(host: u32, name: &str, epoch: u64, code: String, delay_ms: f64, repeat: bool, now: Instant) -> Result<u32, String> {
|
||||
crate::runtime::check_nesting(&code).map_err(|e| format!("app.scheduleTask: {}", e.trim_start_matches("Error: ")))?;
|
||||
let ms = if delay_ms.is_finite() { delay_ms.clamp(0.0, MAX_DELAY_MS) } else { 0.0 } as u64;
|
||||
let interval = Duration::from_millis(if repeat { ms.max(MIN_REPEAT_MS) } else { ms });
|
||||
let due = now.checked_add(interval).ok_or("app.scheduleTask: the delay is too long")?;
|
||||
TASKS.with(|t| {
|
||||
let mut t = t.borrow_mut();
|
||||
let h = t.entry(host).or_default();
|
||||
if h.list.len() >= MAX_TASKS {
|
||||
return Err(format!("app.scheduleTask: a script can have at most {MAX_TASKS} scheduled tasks"));
|
||||
}
|
||||
h.next_id = h.next_id.wrapping_add(1).max(1);
|
||||
let id = h.next_id;
|
||||
h.list.push(Task { id, code, interval, due, repeat });
|
||||
h.name = name.to_string();
|
||||
h.epoch = epoch;
|
||||
Ok(id)
|
||||
})
|
||||
}
|
||||
|
||||
/// `app.cancelTask(id)`.
|
||||
pub(crate) fn cancel(host: u32, id: u32) {
|
||||
TASKS.with(|t| {
|
||||
if let Some(h) = t.borrow_mut().get_mut(&host) {
|
||||
h.list.retain(|task| task.id != id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Take the one-shot tasks of `host` (run right after a script file).
|
||||
pub(crate) fn take_one_shots(host: u32) -> Vec<String> {
|
||||
TASKS.with(|t| {
|
||||
let mut t = t.borrow_mut();
|
||||
let Some(h) = t.get_mut(&host) else { return vec![] };
|
||||
let (once, keep) = std::mem::take(&mut h.list).into_iter().partition(|task| !task.repeat);
|
||||
h.list = keep;
|
||||
once.into_iter().map(|task: Task| task.code).collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// Take the tasks due at `now`: one-shot tasks leave the queue, repeating ones are due again an
|
||||
/// interval from now. (id, code, repeat).
|
||||
fn take_due(host: u32, now: Instant) -> Vec<(u32, String, bool)> {
|
||||
TASKS.with(|t| {
|
||||
let mut t = t.borrow_mut();
|
||||
let Some(h) = t.get_mut(&host) else { return vec![] };
|
||||
let mut due = vec![];
|
||||
h.list.retain_mut(|task| {
|
||||
if task.due > now {
|
||||
return true;
|
||||
}
|
||||
due.push((task.id, task.code.clone(), task.repeat));
|
||||
match now.checked_add(task.interval) {
|
||||
Some(next) if task.repeat => {
|
||||
task.due = next;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
});
|
||||
due
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `host` has pending tasks.
|
||||
pub(crate) fn has(host: u32) -> bool {
|
||||
TASKS.with(|t| t.borrow().get(&host).is_some_and(|h| !h.list.is_empty()))
|
||||
}
|
||||
|
||||
/// When `host`'s next task is due.
|
||||
pub(crate) fn next_due(host: u32) -> Option<Instant> {
|
||||
TASKS.with(|t| t.borrow().get(&host).and_then(|h| h.list.iter().map(|task| task.due).min()))
|
||||
}
|
||||
|
||||
/// Forget `host`'s tasks.
|
||||
pub(crate) fn drop_host(host: u32) {
|
||||
TASKS.with(|t| t.borrow_mut().remove(&host));
|
||||
}
|
||||
|
||||
/// The hosts on this thread with tasks: (host, script name, epoch).
|
||||
pub(crate) fn hosts() -> Vec<(u32, String, u64)> {
|
||||
TASKS.with(|t| t.borrow().iter().filter(|(_, h)| !h.list.is_empty()).map(|(id, h)| (*id, h.name.clone(), h.epoch)).collect())
|
||||
}
|
||||
|
||||
pub(crate) fn set_epoch(host: u32, epoch: u64) {
|
||||
TASKS.with(|t| {
|
||||
if let Some(h) = t.borrow_mut().get_mut(&host) {
|
||||
h.epoch = epoch;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Run `host`'s due tasks in `ctx` (the session is active): how many ran, and the first error.
|
||||
/// A task that throws is cancelled (a repeating one would only repeat the error).
|
||||
pub(crate) fn run_due(ctx: &mut Context, host: u32, now: Instant) -> (usize, Option<ScriptError>) {
|
||||
let (mut ran, mut first) = (0, None);
|
||||
for (id, code, repeat) in take_due(host, now) {
|
||||
ran += 1;
|
||||
if let Err(mut e) = eval_in(ctx, &code, "scheduled task") {
|
||||
if repeat {
|
||||
cancel(host, id);
|
||||
e.message = format!("{} (repeating task {id} cancelled)", e.message);
|
||||
}
|
||||
let line = e.line.map(|l| format!(" (line {l})")).unwrap_or_default();
|
||||
let _ = crate::runtime::with_active(|a| {
|
||||
a.output.push(format!("Error in scheduled task {id}{line}: {}", e.message));
|
||||
Ok(())
|
||||
});
|
||||
first.get_or_insert(e);
|
||||
}
|
||||
}
|
||||
(ran, first)
|
||||
}
|
||||
|
||||
/// What a pass over the scheduled tasks did.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Report {
|
||||
ran: usize,
|
||||
output: Vec<String>,
|
||||
errors: Vec<ScriptError>,
|
||||
next: Option<Instant>,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
pub(crate) fn add(&mut self, out: crate::Outcome) {
|
||||
self.ran += out.result.as_u64().unwrap_or(0) as usize;
|
||||
self.output.extend(out.output);
|
||||
self.errors.extend(out.error);
|
||||
}
|
||||
|
||||
pub(crate) fn due(&mut self, at: Option<Instant>) {
|
||||
if let Some(at) = at {
|
||||
self.next = Some(self.next.map_or(at, |n| n.min(at)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The [`effectcraft_engine::scriptui::ScriptTaskRunner`] this crate provides: run the tasks
|
||||
/// that are due now.
|
||||
pub fn run_tasks(session: &mut Session) -> J {
|
||||
run_tasks_at(session, Instant::now())
|
||||
}
|
||||
|
||||
/// [`run_tasks`] at `now`.
|
||||
pub(crate) fn run_tasks_at(session: &mut Session, now: Instant) -> J {
|
||||
let mut report = Report::default();
|
||||
// Not from inside a script (`script.runTasks` through `app.executeCommand`).
|
||||
let running = crate::runtime::ACTIVE.with(|a| a.try_borrow().map(|g| g.is_some()).unwrap_or(true));
|
||||
if !running {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
crate::ui::tick_threaded(session, now, &mut report);
|
||||
crate::ui::tick_inline(session, now, &mut report);
|
||||
crate::ui::prune_task_hosts(session);
|
||||
}
|
||||
let errors: Vec<J> = report.errors.iter().map(|e| json!({"message": e.message, "line": e.line, "column": e.column, "file": e.file})).collect();
|
||||
let next = report.next.map(|n| n.saturating_duration_since(now).as_millis() as u64);
|
||||
json!({"ran": report.ran, "output": report.output, "errors": errors, "next": next})
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//! Layers, property groups and properties are callable like After Effects (#758):
|
||||
//! `layer("ADBE Effect Parade")`, `fxGroup(1)` and `fx("Slider")` are `.property(...)`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::keyframe::Value as KV;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Outcome, run_code};
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session { expr: Some(Arc::new(effectcraft_expr::Expressions)), expr_check: Some(effectcraft_expr::check_syntax), ..Default::default() };
|
||||
crate::install(&mut s);
|
||||
s
|
||||
}
|
||||
|
||||
fn ok(s: &mut Session, code: &str) -> Outcome {
|
||||
let o = run_code(s, code, "test.jsx");
|
||||
assert!(o.error.is_none(), "script failed: {:?}\noutput: {:?}", o.error, o.output);
|
||||
o
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_groups_and_properties_are_callable() {
|
||||
let mut s = session();
|
||||
let o = ok(
|
||||
&mut s,
|
||||
r#"
|
||||
var c = app.project.items.addComp("M", 200, 200, 1, 2, 30);
|
||||
var l = c.layers.addSolid([1, 1, 1], "S", 200, 200, 1);
|
||||
l("ADBE Effect Parade").addProperty("ADBE Slider Control");
|
||||
var fxGroup = l("Effects");
|
||||
var fx = fxGroup(1);
|
||||
fx("Slider").setValue(42);
|
||||
l("ADBE Mask Parade").addProperty("ADBE Mask Atom");
|
||||
var pos = l("ADBE Transform Group")("ADBE Position");
|
||||
[
|
||||
l("ADBE Effect Parade").matchName, fxGroup.numProperties, fx.matchName, fx.name,
|
||||
l("Effects")("Slider Control")("Slider").value, fxGroup(1)(1).value,
|
||||
l("Masks")(1).matchName, l("Masks")(1)("ADBE Mask Shape").name,
|
||||
pos.matchName, pos.name, pos.propertyIndex, pos.parentProperty("Opacity").value,
|
||||
pos.parentProperty.parentProperty(1).matchName === l.property(1).matchName,
|
||||
l("Nope") === null, fxGroup(9) === null, pos("anything") === null,
|
||||
l instanceof AVLayer, fxGroup instanceof PropertyGroup, pos instanceof Property, l.name,
|
||||
l.transform.position.matchName, l.Effects(1).matchName
|
||||
]
|
||||
"#,
|
||||
);
|
||||
assert_eq!(
|
||||
o.result,
|
||||
json!([
|
||||
"ADBE Effect Parade",
|
||||
1,
|
||||
"ADBE Slider Control",
|
||||
"Slider Control",
|
||||
42,
|
||||
42,
|
||||
"ADBE Mask Atom",
|
||||
"Mask Path",
|
||||
"ADBE Position",
|
||||
"Position",
|
||||
2,
|
||||
100,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
"S",
|
||||
"ADBE Position",
|
||||
"ADBE Slider Control"
|
||||
])
|
||||
);
|
||||
let c = s.project.items.values().find_map(|i| i.as_comp()).expect("comp");
|
||||
let l = c.layers.first().expect("layer");
|
||||
assert_eq!(l.props.prop("effects/#1/slider").map(|p| p.value.clone()), Some(KV::Scalar(42.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calling_without_a_name_is_a_script_error() {
|
||||
let mut s = session();
|
||||
for code in [
|
||||
"var c = app.project.items.addComp('M', 100, 100, 1, 1, 25); var l = c.layers.addNull(); l();",
|
||||
"var c = app.project.items.addComp('M', 100, 100, 1, 1, 25); var l = c.layers.addNull(); l('Transform')(null);",
|
||||
"var c = app.project.items.addComp('M', 100, 100, 1, 1, 25); var l = c.layers.addNull(); new l('Transform');",
|
||||
] {
|
||||
let o = run_code(&mut s, code, "test.jsx");
|
||||
assert!(o.error.is_some(), "expected an error for {code}");
|
||||
}
|
||||
// A called layer still reports itself as a layer, and its JSON result is unchanged.
|
||||
let o = ok(&mut s, "var c = app.project.items.addComp('N', 100, 100, 1, 1, 25); var l = c.layers.addNull(); l.name = 'Ctl'; l");
|
||||
assert_eq!(o.result, json!({ "type": "[object AVLayer]", "name": "Ctl" }));
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//! #759: ExtendScript's `#include` / `//@include` / `#includepath` directives.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Outcome, run_code};
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::default();
|
||||
crate::install(&mut s);
|
||||
s
|
||||
}
|
||||
|
||||
fn tmp(tag: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("ec-include-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn write(path: &Path, text: &str) {
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, text).unwrap();
|
||||
}
|
||||
|
||||
/// Run the script file `main` (its code read from disk, as File ▸ Scripts ▸ Run Script File does).
|
||||
fn run_file(s: &mut Session, main: &Path) -> Outcome {
|
||||
let code = std::fs::read_to_string(main).unwrap();
|
||||
run_code(s, &code, &main.to_string_lossy())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn includes_are_inlined_relative_to_the_script() {
|
||||
let dir = tmp("ok");
|
||||
write(&dir.join("lib/math.jsx"), "#include \"util.jsxinc\"\nfunction twice(x) { return 2 * x; }");
|
||||
write(&dir.join("lib/util.jsxinc"), "// includes its includer back: inlined once only\n#include \"math.jsx\"\nvar unit = 'px';");
|
||||
write(&dir.join("vendor/extra.jsx"), "var extra = 40;");
|
||||
write(
|
||||
&dir.join("main.jsx"),
|
||||
"#target aftereffects\n#targetengine \"main\"\n#strict on\n//@include 'lib/math.jsx'\n#includepath \"vendor;lib\"\n#include \"extra.jsx\";\n#include \"util.jsxinc\"\n[twice(1) + extra, unit, $.fileName.split('/').pop()]",
|
||||
);
|
||||
let mut s = session();
|
||||
let o = run_file(&mut s, &dir.join("main.jsx"));
|
||||
assert!(o.error.is_none(), "{:?}", o.error);
|
||||
assert_eq!(o.result, json!([42, "px", "main.jsx"]));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_point_at_the_file_and_line_they_came_from() {
|
||||
let dir = tmp("err");
|
||||
write(&dir.join("lib.jsx"), "var a = 1;\nvar b = 2;\nnotDefined();");
|
||||
write(&dir.join("main.jsx"), "// main\n#include \"lib.jsx\"\nvar c = 3;");
|
||||
let mut s = session();
|
||||
let e = run_file(&mut s, &dir.join("main.jsx")).error.expect("an error");
|
||||
assert!(e.file.ends_with("lib.jsx") && e.line == Some(3), "{e:?}");
|
||||
// After the include, lines in the script itself keep their numbers.
|
||||
write(&dir.join("lib.jsx"), "var a = 1;\nvar b = 2;");
|
||||
write(&dir.join("main.jsx"), "// main\n#include \"lib.jsx\"\nvar c = 3;\nalsoNotDefined();");
|
||||
let e = run_file(&mut s, &dir.join("main.jsx")).error.expect("an error");
|
||||
assert!(e.file.ends_with("main.jsx") && e.line == Some(4), "{e:?}");
|
||||
// A missing file is a clear error on the directive's line.
|
||||
write(&dir.join("main.jsx"), "var x = 1;\n#include \"missing.jsx\"");
|
||||
let e = run_file(&mut s, &dir.join("main.jsx")).error.expect("an error");
|
||||
assert!(e.message.contains("missing.jsx") && e.line == Some(2), "{e:?}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_includes_are_bounded_and_gated() {
|
||||
let dir = tmp("hostile");
|
||||
// A chain deeper than the limit is an error, not a stack overflow.
|
||||
for i in 0..100 {
|
||||
write(&dir.join(format!("chain/{i}.jsx")), &format!("#include \"{}.jsx\"", i + 1));
|
||||
}
|
||||
write(&dir.join("chain/100.jsx"), "1");
|
||||
let mut s = session();
|
||||
let e = run_file(&mut s, &dir.join("chain/0.jsx")).error.expect("an error");
|
||||
assert!(e.message.contains("nested too deeply"), "{e:?}");
|
||||
// Files outside the script's (and the project's) folder need the file preference.
|
||||
write(&dir.join("outside.jsx"), "var secret = 7;");
|
||||
write(&dir.join("app/main.jsx"), "#include \"../outside.jsx\"\nsecret");
|
||||
let e = run_file(&mut s, &dir.join("app/main.jsx")).error.expect("blocked");
|
||||
assert!(e.message.contains("Allow Scripts to Write Files"), "{e:?}");
|
||||
// Code run without a file can't include relative to anything but the project's folder.
|
||||
let e = run_code(&mut s, "#include \"outside.jsx\"", "console").error.expect("no folder");
|
||||
assert!(e.message.contains("outside.jsx"), "{e:?}");
|
||||
s.prefs.scripting.allow_scripts_write_files = true;
|
||||
let o = run_file(&mut s, &dir.join("app/main.jsx"));
|
||||
assert_eq!(o.result, json!(7), "{:?}", o.error);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
//! `app.scheduleTask` / `app.cancelTask` (#773): repeating tasks run whenever they are due (frontends
|
||||
//! tick [`crate::run_tasks`] every frame) until they are cancelled, throw, or their owner goes
|
||||
//! away; one-shot tasks scheduled later run when due too.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_engine::{ScriptRequest, Session};
|
||||
use serde_json::{Value, json};
|
||||
use web_time::{Duration, Instant};
|
||||
|
||||
use crate::run_code;
|
||||
use crate::tasks::run_tasks_at;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session { expr: Some(Arc::new(effectcraft_expr::Expressions)), ..Default::default() };
|
||||
crate::install(&mut s);
|
||||
s.config = Some(Arc::new(effectcraft_engine::config::MemoryConfig::default()));
|
||||
s
|
||||
}
|
||||
|
||||
fn ok(s: &mut Session, code: &str) {
|
||||
let o = run_code(s, code, "tasks.jsx");
|
||||
assert!(o.error.is_none(), "script failed: {:?}\noutput: {:?}", o.error, o.output);
|
||||
}
|
||||
|
||||
fn console(s: &mut Session, code: &str) -> crate::Outcome {
|
||||
let o = crate::run(s, &ScriptRequest { code, name: "console", console: true });
|
||||
assert!(o.error.is_none(), "console failed: {:?}", o.error);
|
||||
o
|
||||
}
|
||||
|
||||
fn ms(n: u64) -> Duration {
|
||||
Duration::from_millis(n)
|
||||
}
|
||||
|
||||
fn output(r: &Value) -> Vec<String> {
|
||||
r["output"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect()
|
||||
}
|
||||
|
||||
fn comps(s: &Session) -> usize {
|
||||
s.project.items.values().filter(|i| i.as_comp().is_some()).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repeating_task_runs_every_interval_until_cancelled() {
|
||||
let mut s = session();
|
||||
ok(
|
||||
&mut s,
|
||||
"var n = 0;\nvar id = app.scheduleTask(\"n++; writeLn('tick ' + n); app.project.items.addComp('C' + n, 64, 64, 1, 1, 25); if (n == 3) app.cancelTask(id);\", 1000, true);",
|
||||
);
|
||||
let t0 = Instant::now();
|
||||
// Not due yet: nothing runs, and the frontend learns when to come back.
|
||||
let r = run_tasks_at(&mut s, t0);
|
||||
assert_eq!(r["ran"], json!(0));
|
||||
assert!(r["next"].as_u64().is_some_and(|n| n <= 1000), "{r}");
|
||||
let r = run_tasks_at(&mut s, t0 + ms(1500));
|
||||
assert_eq!((r["ran"].clone(), output(&r)), (json!(1), vec!["tick 1".to_string()]), "{r}");
|
||||
assert_eq!(comps(&s), 1, "the task edits the project through engine commands");
|
||||
// Due again an interval after it ran, once per pass (no catch-up burst).
|
||||
assert_eq!(run_tasks_at(&mut s, t0 + ms(1500))["ran"], json!(0));
|
||||
assert_eq!(run_tasks_at(&mut s, t0 + ms(2600))["ran"], json!(1));
|
||||
let r = run_tasks_at(&mut s, t0 + ms(9000));
|
||||
assert_eq!(output(&r), vec!["tick 3"]);
|
||||
// Cancelled: the script (no windows, no tasks) has ended.
|
||||
let r = run_tasks_at(&mut s, t0 + ms(20_000));
|
||||
assert_eq!((r["ran"].clone(), r["next"].clone()), (json!(0), Value::Null), "{r}");
|
||||
assert_eq!(comps(&s), 3);
|
||||
assert!(s.history.undo.len() >= 3, "every run is undoable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panels_task_refreshes_it_until_the_panel_closes() {
|
||||
let mut s = session();
|
||||
ok(
|
||||
&mut s,
|
||||
r#"
|
||||
var w = new Window("palette", "Clock");
|
||||
var label = w.add("statictext", undefined, "0");
|
||||
var n = 0;
|
||||
app.scheduleTask("n++; label.text = String(n);", 200, true);
|
||||
w.show();
|
||||
"#,
|
||||
);
|
||||
let win = s.script_ui.windows[0].id;
|
||||
let text = |s: &Session| s.script_ui.window(win).map(|w| w.root.children[0].text.clone());
|
||||
let t0 = Instant::now();
|
||||
run_tasks_at(&mut s, t0 + ms(500));
|
||||
run_tasks_at(&mut s, t0 + ms(1000));
|
||||
assert_eq!(text(&s).as_deref(), Some("2"), "the window shows what the task set");
|
||||
// A project change doesn't stop a script whose window is open.
|
||||
s.execute("file.closeProject", json!({})).unwrap();
|
||||
assert_eq!(run_tasks_at(&mut s, t0 + ms(1500))["ran"], json!(1));
|
||||
assert_eq!(text(&s).as_deref(), Some("3"));
|
||||
// Closing the window ends the script and its tasks.
|
||||
s.execute("scriptui.close", json!({"window": win})).unwrap();
|
||||
let r = run_tasks_at(&mut s, t0 + ms(5000));
|
||||
assert_eq!((r["ran"].clone(), r["next"].clone()), (json!(0), Value::Null), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_throwing_task_is_reported_once_and_cancelled() {
|
||||
let mut s = session();
|
||||
ok(&mut s, "app.scheduleTask('undefinedThing.go()', 100, true); app.scheduleTask('writeLn(\"fine\")', 100, true);");
|
||||
let t0 = Instant::now();
|
||||
let r = run_tasks_at(&mut s, t0 + ms(500));
|
||||
assert_eq!(r["ran"], json!(2));
|
||||
let errors = r["errors"].as_array().cloned().unwrap_or_default();
|
||||
assert_eq!(errors.len(), 1, "{r}");
|
||||
assert!(errors[0]["message"].as_str().is_some_and(|m| m.contains("undefinedThing") && m.contains("cancelled")), "{r}");
|
||||
// The other task goes on; the failing one doesn't run again.
|
||||
let r = run_tasks_at(&mut s, t0 + ms(1000));
|
||||
assert_eq!((r["ran"].clone(), output(&r), r["errors"].clone()), (json!(1), vec!["fine".to_string()], json!([])), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tasks_are_bounded() {
|
||||
let mut s = session();
|
||||
// Repeating tasks run at most every MIN_REPEAT_MS.
|
||||
ok(&mut s, "var hits = 0; app.scheduleTask('hits++', 0, true);");
|
||||
let t0 = Instant::now();
|
||||
assert_eq!(run_tasks_at(&mut s, t0 + ms(crate::MIN_REPEAT_MS) + ms(20))["ran"], json!(1));
|
||||
assert_eq!(run_tasks_at(&mut s, t0 + ms(crate::MIN_REPEAT_MS) + ms(30))["ran"], json!(0));
|
||||
// At most MAX_TASKS pending tasks per script.
|
||||
let o = run_code(&mut s, "for (var i = 0; i < 1000; i++) app.scheduleTask('1', 1000, true);", "many.jsx");
|
||||
assert!(o.error.as_ref().is_some_and(|e| e.message.contains(&format!("at most {}", crate::MAX_TASKS))), "{:?}", o.error);
|
||||
// Task code nested too deeply for the parser is refused up front.
|
||||
let deep = format!("app.scheduleTask('{}', 10, false);", "(".repeat(10_000));
|
||||
let o = run_code(&mut s, &deep, "deep.jsx");
|
||||
assert!(o.error.as_ref().is_some_and(|e| e.message.contains("nested too deeply")), "{:?}", o.error);
|
||||
// Odd delays and ids are harmless.
|
||||
ok(
|
||||
&mut s,
|
||||
"app.cancelTask(-1); app.cancelTask(NaN); app.cancelTask(1e20); app.scheduleTask('1', NaN, false); app.scheduleTask('1', -5, false); app.scheduleTask('1', 1e300, true);",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_the_project_stops_tasks_of_scripts_without_windows() {
|
||||
let mut s = session();
|
||||
ok(&mut s, "app.scheduleTask('writeLn(1)', 100, true);");
|
||||
console(&mut s, "var cid = app.scheduleTask('writeLn(2)', 100, true);");
|
||||
let t0 = Instant::now();
|
||||
assert_eq!(run_tasks_at(&mut s, t0 + ms(500))["ran"], json!(2));
|
||||
s.execute("file.closeProject", json!({})).unwrap();
|
||||
let r = run_tasks_at(&mut s, t0 + ms(5000));
|
||||
assert_eq!((r["ran"].clone(), r["next"].clone()), (json!(0), Value::Null), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn console_tasks_run_in_the_console_and_cancel_there() {
|
||||
let mut s = session();
|
||||
console(&mut s, "var hits = 0; var tid = app.scheduleTask('hits++', 100, true); app.scheduleTask('writeLn(\"once \" + hits)', 300, false);");
|
||||
let t0 = Instant::now();
|
||||
assert_eq!(run_tasks_at(&mut s, t0 + ms(200))["ran"], json!(1));
|
||||
let r = run_tasks_at(&mut s, t0 + ms(400));
|
||||
assert_eq!((r["ran"].clone(), output(&r)), (json!(2), vec!["once 2".to_string()]), "{r}");
|
||||
assert_eq!(console(&mut s, "app.cancelTask(tid); hits").result, json!(2));
|
||||
let r = run_tasks_at(&mut s, t0 + ms(5000));
|
||||
assert_eq!((r["ran"].clone(), r["next"].clone()), (json!(0), Value::Null), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handlers_schedule_one_shot_tasks_that_run_when_due() {
|
||||
let mut s = session();
|
||||
ok(
|
||||
&mut s,
|
||||
r#"
|
||||
var w = new Window("palette", "Later");
|
||||
var b = w.add("button", undefined, "Go", { name: "go" });
|
||||
b.onClick = function () { app.scheduleTask("writeLn('later')", 100, false); };
|
||||
w.show();
|
||||
"#,
|
||||
);
|
||||
s.execute("scriptui.click", json!({"widget": "go"})).unwrap();
|
||||
let r = run_tasks_at(&mut s, Instant::now() + ms(500));
|
||||
assert_eq!(output(&r), vec!["later"], "{r}");
|
||||
// The engine command runs the same pass (headless hosts, agents).
|
||||
assert_eq!(s.execute("script.runTasks", json!({})).unwrap()["ran"], json!(0));
|
||||
s.execute("scriptui.close", json!({})).unwrap();
|
||||
}
|
||||
+167
-28
@@ -19,6 +19,7 @@ use boa_engine::{Context, JsResult, JsValue, js_string};
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::scriptui::{ScriptUiEvent, ScriptWindow, layout};
|
||||
use serde_json::{Value as J, json};
|
||||
use web_time::Instant;
|
||||
|
||||
use crate::runtime::{ACTIVE, Active, CONSOLE, Restore, ScriptError, arg_string, js_str, outcome, script_error, settle, throw, with_active};
|
||||
|
||||
@@ -132,6 +133,10 @@ pub(crate) enum ToHost {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
Start(Session),
|
||||
Event(Session, ScriptUiEvent),
|
||||
/// Run the scheduled tasks due at this time (see [`crate::tasks`]; the web has no
|
||||
/// script-host threads).
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
Tick(Session, Instant),
|
||||
}
|
||||
|
||||
impl ToHost {
|
||||
@@ -140,7 +145,7 @@ impl ToHost {
|
||||
match self {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
ToHost::Start(s) => s,
|
||||
ToHost::Event(s, _) => s,
|
||||
ToHost::Event(s, _) | ToHost::Tick(s, _) => s,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,6 +157,8 @@ pub(crate) struct FromHost {
|
||||
name: String,
|
||||
/// The host thread has ended (no windows left).
|
||||
done: bool,
|
||||
/// When its next scheduled task is due.
|
||||
next_due: Option<Instant>,
|
||||
}
|
||||
|
||||
/// The host thread's end of the hand-off.
|
||||
@@ -164,6 +171,12 @@ pub(crate) struct Link {
|
||||
struct HostEnd {
|
||||
tx: std::sync::mpsc::Sender<ToHost>,
|
||||
rx: std::sync::mpsc::Receiver<FromHost>,
|
||||
/// When the host's next scheduled task is due.
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
next_due: Option<Instant>,
|
||||
/// [`effectcraft_engine::scriptui::ScriptUi::project_epoch`] when the host last ran.
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
epoch: u64,
|
||||
}
|
||||
|
||||
fn hosts() -> &'static std::sync::Mutex<BTreeMap<u32, HostEnd>> {
|
||||
@@ -177,38 +190,57 @@ const _: fn() = || {
|
||||
send::<Session>();
|
||||
};
|
||||
|
||||
/// What wakes a script-host thread.
|
||||
enum Wake {
|
||||
Event(ScriptUiEvent),
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
Tick(Instant),
|
||||
}
|
||||
|
||||
/// On the host thread: hand the session (with the windows' state and what the script printed)
|
||||
/// back to the caller, and wait for the next event. `None` when the caller has gone.
|
||||
/// back to the caller, and wait for the next event. `None` when the caller has gone. Scheduled
|
||||
/// tasks wait while a modal dialog does.
|
||||
fn yield_wait(outcome: crate::Outcome, windows: Vec<ScriptWindow>, done: bool) -> Option<ScriptUiEvent> {
|
||||
let mut out = outcome;
|
||||
loop {
|
||||
match yield_wake(out, windows.clone(), done, None)? {
|
||||
Wake::Event(ev) => return Some(ev),
|
||||
Wake::Tick(_) => out = crate::Outcome { waiting: true, ..Default::default() },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`yield_wait`] that also wakes for the scheduled tasks due at `next_due`.
|
||||
fn yield_wake(outcome: crate::Outcome, windows: Vec<ScriptWindow>, done: bool, next_due: Option<Instant>) -> Option<Wake> {
|
||||
let (session, link, name) = ACTIVE.with(|a| {
|
||||
let mut a = a.borrow_mut();
|
||||
let a = a.as_mut()?;
|
||||
Some((std::mem::take(&mut a.session), a.link.take()?, a.name.clone()))
|
||||
})?;
|
||||
if link.tx.send(FromHost { session, outcome, windows, name, done }).is_err() {
|
||||
if link.tx.send(FromHost { session, outcome, windows, name, done, next_due }).is_err() {
|
||||
return None;
|
||||
}
|
||||
if done {
|
||||
return None;
|
||||
}
|
||||
match link.rx.recv() {
|
||||
Ok(ToHost::Event(s, ev)) => {
|
||||
ACTIVE.with(|a| {
|
||||
if let Some(a) = a.borrow_mut().as_mut() {
|
||||
a.session = s;
|
||||
a.link = Some(link);
|
||||
}
|
||||
});
|
||||
Some(ev)
|
||||
let (s, wake) = match link.rx.recv() {
|
||||
Ok(ToHost::Event(s, ev)) => (s, Wake::Event(ev)),
|
||||
Ok(ToHost::Tick(s, now)) => (s, Wake::Tick(now)),
|
||||
_ => return None,
|
||||
};
|
||||
ACTIVE.with(|a| {
|
||||
if let Some(a) = a.borrow_mut().as_mut() {
|
||||
a.session = s;
|
||||
a.link = Some(link);
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
});
|
||||
Some(wake)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn host_main(name: String, code: String, link: Link) {
|
||||
fn host_main(host: u32, name: String, code: String, link: Link) {
|
||||
let Ok(ToHost::Start(session)) = link.rx.recv() else { return };
|
||||
let mut a = Active::new(session, &name);
|
||||
let mut a = Active::new(session, &name, host);
|
||||
a.link = Some(link);
|
||||
ACTIVE.with(|c| *c.borrow_mut() = Some(a));
|
||||
let file = if name.is_empty() { "script".to_string() } else { name.clone() };
|
||||
@@ -220,19 +252,29 @@ fn host_main(name: String, code: String, link: Link) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
let r = crate::runtime::eval_with_tasks(&mut ctx, &code, &file);
|
||||
let r = crate::runtime::eval_with_tasks(&mut ctx, &code, &file, host);
|
||||
let mut out = outcome(settle(), r);
|
||||
let mut had_windows = false;
|
||||
loop {
|
||||
let windows = snapshot(&mut ctx);
|
||||
let open = windows.iter().any(|w| w.visible);
|
||||
match yield_wait(out, windows, !open) {
|
||||
Some(ev) => {
|
||||
had_windows |= open;
|
||||
// The host lives while its windows are open, or (a script that never showed one)
|
||||
// while it has scheduled tasks.
|
||||
let done = !open && (had_windows || !crate::tasks::has(host));
|
||||
match yield_wake(out, windows, done, crate::tasks::next_due(host)) {
|
||||
Some(Wake::Event(ev)) => {
|
||||
let r = dispatch_js(&mut ctx, &ev);
|
||||
out = outcome(settle(), r);
|
||||
}
|
||||
None => return,
|
||||
Some(Wake::Tick(now)) => {
|
||||
let (ran, error) = crate::tasks::run_due(&mut ctx, host, now);
|
||||
out = crate::Outcome { result: json!(ran), output: settle(), error, waiting: false };
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
crate::tasks::drop_host(host);
|
||||
}));
|
||||
// A panic: give the session back all the same.
|
||||
let left = ACTIVE.with(|c| c.borrow_mut().take());
|
||||
@@ -241,21 +283,21 @@ fn host_main(name: String, code: String, link: Link) {
|
||||
{
|
||||
let message = if body.is_err() { "the script engine stopped unexpectedly" } else { "the script ended" };
|
||||
let out = crate::Outcome { error: Some(ScriptError { message: message.into(), file, ..Default::default() }), ..Default::default() };
|
||||
let _ = link.tx.send(FromHost { session: std::mem::take(&mut a.session), outcome: out, windows: vec![], name, done: true });
|
||||
let _ = link.tx.send(FromHost { session: std::mem::take(&mut a.session), outcome: out, windows: vec![], name, done: true, next_due: None });
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for the host to hand the session back; keep its link while it has windows open.
|
||||
fn receive(session: &mut Session, host: u32, end: HostEnd) -> crate::Outcome {
|
||||
match end.rx.recv() {
|
||||
Ok(FromHost { session: s, outcome, windows, name, done }) => {
|
||||
Ok(FromHost { session: s, outcome, windows, name, done, next_due }) => {
|
||||
*session = s;
|
||||
publish(session, host, &name, windows);
|
||||
if done {
|
||||
session.script_ui.windows.retain(|w| w.host != host);
|
||||
session.script_ui.revision += 1;
|
||||
} else if let Ok(mut h) = hosts().lock() {
|
||||
h.insert(host, end);
|
||||
h.insert(host, HostEnd { next_due, epoch: session.script_ui.project_epoch, ..end });
|
||||
}
|
||||
outcome
|
||||
}
|
||||
@@ -271,8 +313,10 @@ pub(crate) fn run_threaded(session: &mut Session, req: &effectcraft_engine::Scri
|
||||
let (to_tx, to_rx) = std::sync::mpsc::channel();
|
||||
let (from_tx, from_rx) = std::sync::mpsc::channel();
|
||||
let (name, code) = (req.name.to_string(), req.code.to_string());
|
||||
let spawned =
|
||||
std::thread::Builder::new().name(format!("script-{host}")).stack_size(64 << 20).spawn(move || host_main(name, code, Link { rx: to_rx, tx: from_tx }));
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name(format!("script-{host}"))
|
||||
.stack_size(64 << 20)
|
||||
.spawn(move || host_main(host, name, code, Link { rx: to_rx, tx: from_tx }));
|
||||
if spawned.is_err() {
|
||||
return crate::runtime::run_inline(session, req);
|
||||
}
|
||||
@@ -280,7 +324,7 @@ pub(crate) fn run_threaded(session: &mut Session, req: &effectcraft_engine::Scri
|
||||
*session = msg.into_session();
|
||||
return crate::Outcome { error: Some(ScriptError { message: "the script host did not start".into(), ..Default::default() }), ..Default::default() };
|
||||
}
|
||||
receive(session, host, HostEnd { tx: to_tx, rx: from_rx })
|
||||
receive(session, host, HostEnd { tx: to_tx, rx: from_rx, next_due: None, epoch: 0 })
|
||||
}
|
||||
|
||||
/// Record the windows a host published (named after the script that made them).
|
||||
@@ -295,8 +339,11 @@ pub(crate) fn publish(session: &mut Session, host: u32, name: &str, mut windows:
|
||||
return;
|
||||
}
|
||||
session.script_ui.publish(host, windows);
|
||||
if host != CONSOLE_HOST && !session.script_ui.windows.iter().any(|w| w.host == host) {
|
||||
// Its last window closed: the context and its scheduled tasks go (a script that never showed
|
||||
// a window keeps them, see [`crate::tasks`]).
|
||||
if host != CONSOLE_HOST && !session.script_ui.windows.iter().any(|w| w.host == host) && (had || !crate::tasks::has(host)) {
|
||||
drop_inline(host);
|
||||
crate::tasks::drop_host(host);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,7 +377,7 @@ pub fn dispatch_ui(session: &mut Session, ev: &ScriptUiEvent) -> Result<J, Strin
|
||||
|
||||
fn dispatch_inline(session: &mut Session, host: u32, name: &str, ev: &ScriptUiEvent) -> crate::Outcome {
|
||||
let s = std::mem::take(session);
|
||||
ACTIVE.with(|a| *a.borrow_mut() = Some(Active::new(s, name)));
|
||||
ACTIVE.with(|a| *a.borrow_mut() = Some(Active::new(s, name, host)));
|
||||
let guard = Restore { target: session };
|
||||
let (r, windows) = if host == CONSOLE_HOST {
|
||||
CONSOLE.with(|c| match c.borrow_mut().as_mut() {
|
||||
@@ -354,3 +401,95 @@ fn dispatch_inline(session: &mut Session, host: u32, name: &str, ev: &ScriptUiEv
|
||||
publish(session, host, name, windows);
|
||||
outcome(output, r)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- scheduled tasks
|
||||
|
||||
/// Run the due tasks of the script-host threads; hosts without windows whose project was closed
|
||||
/// end.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn tick_threaded(session: &mut Session, now: Instant, report: &mut crate::tasks::Report) {
|
||||
let epoch = session.script_ui.project_epoch;
|
||||
let (mut due, mut stale) = (vec![], vec![]);
|
||||
if let Ok(mut h) = hosts().lock() {
|
||||
for host in session.script_ui.task_hosts.clone() {
|
||||
let Some(end) = h.get(&host) else { continue };
|
||||
let windowless = !session.script_ui.windows.iter().any(|w| w.host == host);
|
||||
if windowless && end.epoch != epoch {
|
||||
// Dropping its end lets the host thread finish.
|
||||
stale.extend(h.remove(&host));
|
||||
} else if end.next_due.is_some_and(|d| d <= now) {
|
||||
due.push(host);
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(stale);
|
||||
for host in due {
|
||||
let Some(end) = hosts().lock().ok().and_then(|mut h| h.remove(&host)) else { continue };
|
||||
if let Err(std::sync::mpsc::SendError(msg)) = end.tx.send(ToHost::Tick(std::mem::take(session), now)) {
|
||||
*session = msg.into_session();
|
||||
session.script_ui.windows.retain(|w| w.host != host);
|
||||
session.script_ui.revision += 1;
|
||||
continue;
|
||||
}
|
||||
report.add(receive(session, host, end));
|
||||
}
|
||||
if let Ok(h) = hosts().lock() {
|
||||
for host in &session.script_ui.task_hosts {
|
||||
report.due(h.get(host).and_then(|end| end.next_due));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the due tasks of the inline hosts on this thread (the Script Console, web runs); those
|
||||
/// without windows whose project was closed stop.
|
||||
pub(crate) fn tick_inline(session: &mut Session, now: Instant, report: &mut crate::tasks::Report) {
|
||||
for (host, name, epoch) in crate::tasks::hosts() {
|
||||
if !session.script_ui.task_hosts.contains(&host) {
|
||||
continue;
|
||||
}
|
||||
let windowless = !session.script_ui.windows.iter().any(|w| w.host == host);
|
||||
let alive = host == CONSOLE_HOST || INLINE.with(|m| m.borrow().contains_key(&host));
|
||||
if !alive || (windowless && epoch != session.script_ui.project_epoch) {
|
||||
crate::tasks::drop_host(host);
|
||||
if host != CONSOLE_HOST && windowless {
|
||||
drop_inline(host);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if crate::tasks::next_due(host).is_some_and(|d| d <= now) {
|
||||
report.add(tick_inline_host(session, host, &name, now));
|
||||
}
|
||||
if host != CONSOLE_HOST && !crate::tasks::has(host) && !session.script_ui.windows.iter().any(|w| w.host == host) {
|
||||
// A script without windows whose tasks have all run.
|
||||
drop_inline(host);
|
||||
}
|
||||
report.due(crate::tasks::next_due(host));
|
||||
}
|
||||
}
|
||||
|
||||
fn tick_inline_host(session: &mut Session, host: u32, name: &str, now: Instant) -> crate::Outcome {
|
||||
let s = std::mem::take(session);
|
||||
ACTIVE.with(|a| *a.borrow_mut() = Some(Active::new(s, name, host)));
|
||||
let guard = Restore { target: session };
|
||||
let run = |ctx: &mut Context| {
|
||||
let r = crate::tasks::run_due(ctx, host, now);
|
||||
(r, snapshot(ctx))
|
||||
};
|
||||
let ((ran, error), windows) = if host == CONSOLE_HOST {
|
||||
CONSOLE.with(|c| c.borrow_mut().as_mut().map(|ctx| run(ctx)))
|
||||
} else {
|
||||
INLINE.with(|m| m.borrow_mut().get_mut(&host).map(|ctx| run(ctx)))
|
||||
}
|
||||
.unwrap_or(((0, None), vec![]));
|
||||
let output = settle();
|
||||
drop(guard);
|
||||
crate::tasks::set_epoch(host, session.script_ui.project_epoch);
|
||||
publish(session, host, name, windows);
|
||||
crate::Outcome { result: json!(ran), output, error, waiting: false }
|
||||
}
|
||||
|
||||
/// Forget the session's task hosts that have ended (inline ones: that have no tasks left).
|
||||
pub(crate) fn prune_task_hosts(session: &mut Session) {
|
||||
let threaded = |host: &u32| hosts().lock().ok().is_some_and(|h| h.contains_key(host));
|
||||
session.script_ui.task_hosts.retain(|host| crate::tasks::has(*host) || threaded(host));
|
||||
}
|
||||
|
||||
@@ -58,6 +58,8 @@ pub struct FaceInfo {
|
||||
/// be requested the way After Effects scripts name fonts.
|
||||
pub full_name: String,
|
||||
pub postscript: Option<String>,
|
||||
/// The face has variation axes (an `fvar` table), read from the table directory at scan time.
|
||||
pub variable: bool,
|
||||
}
|
||||
|
||||
/// Something that can list fonts (system folders, a web font picker, a project's font folder…).
|
||||
@@ -150,6 +152,7 @@ fn info_from(n: FaceNames, data: FaceData, origin: &'static str) -> FaceInfo {
|
||||
native_family: n.native_family,
|
||||
full_name: n.full_name,
|
||||
postscript: n.postscript,
|
||||
variable: n.variable,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,6 +406,13 @@ pub fn native_families() -> std::collections::BTreeMap<String, String> {
|
||||
all_faces().into_iter().filter_map(|f| Some((f.info.family.clone(), f.info.native_family.clone()?))).collect()
|
||||
}
|
||||
|
||||
/// Families with at least one variable face (variation axes), for the font menu's Variable
|
||||
/// Fonts filter. Scans the system fonts first if that hasn't happened yet.
|
||||
pub fn variable_families() -> std::collections::BTreeSet<String> {
|
||||
scan_system();
|
||||
all_faces().into_iter().filter(|f| f.info.variable).map(|f| f.info.family.clone()).collect()
|
||||
}
|
||||
|
||||
/// Families with their style names, sorted by family; styles in weight order. This is what font
|
||||
/// menus list, so it scans the system fonts first if that hasn't happened yet (before, only a
|
||||
/// project asking for a font the bundled ones lack started the scan, and the menus offered the
|
||||
|
||||
+60
-1
@@ -19,6 +19,9 @@ pub struct FaceNames {
|
||||
/// The PostScript name (name id 6, e.g. `YuGothic-Bold`): what After Effects scripts and
|
||||
/// `.aep`-derived data call a font.
|
||||
pub postscript: Option<String>,
|
||||
/// A variable font: it has an `fvar` table with at least one axis (the font menu's
|
||||
/// Variable Fonts filter).
|
||||
pub variable: bool,
|
||||
}
|
||||
|
||||
fn be16(b: &[u8], o: usize) -> Option<u16> {
|
||||
@@ -66,12 +69,14 @@ fn read_face<R: Read + Seek>(r: &mut R, off: u64, index: u32) -> Option<FaceName
|
||||
let dir = read_at(r, off + 12, n * 16)?;
|
||||
let mut name = None;
|
||||
let mut os2 = None;
|
||||
let mut fvar = None;
|
||||
for i in 0..n {
|
||||
let rec = &dir[i * 16..i * 16 + 16];
|
||||
let (o, l) = (be32(rec, 8)? as u64, be32(rec, 12)? as usize);
|
||||
match &rec[0..4] {
|
||||
b"name" => name = Some((o, l)),
|
||||
b"OS/2" => os2 = Some((o, l)),
|
||||
b"fvar" => fvar = Some((o, l)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -92,7 +97,9 @@ fn read_face<R: Read + Seek>(r: &mut R, off: u64, index: u32) -> Option<FaceName
|
||||
}
|
||||
let sl = style.to_ascii_lowercase();
|
||||
italic |= sl.contains("italic") || sl.contains("oblique");
|
||||
Some(FaceNames { index, family, style, full_name, weight, italic, native_family, postscript })
|
||||
// fvar header: version (4), axesArrayOffset (2), reserved (2), axisCount (2).
|
||||
let variable = fvar.filter(|(_, l)| *l >= 10).and_then(|(fo, _)| read_at(r, fo, 10)).and_then(|fb| be16(&fb, 8)).is_some_and(|axes| axes > 0);
|
||||
Some(FaceNames { index, family, style, full_name, weight, italic, native_family, postscript, variable })
|
||||
}
|
||||
|
||||
/// A Windows Unicode name-table string by id in a language other than US English.
|
||||
@@ -165,5 +172,57 @@ mod tests {
|
||||
let n = read_faces_bytes(crate::fonts::NOTO_SERIF_REGULAR);
|
||||
assert_eq!(n[0].family, "Noto Serif");
|
||||
assert!(read_faces_bytes(b"not a font").is_empty());
|
||||
assert!(!f[0].variable && !n[0].variable, "the bundled fonts are static");
|
||||
}
|
||||
|
||||
/// A font file of just `name` (family, Regular) and, when `fvar` is given, an `fvar` table
|
||||
/// with that many axes (or a truncated one with `Some(None)`).
|
||||
fn names_font(family: &str, fvar: Option<Option<u16>>) -> Vec<u8> {
|
||||
let u16s = |v: &[u16]| -> Vec<u8> { v.iter().flat_map(|x| x.to_be_bytes()).collect() };
|
||||
let strings: Vec<Vec<u8>> = [family, "Regular"].iter().map(|s| s.encode_utf16().flat_map(|u| u.to_be_bytes()).collect()).collect();
|
||||
let mut name = u16s(&[0, 2, 30]);
|
||||
let mut off = 0;
|
||||
for (id, st) in strings.iter().enumerate() {
|
||||
name.extend(u16s(&[3, 1, 0x409, id as u16 + 1, st.len() as u16, off]));
|
||||
off += st.len() as u16;
|
||||
}
|
||||
strings.iter().for_each(|st| name.extend(st));
|
||||
let mut tables: Vec<(&[u8; 4], Vec<u8>)> = vec![];
|
||||
match fvar {
|
||||
Some(Some(n)) => {
|
||||
// Header, then `n` axis records: `wght` pinned at 400 (min = default = max).
|
||||
let mut t = u16s(&[1, 0, 16, 2, n, 20, 0, 4 + 4 * n]);
|
||||
(0..n).for_each(|_| t.extend(b"wght".iter().copied().chain(u16s(&[0x190, 0, 0x190, 0, 0x190, 0, 0, 256]))));
|
||||
tables.push((b"fvar", t));
|
||||
}
|
||||
Some(None) => tables.push((b"fvar", u16s(&[1, 0, 16]))),
|
||||
None => {}
|
||||
}
|
||||
tables.push((b"name", name));
|
||||
let mut out = u16s(&[1, 0, tables.len() as u16, 0, 0, 0]);
|
||||
let mut offset = 12 + 16 * tables.len();
|
||||
let mut body: Vec<u8> = vec![];
|
||||
for (tag, data) in &tables {
|
||||
out.extend(*tag);
|
||||
out.extend([0u8; 4]);
|
||||
out.extend((offset as u32).to_be_bytes());
|
||||
out.extend((data.len() as u32).to_be_bytes());
|
||||
body.extend(data);
|
||||
offset += data.len();
|
||||
}
|
||||
out.extend(body);
|
||||
out
|
||||
}
|
||||
|
||||
/// The scan marks a face with an `fvar` table that declares axes as variable, reading only
|
||||
/// the table directory and the fvar header; a static font, an fvar without axes and a
|
||||
/// truncated fvar are not.
|
||||
#[test]
|
||||
fn detects_variable_faces_from_fvar() {
|
||||
let v = read_faces_bytes(&names_font("Var Test", Some(Some(1))));
|
||||
assert_eq!((v.len(), v[0].family.as_str(), v[0].variable), (1, "Var Test", true), "{v:?}");
|
||||
assert!(!read_faces_bytes(&names_font("Static Test", None))[0].variable);
|
||||
assert!(!read_faces_bytes(&names_font("No Axes", Some(Some(0))))[0].variable);
|
||||
assert!(!read_faces_bytes(&names_font("Cut", Some(None)))[0].variable);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,26 +197,53 @@ mod tests {
|
||||
eprintln!("no variable font installed: skipped");
|
||||
return;
|
||||
};
|
||||
// An axis that changes widths (wdth or wght, else the first that moves).
|
||||
let a = axes.iter().find(|a| a.tag == "wdth" && a.max > a.min).or_else(|| axes.iter().find(|a| a.tag == "wght" && a.max > a.min)).unwrap_or(&axes[0]);
|
||||
let info = crate::fonts::face(face).info.clone();
|
||||
let st = |v: f32| crate::TextStyle {
|
||||
// An axis that moves the advance: `wdth` and `wght` usually do, but a monospaced family
|
||||
// keeps its advances fixed along `wght` (Google Sans Code does) and moves them along its
|
||||
// own axis instead, so try the axes in order and take the first one that moves. A face
|
||||
// with no axis carrying a range is the only case that skips: a variation path that broke
|
||||
// completely moves neither the advances nor the outlines, and that has to fail below.
|
||||
let st = |a: &FontAxis, v: f32| crate::TextStyle {
|
||||
family: info.family.clone(),
|
||||
style: info.style.clone(),
|
||||
size: 100.0,
|
||||
variations: vec![(a.tag.clone(), v)],
|
||||
..Default::default()
|
||||
};
|
||||
let (lo, hi) = (crate::layout::measure("Hamburgefonts", &st(a.min)), crate::layout::measure("Hamburgefonts", &st(a.max)));
|
||||
let ranged: Vec<&FontAxis> = axes.iter().filter(|a| a.max > a.min).collect();
|
||||
if ranged.is_empty() {
|
||||
eprintln!("no variable axis installed here carries a range: skipped");
|
||||
return;
|
||||
}
|
||||
let mut moving = None;
|
||||
for a in &ranged {
|
||||
let (lo, hi) = (crate::layout::measure("Hamburgefonts", &st(a, a.min)), crate::layout::measure("Hamburgefonts", &st(a, a.max)));
|
||||
if (lo - hi).abs() > 1.0 {
|
||||
moving = Some(((*a).clone(), lo, hi));
|
||||
break;
|
||||
}
|
||||
}
|
||||
if crate::resolve(&info.family, &info.style).face != face {
|
||||
eprintln!("variable face not reachable by family/style: skipped");
|
||||
return;
|
||||
}
|
||||
assert!((lo - hi).abs() > 1.0, "{} {}..{}: advances {lo} vs {hi}", a.tag, a.min, a.max);
|
||||
let ink = |v: f32| {
|
||||
let l = crate::layout::layout("H", &st(v), &crate::ParagraphStyle::default());
|
||||
let ink = |a: &FontAxis, v: f32| {
|
||||
let l = crate::layout::layout("H", &st(a, v), &crate::ParagraphStyle::default());
|
||||
l.glyphs.iter().map(|g| crate::glyph_outline(g).area().abs()).sum::<f64>()
|
||||
};
|
||||
assert!((ink(a.min) - ink(a.max)).abs() > 1.0);
|
||||
if let Some((a, lo, hi)) = moving {
|
||||
// The axis was picked because it moves the advance; the outline has to follow it too.
|
||||
assert!((lo - hi).abs() > 1.0, "{} {}..{}: advances {lo} vs {hi}", a.tag, a.min, a.max);
|
||||
let (i_lo, i_hi) = (ink(&a, a.min), ink(&a, a.max));
|
||||
assert!((i_lo - i_hi).abs() > 1.0, "{} {}..{}: outlines do not move", a.tag, a.min, a.max);
|
||||
} else {
|
||||
// Nothing here moves an advance. Holding the advances is what a monospaced family does
|
||||
// on purpose, but it is also what a broken variation path does, so this case still has
|
||||
// to fail rather than skip: the outline must move along `wght`, or, on a face without
|
||||
// one, along the first axis that carries a range.
|
||||
let a = axes.iter().find(|a| a.tag == "wght" && a.max > a.min).unwrap_or(ranged[0]);
|
||||
let (i_lo, i_hi) = (ink(a, a.min), ink(a, a.max));
|
||||
assert!((i_lo - i_hi).abs() > 1.0, "{} {}..{}: neither the advances nor the outlines move, so variation support looks broken", a.tag, a.min, a.max);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,8 @@ pub fn color_popup(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, c: &mut [f32; 3])
|
||||
let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos + vec2(0.0, 4.0)).show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| out = color_picker(ui, id, c, original));
|
||||
});
|
||||
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
// Escape is the picker's: consume it, so nothing later this pass takes it too.
|
||||
if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
|
||||
out.close = Some(false);
|
||||
}
|
||||
if pressed_outside(ui.ctx(), &area.response) {
|
||||
@@ -55,11 +56,30 @@ pub fn color_popup(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, c: &mut [f32; 3])
|
||||
ui.data_mut(|d| {
|
||||
d.insert_temp(id.with("open"), false);
|
||||
d.remove::<[f32; 3]>(orig_id);
|
||||
// Closed: the next Escape is the dialog's again.
|
||||
d.remove::<u64>(open_pass_id());
|
||||
});
|
||||
} else {
|
||||
// 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| d.insert_temp(open_pass_id(), pass));
|
||||
}
|
||||
out.changed
|
||||
}
|
||||
|
||||
fn open_pass_id() -> egui::Id {
|
||||
egui::Id::new("color-picker-open-pass")
|
||||
}
|
||||
|
||||
/// A colour picker is open: it was drawn, and stayed open, in this pass or the last one (so one
|
||||
/// that stops being drawn without closing, its dialog gone, doesn't count for long). Escape then
|
||||
/// belongs to the picker (it goes back to the original colour and closes), not to the dialog it
|
||||
/// was opened from. Closing clears this at once, so the next Escape reaches the dialog.
|
||||
pub fn open_recently(ctx: &egui::Context) -> bool {
|
||||
let pass: Option<u64> = ctx.data(|d| d.get_temp(open_pass_id()));
|
||||
pass.is_some_and(|p| p.saturating_add(1) >= ctx.cumulative_pass_nr())
|
||||
}
|
||||
|
||||
/// What the picker did this frame.
|
||||
#[derive(Default)]
|
||||
struct Outcome {
|
||||
|
||||
@@ -133,13 +133,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
app.session.state.selected_layers.iter().filter(|id| c.layer(**id).is_some_and(|l| matches!(l.source, LayerSource::Shape))).map(|id| id.0).collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let layer_paint = shape_layers
|
||||
.first()
|
||||
.and_then(|id| app.session.active_comp()?.layer(LayerId(*id)))
|
||||
.map(|l| effectcraft_engine::commands::shape_stroke::first_paint(l, l.layer_time(app.session.time())));
|
||||
if let Some((fill, stroke, width)) = layer_paint {
|
||||
let layer_paint = shape_layers.first().and_then(|id| app.session.active_comp()?.layer(LayerId(*id))).map(|l| {
|
||||
let lt = l.layer_time(app.session.time());
|
||||
(effectcraft_engine::commands::shape_stroke::first_paint(l, lt), effectcraft_engine::commands::shape_stroke::first_paint_kinds(l, lt))
|
||||
});
|
||||
if let Some(((fill, stroke, width), (fill_kind, stroke_kind))) = layer_paint {
|
||||
let rgb = |c: [f64; 4]| [c[0] as f32, c[1] as f32, c[2] as f32];
|
||||
let tool = &mut app.session.state.shape_tool;
|
||||
tool.fill.kind = fill_kind;
|
||||
tool.stroke.kind = stroke_kind;
|
||||
if let Some(c) = fill {
|
||||
tool.fill.color = rgb(c);
|
||||
}
|
||||
@@ -363,8 +365,7 @@ fn fill_stroke_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Pa
|
||||
if ui.input(|i| i.modifiers.alt) {
|
||||
let at = PaintKind::ALL.iter().position(|k| *k == tp.kind).unwrap_or(0);
|
||||
let next = PaintKind::ALL.get((at + 1) % PaintKind::ALL.len()).copied().unwrap_or_default();
|
||||
let k = format!("{key}Type");
|
||||
tool_options(app, json!({k: next.id()}));
|
||||
paint_kind(app, shape_layers, key, next);
|
||||
} else if tp.kind == PaintKind::Solid {
|
||||
widgets::open_popup(ui, picker_id(key));
|
||||
} else {
|
||||
@@ -376,7 +377,7 @@ fn fill_stroke_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Pa
|
||||
tool_options(app, json!({key: c}));
|
||||
paint(app, key, json!(c));
|
||||
}
|
||||
paint_options_popup(app, ui, key, &options, wr.left_bottom());
|
||||
paint_options_popup(app, ui, shape_layers, key, &options, wr.left_bottom());
|
||||
x = sr.max.x + 12.0;
|
||||
}
|
||||
let width = app.session.state.shape_tool.stroke_width;
|
||||
@@ -389,9 +390,28 @@ fn fill_stroke_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Pa
|
||||
r.max.x + 14.0
|
||||
}
|
||||
|
||||
/// A Fill or Stroke kind: for new shapes, and (as in After Effects) for the selected shape
|
||||
/// layers' fills or strokes, which None switches off (#732).
|
||||
fn paint_kind(app: &mut EffectcraftApp, shape_layers: &[u64], key: &str, kind: PaintKind) {
|
||||
let k_type = format!("{key}Type");
|
||||
tool_options(app, json!({&k_type: kind.id()}));
|
||||
if shape_layers.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut v = json!({"layers": shape_layers, k_type: kind.id()});
|
||||
// A Solid Color takes the swatch's colour.
|
||||
let color = if key == "fill" { app.session.state.shape_tool.fill.color } else { app.session.state.shape_tool.stroke.color };
|
||||
if let (PaintKind::Solid, Some(o)) = (kind, v.as_object_mut()) {
|
||||
o.insert(key.to_string(), json!(color));
|
||||
}
|
||||
if let Err(e) = app.session.execute("shape.fillStroke", v) {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill Options / Stroke Options: None, Solid Color, Linear Gradient or Radial Gradient, and the
|
||||
/// blend mode and opacity new shapes get (After Effects' dialogs, applied as they change).
|
||||
fn paint_options_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, key: &str, title: &str, pos: egui::Pos2) {
|
||||
fn paint_options_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, shape_layers: &[u64], key: &str, title: &str, pos: egui::Pos2) {
|
||||
let id = egui::Id::new(("tool-paint-options", key));
|
||||
if !widgets::popup_is_open(ui, id) {
|
||||
return;
|
||||
@@ -400,6 +420,7 @@ fn paint_options_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, key: &str, t
|
||||
let tp = if key == "fill" { &app.session.state.shape_tool.fill } else { &app.session.state.shape_tool.stroke }.clone();
|
||||
let blend_pop = id.with("blend");
|
||||
let mut set = None;
|
||||
let mut kind = None;
|
||||
let mut blend_rect = Rect::NOTHING;
|
||||
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| {
|
||||
@@ -416,8 +437,7 @@ fn paint_options_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, key: &str, t
|
||||
paint_swatch(&p, r.shrink(3.0), &ToolPaint { kind: k, ..tp.clone() }, resp.hovered(), &t);
|
||||
app.auto.add(&format!("header.{key}Options.{}", k.id()), r, k.label());
|
||||
if resp.clicked() {
|
||||
let k_type = format!("{key}Type");
|
||||
set = Some(json!({k_type: k.id()}));
|
||||
kind = Some(k);
|
||||
}
|
||||
}
|
||||
p.text(row(33.0) + vec2(134.0, 0.0), Align2::LEFT_CENTER, tp.kind.label(), Tokens::ui(11.5), t.text_dim);
|
||||
@@ -451,6 +471,9 @@ fn paint_options_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, key: &str, t
|
||||
if let Some(v) = set {
|
||||
tool_options(app, v);
|
||||
}
|
||||
if let Some(k) = kind {
|
||||
paint_kind(app, shape_layers, key, k);
|
||||
}
|
||||
}
|
||||
|
||||
/// A Fill or Stroke swatch: its colour, a gradient (white to black), or None's red slash.
|
||||
|
||||
@@ -426,6 +426,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str, &str)] = &[
|
||||
("comp.saveFrameAs", "File...", "文件..."),
|
||||
("comp.saveFrameAsPsd", "Photoshop Layers...", "Photoshop 图层..."),
|
||||
("comp.saveFrameAsExr", "ProEXR...", "ProEXR..."),
|
||||
("comp.copyFrame", "Copy Frame to Clipboard", "将帧复制到剪贴板"),
|
||||
("render.preRender", "Pre-render...", "预渲染..."),
|
||||
("render.saveCurrentPreview", "Save Current Preview...", "保存当前预览..."),
|
||||
("comp.openInEssentialGraphics", "Open in Essential Graphics", "在基本图形中打开"),
|
||||
@@ -1050,6 +1051,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str, &str)] = &[
|
||||
("comp.saveFrameAs", "File...", "檔案..."),
|
||||
("comp.saveFrameAsPsd", "Photoshop Layers...", "Photoshop 圖層..."),
|
||||
("comp.saveFrameAsExr", "ProEXR...", "ProEXR..."),
|
||||
("comp.copyFrame", "Copy Frame to Clipboard", "將影格拷貝到剪貼簿"),
|
||||
("render.preRender", "Pre-render...", "預先渲染..."),
|
||||
("render.saveCurrentPreview", "Save Current Preview...", "儲存目前預覽..."),
|
||||
("comp.openInEssentialGraphics", "Open in Essential Graphics", "在基本圖形中開啟"),
|
||||
@@ -1671,6 +1673,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[
|
||||
("comp.saveFrameAs", "File...", "ファイル..."),
|
||||
("comp.saveFrameAsPsd", "Photoshop Layers...", "Photoshopレイヤー..."),
|
||||
("comp.saveFrameAsExr", "ProEXR...", "ProEXR..."),
|
||||
("comp.copyFrame", "Copy Frame to Clipboard", "フレームをクリップボードにコピー"),
|
||||
("render.preRender", "Pre-render...", "プリレンダリング..."),
|
||||
("render.saveCurrentPreview", "Save Current Preview...", "現在のプレビューを保存..."),
|
||||
("comp.openInEssentialGraphics", "Open in Essential Graphics", "エッセンシャルグラフィックスで開く"),
|
||||
@@ -2295,6 +2298,7 @@ const UKRAINIAN: &[(&str, &str, &str)] = &[
|
||||
("comp.saveFrameAs", "File...", "Файл..."),
|
||||
("comp.saveFrameAsPsd", "Photoshop Layers...", "Шари Photoshop..."),
|
||||
("comp.saveFrameAsExr", "ProEXR...", "ProEXR..."),
|
||||
("comp.copyFrame", "Copy Frame to Clipboard", "Копіювати кадр у буфер обміну"),
|
||||
("render.preRender", "Pre-render...", "Попередній рендеринг..."),
|
||||
("render.saveCurrentPreview", "Save Current Preview...", "Зберегти поточний попередній перегляд..."),
|
||||
("comp.openInEssentialGraphics", "Open in Essential Graphics", "Відкрити в основній графіці"),
|
||||
@@ -2920,6 +2924,7 @@ const SPANISH: &[(&str, &str, &str)] = &[
|
||||
("comp.saveFrameAs", "File...", "Archivo..."),
|
||||
("comp.saveFrameAsPsd", "Photoshop Layers...", "Capas de Photoshop..."),
|
||||
("comp.saveFrameAsExr", "ProEXR...", "ProEXR..."),
|
||||
("comp.copyFrame", "Copy Frame to Clipboard", "Copiar fotograma al portapapeles"),
|
||||
("render.preRender", "Pre-render...", "Preprocesar..."),
|
||||
("render.saveCurrentPreview", "Save Current Preview...", "Guardar la previsualización actual..."),
|
||||
("comp.openInEssentialGraphics", "Open in Essential Graphics", "Abrir en Gráficos esenciales"),
|
||||
@@ -3543,6 +3548,7 @@ const PORTUGUESE: &[(&str, &str, &str)] = &[
|
||||
("comp.saveFrameAs", "File...", "Arquivo..."),
|
||||
("comp.saveFrameAsPsd", "Photoshop Layers...", "Camadas do Photoshop..."),
|
||||
("comp.saveFrameAsExr", "ProEXR...", "ProEXR..."),
|
||||
("comp.copyFrame", "Copy Frame to Clipboard", "Copiar quadro para a área de transferência"),
|
||||
("render.preRender", "Pre-render...", "Pré-renderização..."),
|
||||
("render.saveCurrentPreview", "Save Current Preview...", "Salvar pré-visualização atual..."),
|
||||
("comp.openInEssentialGraphics", "Open in Essential Graphics", "Abrir em Gráficos essenciais"),
|
||||
@@ -4165,6 +4171,7 @@ const CZECH: &[(&str, &str, &str)] = &[
|
||||
("comp.saveFrameAs", "File...", "Soubor..."),
|
||||
("comp.saveFrameAsPsd", "Photoshop Layers...", "Vrstvy Photoshopu..."),
|
||||
("comp.saveFrameAsExr", "ProEXR...", "ProEXR..."),
|
||||
("comp.copyFrame", "Copy Frame to Clipboard", "Kopírovat snímek do schránky"),
|
||||
("render.preRender", "Pre-render...", "Předvykreslit..."),
|
||||
("render.saveCurrentPreview", "Save Current Preview...", "Uložit aktuální náhled..."),
|
||||
("comp.openInEssentialGraphics", "Open in Essential Graphics", "Otevřít v základní grafice"),
|
||||
|
||||
@@ -147,6 +147,10 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
("Clear", "消去"),
|
||||
("Clear list", "リストを消去"),
|
||||
("Click a key to see its commands.", "キーをクリックすると、割り当てられたコマンドが表示されます。"),
|
||||
(
|
||||
"Click above the bar to add an opacity stop, below it to add a color stop.",
|
||||
"バーの上をクリックすると不透明度の分岐点、下をクリックするとカラーの分岐点を追加します。",
|
||||
),
|
||||
("Click adds a point (½ height = no change)", "クリックでポイントを追加(高さの½=変化なし)"),
|
||||
("Click adds a point; drag one off to remove", "クリックでポイントを追加、外へドラッグで削除"),
|
||||
("Click to make this viewer active", "クリックしてこのビューアーをアクティブ化"),
|
||||
@@ -240,6 +244,7 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
("Frame Render Time {}ms", "フレームのレンダリング時間 {}ms"),
|
||||
("Frame Target:", "フレームターゲット:"),
|
||||
("Grab bag", "いろいろ"),
|
||||
("Gradient Editor", "グラデーションエディター"),
|
||||
("Grid", "グリッド"),
|
||||
("Guide Layers: {}", "ガイドレイヤー:{}"),
|
||||
("Guides", "ガイド"),
|
||||
@@ -276,6 +281,7 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
("Light Type", "ライトの種類"),
|
||||
("Lighter", "明るく"),
|
||||
("Link Selected Property", "選択したプロパティをリンク"),
|
||||
("Location:", "位置:"),
|
||||
("Lock Aspect Ratio to {}", "アスペクト比を{}に固定"),
|
||||
("Lock to Zoom", "ズームにロック"),
|
||||
("Locked: {} — {}", "ロック済み:{} — {}"),
|
||||
@@ -324,6 +330,7 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
("No layer selected", "レイヤーが選択されていません"),
|
||||
("No matching effects or presets", "一致するエフェクトまたはプリセットはありません"),
|
||||
("No matching fonts", "一致するフォントはありません"),
|
||||
("Variable fonts only", "可変フォントのみ"),
|
||||
("No model credits were built into this copy.", "このビルドにはモデルのクレジットが含まれていません。"),
|
||||
("No nested compositions", "ネストされたコンポジションはありません"),
|
||||
("No shortcut", "ショートカットなし"),
|
||||
@@ -463,6 +470,7 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
("The Media Browser is not available here (use File ▸ Import)", "ここではメディアブラウザーを使用できません(「ファイル ▸ 読み込み」を使ってください)"),
|
||||
("The OpenEXR layer shown in red, green, blue and alpha", "赤・緑・青・アルファとして表示するOpenEXRレイヤー"),
|
||||
("The render queue is empty.", "レンダーキューは空です。"),
|
||||
("These adjacent keyframes cannot interpolate because their stop counts differ.", "これらの隣接するキーフレームは分岐点の数が異なるため、補間できません。"),
|
||||
("This ScriptUI panel has closed", "このScriptUIパネルは閉じられました"),
|
||||
("This change will create a new solid for this layer.", "この変更により、このレイヤー用に新しい平面が作成されます。"),
|
||||
("Time", "時間"),
|
||||
@@ -579,6 +587,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
|
||||
("Clear", "清除"),
|
||||
("Clear list", "清空列表"),
|
||||
("Click a key to see its commands.", "单击某个按键以查看其命令。"),
|
||||
("Click above the bar to add an opacity stop, below it to add a color stop.", "点击渐变条上方添加不透明度色标,点击下方添加颜色色标。"),
|
||||
("Click adds a point (½ height = no change)", "单击可添加点(上下滑动高度的一半 = 无变化)"),
|
||||
("Click adds a point; drag one off to remove", "单击可添加点;拖出一个即可删除"),
|
||||
("Click to make this viewer active", "单击可激活此视图"),
|
||||
@@ -675,6 +684,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
|
||||
("Frame Target:", "帧目标:"),
|
||||
("Freeze", "冻结"),
|
||||
("Grab bag", "列表"),
|
||||
("Gradient Editor", "渐变编辑器"),
|
||||
("Grid", "网格"),
|
||||
("Guide Layers: {}", "参考图层:{}"),
|
||||
("Guides", "参考线"),
|
||||
@@ -713,6 +723,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
|
||||
("Light Type", "灯光类型"),
|
||||
("Lighter", "更浅"),
|
||||
("Link Selected Property", "链接所选属性"),
|
||||
("Location:", "位置:"),
|
||||
("Lock Aspect Ratio to {}", "锁定长宽比为 {}"),
|
||||
("Lock to Zoom", "锁定缩放"),
|
||||
("Locked: {} — {}", "已锁定:{} — {}"),
|
||||
@@ -758,6 +769,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
|
||||
("No layer selected", "未选择图层"),
|
||||
("No matching effects or presets", "没有匹配的效果或预设"),
|
||||
("No matching fonts", "没有匹配的字体"),
|
||||
("Variable fonts only", "仅可变字体"),
|
||||
("No model credits were built into this copy.", "此副本未内置模型致谢。"),
|
||||
("No nested compositions", "没有嵌套合成"),
|
||||
("No shortcut", "无快捷键"),
|
||||
@@ -901,6 +913,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
|
||||
("The Media Browser is not available here (use File ▸ Import)", "此处不支持媒体浏览器(请使用 文件 ▸ 导入)"),
|
||||
("The OpenEXR layer shown in red, green, blue and alpha", "以红、绿、蓝和 Alpha 显示的 OpenEXR 图层"),
|
||||
("The render queue is empty.", "渲染队列为空。"),
|
||||
("These adjacent keyframes cannot interpolate because their stop counts differ.", "这些相邻关键帧的色标数量不同,无法插值。"),
|
||||
("This ScriptUI panel has closed", "此 ScriptUI 面板已关闭"),
|
||||
("This change will create a new solid for this layer.", "此更改将为该图层创建新的纯色。"),
|
||||
("Time", "时间"),
|
||||
@@ -1104,6 +1117,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
|
||||
("Clear", "清除"),
|
||||
("Clear list", "清空清單"),
|
||||
("Click a key to see its commands.", "按一下某個按鍵以查看其命令。"),
|
||||
("Click above the bar to add an opacity stop, below it to add a color stop.", "按一下漸變條上方以新增不透明度色標,按一下下方以新增顏色色標。"),
|
||||
("Click adds a point (½ height = no change)", "按一下可添加點(上下滑動高度的一半 = 無變化)"),
|
||||
("Click adds a point; drag one off to remove", "按一下可添加點;拖出一個即可刪除"),
|
||||
("Click to make this viewer active", "按一下可激活此檢視"),
|
||||
@@ -1200,6 +1214,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
|
||||
("Frame Target:", "幀目標:"),
|
||||
("Freeze", "凍結"),
|
||||
("Grab bag", "清單"),
|
||||
("Gradient Editor", "漸變編輯器"),
|
||||
("Grid", "格點"),
|
||||
("Guide Layers: {}", "參考圖層:{}"),
|
||||
("Guides", "參考線"),
|
||||
@@ -1238,6 +1253,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
|
||||
("Light Type", "燈光類型"),
|
||||
("Lighter", "更淺"),
|
||||
("Link Selected Property", "鏈接所選屬性"),
|
||||
("Location:", "位置:"),
|
||||
("Lock Aspect Ratio to {}", "鎖定長寬比例為 {}"),
|
||||
("Lock to Zoom", "鎖定縮放"),
|
||||
("Locked: {} — {}", "已鎖定:{} — {}"),
|
||||
@@ -1283,6 +1299,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
|
||||
("No layer selected", "未選取圖層"),
|
||||
("No matching effects or presets", "沒有匹配的效果或預設集"),
|
||||
("No matching fonts", "沒有符合的字體"),
|
||||
("Variable fonts only", "僅可變字型"),
|
||||
("No model credits were built into this copy.", "此副本未內置模型致謝。"),
|
||||
("No nested compositions", "沒有嵌套合成"),
|
||||
("No shortcut", "無快速鍵"),
|
||||
@@ -1426,6 +1443,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
|
||||
("The Media Browser is not available here (use File ▸ Import)", "此處不支援媒體瀏覽器(請使用 文件 ▸ 匯入)"),
|
||||
("The OpenEXR layer shown in red, green, blue and alpha", "以紅、綠、藍和 Alpha 顯示的 OpenEXR 圖層"),
|
||||
("The render queue is empty.", "算圖佇列為空。"),
|
||||
("These adjacent keyframes cannot interpolate because their stop counts differ.", "這些相鄰關鍵影格的色標數量不同,無法內插。"),
|
||||
("This ScriptUI panel has closed", "此 ScriptUI 面板已關閉"),
|
||||
("This change will create a new solid for this layer.", "此更改將為該圖層創建新的純色。"),
|
||||
("Time", "時間"),
|
||||
@@ -1624,6 +1642,10 @@ const UKRAINIAN: &[(&str, &str)] = &[
|
||||
("Clear", "Очистити"),
|
||||
("Clear list", "Очистити список"),
|
||||
("Click a key to see its commands.", "Клацніть клавішу, щоб переглянути її команди."),
|
||||
(
|
||||
"Click above the bar to add an opacity stop, below it to add a color stop.",
|
||||
"Клацніть над смугою, щоб додати точку непрозорості, під нею — щоб додати точку кольору.",
|
||||
),
|
||||
("Click adds a point (½ height = no change)", "Клацніть, щоб додати точку (½ висоти = без змін)"),
|
||||
("Click adds a point; drag one off to remove", "Клацніть, щоб додати точку; перетягніть її за межі, щоб вилучити"),
|
||||
("Click to make this viewer active", "Клацніть, щоб зробити цю область перегляду активною"),
|
||||
@@ -1717,6 +1739,7 @@ const UKRAINIAN: &[(&str, &str)] = &[
|
||||
("Frame Render Time {}ms", "Час рендерингу кадру {} мс"),
|
||||
("Frame Target:", "Цільовий фрейм:"),
|
||||
("Grab bag", "Список"),
|
||||
("Gradient Editor", "Редактор градієнта"),
|
||||
("Grid", "Сітка"),
|
||||
("Guide Layers: {}", "Допоміжні шари: {}"),
|
||||
("Guides", "Напрямні"),
|
||||
@@ -1753,6 +1776,7 @@ const UKRAINIAN: &[(&str, &str)] = &[
|
||||
("Light Type", "Тип світла"),
|
||||
("Lighter", "Світліше"),
|
||||
("Link Selected Property", "Зв’язати виділену властивість"),
|
||||
("Location:", "Розташування:"),
|
||||
("Lock Aspect Ratio to {}", "Зафіксувати співвідношення сторін на {}"),
|
||||
("Lock to Zoom", "Прив’язати до масштабу"),
|
||||
("Locked: {} — {}", "Заблоковано: {} — {}"),
|
||||
@@ -1804,6 +1828,7 @@ const UKRAINIAN: &[(&str, &str)] = &[
|
||||
("No layer selected", "Шар не виділено"),
|
||||
("No matching effects or presets", "Не знайдено відповідних ефектів або наборів"),
|
||||
("No matching fonts", "Не знайдено відповідних шрифтів"),
|
||||
("Variable fonts only", "Лише змінні шрифти"),
|
||||
("No model credits were built into this copy.", "Ця копія не містить відомостей про моделі."),
|
||||
("No nested compositions", "Немає вкладених композицій"),
|
||||
("No shortcut", "Немає клавіатурного скорочення"),
|
||||
@@ -1943,6 +1968,10 @@ const UKRAINIAN: &[(&str, &str)] = &[
|
||||
("The Media Browser is not available here (use File ▸ Import)", "Браузер матеріалів тут недоступний (скористайтеся меню «Файл ▸ Імпорт»)"),
|
||||
("The OpenEXR layer shown in red, green, blue and alpha", "Шар OpenEXR, показаний у червоному, зеленому, синьому та альфа-каналі"),
|
||||
("The render queue is empty.", "Черга рендерингу порожня."),
|
||||
(
|
||||
"These adjacent keyframes cannot interpolate because their stop counts differ.",
|
||||
"Ці сусідні ключові кадри не можуть інтерполюватися, бо кількість точок у них різна.",
|
||||
),
|
||||
("This ScriptUI panel has closed", "Цю панель ScriptUI закрито"),
|
||||
("This change will create a new solid for this layer.", "Ця зміна створить новий матеріал суцільного кольору для цього шару."),
|
||||
("Time", "Час"),
|
||||
@@ -2517,6 +2546,14 @@ const CZECH: &[(&str, &str)] = &[
|
||||
("After Effects project", "Projekt After Effects"),
|
||||
("Lottie animation", "Animace Lottie"),
|
||||
("Project", "Projekt"),
|
||||
("Click above the bar to add an opacity stop, below it to add a color stop.", "Kliknutím nad pruh přidáte bod krytí, kliknutím pod něj bod barvy."),
|
||||
("Gradient Editor", "Editor přechodu"),
|
||||
("Location:", "Umístění:"),
|
||||
(
|
||||
"These adjacent keyframes cannot interpolate because their stop counts differ.",
|
||||
"Tyto sousední klíčové snímky nelze interpolovat, protože mají různý počet bodů přechodu.",
|
||||
),
|
||||
("Variable fonts only", "Jen proměnná písma"),
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -2536,6 +2573,7 @@ mod tests {
|
||||
("panels/comp_settings.rs", include_str!("../panels/comp_settings.rs")),
|
||||
("panels/content_fill_panel.rs", include_str!("../panels/content_fill_panel.rs")),
|
||||
("panels/delete_items.rs", include_str!("../panels/delete_items.rs")),
|
||||
("panels/gradient_editor.rs", include_str!("../panels/gradient_editor.rs")),
|
||||
("panels/dialogs.rs", include_str!("../panels/dialogs.rs")),
|
||||
("panels/dialogs_3d.rs", include_str!("../panels/dialogs_3d.rs")),
|
||||
("panels/effect_controls.rs", include_str!("../panels/effect_controls.rs")),
|
||||
|
||||
@@ -94,6 +94,8 @@ pub enum Dialog {
|
||||
UnsavedChanges,
|
||||
/// Delete Project items that compositions use? (`panels::delete_items`).
|
||||
DeleteItems,
|
||||
/// A gradient property's Gradient Editor (`panels::gradient_editor`, `prop.editGradient`).
|
||||
GradientEditor,
|
||||
}
|
||||
|
||||
/// The desktop's light or dark appearance where egui cannot see it (Wayland compositors that send
|
||||
@@ -544,6 +546,17 @@ impl EffectcraftApp {
|
||||
self.ui.saved_workspaces.keys().filter(|k| !dock::WORKSPACES.contains(&k.as_str())).cloned().collect()
|
||||
}
|
||||
|
||||
/// The open Gradient Editor's draft (`panels::gradient_editor`): what OK would set.
|
||||
pub fn gradient_draft(&self) -> &panels::gradient_editor::Draft {
|
||||
&self.dialog_state.gradient
|
||||
}
|
||||
|
||||
/// The open Gradient Editor's draft, to edit (tests and automation; the editor's own fields
|
||||
/// do the same).
|
||||
pub fn gradient_draft_mut(&mut self) -> &mut panels::gradient_editor::Draft {
|
||||
&mut self.dialog_state.gradient
|
||||
}
|
||||
|
||||
/// Show panel `p` (opening it in its usual place if it is closed), bring it to the front and
|
||||
/// give it the focus: Window ▸ <panel>.
|
||||
pub fn show_panel(&mut self, p: PanelKind) {
|
||||
@@ -1542,6 +1555,11 @@ impl EffectcraftApp {
|
||||
}
|
||||
}
|
||||
effectcraft_engine::Event::PurgeCaches => self.frames.clear(),
|
||||
effectcraft_engine::Event::CopyImage { width, height, rgba } => {
|
||||
if let Err(e) = copy_image(ctx, width, height, &rgba) {
|
||||
self.ui.status = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1585,6 +1603,7 @@ impl EffectcraftApp {
|
||||
self.session.poll_camera(true);
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
}
|
||||
self.run_script_tasks(&ctx);
|
||||
self.apply_prefs(&ctx);
|
||||
self.handle_events(&ctx);
|
||||
panels::unsaved::on_close_requested(self, &ctx);
|
||||
@@ -1696,6 +1715,24 @@ impl EffectcraftApp {
|
||||
}
|
||||
|
||||
impl EffectcraftApp {
|
||||
/// Scripts' `app.scheduleTask` tasks that are due (repeating ones keep a ScriptUI panel
|
||||
/// fresh): errors as a toast, the last line printed in the status bar, and a repaint when
|
||||
/// the next one is due.
|
||||
fn run_script_tasks(&mut self, ctx: &egui::Context) {
|
||||
if self.session.script_ui.task_hosts.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(r) = effectcraft_engine::scriptui::ScriptUi::run_tasks(&mut self.session) else { return };
|
||||
if let Some(msg) = r["errors"].as_array().and_then(|e| e.first()).and_then(|e| e["message"].as_str()) {
|
||||
self.toast = Some((format!("Scheduled script task: {msg}"), ctx.input(|i| i.time)));
|
||||
} else if let Some(line) = r["output"].as_array().and_then(|o| o.last()).and_then(|l| l.as_str()) {
|
||||
self.ui.status = line.to_string();
|
||||
}
|
||||
if let Some(next) = r["next"].as_u64() {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(next));
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto-save a dirty project when the interval has passed (Settings ▸ Project ▸ Auto-Save).
|
||||
fn tick_autosave(&mut self, ctx: &egui::Context) {
|
||||
let now = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0);
|
||||
@@ -1862,6 +1899,18 @@ pub(crate) fn tick_playback(app: &mut EffectcraftApp, ctx: &egui::Context, scale
|
||||
app.advance_playback(ctx, scale);
|
||||
}
|
||||
|
||||
/// Put `width` × `height` straight-alpha RGBA8 pixels on the system clipboard (Composition ▸ Copy
|
||||
/// Frame to Clipboard). The pixel count is checked first: egui's image constructor panics on a
|
||||
/// size that doesn't match its buffer.
|
||||
fn copy_image(ctx: &egui::Context, width: u32, height: u32, rgba: &[u8]) -> Result<(), String> {
|
||||
let (w, h) = (width as usize, height as usize);
|
||||
if w == 0 || h == 0 || w.checked_mul(h).and_then(|n| n.checked_mul(4)) != Some(rgba.len()) {
|
||||
return Err(format!("cannot copy a malformed {width}×{height} image to the clipboard"));
|
||||
}
|
||||
ctx.copy_image(egui::ColorImage::from_rgba_unmultiplied([w, h], rgba));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Maximize a first window that doesn't fit the screen, which fits it to the space beside the
|
||||
/// taskbar or dock. The 1680 × 1020 window on a smaller screen is only shrunk to the monitor's
|
||||
/// size, so with its title bar it ran under the taskbar and cut menus off at the bottom of the
|
||||
@@ -1895,6 +1944,55 @@ mod fit_window_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod copy_frame_tests {
|
||||
use super::*;
|
||||
|
||||
fn copied(output: &egui::FullOutput) -> Vec<&egui::ColorImage> {
|
||||
output
|
||||
.platform_output
|
||||
.commands
|
||||
.iter()
|
||||
.filter_map(|c| match c {
|
||||
egui::OutputCommand::CopyImage(image) => Some(image),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Composition ▸ Copy Frame to Clipboard puts the rendered frame on the system clipboard.
|
||||
#[test]
|
||||
fn copy_frame_puts_the_frame_on_the_clipboard() {
|
||||
let mut session = Session::default();
|
||||
session.execute("comp.new", json!({"name": "Copy", "width": 8, "height": 6, "duration": 1})).unwrap();
|
||||
session.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap();
|
||||
let mut app = EffectcraftApp::new(session);
|
||||
let ctx = egui::Context::default();
|
||||
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
crate::menus::invoke(&mut app, ui.ctx(), "comp.copyFrame", json!({})).unwrap();
|
||||
app.handle_events(ui.ctx());
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
let images = copied(&output);
|
||||
assert_eq!(images.len(), 1);
|
||||
assert_eq!(images[0].size, [8, 6]);
|
||||
assert!(images[0].pixels.iter().all(|p| *p == egui::Color32::from_rgb(255, 0, 0)));
|
||||
}
|
||||
|
||||
/// A malformed image never reaches egui's panicking constructor.
|
||||
#[test]
|
||||
fn a_malformed_image_is_an_error_not_a_crash() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
assert!(copy_image(ui.ctx(), 4, 4, &[0; 10]).is_err());
|
||||
assert!(copy_image(ui.ctx(), 0, 4, &[]).is_err());
|
||||
assert!(copy_image(ui.ctx(), u32::MAX, u32::MAX, &[0; 4]).is_err());
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
assert!(copied(&output).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod gpu_failure_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -291,6 +291,15 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Edit ▸ Paste with SVG markup on the system clipboard pastes it as a shape layer (After
|
||||
// Effects 26.3). Copying in the app replaces the system clipboard with a note naming what
|
||||
// was copied, so SVG there was copied after anything copied in the app.
|
||||
if id == "edit.paste" && no_params(¶ms) && app.session.state.text_edit.is_none() {
|
||||
let text = ctx.data_mut(|d| d.remove_temp::<String>(system_paste_id())).or_else(|| app.hooks.clipboard_text.as_ref().and_then(|f| f()));
|
||||
if let Some(svg) = text.filter(|t| effectcraft_engine::vector::is_svg_text(t)) {
|
||||
return run_engine(app, ctx, "edit.pasteSvg", json!({"svg": svg}));
|
||||
}
|
||||
}
|
||||
// Clear follows panel focus, including the Edit menu and keyboard shortcut.
|
||||
if id == "edit.clear" && params.as_object().is_some_and(|p| p.is_empty()) && app.ui.focused == PanelKind::Project {
|
||||
// Through `invoke`, so deleting items that compositions use still asks first (M3.16).
|
||||
@@ -758,6 +767,8 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
|
||||
"view.newViewer" => json!({"viewer": crate::panels::viewers::new_viewer(app)}),
|
||||
// Run by id (control channel, MCP): the Project panel's selection, whatever has focus.
|
||||
"project.deleteWithoutConfirmation" => crate::panels::delete_items::delete_confirmed(app, ctx, json!({}))?,
|
||||
// A gradient property's "Edit Gradient…" (the Timeline's value): the Gradient Editor.
|
||||
"prop.editGradient" => crate::panels::gradient_editor::open(app, &p)?,
|
||||
"window.scriptPanel" => {
|
||||
let id = p.get("window").and_then(Value::as_u64).ok_or("no ScriptUI panel window")? as u32;
|
||||
app.show_panel(PanelKind::ScriptPanel(id));
|
||||
@@ -773,12 +784,12 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
|
||||
Value::Null
|
||||
}
|
||||
"app.gpuInfo" => {
|
||||
let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
|
||||
crate::panels::dialogs::info(
|
||||
app,
|
||||
"GPU Information",
|
||||
&format!(
|
||||
"Compositing: CPU, {threads} threads (pure Rust, rayon).\nDisplay: egui on wgpu.\nLayer cache: {} MB • Preview cache: {} MB.",
|
||||
"{}Layer cache: {} MB • Preview cache: {} MB.",
|
||||
effectcraft_engine::sysinfo::gpu_summary(&app.session),
|
||||
app.session.layer_cache.budget() >> 20,
|
||||
app.frames.budget() >> 20
|
||||
),
|
||||
@@ -1226,7 +1237,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Res
|
||||
}
|
||||
const MEDIA: &[&str] = &[
|
||||
"mp4", "mov", "m4v", "mkv", "webm", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "wav", "aif", "aiff", "mp3", "flac", "ogg",
|
||||
"opus", "svg", "pdf", "ai", "eps", "psd", "psb", "gltf", "glb", "obj", "json", "csv", "tsv",
|
||||
"opus", "svg", "pdf", "ai", "eps", "psd", "psb", "gltf", "glb", "obj", "stl", "json", "csv", "tsv",
|
||||
];
|
||||
// A host whose modal dialogs would run a nested event loop inside this frame (macOS: a
|
||||
// drag over the window then re-entered winit's event handler and aborted, #511) shows the
|
||||
@@ -1561,6 +1572,11 @@ fn mods_match(want: egui::Modifiers, got: egui::Modifiers) -> bool {
|
||||
|
||||
/// Set (egui temp data) from a plain Spacebar press until its release while a tap would still
|
||||
/// run the Spacebar shortcut.
|
||||
/// The system clipboard's text of this frame's paste event, for the Edit ▸ Paste it runs.
|
||||
fn system_paste_id() -> egui::Id {
|
||||
egui::Id::new("menus-system-paste")
|
||||
}
|
||||
|
||||
fn space_tap_id() -> egui::Id {
|
||||
egui::Id::new("spacebar-tap")
|
||||
}
|
||||
@@ -1606,7 +1622,8 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only
|
||||
let start = modifiers_at_start(ctx);
|
||||
// Dialog cancellation owns Escape even when a text field has keyboard focus.
|
||||
if !tab_only && app.dialog.is_some() && ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
if crate::panels::shortcut_editor::recording(app) || egui::Popup::is_any_open(ctx) {
|
||||
// A popup or colour picker takes Escape first (the picker goes back to its original colour).
|
||||
if crate::panels::shortcut_editor::recording(app) || egui::Popup::is_any_open(ctx) || crate::color_picker::open_recently(ctx) {
|
||||
return;
|
||||
}
|
||||
ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape));
|
||||
@@ -1641,6 +1658,9 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only
|
||||
// Text editing in the viewer takes the clipboard events itself.
|
||||
let clipboard = app.session.state.text_edit.is_none();
|
||||
let project = app.ui.focused == PanelKind::Project;
|
||||
// The paste event's text: Edit ▸ Paste pastes SVG markup as a shape layer.
|
||||
let pasted =
|
||||
if clipboard { ctx.input(|i| i.events.iter().rev().find_map(|e| if let egui::Event::Paste(t) = e { Some(t.clone()) } else { None })) } else { None };
|
||||
let mut events: Vec<(egui::Key, egui::Modifiers, bool)> = ctx.input(|i| {
|
||||
// The windowing layer turns Ctrl+C / Ctrl+X / Ctrl+V into clipboard events instead of
|
||||
// key presses: map them back to the keys (with the modifiers held) so Edit ▸ Copy, Cut,
|
||||
@@ -1756,9 +1776,15 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only
|
||||
}
|
||||
if let Some((_, _, id, params)) = binds.iter().find(|(m, k, ..)| *k == key && mods_match(*m, mods)) {
|
||||
let (id, params) = (id.clone(), if params.is_null() { json!({}) } else { params.clone() });
|
||||
if id == "edit.paste"
|
||||
&& let Some(t) = &pasted
|
||||
{
|
||||
ctx.data_mut(|d| d.insert_temp(system_paste_id(), t.clone()));
|
||||
}
|
||||
if let Err(e) = invoke(app, ctx, &id, params) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
ctx.data_mut(|d| d.remove_temp::<String>(system_paste_id()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,8 @@ pub struct DialogState {
|
||||
pub unsaved: super::unsaved::Pending,
|
||||
/// Deleting Project items in use: the deletion waiting on the prompt.
|
||||
pub delete_items: super::delete_items::Pending,
|
||||
/// The Gradient Editor's draft and the property it edits.
|
||||
pub gradient: super::gradient_editor::Draft,
|
||||
}
|
||||
|
||||
pub fn open_new_comp(app: &mut EffectcraftApp) {
|
||||
@@ -149,9 +151,23 @@ pub fn route_layer_settings(app: &mut EffectcraftApp, id: &str, params: &Value)
|
||||
}
|
||||
}
|
||||
|
||||
/// The room a modal dialog may take: the window less a margin.
|
||||
fn modal_space(ctx: &egui::Context) -> egui::Vec2 {
|
||||
(ctx.content_rect().size() - vec2(48.0, 48.0)).max(vec2(120.0, 80.0))
|
||||
}
|
||||
|
||||
pub(crate) fn modal(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, body: impl FnOnce(&mut egui::Ui)) {
|
||||
let available = (ctx.content_rect().size() - vec2(48.0, 48.0)).max(vec2(120.0, 80.0));
|
||||
let size = size.min(available);
|
||||
let max_height = (modal_space(ctx).y - 84.0).max(40.0);
|
||||
modal_fit(ctx, title, size, t, |ui, _| {
|
||||
egui::ScrollArea::both().max_height(max_height).auto_shrink([false, true]).show(ui, body);
|
||||
});
|
||||
}
|
||||
|
||||
/// [`modal`] without its scroll area, for a dialog that keeps its buttons in view and scrolls
|
||||
/// only its content: `body` gets the height left under the title, with `size` kept within the
|
||||
/// window.
|
||||
pub(crate) fn modal_fit(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, body: impl FnOnce(&mut egui::Ui, f32)) {
|
||||
let size = size.min(modal_space(ctx));
|
||||
egui::Modal::new(egui::Id::new(("modal", title)))
|
||||
.backdrop_color(Color32::from_black_alpha(120))
|
||||
.frame(egui::Frame::window(&ctx.style_of(ctx.theme())).fill(t.panel_bg).inner_margin(egui::Margin::same(18)))
|
||||
@@ -161,7 +177,8 @@ pub(crate) fn modal(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Toke
|
||||
// The id stays keyed on the untranslated source, so switching language cannot move it.
|
||||
ui.label(egui::RichText::new(tr(title)).font(Tokens::semibold(15.0)).color(t.tab_text_active));
|
||||
ui.add_space(10.0);
|
||||
egui::ScrollArea::both().max_height((available.y - 84.0).max(40.0)).auto_shrink([false, true]).show(ui, body);
|
||||
let title_h = ui.cursor().top() - ui.min_rect().top();
|
||||
body(ui, (size.y - 36.0 - title_h).max(40.0));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -193,6 +210,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
Dialog::RenderTemplates => super::rq_templates::show(app, ctx, &t),
|
||||
Dialog::UnsavedChanges => super::unsaved::show(app, ctx, &t),
|
||||
Dialog::DeleteItems => super::delete_items::show(app, ctx, &t),
|
||||
Dialog::GradientEditor => super::gradient_editor::show(app, ctx, &t),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1415,6 +1415,9 @@ pub fn viewer_hook(
|
||||
if !picking && app.ui.viewer.show_layer_controls && matches!(&g.kind, GroupKind::Effect { effect } if effect == super::fx_editors::RESHAPE) {
|
||||
super::fx_editors::reshape_overlay(app, ui, painter, map, ectx, layer, g, &m, &mut actions);
|
||||
}
|
||||
if !picking && app.ui.viewer.show_layer_controls && matches!(&g.kind, GroupKind::Effect { effect } if effect == super::fx_editors::MESH_WARP) {
|
||||
super::fx_editors::mesh_warp_overlay(app, ui, painter, map, ectx, layer, g, &fm, &mut actions);
|
||||
}
|
||||
let scope = FxScope { effect: &g.match_id, root: g, path: String::new() };
|
||||
let mut pts = Vec::new();
|
||||
visible_points(g, &scope, layer, ectx, 0, &mut pts);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Effects & Presets: search (matches highlighted), the contents menu (colour-depth / GPU
|
||||
//! filters, animation presets on/off, categories or alphabetical, refresh), Favorites (star an
|
||||
//! effect), Recently Used, "* Animation Presets" (EffectCraft's own text animation presets and
|
||||
//! the user's `.ecpreset` files) and the effect categories. Double-click applies to the
|
||||
//! effect), Recently Used, "* Animation Presets" (EffectCraft's own text animation and transition
|
||||
//! presets and the user's `.ecpreset` files) and the effect categories. Double-click applies to the
|
||||
//! selected layers; dragging an effect onto a layer (timeline, viewer, Effect Controls) applies
|
||||
//! it there.
|
||||
|
||||
@@ -108,6 +108,8 @@ fn highlighted(p: &egui::Painter, pos: egui::Pos2, name: &str, query: &str, t: &
|
||||
enum Apply {
|
||||
Effect(String),
|
||||
TextPreset(String),
|
||||
/// A built-in animation preset (Transitions…) by id.
|
||||
Builtin(String),
|
||||
PresetFile(String),
|
||||
}
|
||||
|
||||
@@ -340,18 +342,35 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
}
|
||||
|
||||
// * Animation Presets: our text presets and the user's .ecpreset files.
|
||||
// * Animation Presets: our own presets (Text, Transitions…) and the user's .ecpreset files.
|
||||
if view.presets {
|
||||
let text: Vec<(String, String)> =
|
||||
effectcraft_engine::text_presets().into_iter().filter(|(_, n)| query.is_empty() || n.to_lowercase().contains(&query)).collect();
|
||||
let builtin: Vec<(String, String, String)> =
|
||||
effectcraft_engine::animation_presets().into_iter().filter(|(_, n, _)| query.is_empty() || n.to_lowercase().contains(&query)).collect();
|
||||
let mut categories: Vec<&str> = Vec::new();
|
||||
for (_, _, c) in &builtin {
|
||||
if !categories.contains(&c.as_str()) {
|
||||
categories.push(c);
|
||||
}
|
||||
}
|
||||
let files: Vec<PresetFile> =
|
||||
user_presets(&ctx, force_scan).into_iter().filter(|f| query.is_empty() || f.name.to_lowercase().contains(&query)).collect();
|
||||
if (!text.is_empty() || !files.is_empty()) && folder_row(app, ui, &lp, list, &mut y, "presets", "* Animation Presets", 0.0, searching, None) {
|
||||
let any = !text.is_empty() || !builtin.is_empty() || !files.is_empty();
|
||||
if any && folder_row(app, ui, &lp, list, &mut y, "presets", "* Animation Presets", 0.0, searching, None) {
|
||||
if !text.is_empty() && folder_row(app, ui, &lp, list, &mut y, "presets/Text", "Text", 14.0, searching, None) {
|
||||
for (id, name) in text {
|
||||
preset_row(app, ui, &lp, list, &mut y, &name, &format!("effects.preset.text.{id}"), &query, Apply::TextPreset(id.clone()), &mut apply);
|
||||
}
|
||||
}
|
||||
for cat in &categories {
|
||||
if !folder_row(app, ui, &lp, list, &mut y, &format!("presets/{cat}"), cat, 14.0, searching, None) {
|
||||
continue;
|
||||
}
|
||||
for (id, name, _) in builtin.iter().filter(|(_, _, c)| c == cat) {
|
||||
preset_row(app, ui, &lp, list, &mut y, name, &format!("effects.preset.builtin.{id}"), &query, Apply::Builtin(id.clone()), &mut apply);
|
||||
}
|
||||
}
|
||||
if !files.is_empty() && folder_row(app, ui, &lp, list, &mut y, "presets/User", "User Presets", 14.0, searching, None) {
|
||||
for f in files {
|
||||
preset_row(
|
||||
@@ -418,6 +437,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let (cmd, params) = match &apply {
|
||||
Apply::Effect(id) => ("effect.apply", json!({"effect": id})),
|
||||
Apply::TextPreset(id) => ("layer.applyTextPreset", json!({"preset": id})),
|
||||
Apply::Builtin(id) => ("anim.applyBuiltinPreset", json!({"preset": id})),
|
||||
Apply::PresetFile(path) => ("anim.applyPreset", json!({"path": path})),
|
||||
};
|
||||
match app.session.execute(cmd, params) {
|
||||
|
||||
@@ -215,16 +215,15 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
|
||||
let lt = layer.map(|l| l.layer_time(t)).unwrap_or(t);
|
||||
let base = p.clone();
|
||||
let (title, fields): (String, Vec<Field>) = match id {
|
||||
// Photoshop files: Import Kind (Footage / Composition / – Retain Layer Sizes).
|
||||
"file.import" if !has(p, &["importAs"]) && is_layered(p) => (
|
||||
"Import Photoshop File".into(),
|
||||
vec![Field::choice(
|
||||
"importAs",
|
||||
"Import Kind",
|
||||
&[("Footage", json!("footage")), ("Composition", json!("composition")), ("Composition - Retain Layer Sizes", json!("compositionLayerSizes"))],
|
||||
1,
|
||||
)],
|
||||
),
|
||||
// Photoshop files: Import Kind (Footage / Composition / – Retain Layer Sizes). Dropped
|
||||
// files start at Settings ▸ Import ▸ Default Drag Import As.
|
||||
"file.import" if !has(p, &["importAs"]) && is_layered(p) => {
|
||||
let kinds =
|
||||
[("Footage", json!("footage")), ("Composition", json!("composition")), ("Composition - Retain Layer Sizes", json!("compositionLayerSizes"))];
|
||||
let drag = p.get("drag").and_then(Value::as_bool).unwrap_or(false);
|
||||
let sel = if drag { kinds.iter().position(|(_, v)| v.as_str() == Some(s.prefs.drag_import_as())).unwrap_or(0) } else { 1 };
|
||||
("Import Photoshop File".into(), vec![Field::choice("importAs", "Import Kind", &kinds, sel)])
|
||||
}
|
||||
// PDF / Illustrator files: Import Kind and the page.
|
||||
"file.import" if !has(p, &["importAs", "page"]) && is_pdf(p) => (
|
||||
"Import PDF / Illustrator File".into(),
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
//! - Reshape: correspondence point list buttons here, and on the Composition viewer the source
|
||||
//! and destination mask outlines with draggable correspondence handles ([`reshape_overlay`]);
|
||||
//! - EXtractoR: a Layer popup of the footage's OpenEXR layers, and Red / Green / Blue / Alpha
|
||||
//! popups of its channels ([`channel_popup`]).
|
||||
//! popups of its channels ([`channel_popup`]);
|
||||
//! - Mesh Warp: on the Composition viewer, the distortion mesh's grid over the layer with a
|
||||
//! draggable handle on every vertex ([`mesh_warp_overlay`], #303).
|
||||
//!
|
||||
//! Every edit is a `prop.set` engine command (undoable; one drag = one undo step) and every
|
||||
//! handle registers an automation id.
|
||||
@@ -37,6 +39,7 @@ pub const COLORAMA: &str = "ec.color.colorama";
|
||||
pub const GLOW: &str = "ec.stylize.glow";
|
||||
pub const RESHAPE: &str = "ec.distort.reshape";
|
||||
pub const EXTRACTOR: &str = "ec.3d.extractor";
|
||||
pub const MESH_WARP: &str = "ec.distort.meshwarp";
|
||||
|
||||
fn str_value(layer: &Layer, ectx: &EvalCtx, g: &PropGroup, id: &str) -> String {
|
||||
match g.get(id).map(|p| ectx.value(layer, p)) {
|
||||
@@ -1018,10 +1021,109 @@ pub fn reshape_overlay(
|
||||
}
|
||||
}
|
||||
|
||||
/// Mesh Warp's grid: (rows, columns, vertex offsets in layer pixels, row-major). A stored mesh
|
||||
/// that doesn't match the grid (Rows or Columns changed since) reads as undistorted, as it
|
||||
/// renders.
|
||||
fn mesh_grid(layer: &Layer, ectx: &EvalCtx, g: &PropGroup) -> (usize, usize, Vec<(f64, f64)>) {
|
||||
let n = |id: &str| {
|
||||
let v = g.get(id).map(|p| ectx.value(layer, p).as_f64()).unwrap_or(7.0);
|
||||
if v.is_finite() { v.round().clamp(1.0, 31.0) as usize } else { 7 }
|
||||
};
|
||||
let (rows, cols) = (n("rows"), n("columns"));
|
||||
let count = (rows + 1) * (cols + 1);
|
||||
let mut offs = effectcraft_engine::effects::parse_mesh(&str_value(layer, ectx, g, "mesh"));
|
||||
if offs.len() != count {
|
||||
offs = vec![(0.0, 0.0); count];
|
||||
}
|
||||
(rows, cols, offs)
|
||||
}
|
||||
|
||||
/// The `mesh` parameter's text for these offsets (the inverse of `parse_mesh`).
|
||||
fn format_mesh(offs: &[(f64, f64)]) -> String {
|
||||
let r = |v: f64| if v.is_finite() { (v * 1000.0).round() / 1000.0 + 0.0 } else { 0.0 };
|
||||
offs.iter().map(|o| format!("{},{}", r(o.0), r(o.1))).collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
/// Mesh Warp on the Composition viewer (#303): the distortion mesh's grid over the layer, with
|
||||
/// a handle on every vertex. Dragging a vertex moves it (its offset in the hidden `mesh`
|
||||
/// parameter; the first drag of an undistorted mesh writes the whole grid). `fm` maps effect
|
||||
/// space to comp.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn mesh_warp_overlay(
|
||||
app: &mut EffectcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
painter: &egui::Painter,
|
||||
map: &super::viewer::ViewerMap,
|
||||
ectx: &EvalCtx,
|
||||
layer: &Layer,
|
||||
g: &PropGroup,
|
||||
fm: &Mat3,
|
||||
actions: &mut Actions,
|
||||
) {
|
||||
let (rows, cols, offs) = mesh_grid(layer, ectx, g);
|
||||
let [lw, lh] = ectx.effect_bounds(layer).0;
|
||||
let rest = |k: usize| [(k % (cols + 1)) as f64 / cols as f64 * lw, (k / (cols + 1)) as f64 / rows as f64 * lh];
|
||||
let screen: Vec<Pos2> = offs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(k, o)| {
|
||||
let r = rest(k);
|
||||
map.to_screen(fw::layer_to_comp(fm, [r[0] + o.0, r[1] + o.1]))
|
||||
})
|
||||
.collect();
|
||||
let line = Stroke::new(1.0, Color32::from_white_alpha(150));
|
||||
for (k, s) in screen.iter().enumerate() {
|
||||
if k % (cols + 1) < cols
|
||||
&& let Some(e) = screen.get(k + 1)
|
||||
{
|
||||
painter.line_segment([*s, *e], line);
|
||||
}
|
||||
if let Some(e) = screen.get(k + cols + 1) {
|
||||
painter.line_segment([*s, *e], line);
|
||||
}
|
||||
}
|
||||
let ctx = ui.ctx().clone();
|
||||
let accent = app.tokens.accent;
|
||||
for (k, s) in screen.iter().enumerate() {
|
||||
let hr = Rect::from_center_size(*s, vec2(10.0, 10.0));
|
||||
let id = egui::Id::new(("mesh-warp-vertex", g.uid, k));
|
||||
let resp = ui.interact(hr, id, Sense::drag());
|
||||
let hot = resp.hovered() || resp.dragged();
|
||||
let dot = Rect::from_center_size(*s, vec2(5.0, 5.0));
|
||||
painter.rect_filled(dot, 0.0, if hot { accent } else { Color32::WHITE });
|
||||
painter.rect_stroke(dot, 0.0, Stroke::new(1.0, Color32::BLACK), StrokeKind::Outside);
|
||||
let (row, col) = (k / (cols + 1), k % (cols + 1));
|
||||
app.auto.add(&format!("viewer.meshWarp.{}.{row}.{col}", g.uid), hr, &format!("Mesh vertex (row {row}, column {col})"));
|
||||
if hot {
|
||||
ctx.set_cursor_icon(egui::CursorIcon::Move);
|
||||
}
|
||||
if resp.dragged()
|
||||
&& let Some(pos) = resp.interact_pointer_pos()
|
||||
&& let Some(lp) = fw::comp_to_layer(fm, map.to_comp(pos))
|
||||
{
|
||||
let mut v = offs.clone();
|
||||
let r = rest(k);
|
||||
if let Some(o) = v.get_mut(k) {
|
||||
*o = (lp[0] - r[0], lp[1] - r[1]);
|
||||
}
|
||||
let key = gesture_key(ui, id, resp.drag_started());
|
||||
set_str(actions, layer, g, "mesh", format_mesh(&v), Some(key));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mesh_text_round_trips() {
|
||||
let offs = vec![(0.0, 0.0), (12.5, -3.25), (1.0 / 3.0, f64::NAN)];
|
||||
let s = format_mesh(&offs);
|
||||
assert_eq!(s, "0,0 12.5,-3.25 0.333,0");
|
||||
assert_eq!(effectcraft_engine::effects::parse_mesh(&s), vec![(0.0, 0.0), (12.5, -3.25), (0.333, 0.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_points_and_palettes_round_trip() {
|
||||
let p = OffsetPoints::parse("0.5,0.75 0.1,0.5");
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
//! The Gradient Editor (#612), opened by a gradient property's "Edit Gradient…" in the Timeline
|
||||
//! (Gradient Fill / Gradient Stroke ▸ Colors, Gradient Overlay ▸ Gradient): `prop.editGradient`.
|
||||
//! Opacity stops sit above the preview bar and colour stops below it. Click above or below the
|
||||
//! bar to add a stop (it takes that track's value there), drag a stop to move it, edit the
|
||||
//! selected stop's location, opacity or colour, and Delete removes it (never the last stop of a
|
||||
//! track). The editor works on a draft: OK sets the property once with `prop.set` at the time it
|
||||
//! opened (one undo step, nothing at all when nothing changed); Cancel and Escape leave it as it
|
||||
//! was.
|
||||
//!
|
||||
//! Automation ids: `dialog.gradient.bar`, `dialog.gradient.addOpacity`, `dialog.gradient.addColor`,
|
||||
//! `dialog.gradient.opacityStop.<i>`, `dialog.gradient.colorStop.<i>` (in order along the bar),
|
||||
//! `dialog.gradient.location`, `dialog.gradient.opacity`, `dialog.gradient.color`,
|
||||
//! `dialog.gradient.delete`, `dialog.gradient.ok`, `dialog.gradient.cancel`.
|
||||
|
||||
use effectcraft_engine::keyframe::{Gradient, Value};
|
||||
use effectcraft_engine::project::{ItemId, LayerId};
|
||||
use egui::{Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use crate::i18n::tr;
|
||||
use crate::theme::Tokens;
|
||||
use crate::{Dialog, EffectcraftApp};
|
||||
|
||||
/// Which row of stops: opacity (above the bar) or colour (below it).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Track {
|
||||
Opacity,
|
||||
Color,
|
||||
}
|
||||
|
||||
/// A stop in the draft. `id` stays with the stop when it moves past another one (the tracks are
|
||||
/// kept in order along the bar, so its index changes).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Stop<T> {
|
||||
pub id: u64,
|
||||
pub pos: f64,
|
||||
pub value: T,
|
||||
}
|
||||
|
||||
/// The property being edited, as it was when the editor opened.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Target {
|
||||
pub comp: u64,
|
||||
pub layer: u64,
|
||||
pub prop: u64,
|
||||
/// Composition seconds, snapped to the nearest frame: the value is read and set there.
|
||||
pub time: f64,
|
||||
}
|
||||
|
||||
/// The editor's draft gradient and selection.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Draft {
|
||||
pub target: Option<Target>,
|
||||
pub colors: Vec<Stop<[f32; 4]>>,
|
||||
pub opacities: Vec<Stop<f32>>,
|
||||
pub selected: Option<(Track, u64)>,
|
||||
/// The gradient as it opened, stops in order: OK with an equal draft changes nothing.
|
||||
pub baseline: Gradient,
|
||||
/// Stop counts (colour, opacity) of the keyframes just before and after the edit time.
|
||||
pub neighbors: Vec<(usize, usize)>,
|
||||
next_id: u64,
|
||||
}
|
||||
|
||||
fn by_pos<T>(v: &mut [Stop<T>]) {
|
||||
// Stable: stops at the same location keep their order.
|
||||
v.sort_by(|a, b| a.pos.total_cmp(&b.pos));
|
||||
}
|
||||
|
||||
fn clamp_pos(p: f64) -> f64 {
|
||||
if p.is_finite() { p.clamp(0.0, 1.0) } else { 0.0 }
|
||||
}
|
||||
|
||||
impl Draft {
|
||||
/// A draft of `g` (stops put in order, locations clamped to 0..1).
|
||||
pub fn new(g: &Gradient, target: Option<Target>, neighbors: Vec<(usize, usize)>) -> Draft {
|
||||
let mut d = Draft { target, neighbors, ..Default::default() };
|
||||
for &(pos, value) in &g.colors {
|
||||
let id = d.fresh();
|
||||
d.colors.push(Stop { id, pos: clamp_pos(pos), value });
|
||||
}
|
||||
for &(pos, value) in &g.opacities {
|
||||
let id = d.fresh();
|
||||
d.opacities.push(Stop { id, pos: clamp_pos(pos), value });
|
||||
}
|
||||
by_pos(&mut d.colors);
|
||||
by_pos(&mut d.opacities);
|
||||
d.baseline = d.gradient();
|
||||
d
|
||||
}
|
||||
|
||||
fn fresh(&mut self) -> u64 {
|
||||
self.next_id = self.next_id.saturating_add(1);
|
||||
self.next_id
|
||||
}
|
||||
|
||||
/// The draft as a gradient (the tracks are always in order).
|
||||
pub fn gradient(&self) -> Gradient {
|
||||
Gradient { colors: self.colors.iter().map(|s| (s.pos, s.value)).collect(), opacities: self.opacities.iter().map(|s| (s.pos, s.value)).collect() }
|
||||
}
|
||||
|
||||
pub fn len(&self, track: Track) -> usize {
|
||||
match track {
|
||||
Track::Color => self.colors.len(),
|
||||
Track::Opacity => self.opacities.len(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The id of the stop `i`-th along the bar.
|
||||
pub fn id_at(&self, track: Track, i: usize) -> Option<u64> {
|
||||
match track {
|
||||
Track::Color => self.colors.get(i).map(|s| s.id),
|
||||
Track::Opacity => self.opacities.get(i).map(|s| s.id),
|
||||
}
|
||||
}
|
||||
|
||||
/// Where stop `id` is along the bar (its index), if it exists.
|
||||
pub fn index_of(&self, track: Track, id: u64) -> Option<usize> {
|
||||
match track {
|
||||
Track::Color => self.colors.iter().position(|s| s.id == id),
|
||||
Track::Opacity => self.opacities.iter().position(|s| s.id == id),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a stop at `pos` with that track's current value there, select it, return its id.
|
||||
pub fn add(&mut self, track: Track, pos: f64) -> u64 {
|
||||
let pos = clamp_pos(pos);
|
||||
let g = self.gradient();
|
||||
let id = self.fresh();
|
||||
match track {
|
||||
Track::Color => self.colors.push(Stop { id, pos, value: g.color_at(pos) }),
|
||||
Track::Opacity => self.opacities.push(Stop { id, pos, value: g.opacity_at(pos) }),
|
||||
}
|
||||
self.sort();
|
||||
self.selected = Some((track, id));
|
||||
id
|
||||
}
|
||||
|
||||
/// Move stop `id` to `pos` (clamped) and keep the track in order.
|
||||
pub fn move_to(&mut self, track: Track, id: u64, pos: f64) {
|
||||
let pos = clamp_pos(pos);
|
||||
match track {
|
||||
Track::Color => {
|
||||
if let Some(s) = self.colors.iter_mut().find(|s| s.id == id) {
|
||||
s.pos = pos;
|
||||
}
|
||||
}
|
||||
Track::Opacity => {
|
||||
if let Some(s) = self.opacities.iter_mut().find(|s| s.id == id) {
|
||||
s.pos = pos;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.sort();
|
||||
}
|
||||
|
||||
/// Remove stop `id` unless it is the last of its track. Returns whether it went.
|
||||
pub fn remove(&mut self, track: Track, id: u64) -> bool {
|
||||
if self.len(track) <= 1 {
|
||||
return false;
|
||||
}
|
||||
let before = self.len(track);
|
||||
match track {
|
||||
Track::Color => self.colors.retain(|s| s.id != id),
|
||||
Track::Opacity => self.opacities.retain(|s| s.id != id),
|
||||
}
|
||||
if self.selected == Some((track, id)) {
|
||||
self.selected = None;
|
||||
}
|
||||
self.len(track) < before
|
||||
}
|
||||
|
||||
/// Set colour stop `id`'s RGB, keeping its alpha.
|
||||
pub fn set_rgb(&mut self, id: u64, rgb: [f32; 3]) {
|
||||
if let Some(s) = self.colors.iter_mut().find(|s| s.id == id) {
|
||||
s.value = [rgb[0], rgb[1], rgb[2], s.value[3]];
|
||||
}
|
||||
}
|
||||
|
||||
/// Set opacity stop `id` (0..1).
|
||||
pub fn set_opacity(&mut self, id: u64, a: f32) {
|
||||
if let Some(s) = self.opacities.iter_mut().find(|s| s.id == id) {
|
||||
s.value = if a.is_finite() { a.clamp(0.0, 1.0) } else { s.value };
|
||||
}
|
||||
}
|
||||
|
||||
fn sort(&mut self) {
|
||||
by_pos(&mut self.colors);
|
||||
by_pos(&mut self.opacities);
|
||||
}
|
||||
|
||||
/// The draft's stop counts differ from a neighbouring keyframe's, so the two can't
|
||||
/// interpolate (gradient keyframes blend only with the same number of stops).
|
||||
pub fn mismatched(&self) -> bool {
|
||||
self.neighbors.iter().any(|&(c, o)| c != self.colors.len() || o != self.opacities.len())
|
||||
}
|
||||
}
|
||||
|
||||
/// `prop.editGradient {layer, prop, comp?}`: open the editor on that gradient property at the
|
||||
/// current time (snapped to the nearest frame, as `prop.set` snaps it).
|
||||
pub fn open(app: &mut EffectcraftApp, p: &Json) -> Result<Json, String> {
|
||||
let cid = match p.get("comp").and_then(Json::as_u64) {
|
||||
Some(c) => ItemId(c),
|
||||
None => app.session.active_comp_id().ok_or("no composition is open")?,
|
||||
};
|
||||
let comp = app.session.project.comp(cid).ok_or_else(|| format!("no composition {}", cid.0))?;
|
||||
let lid = p.get("layer").and_then(Json::as_u64).ok_or("missing `layer`")?;
|
||||
let layer = comp.layer(LayerId(lid)).ok_or_else(|| format!("no layer {lid}"))?;
|
||||
let uid = p.get("prop").and_then(Json::as_u64).ok_or("missing `prop`")?;
|
||||
let prop = layer.props.find(uid).ok_or_else(|| format!("no property @{uid}"))?;
|
||||
let ct = comp.frame_rate.snap_nearest(app.session.time_of(cid));
|
||||
let lt = layer.layer_time(ct);
|
||||
let Value::Gradient(g) = prop.value_at(lt) else {
|
||||
return Err(format!("{} is not a gradient", prop.name));
|
||||
};
|
||||
// The keyframes either side of the edit time (one exactly there is the one being edited).
|
||||
let counts = |v: &Value| match v {
|
||||
Value::Gradient(g) => Some((g.colors.len(), g.opacities.len())),
|
||||
_ => None,
|
||||
};
|
||||
let before = prop.keys.iter().rev().find(|k| k.time < lt).and_then(|k| counts(&k.value));
|
||||
let after = prop.keys.iter().find(|k| k.time > lt).and_then(|k| counts(&k.value));
|
||||
let target = Target { comp: cid.0, layer: lid, prop: uid, time: ct.seconds() };
|
||||
app.dialog_state.gradient = Draft::new(&g, Some(target), before.into_iter().chain(after).collect());
|
||||
app.dialog = Some(Dialog::GradientEditor);
|
||||
Ok(json!({"dialog": "gradientEditor"}))
|
||||
}
|
||||
|
||||
/// Set the property to the draft: one `prop.set` at the time the editor opened, in composition
|
||||
/// time, or nothing when the draft is unchanged (so OK adds no keyframe).
|
||||
fn commit(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
app.dialog = None;
|
||||
let d = std::mem::take(&mut app.dialog_state.gradient);
|
||||
let g = d.gradient();
|
||||
let Some(tg) = d.target else { return };
|
||||
if g == d.baseline {
|
||||
return;
|
||||
}
|
||||
let params = json!({"comp": tg.comp, "layer": tg.layer, "prop": tg.prop, "time": tg.time, "timeBase": "comp", "value": Value::Gradient(g).to_json()});
|
||||
if let Err(e) = crate::menus::invoke(app, ctx, "prop.set", params) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
|
||||
fn to_color32(c: [f32; 4]) -> Color32 {
|
||||
let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
Color32::from_rgba_unmultiplied(b(c[0]), b(c[1]), b(c[2]), b(c[3]))
|
||||
}
|
||||
|
||||
pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
if app.dialog_state.gradient.target.is_none() {
|
||||
app.dialog = None;
|
||||
return;
|
||||
}
|
||||
let mut answer: Option<bool> = None;
|
||||
super::dialogs::modal(ctx, tr("Gradient Editor"), vec2(520.0, 270.0), t, |ui| {
|
||||
let w = ui.available_width().max(200.0);
|
||||
let (area, _) = ui.allocate_exact_size(vec2(w, 72.0), Sense::hover());
|
||||
let bar = Rect::from_min_max(pos2(area.min.x + 8.0, area.min.y + 22.0), pos2(area.max.x - 8.0, area.min.y + 48.0));
|
||||
let opacity_row = Rect::from_min_max(pos2(bar.min.x, area.min.y), pos2(bar.max.x, bar.min.y - 2.0));
|
||||
let color_row = Rect::from_min_max(pos2(bar.min.x, bar.max.y + 2.0), pos2(bar.max.x, area.max.y));
|
||||
let pos_of = |x: f32| ((x - bar.min.x) / bar.width().max(1.0)) as f64;
|
||||
let x_of = |p: f64| bar.min.x + bar.width() * p as f32;
|
||||
let painter = ui.painter().clone();
|
||||
|
||||
// The bar: a checkerboard under the gradient, so opacity shows.
|
||||
let cell = 6.0;
|
||||
let mut y = bar.min.y;
|
||||
let mut row = 0;
|
||||
while y < bar.max.y {
|
||||
let mut x = bar.min.x;
|
||||
let mut col = row;
|
||||
while x < bar.max.x {
|
||||
let c = if col % 2 == 0 { Color32::from_gray(200) } else { Color32::from_gray(150) };
|
||||
painter.rect_filled(Rect::from_min_max(pos2(x, y), pos2((x + cell).min(bar.max.x), (y + cell).min(bar.max.y))), 0.0, c);
|
||||
x += cell;
|
||||
col += 1;
|
||||
}
|
||||
y += cell;
|
||||
row += 1;
|
||||
}
|
||||
let g = app.dialog_state.gradient.gradient();
|
||||
let n = 96;
|
||||
for k in 0..n {
|
||||
let x0 = bar.min.x + bar.width() * k as f32 / n as f32;
|
||||
let x1 = bar.min.x + bar.width() * (k + 1) as f32 / n as f32;
|
||||
let c = g.sample((k as f64 + 0.5) / n as f64);
|
||||
painter.rect_filled(Rect::from_min_max(pos2(x0, bar.min.y), pos2(x1, bar.max.y)), 0.0, to_color32(c));
|
||||
}
|
||||
painter.rect_stroke(bar, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside);
|
||||
app.auto.add("dialog.gradient.bar", bar, "Gradient");
|
||||
|
||||
// Clicking a row's empty space adds a stop there.
|
||||
for (track, rowr, id) in [(Track::Opacity, opacity_row, "dialog.gradient.addOpacity"), (Track::Color, color_row, "dialog.gradient.addColor")] {
|
||||
let r = ui.interact(rowr, egui::Id::new(("gradient-add", id)), Sense::click());
|
||||
app.auto.add(id, rowr, id);
|
||||
if r.clicked()
|
||||
&& let Some(p) = r.interact_pointer_pos()
|
||||
{
|
||||
app.dialog_state.gradient.add(track, pos_of(p.x));
|
||||
}
|
||||
}
|
||||
|
||||
// The stops (drawn and hit-tested over the rows, so they take the clicks).
|
||||
for track in [Track::Opacity, Track::Color] {
|
||||
for i in 0..app.dialog_state.gradient.len(track) {
|
||||
let d = &app.dialog_state.gradient;
|
||||
let Some(id) = d.id_at(track, i) else { continue };
|
||||
let (pos, fill) = match track {
|
||||
Track::Color => d.colors.get(i).map(|s| (s.pos, to_color32([s.value[0], s.value[1], s.value[2], 1.0]))),
|
||||
Track::Opacity => d.opacities.get(i).map(|s| (s.pos, Color32::from_gray((s.value.clamp(0.0, 1.0) * 255.0) as u8))),
|
||||
}
|
||||
.unwrap_or((0.0, Color32::WHITE));
|
||||
let selected = d.selected == Some((track, id));
|
||||
let x = x_of(pos);
|
||||
let r = match track {
|
||||
Track::Opacity => Rect::from_min_max(pos2(x - 5.0, opacity_row.max.y - 15.0), pos2(x + 5.0, opacity_row.max.y)),
|
||||
Track::Color => Rect::from_min_max(pos2(x - 5.0, color_row.min.y), pos2(x + 5.0, color_row.min.y + 15.0)),
|
||||
};
|
||||
painter.rect_filled(r, 1.0, fill);
|
||||
painter.rect_stroke(
|
||||
r,
|
||||
1.0,
|
||||
Stroke::new(if selected { 2.0 } else { 1.0 }, if selected { t.accent } else { t.field_border }),
|
||||
StrokeKind::Outside,
|
||||
);
|
||||
let resp = ui.interact(r.expand(2.0), egui::Id::new(("gradient-stop", track == Track::Color, id)), Sense::click_and_drag());
|
||||
let name = match track {
|
||||
Track::Opacity => format!("dialog.gradient.opacityStop.{i}"),
|
||||
Track::Color => format!("dialog.gradient.colorStop.{i}"),
|
||||
};
|
||||
app.auto.add(&name, r, &name);
|
||||
if resp.clicked() || resp.drag_started() {
|
||||
app.dialog_state.gradient.selected = Some((track, id));
|
||||
}
|
||||
if resp.dragged()
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
{
|
||||
app.dialog_state.gradient.move_to(track, id, pos_of(p.x));
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
|
||||
// The selected stop's fields.
|
||||
let selected = app.dialog_state.gradient.selected;
|
||||
let selected = selected.and_then(|(track, id)| app.dialog_state.gradient.index_of(track, id).map(|i| (track, id, i)));
|
||||
ui.horizontal(|ui| {
|
||||
let Some((track, id, i)) = selected else {
|
||||
ui.label(egui::RichText::new(tr("Click above the bar to add an opacity stop, below it to add a color stop.")).color(t.text_dim));
|
||||
return;
|
||||
};
|
||||
ui.label(tr("Location:"));
|
||||
let d = &app.dialog_state.gradient;
|
||||
let pos = match track {
|
||||
Track::Color => d.colors.get(i).map(|s| s.pos),
|
||||
Track::Opacity => d.opacities.get(i).map(|s| s.pos),
|
||||
}
|
||||
.unwrap_or(0.0);
|
||||
let mut pct = pos * 100.0;
|
||||
let r = ui.add(egui::DragValue::new(&mut pct).range(0.0..=100.0).speed(0.5).max_decimals(1).suffix("%"));
|
||||
app.auto.add("dialog.gradient.location", r.rect, "Location");
|
||||
if r.changed() {
|
||||
app.dialog_state.gradient.move_to(track, id, pct / 100.0);
|
||||
}
|
||||
// Moving can reorder the track: find the stop again by its id, so the fields below
|
||||
// show this stop and not the one now at its old index.
|
||||
let Some(i) = app.dialog_state.gradient.index_of(track, id) else { return };
|
||||
match track {
|
||||
Track::Opacity => {
|
||||
ui.label(tr("Opacity:"));
|
||||
let mut a = app.dialog_state.gradient.opacities.get(i).map_or(1.0, |s| s.value) as f64 * 100.0;
|
||||
let r = ui.add(egui::DragValue::new(&mut a).range(0.0..=100.0).speed(0.5).max_decimals(1).suffix("%"));
|
||||
app.auto.add("dialog.gradient.opacity", r.rect, "Opacity");
|
||||
if r.changed() {
|
||||
app.dialog_state.gradient.set_opacity(id, (a / 100.0) as f32);
|
||||
}
|
||||
}
|
||||
Track::Color => {
|
||||
ui.label(tr("Color:"));
|
||||
let c = app.dialog_state.gradient.colors.get(i).map_or([1.0; 4], |s| s.value);
|
||||
// The shared colour picker (sRGB, hex field, OK / Cancel); the stop keeps its alpha.
|
||||
let mut rgb = [c[0], c[1], c[2]];
|
||||
let swatch = crate::widgets::srgb_color_button(ui, &mut rgb);
|
||||
app.auto.add("dialog.gradient.color", swatch.rect, "Color");
|
||||
if swatch.changed() {
|
||||
app.dialog_state.gradient.set_rgb(id, rgb);
|
||||
}
|
||||
}
|
||||
}
|
||||
let can_delete = app.dialog_state.gradient.len(track) > 1;
|
||||
let r = ui.add_enabled(can_delete, egui::Button::new(tr("Delete")));
|
||||
app.auto.add("dialog.gradient.delete", r.rect, "Delete");
|
||||
if r.clicked() {
|
||||
app.dialog_state.gradient.remove(track, id);
|
||||
}
|
||||
});
|
||||
if app.dialog_state.gradient.mismatched() {
|
||||
ui.add_space(4.0);
|
||||
ui.label(egui::RichText::new(tr("These adjacent keyframes cannot interpolate because their stop counts differ.")).color(t.danger).size(11.0));
|
||||
}
|
||||
ui.add_space(14.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let r = ui.button(tr("Cancel"));
|
||||
app.auto.add("dialog.gradient.cancel", r.rect, "Cancel");
|
||||
if r.clicked() {
|
||||
answer = Some(false);
|
||||
}
|
||||
let r = ui.button(tr("OK"));
|
||||
app.auto.add("dialog.gradient.ok", r.rect, "OK");
|
||||
if r.clicked() {
|
||||
answer = Some(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
// Delete / Backspace remove the selected stop, but not while typing in a field.
|
||||
if answer.is_none()
|
||||
&& !ctx.egui_wants_keyboard_input()
|
||||
&& ctx.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace))
|
||||
&& let Some((track, id)) = app.dialog_state.gradient.selected
|
||||
{
|
||||
app.dialog_state.gradient.remove(track, id);
|
||||
}
|
||||
match answer {
|
||||
Some(true) => commit(app, ctx),
|
||||
Some(false) => {
|
||||
app.dialog = None;
|
||||
app.dialog_state.gradient = Draft::default();
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn draft() -> Draft {
|
||||
Draft::new(&Gradient { colors: vec![(0.0, [1.0, 0.0, 0.0, 1.0]), (1.0, [0.0, 0.0, 1.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 0.5)] }, None, vec![])
|
||||
}
|
||||
|
||||
/// Dragging the first stop past the second keeps the track in order and the same stop
|
||||
/// selected; a stop added afterwards samples the reordered track.
|
||||
#[test]
|
||||
fn stops_stay_in_order_and_selected_while_moving() {
|
||||
for track in [Track::Color, Track::Opacity] {
|
||||
let g = Gradient { colors: vec![(0.0, [1.0, 0.0, 0.0, 1.0]), (0.6, [0.0, 0.0, 1.0, 1.0])], opacities: vec![(0.0, 1.0), (0.6, 0.5)] };
|
||||
let mut d = Draft::new(&g, None, vec![]);
|
||||
let first = d.id_at(track, 0).unwrap();
|
||||
d.selected = Some((track, first));
|
||||
d.move_to(track, first, 0.3);
|
||||
d.move_to(track, first, 0.8);
|
||||
assert_eq!(d.index_of(track, first), Some(1), "{track:?}: dragged past the stop at 0.6, it is now second");
|
||||
assert_eq!(d.selected, Some((track, first)), "{track:?}: the selection followed it");
|
||||
let g = d.gradient();
|
||||
let positions: Vec<f64> = match track {
|
||||
Track::Color => g.colors.iter().map(|c| c.0).collect(),
|
||||
Track::Opacity => g.opacities.iter().map(|c| c.0).collect(),
|
||||
};
|
||||
assert!(positions.windows(2).all(|w| w[0] <= w[1]), "{track:?}: {positions:?}");
|
||||
let added = d.add(track, 0.5);
|
||||
assert_eq!(d.index_of(track, added), Some(0), "{track:?}: the new stop sorts before the stops at 0.6 and 0.8");
|
||||
assert_eq!(d.selected, Some((track, added)));
|
||||
}
|
||||
// After the swap the colour track runs blue (at 0) to red (at 1): a stop added at 0.5 is
|
||||
// their mix in the swapped order, not the original red-to-blue.
|
||||
let mut d = draft();
|
||||
let red = d.id_at(Track::Color, 0).unwrap();
|
||||
let blue = d.id_at(Track::Color, 1).unwrap();
|
||||
d.move_to(Track::Color, red, 1.0);
|
||||
d.move_to(Track::Color, blue, 0.0);
|
||||
let id = d.add(Track::Color, 0.25);
|
||||
let i = d.index_of(Track::Color, id).unwrap();
|
||||
let c = d.colors[i].value;
|
||||
assert!(c[2] > c[0], "blue dominates near 0 after the swap: {c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_stop_stays_and_new_stops_keep_their_tracks_values() {
|
||||
let mut d = draft();
|
||||
let a = d.id_at(Track::Opacity, 0).unwrap();
|
||||
let b = d.id_at(Track::Opacity, 1).unwrap();
|
||||
assert!(d.remove(Track::Opacity, a));
|
||||
assert!(!d.remove(Track::Opacity, b), "the last opacity stop stays");
|
||||
let mut d = draft();
|
||||
let id = d.add(Track::Opacity, 0.5);
|
||||
let i = d.index_of(Track::Opacity, id).unwrap();
|
||||
assert!((d.opacities[i].value - 0.75).abs() < 1e-6, "the opacity track's own value");
|
||||
let id = d.add(Track::Color, 0.5);
|
||||
let i = d.index_of(Track::Color, id).unwrap();
|
||||
assert!((d.colors[i].value[3] - 1.0).abs() < 1e-6, "colour stops keep their own alpha, not times opacity");
|
||||
d.set_rgb(id, [0.0, 1.0, 0.0]);
|
||||
assert_eq!(d.colors[i].value, [0.0, 1.0, 0.0, 1.0]);
|
||||
d.set_opacity(d.id_at(Track::Opacity, 0).unwrap(), f32::NAN);
|
||||
assert!(d.opacities.iter().all(|s| s.value.is_finite()));
|
||||
d.move_to(Track::Color, id, f64::INFINITY);
|
||||
assert!(d.colors.iter().all(|s| (0.0..=1.0).contains(&s.pos)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_drafts_equal_their_baseline() {
|
||||
// Out-of-order and out-of-range stops come in sorted and clamped: still "unchanged".
|
||||
let g = Gradient { colors: vec![(1.2, [0.0, 0.0, 1.0, 1.0]), (0.0, [1.0, 0.0, 0.0, 1.0])], opacities: vec![(0.5, 1.0)] };
|
||||
let d = Draft::new(&g, None, vec![]);
|
||||
assert_eq!(d.gradient(), d.baseline);
|
||||
assert_eq!(d.gradient().colors.iter().map(|c| c.0).collect::<Vec<_>>(), vec![0.0, 1.0]);
|
||||
let mut e = d.clone();
|
||||
e.add(Track::Opacity, 0.2);
|
||||
assert_ne!(e.gradient(), e.baseline);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_warning_follows_the_neighbours_stop_counts() {
|
||||
let mut d = Draft::new(&Gradient::default(), None, vec![(2, 2), (2, 2)]);
|
||||
assert!(!d.mismatched(), "same counts as both neighbours");
|
||||
d.add(Track::Color, 0.5);
|
||||
assert!(d.mismatched(), "three colour stops next to keyframes with two");
|
||||
assert!(!Draft::new(&Gradient::default(), None, vec![]).mismatched(), "not animated: no neighbours");
|
||||
}
|
||||
}
|
||||
@@ -419,7 +419,10 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
||||
&& let Some((press, grabs)) = ctx.data(|dd| dd.get_temp::<HandleDrag>(handle_drag_id()))
|
||||
{
|
||||
ctx.data_mut(|dd| dd.insert_temp(drag_id, true));
|
||||
let (dt, dv) = (tm.t(pt.x) - tm.t(press.x), vmap(pt.y) - vmap(press.y));
|
||||
// Shift keeps the handle on the time or the value axis, whichever moved more (as for keys).
|
||||
let (time_ok, value_ok) = shift_axes(ui.input(|i| i.modifiers.shift), pt.x - press.x, pt.y - press.y);
|
||||
let dt = if time_ok { tm.t(pt.x) - tm.t(press.x) } else { 0.0 };
|
||||
let dv = if value_ok { vmap(pt.y) - vmap(press.y) } else { 0.0 };
|
||||
let merge = format!("ghandle-{uid}-{}", k.idx);
|
||||
for g in &grabs {
|
||||
actions.push(("keys.setEase".into(), g.ease(g.end.0 + dt, g.end.1 + dv, speed, &merge)));
|
||||
@@ -470,7 +473,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
||||
{
|
||||
ctx.data_mut(|dd| dd.insert_temp(drag_id, true));
|
||||
let (dx, dy) = (pt.x - g.press.x, pt.y - g.press.y);
|
||||
let (time_ok, value_ok) = if mods.shift { (dx.abs() >= dy.abs(), dy.abs() > dx.abs()) } else { (true, true) };
|
||||
let (time_ok, value_ok) = shift_axes(mods.shift, dx, dy);
|
||||
// Graph Editor ▸ Snap: to the current time and other keys.
|
||||
let others: Vec<f64> = all_key_times.iter().filter(|(u, i, _)| !(*u == uid && *i == k.idx)).map(|x| x.2).collect();
|
||||
let t = if time_ok { super::graph_tools::snap_time(app, tm, (g.t + (tm.t(pt.x) - tm.t(g.press.x))).max(0.0), &others) } else { g.t };
|
||||
@@ -703,3 +706,21 @@ type HandleDrag = (Pos2, Vec<HandleGrab>);
|
||||
fn handle_drag_id() -> egui::Id {
|
||||
egui::Id::new("graph-handle-drag")
|
||||
}
|
||||
|
||||
/// Which axes a drag may move on (time, value): both, or with Shift only the one the pointer moved more along.
|
||||
fn shift_axes(shift: bool, dx: f32, dy: f32) -> (bool, bool) {
|
||||
if shift { (dx.abs() >= dy.abs(), dy.abs() > dx.abs()) } else { (true, true) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::shift_axes;
|
||||
|
||||
#[test]
|
||||
fn shift_locks_drag_to_dominant_axis() {
|
||||
assert_eq!(shift_axes(false, 10.0, 3.0), (true, true));
|
||||
assert_eq!(shift_axes(true, 10.0, -3.0), (true, false));
|
||||
assert_eq!(shift_axes(true, 2.0, -9.0), (false, true));
|
||||
assert_eq!(shift_axes(true, 5.0, 5.0), (true, false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ pub mod footage_panel;
|
||||
pub mod forms;
|
||||
pub mod fx_editors;
|
||||
pub mod fx_widgets;
|
||||
pub mod gradient_editor;
|
||||
pub mod graph;
|
||||
pub mod graph_tools;
|
||||
pub mod home;
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
//! Media Duration, Frame Rate, File Path, Comment — shown or hidden from the header's context
|
||||
//! menu), folders (drag items into and out of them), renaming (Enter, or double-click the name),
|
||||
//! the label colour picker, and the bottom bar (interpret, new folder, new comp: click, or drop
|
||||
//! items on it for a comp from them, bit depth, delete). Edits are engine commands, so they are
|
||||
//! undoable.
|
||||
//! items on it for a comp from them, bit depth, delete), and the keyboard (the arrow keys select,
|
||||
//! extend the selection and open / close folders; rows and buttons carry AccessKit names and
|
||||
//! states for screen readers). Edits are engine commands, so they are undoable.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -191,6 +192,130 @@ pub fn begin_rename(app: &EffectcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a Shift-extended selection starts (the last item clicked or moved to without Shift).
|
||||
fn anchor_id() -> egui::Id {
|
||||
egui::Id::new("project-selection-anchor")
|
||||
}
|
||||
|
||||
/// The item the arrow keys move from: the last item clicked or moved to.
|
||||
fn cursor_id() -> egui::Id {
|
||||
egui::Id::new("project-selection-cursor")
|
||||
}
|
||||
|
||||
/// An item row's widget: its click / drag area, keyboard focus and accessibility node.
|
||||
fn row_id(item: ItemId) -> egui::Id {
|
||||
egui::Id::new(("pitem", item.0))
|
||||
}
|
||||
|
||||
/// The last row drawn with keyboard focus (egui drops the focus of a row scrolled out of view).
|
||||
fn focused_row_id() -> egui::Id {
|
||||
egui::Id::new("project-focused-row")
|
||||
}
|
||||
|
||||
/// The focused widget is one of the panel's rows, or nothing has keyboard focus: the panel's keys
|
||||
/// apply. Any other focused widget (a text field, a button reached with Tab) keeps them.
|
||||
fn keys_reach_rows(app: &EffectcraftApp, ctx: &egui::Context) -> bool {
|
||||
match ctx.memory(|m| m.focused()) {
|
||||
None => app.ui.focused == crate::dock::PanelKind::Project,
|
||||
Some(f) => ctx.data(|d| d.get_temp::<ItemId>(focused_row_id())).is_some_and(|row| row_id(row) == f),
|
||||
}
|
||||
}
|
||||
|
||||
/// The Project panel's arrow keys, as in After Effects: Up / Down Arrow select the previous /
|
||||
/// next item (the first or last when nothing is selected), Shift+Up / Down extend the selection
|
||||
/// from its anchor, Right Arrow opens the selected folder and Left Arrow closes it (on an item
|
||||
/// inside a folder, Left selects the folder). They apply while the panel has focus and nothing
|
||||
/// else takes them: a dialog, an open menu, an inline rename. A row focused with Tab keeps them
|
||||
/// too, and keyboard focus then follows the selection, so a screen reader reads the new item.
|
||||
/// Returns the item the selection moved to.
|
||||
fn keyboard_nav(app: &mut EffectcraftApp, ctx: &egui::Context) -> Option<ItemId> {
|
||||
use egui::Key;
|
||||
let keys: Vec<(Key, egui::Modifiers)> = ctx.input(|i| {
|
||||
i.events
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
egui::Event::Key { key: k @ (Key::ArrowUp | Key::ArrowDown | Key::ArrowLeft | Key::ArrowRight), pressed: true, modifiers, .. } => {
|
||||
Some((*k, *modifiers))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
if keys.is_empty()
|
||||
|| app.dialog.is_some()
|
||||
|| widgets::any_menu_open(ctx)
|
||||
|| ctx.data(|d| d.get_temp::<Editing>(edit_id())).is_some()
|
||||
|| !keys_reach_rows(app, ctx)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let row_focused = ctx.memory(|m| m.focused()).is_some();
|
||||
let mut moved = None;
|
||||
for (key, m) in keys {
|
||||
let (plain, shift) = (!m.any(), m.shift_only());
|
||||
// Folders opened or closed by an earlier key in this frame change the rows.
|
||||
let rows = visible_rows(app);
|
||||
let pos = |id: ItemId| rows.iter().position(|(r, _)| *r == id);
|
||||
let sel = &app.session.state.project_selection;
|
||||
let cursor = ctx.data(|d| d.get_temp::<ItemId>(cursor_id())).filter(|c| sel.contains(c)).or_else(|| sel.last().copied());
|
||||
let cur = cursor.and_then(pos);
|
||||
let to = match key {
|
||||
Key::ArrowUp | Key::ArrowDown if plain || shift => {
|
||||
let Some(last) = rows.len().checked_sub(1) else { continue };
|
||||
let next = match (cur, key == Key::ArrowDown) {
|
||||
(Some(c), true) => c.saturating_add(1).min(last),
|
||||
(Some(c), false) => c.saturating_sub(1),
|
||||
(None, true) => 0,
|
||||
(None, false) => last,
|
||||
};
|
||||
let Some((to, _)) = rows.get(next).copied() else { continue };
|
||||
if shift {
|
||||
let anchor = ctx.data(|d| d.get_temp::<ItemId>(anchor_id())).and_then(pos).or(cur).unwrap_or(next);
|
||||
app.session.state.project_selection = rows.get(anchor.min(next)..=anchor.max(next)).unwrap_or_default().iter().map(|(i, _)| *i).collect();
|
||||
to
|
||||
} else {
|
||||
app.session.state.project_selection = vec![to];
|
||||
ctx.data_mut(|d| d.insert_temp(anchor_id(), to));
|
||||
to
|
||||
}
|
||||
}
|
||||
Key::ArrowRight | Key::ArrowLeft if plain => {
|
||||
let Some((id, folder, parent)) = cursor.and_then(|c| app.session.project.item(c)).map(|it| (it.id, it.is_folder(), it.parent)) else {
|
||||
continue;
|
||||
};
|
||||
let open = app.ui.project_open_folders.contains(&id.0);
|
||||
if key == Key::ArrowRight {
|
||||
if folder {
|
||||
app.ui.project_open_folders.insert(id.0);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if folder && open {
|
||||
app.ui.project_open_folders.remove(&id.0);
|
||||
continue;
|
||||
}
|
||||
let Some(parent) = parent.filter(|p| pos(*p).is_some()) else { continue };
|
||||
app.session.state.project_selection = vec![parent];
|
||||
ctx.data_mut(|d| d.insert_temp(anchor_id(), parent));
|
||||
parent
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
ctx.data_mut(|d| d.insert_temp(cursor_id(), to));
|
||||
moved = Some(to);
|
||||
}
|
||||
// The arrows are the list's, not egui's spatial focus moves.
|
||||
if row_focused {
|
||||
ctx.memory_mut(|m| {
|
||||
m.move_focus(egui::FocusDirection::None);
|
||||
if let Some(to) = moved {
|
||||
m.request_focus(row_id(to));
|
||||
}
|
||||
});
|
||||
}
|
||||
moved
|
||||
}
|
||||
|
||||
pub(crate) fn visible_rows(app: &EffectcraftApp) -> Vec<(ItemId, usize)> {
|
||||
fn walk(app: &EffectcraftApp, folder: Option<ItemId>, depth: usize, q: &str, out: &mut Vec<(ItemId, usize)>) {
|
||||
let mut kids = app.session.project.children(folder);
|
||||
@@ -307,6 +432,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
));
|
||||
}
|
||||
app.auto.add(&format!("project.sort.{key}"), hr, if label.is_empty() { "Label" } else { label });
|
||||
let column = crate::i18n::tr(if label.is_empty() { "Label" } else { label });
|
||||
let order = (app.ui.project_sort == *key).then(|| crate::i18n::tr(if app.ui.project_sort_desc { "descending" } else { "ascending" }));
|
||||
resp.widget_info(|| {
|
||||
let name = match order {
|
||||
Some(o) => crate::i18n::tr_args("Sort by {}, {}", &[&column, &o]),
|
||||
None => crate::i18n::tr_args("Sort by {}", &[&column]),
|
||||
};
|
||||
egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name)
|
||||
});
|
||||
if resp.clicked() {
|
||||
if app.ui.project_sort == *key {
|
||||
app.ui.project_sort_desc = !app.ui.project_sort_desc;
|
||||
@@ -317,7 +451,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
column_menu(app, &resp);
|
||||
}
|
||||
let hresp = ui.interact(Rect::from_min_max(pos2(x, hdr.min.y), hdr.max), egui::Id::new("proj-hdr-rest"), Sense::click());
|
||||
let hresp = ui.interact(Rect::from_min_max(pos2(x, hdr.min.y), hdr.max), egui::Id::new("proj-hdr-rest"), Sense::CLICK);
|
||||
column_menu(app, &hresp);
|
||||
app.auto.add("project.columns", hdr, "Columns (right-click to show or hide)");
|
||||
// Rows.
|
||||
@@ -327,7 +461,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
// The list's empty area (under the rows and the scroll bar, which take their own clicks):
|
||||
// a click deselects, a drag draws a selection box, a double-click imports, as in After
|
||||
// Effects (File ▸ Import ▸ File...).
|
||||
let empty = ui.interact(list, egui::Id::new("proj-empty"), Sense::click_and_drag());
|
||||
// Not a Tab stop: the keyboard reaches the rows, and nothing else here takes a key.
|
||||
let empty = ui.interact(list, egui::Id::new("proj-empty"), Sense::CLICK | Sense::DRAG);
|
||||
app.auto.add("project.empty", list, "Double-click to import files; drag to select items; right-click for project actions");
|
||||
if overflow > 0.0 && ui.rect_contains_pointer(list) {
|
||||
let (dx, dy, shift) = ui.input(|i| (i.smooth_scroll_delta.x, i.smooth_scroll_delta.y, i.modifiers.shift));
|
||||
@@ -340,11 +475,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
};
|
||||
app.ui.project_hscroll = (app.ui.project_hscroll - d).clamp(0.0, overflow);
|
||||
}
|
||||
let moved = keyboard_nav(app, &ctx);
|
||||
let rows = visible_rows(app);
|
||||
// Enter renames the selected item (Project panel focused, not typing).
|
||||
if app.ui.focused == crate::dock::PanelKind::Project
|
||||
&& app.dialog.is_none()
|
||||
&& !ctx.egui_wants_keyboard_input()
|
||||
&& keys_reach_rows(app, &ctx)
|
||||
&& ctx.data(|d| d.get_temp::<Editing>(edit_id())).is_none()
|
||||
&& ctx.input(|i| i.key_pressed(egui::Key::Enter))
|
||||
{
|
||||
@@ -382,6 +518,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// The arrow keys' item scrolls into view (Shift+Up extends upward, past the selection's end).
|
||||
if let Some(row) = moved.and_then(|m| rows.iter().position(|(id, _)| *id == m)) {
|
||||
let top = row as f32 * ROW_H;
|
||||
// Not `clamp`: a panel shorter than a row would make its bounds cross. The row's top wins.
|
||||
app.ui.project_scroll = app.ui.project_scroll.max(top + ROW_H - list.height()).min(top);
|
||||
}
|
||||
app.ui.project_scroll = app.ui.project_scroll.clamp(0.0, max_scroll);
|
||||
let scroll = app.ui.project_scroll;
|
||||
let first = ((scroll / ROW_H).floor() as usize).min(rows.len());
|
||||
@@ -406,14 +548,37 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
);
|
||||
// The row's click / drag area goes first, so the twirl, proxy, label and comment widgets
|
||||
// on top of it take their own clicks.
|
||||
let mut resp = ui.interact(r.intersect(list), egui::Id::new(("pitem", id.0)), Sense::click_and_drag());
|
||||
let mut resp = ui.interact(r.intersect(list), row_id(*id), Sense::click_and_drag());
|
||||
app.auto.add(&format!("project.item.{}", id.0), r, &it.name);
|
||||
// For screen readers: a tree item named by the item and its type, with its selection,
|
||||
// depth and (a folder) open state. One Tab stop per row; the panel's keys then apply.
|
||||
let open = it.is_folder().then(|| app.ui.project_open_folders.contains(&id.0) || !app.ui.project_search.is_empty());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, format!("{}, {}", it.name, crate::i18n::tr(it.type_name()))));
|
||||
ctx.accesskit_node_builder(resp.id, |n| {
|
||||
n.set_role(egui::accesskit::Role::TreeItem);
|
||||
n.set_selected(selected);
|
||||
n.set_level(depth.saturating_add(1));
|
||||
if let Some(open) = open {
|
||||
n.set_expanded(open);
|
||||
}
|
||||
});
|
||||
if resp.has_focus() {
|
||||
ctx.data_mut(|d| d.insert_temp(focused_row_id(), *id));
|
||||
}
|
||||
if resp.gained_focus() {
|
||||
app.ui.focused = crate::dock::PanelKind::Project;
|
||||
}
|
||||
let _ = i;
|
||||
let x0 = r.min.x + 8.0 + 14.0 * *depth as f32;
|
||||
if it.is_folder() {
|
||||
let open = app.ui.project_open_folders.contains(&id.0);
|
||||
let tw = Rect::from_center_size(pos2(x0 + 4.0, r.center().y), vec2(12.0, 12.0));
|
||||
if widgets::twirl(ui, tw, open, egui::Id::new(("pf", id.0)), &t).clicked() {
|
||||
let tresp = widgets::twirl(ui, tw, open, egui::Id::new(("pf", id.0)), &t);
|
||||
tresp.widget_info(|| {
|
||||
let name = crate::i18n::tr_args(if open { "Close folder {}" } else { "Open folder {}" }, &[&it.name]);
|
||||
egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name)
|
||||
});
|
||||
if tresp.clicked() {
|
||||
if open {
|
||||
app.ui.project_open_folders.remove(&id.0);
|
||||
} else {
|
||||
@@ -437,6 +602,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if px.enabled { "in use" } else { "off" }
|
||||
));
|
||||
app.auto.add(&format!("project.item.{}.proxy", id.0), pr, if px.enabled { "Proxy in use" } else { "Proxy off" });
|
||||
presp.widget_info(|| {
|
||||
let name = crate::i18n::tr_args(if px.enabled { "Proxy of {}: in use" } else { "Proxy of {}: off" }, &[&it.name]);
|
||||
egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name)
|
||||
});
|
||||
if presp.clicked() {
|
||||
actions.push(("file.useProxy".into(), json!({"item": id.0})));
|
||||
}
|
||||
@@ -485,6 +654,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
lp.rect_filled(sw, 2.0, t.label(it.label));
|
||||
let sresp = ui.interact(sw.expand(3.0).intersect(list), egui::Id::new(("plabel", id.0)), Sense::click());
|
||||
app.auto.add(&format!("project.item.{}.label", id.0), sw, it.label.name());
|
||||
sresp.widget_info(|| {
|
||||
let name = crate::i18n::tr_args("Label of {}: {}", &[&it.name, &app.session.prefs.label_name(it.label)]);
|
||||
egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name)
|
||||
});
|
||||
let pop = egui::Id::new(("plabel-pop", id.0));
|
||||
if sresp.clicked() || sresp.secondary_clicked() {
|
||||
widgets::open_popup(ui, pop);
|
||||
@@ -510,8 +683,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if cell_edit(app, ui, &mut actions, "comment", cr) {
|
||||
continue;
|
||||
}
|
||||
let cresp = ui.interact(cell, egui::Id::new(("pcomment", id.0)), Sense::click());
|
||||
let cresp = ui.interact(cell, egui::Id::new(("pcomment", id.0)), Sense::CLICK);
|
||||
app.auto.add(&format!("project.item.{}.comment", id.0), cell, &it.comment);
|
||||
cresp.widget_info(|| {
|
||||
egui::WidgetInfo::labeled(egui::WidgetType::Other, true, crate::i18n::tr_args("Comment of {}: {}", &[&it.name, &it.comment]))
|
||||
});
|
||||
if cresp.double_clicked() {
|
||||
ctx.data_mut(|d| d.insert_temp::<Editing>(edit_id(), (id.0, "comment".into(), it.comment.clone())));
|
||||
} else {
|
||||
@@ -529,12 +705,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if dragging.is_some_and(|d| d != id.0) && drop_row == Some(*id) && it.is_folder() {
|
||||
lp.rect_stroke(r.shrink(1.0), 2.0, Stroke::new(1.5, t.accent), egui::StrokeKind::Inside);
|
||||
}
|
||||
let nresp = ui.interact(name_clip, egui::Id::new(("pname", id.0)), Sense::click_and_drag());
|
||||
// The row with keyboard focus (Tab, or the arrow keys after it) shows a focus ring.
|
||||
if ctx.memory(|m| m.has_focus(row_id(*id))) {
|
||||
lp.rect_stroke(r.shrink(0.5), 0.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
|
||||
}
|
||||
// Part of the row: not a second Tab stop.
|
||||
let nresp = ui.interact(name_clip, egui::Id::new(("pname", id.0)), Sense::CLICK | Sense::DRAG);
|
||||
app.auto.add(&format!("project.item.{}.name", id.0), name_clip, &it.name);
|
||||
let resp = resp.union(nresp);
|
||||
if resp.clicked() {
|
||||
let m = ui.input(|i| i.modifiers);
|
||||
let anchor_key = egui::Id::new("project-selection-anchor");
|
||||
let anchor_key = anchor_id();
|
||||
ctx.data_mut(|d| d.insert_temp(cursor_id(), *id));
|
||||
let anchor = ctx.data(|d| d.get_temp::<ItemId>(anchor_key)).or_else(|| app.session.state.project_selection.first().copied());
|
||||
if m.shift
|
||||
&& let Some(a) = anchor.and_then(|a| rows.iter().position(|(i, _)| *i == a))
|
||||
@@ -692,6 +874,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let r = Rect::from_min_size(pos2(x, foot.min.y + 3.0), vec2(22.0, 22.0));
|
||||
let resp = widgets::icon_button(ui, r, icon, false, &t, egui::Id::new(("pfoot", id))).on_hover_text(tip);
|
||||
app.auto.add(&format!("project.{id}"), r, tip);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr(tip)));
|
||||
if resp.clicked() {
|
||||
let footage =
|
||||
app.session.state.project_selection.first().and_then(|i| app.session.project.item(*i)).is_some_and(|i| matches!(i.kind, ItemKind::Footage(_)));
|
||||
@@ -724,6 +907,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let dresp = ui.interact(dr, egui::Id::new("bpc"), Sense::click());
|
||||
p.text(dr.center(), Align2::CENTER_CENTER, depth, Tokens::ui(11.5), if dresp.hovered() { t.text } else { t.text_dim });
|
||||
app.auto.add("project.bitDepth", dr, depth);
|
||||
dresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr_args("Project color depth: {}", &[&depth])));
|
||||
// Click cycles 8/16/32 bpc; Alt-click opens Project Settings (as in After Effects).
|
||||
if dresp.clicked() {
|
||||
if ui.input(|i| i.modifiers.alt) {
|
||||
@@ -734,9 +918,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
dresp.on_hover_text(crate::i18n::tr("Project color depth: click to cycle 8/16/32 bpc, Alt-click for Project Settings"));
|
||||
let tr = Rect::from_min_size(pos2(foot.max.x - 30.0, foot.min.y + 3.0), vec2(22.0, 22.0));
|
||||
if widgets::icon_button(ui, tr, Icon::Trash, false, &t, egui::Id::new("ptrash")).on_hover_text(crate::i18n::tr("Delete selected project items")).clicked()
|
||||
&& !app.session.state.project_selection.is_empty()
|
||||
{
|
||||
let trash = widgets::icon_button(ui, tr, Icon::Trash, false, &t, egui::Id::new("ptrash")).on_hover_text(crate::i18n::tr("Delete selected project items"));
|
||||
trash.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr("Delete selected project items")));
|
||||
if trash.clicked() && !app.session.state.project_selection.is_empty() {
|
||||
actions.push(("project.delete".into(), json!({})));
|
||||
}
|
||||
app.auto.add("project.delete", tr, "Delete");
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
use crate::i18n::{tr, tr_args};
|
||||
use effectcraft_engine::prefs::{APPEARANCE_MODES, DARK_THEMES, Item, Kind, LIGHT_THEMES, Page, pages};
|
||||
use effectcraft_engine::segment::Task;
|
||||
use egui::{Color32, RichText, vec2};
|
||||
use egui::{Color32, Rect, RichText, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::theme::{ThemeKind, Tokens};
|
||||
@@ -118,7 +118,10 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value
|
||||
let r = match kind {
|
||||
Kind::Int(lo, hi, unit) => {
|
||||
let mut x = v.as_i64().unwrap_or(0);
|
||||
let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut x).range(lo..=hi).speed(0.25)));
|
||||
// A typed number applies once entered: typing 85 into UI Scale
|
||||
// doesn't resize the interface to 75 % at its first digit.
|
||||
let drag = egui::DragValue::new(&mut x).range(lo..=hi).speed(0.25).update_while_editing(false);
|
||||
let r = ui.add(crate::widgets::drag_value(drag));
|
||||
if !unit.is_empty() {
|
||||
ui.label(RichText::new(unit).color(t.text_dim));
|
||||
}
|
||||
@@ -152,7 +155,6 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value
|
||||
r
|
||||
}
|
||||
Kind::Choice(opts) => {
|
||||
// (A number with choices, UI Scale, shows as its text.)
|
||||
let curv = v.as_str().map_or_else(|| v.to_string(), str::to_string);
|
||||
let shown = opts.iter().find(|o| o.1 == curv).map(|o| o.0).unwrap_or(curv.as_str()).to_string();
|
||||
egui::ComboBox::from_id_salt(("settings", key))
|
||||
@@ -602,10 +604,40 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
let cur = serde_json::to_value(&app.session.prefs).unwrap_or_default();
|
||||
let mut acts: Vec<Act> = vec![];
|
||||
let (mut ok, mut cancel) = (false, false);
|
||||
super::dialogs::modal(ctx, "Settings", vec2(820.0, 600.0), t, |ui| {
|
||||
let body_h = (ctx.content_rect().height() - 170.0).clamp(120.0, 470.0);
|
||||
ui.allocate_ui_with_layout(vec2(ui.available_width(), body_h), egui::Layout::left_to_right(egui::Align::Min), |ui| {
|
||||
ui.set_height(body_h);
|
||||
super::dialogs::modal_fit(ctx, "Settings", vec2(820.0, 600.0), t, |ui, height| {
|
||||
// The buttons go first, from the bottom up, and the pages get the height they leave: at
|
||||
// a high UI Scale the pages scroll and the buttons stay in view.
|
||||
let area = ui.allocate_space(vec2(ui.available_width(), height)).1;
|
||||
let mut foot = ui.new_child(egui::UiBuilder::new().max_rect(area).layout(egui::Layout::bottom_up(egui::Align::Min)));
|
||||
foot.horizontal(|ui| {
|
||||
let r = ui.add_enabled(idx > 0, egui::Button::new(tr("Previous")));
|
||||
app.auto.add("settings.previous", r.rect, "Previous");
|
||||
if r.clicked() {
|
||||
page = all[idx - 1].id.to_string();
|
||||
}
|
||||
let r = ui.add_enabled(idx + 1 < all.len(), egui::Button::new(tr("Next")));
|
||||
app.auto.add("settings.next", r.rect, "Next");
|
||||
if r.clicked() {
|
||||
page = all[idx + 1].id.to_string();
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let r = ui.add(egui::Button::new(RichText::new(tr(" OK ")).color(Color32::WHITE)).fill(t.accent));
|
||||
app.auto.add("settings.ok", r.rect, "OK");
|
||||
ok |= r.clicked();
|
||||
let r = ui.button(tr("Cancel"));
|
||||
app.auto.add("settings.cancel", r.rect, "Cancel");
|
||||
cancel |= r.clicked();
|
||||
});
|
||||
});
|
||||
foot.separator();
|
||||
foot.add_space(8.0);
|
||||
let body_h = (foot.min_rect().top() - area.top()).max(0.0);
|
||||
// (A child, clipped to its share, so a wide page can't widen the dialog under the buttons.)
|
||||
let pages = Rect::from_min_size(area.min, vec2(area.width(), body_h));
|
||||
let mut body = ui.new_child(egui::UiBuilder::new().max_rect(pages).layout(egui::Layout::left_to_right(egui::Align::Min)));
|
||||
body.set_clip_rect(pages.intersect(body.clip_rect()));
|
||||
{
|
||||
let ui = &mut body;
|
||||
// Page list (left, like After Effects).
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(180.0);
|
||||
@@ -640,33 +672,11 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
let p = &all[idx];
|
||||
ui.label(RichText::new(tr(p.title)).font(Tokens::semibold(14.0)).color(t.tab_text_active));
|
||||
ui.add_space(6.0);
|
||||
egui::ScrollArea::vertical().max_height(body_h - 30.0).auto_shrink([false, false]).show(ui, |ui| {
|
||||
egui::ScrollArea::vertical().max_height((body_h - 30.0).max(0.0)).auto_shrink([false, false]).show(ui, |ui| {
|
||||
page_ui(app, ui, p, &cur, t, &mut acts);
|
||||
});
|
||||
});
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
let r = ui.add_enabled(idx > 0, egui::Button::new(tr("Previous")));
|
||||
app.auto.add("settings.previous", r.rect, "Previous");
|
||||
if r.clicked() {
|
||||
page = all[idx - 1].id.to_string();
|
||||
}
|
||||
let r = ui.add_enabled(idx + 1 < all.len(), egui::Button::new(tr("Next")));
|
||||
app.auto.add("settings.next", r.rect, "Next");
|
||||
if r.clicked() {
|
||||
page = all[idx + 1].id.to_string();
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let r = ui.add(egui::Button::new(RichText::new(tr(" OK ")).color(Color32::WHITE)).fill(t.accent));
|
||||
app.auto.add("settings.ok", r.rect, "OK");
|
||||
ok |= r.clicked();
|
||||
let r = ui.button(tr("Cancel"));
|
||||
app.auto.add("settings.cancel", r.rect, "Cancel");
|
||||
cancel |= r.clicked();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
app.dialog_state.settings_page = page;
|
||||
for a in acts {
|
||||
|
||||
@@ -579,6 +579,10 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui:
|
||||
});
|
||||
let preview = app.session.prefs.type_.font_preview;
|
||||
let t = app.tokens;
|
||||
// The Variable Fonts filter (After Effects 26.3) stays as set while the app runs, like the
|
||||
// menu's other settings; the search query resets on every open.
|
||||
let variable_id = id.with("variable-only");
|
||||
let mut only_variable = ui.data(|d| d.get_temp::<bool>(variable_id)).unwrap_or(false);
|
||||
// The font field itself becomes editable. The popup contains only results.
|
||||
let arrow = Rect::from_min_max(pos2(rect.max.x - 22.0, rect.min.y), rect.max);
|
||||
let edit_rect = Rect::from_min_max(rect.min, pos2(arrow.min.x, rect.max.y));
|
||||
@@ -608,12 +612,28 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui:
|
||||
let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).pivot(pivot).fixed_pos(anchor).show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
ui.set_width(rect.width().max(180.0));
|
||||
// Filter row: a checkbox that keeps only the variable fonts.
|
||||
let filter_h = 22.0;
|
||||
let row = ui.add_sized([ui.available_width(), filter_h], egui::Button::selectable(false, ""));
|
||||
let box_rect = Rect::from_min_size(row.rect.min, vec2(18.0, filter_h));
|
||||
let label_rect = Rect::from_min_max(pos2(box_rect.max.x + 2.0, row.rect.min.y), row.rect.max);
|
||||
let boxed = widgets::checkbox(ui, box_rect, only_variable, &t, id.with("variable-check"));
|
||||
ui.painter().text(label_rect.left_center(), Align2::LEFT_CENTER, tr("Variable fonts only"), Tokens::ui(11.5), t.text);
|
||||
app.auto.add(&format!("{auto}.variable"), row.rect, "Variable fonts only");
|
||||
let toggled = row.clicked() || boxed.clicked();
|
||||
if toggled {
|
||||
only_variable = !only_variable;
|
||||
ui.data_mut(|d| d.insert_temp(variable_id, only_variable));
|
||||
// Typing goes on into the search field.
|
||||
ui.memory_mut(|m| m.request_focus(search_id));
|
||||
}
|
||||
let query = state.query.trim().to_lowercase();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let filtered: Vec<_> = rows
|
||||
.iter()
|
||||
.filter(|r| {
|
||||
!r.family.is_empty()
|
||||
&& (!only_variable || r.variable)
|
||||
&& (query.is_empty()
|
||||
|| ((r.family.to_lowercase().contains(&query) || r.display.to_lowercase().contains(&query)) && seen.insert(&r.family)))
|
||||
})
|
||||
@@ -621,7 +641,7 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui:
|
||||
if fresh {
|
||||
state.selected = filtered.iter().position(|r| r.family == current).unwrap_or(0);
|
||||
}
|
||||
if search.changed() {
|
||||
if search.changed() || toggled {
|
||||
state.selected = 0;
|
||||
}
|
||||
let last = filtered.len().saturating_sub(1);
|
||||
@@ -637,10 +657,12 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui:
|
||||
}
|
||||
// Give the result list its actual height even when the previous query had
|
||||
// only one row. A cached tiny Area must not collapse a reopened long list.
|
||||
let height = (filtered.len() as f32 * (24.0 + ui.spacing().item_spacing.y) + 16.0).clamp(28.0, max_height);
|
||||
ui.set_height(height);
|
||||
let filter_room = filter_h + ui.spacing().item_spacing.y;
|
||||
let height = (filtered.len() as f32 * (24.0 + ui.spacing().item_spacing.y) + 16.0).clamp(28.0, (max_height - filter_room).max(28.0));
|
||||
// A minimum, from below the filter row (a maximum would move the cursor back up).
|
||||
ui.set_min_height(height);
|
||||
let mut scroll = egui::ScrollArea::vertical().id_salt(id.with("results")).max_height(height).min_scrolled_height(height);
|
||||
if search.changed() {
|
||||
if search.changed() || toggled {
|
||||
scroll = scroll.vertical_scroll_offset(0.0);
|
||||
}
|
||||
scroll.show(ui, |ui| {
|
||||
|
||||
@@ -488,6 +488,21 @@ impl TMap {
|
||||
}
|
||||
}
|
||||
|
||||
/// A Timeline ruler tick label for comp frame `fno`: seconds:frames (`00:15f`, AE) or, when
|
||||
/// the project's Time Display Style is Frames, the frame number the current-time display shows
|
||||
/// (`00015`, counted from Start Numbering Frames At) (#710).
|
||||
fn ruler_label(fno: i64, fps_i: i64, settings: &effectcraft_engine::project::ProjectSettings) -> String {
|
||||
if settings.time_display == effectcraft_engine::project::TimeDisplayStyle::Frames {
|
||||
return format!("{:05}", fno.saturating_add(settings.frame_start));
|
||||
}
|
||||
let fps_i = fps_i.max(1);
|
||||
if fno >= 60 * fps_i * 60 {
|
||||
format!("{}:{:02}:{:02}f", fno / (fps_i * 60), (fno / fps_i) % 60, fno % fps_i)
|
||||
} else {
|
||||
format!("{:02}:{:02}f", fno / fps_i, fno % fps_i)
|
||||
}
|
||||
}
|
||||
|
||||
fn tl_map_id() -> egui::Id {
|
||||
egui::Id::new("timeline-map")
|
||||
}
|
||||
@@ -1513,11 +1528,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let x = tm.x(s);
|
||||
pr.line_segment([pos2(x, ruler.max.y - 10.0), pos2(x, ruler.max.y)], Stroke::new(1.0, t.tl_ruler_tick));
|
||||
let fno = (s / fd).round() as i64;
|
||||
let label = if fno >= 60 * fps_i * 60 {
|
||||
format!("{}:{:02}:{:02}f", fno / (fps_i * 60), (fno / fps_i) % 60, fno % fps_i)
|
||||
} else {
|
||||
format!("{:02}:{:02}f", fno / fps_i, fno % fps_i)
|
||||
};
|
||||
let label = ruler_label(fno, fps_i, &app.session.project.settings);
|
||||
pr.text(pos2(x + 3.0, ruler.max.y - 16.0), Align2::LEFT_CENTER, label, Tokens::ui(10.0), t.tl_ruler_text);
|
||||
for k in 1..5 {
|
||||
let xx = tm.x(s + secs_per_label * k as f64 / 5.0);
|
||||
@@ -3671,7 +3682,16 @@ fn value_editor(
|
||||
);
|
||||
}
|
||||
Value::Gradient(_) => {
|
||||
p.text(pos2(x, at.y), Align2::LEFT_CENTER, crate::i18n::tr("Edit Gradient…"), Tokens::ui(12.0), t.hot_text);
|
||||
// Opens the Gradient Editor at the current time (#612).
|
||||
let r = p.text(pos2(x, at.y), Align2::LEFT_CENTER, crate::i18n::tr("Edit Gradient…"), Tokens::ui(12.0), t.hot_text);
|
||||
let resp = ui.interact(r.expand(2.0), egui::Id::new(("gradient-edit", uid)), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
}
|
||||
app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
|
||||
if resp.clicked() {
|
||||
actions.push(("prop.editGradient".into(), json!({"layer": layer.id.0, "prop": uid})));
|
||||
}
|
||||
}
|
||||
Value::Str(s) => {
|
||||
p.text(pos2(x, at.y), Align2::LEFT_CENTER, s.chars().take(30).collect::<String>(), Tokens::ui(12.0), t.text_dim);
|
||||
@@ -3684,6 +3704,23 @@ fn value_editor(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// #710: with Time Display set to Frames the ruler labels are frame numbers (as under the
|
||||
/// CTI, from Start Numbering Frames At); otherwise seconds:frames.
|
||||
#[test]
|
||||
fn ruler_labels_follow_the_time_display_style() {
|
||||
use effectcraft_engine::project::{ProjectSettings, TimeDisplayStyle};
|
||||
let mut st = ProjectSettings { time_display: TimeDisplayStyle::Timecode, frame_start: 0, ..ProjectSettings::default() };
|
||||
assert_eq!(ruler_label(15, 30, &st), "00:15f");
|
||||
assert_eq!(ruler_label(45, 30, &st), "01:15f");
|
||||
assert_eq!(ruler_label(30 * 3600 + 31, 30, &st), "60:01:01f");
|
||||
st.time_display = TimeDisplayStyle::Frames;
|
||||
assert_eq!(ruler_label(0, 30, &st), "00000");
|
||||
assert_eq!(ruler_label(45, 30, &st), "00045");
|
||||
st.frame_start = 1;
|
||||
assert_eq!(ruler_label(45, 30, &st), "00046");
|
||||
assert_eq!(ruler_label(i64::MAX, 30, &st), format!("{:05}", i64::MAX));
|
||||
}
|
||||
|
||||
/// #468: playback pages the view to the CTI; a scrub past either end scrolls that way.
|
||||
#[test]
|
||||
fn the_view_pages_to_the_cti() {
|
||||
|
||||
@@ -1113,6 +1113,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let context_layers: Vec<u64> =
|
||||
app.session.state.selected_layers.iter().filter(|id| comp.layer(**id).is_some_and(|l| !l.switches.locked)).map(|id| id.0).collect();
|
||||
let mut context_actions = Vec::new();
|
||||
let copy_frame = crate::i18n::label(app, "comp.copyFrame", "Copy Frame to Clipboard");
|
||||
resp.context_menu(|ui| {
|
||||
let hits = ui.data(|d| d.get_temp::<Vec<(u64, String)>>(menu_hits_id)).unwrap_or_default();
|
||||
if !hits.is_empty() {
|
||||
@@ -1133,6 +1134,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
} else {
|
||||
super::timeline::layer_menu(ui, &context_layers, &mut context_actions);
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button(copy_frame).clicked() {
|
||||
context_actions.push(("comp.copyFrame".to_owned(), serde_json::json!({"comp": cid.0})));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
for (id, params) in context_actions {
|
||||
if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) {
|
||||
@@ -2282,7 +2288,7 @@ fn create_shape(app: &mut EffectcraftApp, tool: Tool, a: [f64; 2], b: [f64; 2],
|
||||
|
||||
fn empty_state(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let p = ui.painter();
|
||||
let p = ui.painter().clone();
|
||||
p.rect_filled(rect, 0.0, t.pasteboard);
|
||||
let c = rect.center();
|
||||
p.text(c - vec2(0.0, 40.0), Align2::CENTER_CENTER, crate::i18n::tr("Composition"), Tokens::semibold(16.0), t.text);
|
||||
@@ -2296,6 +2302,36 @@ fn empty_state(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let _ = crate::menus::invoke(app, &ui.ctx().clone(), "file.import", json!({}));
|
||||
}
|
||||
app.auto.add("viewer.empty.import", b2, "New Composition From Footage");
|
||||
// A Project item dropped on the empty viewer creates the first composition,
|
||||
// just like dropping it onto an empty Timeline (#684). Multiple selected
|
||||
// items use the existing New Comp from Selection dialog.
|
||||
let ctx = ui.ctx().clone();
|
||||
if let Some(crate::panels::DragPayload::Item(dragged)) = egui::DragAndDrop::payload::<crate::panels::DragPayload>(&ctx).as_deref()
|
||||
&& ui.rect_contains_pointer(rect)
|
||||
{
|
||||
let items: Vec<effectcraft_engine::project::ItemId> = crate::panels::project_drop_items(app, *dragged)
|
||||
.into_iter()
|
||||
.map(effectcraft_engine::project::ItemId)
|
||||
.filter(|id| {
|
||||
app.session.project.item(*id).is_some_and(|item| match &item.kind {
|
||||
effectcraft_engine::project::ItemKind::Footage(f) => f.kind != effectcraft_engine::project::FootageKind::Data,
|
||||
effectcraft_engine::project::ItemKind::Solid(_) | effectcraft_engine::project::ItemKind::Comp(_) => true,
|
||||
_ => false,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if !items.is_empty() {
|
||||
p.rect_stroke(rect.shrink(2.0), 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside);
|
||||
app.auto.add("viewer.newCompDrop", rect, "Drop footage to create a composition");
|
||||
if ctx.input(|i| i.pointer.any_released()) {
|
||||
app.session.state.project_selection = items;
|
||||
if let Err(e) = crate::menus::invoke(app, &ctx, "file.newCompFromSelection", json!({})) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
egui::DragAndDrop::clear_payload(&ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `#rrggbb` → sRGB bytes.
|
||||
|
||||
@@ -91,14 +91,19 @@ pub fn dropped_files(app: &mut EffectcraftApp, ui: &egui::Ui, bounds: Rect) {
|
||||
// Dropped files import as stills and folders' numbered stills as sequences; Alt-drag
|
||||
// imports a folder's files one by one (After Effects).
|
||||
let alt = ctx.input(|i| i.modifiers.alt);
|
||||
// A dropped Photoshop file asks how to import it (the Import Photoshop File dialog, its
|
||||
// Import Kind starting at Default Drag Import As), as File ▸ Import ▸ File… does (#678).
|
||||
let layered = rest.iter().any(|p| p.rsplit('.').next().is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "psd" | "psb")));
|
||||
let mut params = json!({
|
||||
"paths": rest,
|
||||
"drag": true,
|
||||
"importAs": app.session.prefs.drag_import_as(),
|
||||
"addToComp": at.is_some(),
|
||||
"position": at,
|
||||
"background": true
|
||||
});
|
||||
if !layered {
|
||||
params["importAs"] = json!(app.session.prefs.drag_import_as());
|
||||
}
|
||||
if alt {
|
||||
params["sequence"] = json!(false);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user