diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index a0f3a4f85..f8ac8a300 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -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: diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index a26ec6f70..a074e6dbe 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -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: diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 6750a3273..85827f74e 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -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` diff --git a/Cargo.lock b/Cargo.lock index ba84c75f5..9f0403715 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1560,6 +1560,7 @@ dependencies = [ "effectcraft-render", "effectcraft-time", "serde_json", + "web-time", ] [[package]] diff --git a/contributors/people.toml b/contributors/people.toml index 868c7d8e2..5a4552a2a 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -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 diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 37de10a50..6bb6fbc52 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -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 diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index fa86095be..a83e0d1ec 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -210,7 +210,7 @@ fn list_effects(b: &mut Backend, a: &Value) -> Result { } fn list_fonts(b: &mut Backend, a: &Value) -> Result { - 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."} }), &[], diff --git a/crates/effects/src/channel3d.rs b/crates/effects/src/channel3d.rs index b1cd4a609..4fc1cc81a 100644 --- a/crates/effects/src/channel3d.rs +++ b/crates/effects/src/channel3d.rs @@ -323,11 +323,40 @@ fn wild(pat: &str, name: &str) -> bool { } } -/// Parse a selection: comma-separated names (optionally quoted) or `` 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 { + let mut out = vec![]; + let mut cur = String::new(); + let mut quote: Option = 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 +/// `` hashes / wildcards. pub fn selection_hashes(sel: &str, manifest: Option<&[(String, u32)]>) -> Vec { 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(); diff --git a/crates/effects/src/color_fx.rs b/crates/effects/src/color_fx.rs index 0a6663a52..e431ff22e 100644 --- a/crates/effects/src/color_fx.rs +++ b/crates/effects/src/color_fx.rs @@ -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]; diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 7e968297a..3ad4d6de4 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -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). diff --git a/crates/effects/src/perspective.rs b/crates/effects/src/perspective.rs index 014353fc8..24c561ecd 100644 --- a/crates/effects/src/perspective.rs +++ b/crates/effects/src/perspective.rs @@ -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 { 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]); diff --git a/crates/effects/src/time_fx.rs b/crates/effects/src/time_fx.rs index 67d35378e..d455ce039 100644 --- a/crates/effects/src/time_fx.rs +++ b/crates/effects/src/time_fx.rs @@ -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 { 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 = (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 { + 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 = 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)] diff --git a/crates/engine/presets/transitions.json b/crates/engine/presets/transitions.json new file mode 100644 index 000000000..386e02740 --- /dev/null +++ b/crates/engine/presets/transitions.json @@ -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 } + ] + } + ] +} diff --git a/crates/engine/presets/transitions.json.attribution b/crates/engine/presets/transitions.json.attribution new file mode 100644 index 000000000..d09c75ed5 --- /dev/null +++ b/crates/engine/presets/transitions.json.attribution @@ -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. diff --git a/crates/engine/src/commands/anim_presets.rs b/crates/engine/src/commands/anim_presets.rs new file mode 100644 index 000000000..e14425cbd --- /dev/null +++ b/crates/engine/src/commands/anim_presets.rs @@ -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 { + serde_json::from_str::(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 { + 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 = 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 { + 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 { + 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::>()?; + 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 = Vec::new(); + let mut skipped: Vec = 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 { + 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 { + 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), + ] +} diff --git a/crates/engine/src/commands/app_more.rs b/crates/engine/src/commands/app_more.rs index b6101c5a9..e4ece7c7f 100644 --- a/crates/engine/src/commands/app_more.rs +++ b/crates/engine/src/commands/app_more.rs @@ -316,6 +316,7 @@ pub const IMPORT_FORMATS: &[&str] = &[ "Opus", "glTF/GLB", "OBJ", + "STL", "Lottie JSON", ]; diff --git a/crates/engine/src/commands/comp_more.rs b/crates/engine/src/commands/comp_more.rs index 2c4ca409b..1e753c207 100644 --- a/crates/engine/src/commands/comp_more.rs +++ b/crates/engine/src/commands/comp_more.rs @@ -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 { 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 { + 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 { let cid = comp_id(s, p)?; @@ -156,6 +176,7 @@ pub fn specs() -> Vec { 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), ] } diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index e5d2f912c..55e6c8285 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -531,6 +531,42 @@ fn paste(s: &mut Session, p: &Value) -> Result { Ok(json!(new.iter().map(|l| l.0).collect::>())) } +/// 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 { + 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 { let (cid, ids) = layers_p(s, p)?; let t = s.time(); @@ -818,6 +854,15 @@ pub fn specs() -> Vec { 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), diff --git a/crates/engine/src/commands/frontend.rs b/crates/engine/src/commands/frontend.rs index 57fdb60a3..3fda23c92 100644 --- a/crates/engine/src/commands/frontend.rs +++ b/crates/engine/src/commands/frontend.rs @@ -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 { "{} — 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 { + 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})) +} diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 98fe0cb49..70e9d3457 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -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> = OnceLock::new(); diff --git a/crates/engine/src/commands/model3d.rs b/crates/engine/src/commands/model3d.rs index 0fd840ff2..9bcb3665f 100644 --- a/crates/engine/src/commands/model3d.rs +++ b/crates/engine/src/commands/model3d.rs @@ -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 { "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 ), diff --git a/crates/engine/src/commands/scripts.rs b/crates/engine/src/commands/scripts.rs index 198c9bad9..cc0dbd714 100644 --- a/crates/engine/src/commands/scripts.rs +++ b/crates/engine/src/commands/scripts.rs @@ -384,6 +384,15 @@ pub fn specs() -> Vec { 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", diff --git a/crates/engine/src/commands/shape_stroke.rs b/crates/engine/src/commands/shape_stroke.rs index 87fcd141f..ecd3ed8e0 100644 --- a/crates/engine/src/commands/shape_stroke.rs +++ b/crates/engine/src/commands/shape_stroke.rs @@ -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, 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, + /// A group with a path and no stroke gets one. + add_stroke: bool, + fill_kind: Option, + stroke_kind: Option, +} + +/// 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> { + 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 { let c = "shape.fillStroke"; let (cid, lids) = layers_p(s, p)?; @@ -275,8 +428,10 @@ fn fill_stroke(s: &mut Session, p: &Value) -> Result { 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 { 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 { })?; } 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 { @@ -303,7 +460,7 @@ pub fn specs() -> Vec { "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 ), diff --git a/crates/engine/src/commands/shape_tool.rs b/crates/engine/src/commands/shape_tool.rs index 83ab0f012..a48c32b7f 100644 --- a/crates/engine/src/commands/shape_tool.rs +++ b/crates/engine/src/commands/shape_tool.rs @@ -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 { - 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 { diff --git a/crates/engine/src/commands/text_anim.rs b/crates/engine/src/commands/text_anim.rs index 1496af841..9268a77db 100644 --- a/crates/engine/src/commands/text_anim.rs +++ b/crates/engine/src/commands/text_anim.rs @@ -246,7 +246,7 @@ fn enable_per_char(s: &mut Session, p: &Value) -> Result { } /// JSON → property value shaped like `like`. -fn json_value(v: &Value, like: &KV) -> Option { +pub(super) fn json_value(v: &Value, like: &KV) -> Option { Some(match v { Value::Bool(b) => match like { KV::Bool(_) => KV::Bool(*b), diff --git a/crates/engine/src/commands/text_edit.rs b/crates/engine/src/commands/text_edit.rs index 0ef2d6ccd..5dcb6d09a 100644 --- a/crates/engine/src/commands/text_edit.rs +++ b/crates/engine/src/commands/text_edit.rs @@ -506,7 +506,9 @@ pub fn fonts(_s: &mut Session, p: &Value) -> Result { 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 = 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 { let list: Vec = 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 { "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 ), diff --git a/crates/engine/src/footage_check.rs b/crates/engine/src/footage_check.rs index f5db284e3..423fd69fe 100644 --- a/crates/engine/src/footage_check.rs +++ b/crates/engine/src/footage_check.rs @@ -210,7 +210,13 @@ mod tests { let mut paths: Vec = o.project.items.values().filter_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.path.clone()) } else { None }).collect(); paths.sort(); - assert_eq!(paths, ["/nonexistent/ec/abs.png", clip.to_str().unwrap(), "media/ec-relative-gone.png"]); + // 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); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a9e9b606b..87450a642 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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, + }, } 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 { 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> { } /// 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 { 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 = 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 diff --git a/crates/engine/src/media_browser.rs b/crates/engine/src/media_browser.rs index 3247a588b..87b1b4753 100644 --- a/crates/engine/src/media_browser.rs +++ b/crates/engine/src/media_browser.rs @@ -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> { diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index e98e29f6b..cf84e2a57 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -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 --- diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 019247f62..bc2cf371a 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -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 { 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), diff --git a/crates/engine/src/rq_tests.rs b/crates/engine/src/rq_tests.rs index fe3766685..ff845cbce 100644 --- a/crates/engine/src/rq_tests.rs +++ b/crates/engine/src/rq_tests.rs @@ -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::>(), - [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, .. }))); diff --git a/crates/engine/src/scriptui.rs b/crates/engine/src/scriptui.rs index 88450c990..8049efde7 100644 --- a/crates/engine/src/scriptui.rs +++ b/crates/engine/src/scriptui.rs @@ -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; +/// 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, + /// The scripting engine's scheduled-task runner (set with [`Session::script`]). + #[serde(skip)] + pub tasks: Option, + /// 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, } 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 { + 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) { for w in &mut windows { @@ -687,6 +713,12 @@ pub(crate) fn set(s: &mut Session, p: &Value) -> Result { 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 { + 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 { let window = window_p(s, p, "scriptui.close")?; diff --git a/crates/engine/src/sysinfo.rs b/crates/engine/src/sysinfo.rs index 0debec95c..de39974da 100644 --- a/crates/engine/src/sysinfo.rs +++ b/crates/engine/src/sysinfo.rs @@ -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::*; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 587379a85..6b0d61ece 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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] diff --git a/crates/engine/src/tests_anim_presets.rs b/crates/engine/src/tests_anim_presets.rs new file mode 100644 index 000000000..4dd2576fa --- /dev/null +++ b/crates/engine/src/tests_anim_presets.rs @@ -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 = 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()); +} diff --git a/crates/engine/src/tests_frame_export.rs b/crates/engine/src/tests_frame_export.rs index 3f319b83b..87b0864ac 100644 --- a/crates/engine/src/tests_frame_export.rs +++ b/crates/engine/src/tests_frame_export.rs @@ -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"))); +} diff --git a/crates/engine/src/tests_settings.rs b/crates/engine/src/tests_settings.rs index 58d0a4a70..d8c64e7ea 100644 --- a/crates/engine/src/tests_settings.rs +++ b/crates/engine/src/tests_settings.rs @@ -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 { + None + } + fn supports_effect(&self, _: &str) -> bool { + false + } + fn effects(&self, _: &[FxStep], _: &effectcraft_effects::Buf, _: Option) -> Option { + 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}"); +} diff --git a/crates/engine/src/tests_stubs.rs b/crates/engine/src/tests_stubs.rs index 46d5d17fc..edf773552 100644 --- a/crates/engine/src/tests_stubs.rs +++ b/crates/engine/src/tests_stubs.rs @@ -270,18 +270,26 @@ fn rq() -> (Session, Arc) { (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::>(), - [("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(); diff --git a/crates/engine/src/tests_text_edit.rs b/crates/engine/src/tests_text_edit.rs index ef87bf86b..3e41688d2 100644 --- a/crates/engine/src/tests_text_edit.rs +++ b/crates/engine/src/tests_text_edit.rs @@ -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 { + let u16s = |v: &[u16]| -> Vec { v.iter().flat_map(|x| x.to_be_bytes()).collect() }; + let strings: Vec> = [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 = 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] diff --git a/crates/engine/src/tests_vector_import.rs b/crates/engine/src/tests_vector_import.rs index d205630c8..e33a38b08 100644 --- a/crates/engine/src/tests_vector_import.rs +++ b/crates/engine/src/tests_vector_import.rs @@ -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##" + + + + +"##; + 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", "", ""] { + 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"); +} diff --git a/crates/engine/src/vector.rs b/crates/engine/src/vector.rs index 34edf5b73..23400953f 100644 --- a/crates/engine/src/vector.rs +++ b/crates/engine/src/vector.rs @@ -250,6 +250,12 @@ pub fn vector_clip_masks(doc: &Doc) -> (Vec, 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 diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 788757bfe..11f28c70c 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -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); } } diff --git a/crates/gpu/src/fx_time.rs b/crates/gpu/src/fx_time.rs index 564ba6fda..e7fd628a0 100644 --- a/crates/gpu/src/fx_time.rs +++ b/crates/gpu/src/fx_time.rs @@ -45,13 +45,12 @@ pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option>()) } - "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 = (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 { 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 { // 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 { let pa = e.g.upload_image(&effectcraft_effects::timewarp_crop(&plan.frames[a], &plan.pm))?; diff --git a/crates/gpu/src/fx_transition.rs b/crates/gpu/src/fx_transition.rs index b74607a07..a0ad54658 100644 --- a/crates/gpu/src/fx_transition.rs +++ b/crates/gpu/src/fx_transition.rs @@ -448,6 +448,13 @@ fn cylinder(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { 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) } diff --git a/crates/gpu/src/shaders/fx_transition.wgsl b/crates/gpu/src/shaders/fx_transition.wgsl index 66278a681..379b64469 100644 --- a/crates/gpu/src/shaders/fx_transition.wgsl +++ b/crates/gpu/src/shaders/fx_transition.wgsl @@ -666,7 +666,24 @@ fn ftr_faces(mode: u32, front: vec4, back: vec4) -> vec4 { 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, z: f32, back: bool) -> vec4 { + let m0 = P.f[7].xyz; + let m1 = P.f[8].xyz; + let m2 = P.f[9].xyz; + let q = vec3(d, z); + let local = vec3(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(local.x / P.f[0].z * flip, 0.0, local.z / P.f[0].z * flip); + return ftr_shade(ftr_texel(u, v), vec3(dot(m0, n), dot(m1, n), dot(m2, n))); +} + @compute @workgroup_size(16, 16) fn ftr_cylinder(@builtin(global_invocation_id) gid: vec3) { let p = vec2(gid.xy); @@ -674,18 +691,35 @@ fn ftr_cylinder(@builtin(global_invocation_id) gid: vec3) { 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(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(s, 0.0, cz)); + let back = ftr_shade(ftr_texel((3.141592653589793 - th + spin) / tau + 0.5, v), vec3(-s, 0.0, cz)); + textureStore(out, p, ftr_faces(P.u[0].x, front, back)); + return; + } + let axis = vec3(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(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(s, 0.0, cz)); - let back = ftr_shade(ftr_texel((3.141592653589793 - th + spin) / tau + 0.5, v), vec3(-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)); } diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index 1e1ad1678..15ace4faa 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -621,18 +621,25 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3) { if (P.u[0].y == 1u) { px = vec4(max((c + br - 0.5) * k + 0.5, vec3(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(o * a, a); diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 150f29ad9..784a393db 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -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"); +} diff --git a/crates/gpu/src/tests_fx_transition.rs b/crates/gpu/src/tests_fx_transition.rs index ad6ebdf5b..b4b3572a0 100644 --- a/crates/gpu/src/tests_fx_transition.rs +++ b/crates/gpu/src/tests_fx_transition.rs @@ -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))]); diff --git a/crates/host/tests/gif_animates.rs b/crates/host/tests/gif_animates.rs new file mode 100644 index 000000000..3cf6a8c30 --- /dev/null +++ b/crates/host/tests/gif_animates.rs @@ -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 { + 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:?}"); +} diff --git a/crates/keyframe/src/lib.rs b/crates/keyframe/src/lib.rs index 787a0643b..51951a26f 100644 --- a/crates/keyframe/src/lib.rs +++ b/crates/keyframe/src/lib.rs @@ -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))]; diff --git a/crates/keyframe/src/value.rs b/crates/keyframe/src/value.rs index fc9215050..fa66711b9 100644 --- a/crates/keyframe/src/value.rs +++ b/crates/keyframe/src/value.rs @@ -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(stops: &[(f64, T)], t: f64, lerp: impl Fn(T, T, f64) -> T) -> Option { diff --git a/crates/media/src/convert.rs b/crates/media/src/convert.rs index 7865c3dc7..f22e6e444 100644 --- a/crates/media/src/convert.rs +++ b/crates/media/src/convert.rs @@ -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)] diff --git a/crates/media/src/exr_channels.rs b/crates/media/src/exr_channels.rs index 6e90a6dd8..8c4cb1339 100644 --- a/crates/media/src/exr_channels.rs +++ b/crates/media/src/exr_channels.rs @@ -75,34 +75,21 @@ pub fn read_exr_channels(bytes: &[u8]) -> Option { } }; 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 = 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 { 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> { + 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. diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 000c16245..d9c0b320c 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -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 { diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index ce980c563..5026cdde4 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -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, 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, + /// 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> { - 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> { + fn frame_sized(&self, _item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) -> Option> { 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 { - 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, footage: &Footage, t: Tick, playback: bool, size: Option<(u32, u32)>) -> Result> { + fn frame_at(self: &Arc, footage: &Footage, t: Tick, playback: bool, size: Option<(u32, u32)>, float: FloatCurve) -> Result> { 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, footage: &Footage, t: Tick, size: Option<(u32, u32)>) { + fn prefetch(self: &Arc, 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, _footage: &Footage, _t: Tick, _size: Option<(u32, u32)>) {} + fn prefetch(self: &Arc, _footage: &Footage, _t: Tick, _size: Option<(u32, u32)>, _float: FloatCurve) {} fn decode(&self, loc: &Loc, footage: &Footage) -> Result { - 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> { - 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> { - 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> { + self.frame_sized(item, footage, t, size, float) } fn vector_frame(&self, _item: ItemId, footage: &Footage, scale: f64) -> Option> { @@ -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); + } } diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index 6e8718fdf..099450e8c 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -23,7 +23,7 @@ pub fn probe(path: impl AsRef) -> Result { 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>) -> Result { 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 "OBJ", + Some("stl") => "STL", Some("glb") => "GLB", _ => "glTF", }; diff --git a/crates/media/tests/footage.rs b/crates/media/tests/footage.rs index d26b1a1d1..ecfca32ef 100644 --- a/crates/media/tests/footage.rs +++ b/crates/media/tests/footage.rs @@ -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)"); +} diff --git a/crates/model/Cargo.toml b/crates/model/Cargo.toml index 20a1fde0f..345eea994 100644 --- a/crates/model/Cargo.toml +++ b/crates/model/Cargo.toml @@ -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 diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 9480f29c1..6c3da5eb0 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -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>) -> Result { 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"))), } } diff --git a/crates/model/src/stl.rs b/crates/model/src/stl.rs new file mode 100644 index 000000000..6684858ad --- /dev/null +++ b/crates/model/src/stl.rs @@ -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 { + 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, 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 { + 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 { + 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::().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) -> Option<[f32; 3]> { + Some([it.next()?.parse().ok()?, it.next()?.parse().ok()?, it.next()?.parse().ok()?]) +} + +fn ascii(bytes: &[u8]) -> Result { + let text = String::from_utf8_lossy(bytes); + let mut b = Builder::default(); + let mut name: Option = 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()) +} diff --git a/crates/model/src/tests.rs b/crates/model/src/tests.rs index 032ca47d7..54fda596c 100644 --- a/crates/model/src/tests.rs +++ b/crates/model/src/tests.rs @@ -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 { + 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]); +} diff --git a/crates/render/src/color.rs b/crates/render/src/color.rs index a420256e8..abfa791e8 100644 --- a/crates/render/src/color.rs +++ b/crates/render/src/color.rs @@ -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 { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index f4a5a98a3..a77b7c04c 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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> { 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> { + 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> { 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. diff --git a/crates/render/src/tests_color.rs b/crates/render/src/tests_color.rs index c9b8e56fc..29e4d62c2 100644 --- a/crates/render/src/tests_color.rs +++ b/crates/render/src/tests_color.rs @@ -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> { + self.frame_with(item, f, t, None, FloatCurve::Srgb) + } + fn frame_with(&self, _: ItemId, f: &Footage, _: Tick, _: Option<(u32, u32)>, float: FloatCurve) -> Option> { + 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, 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); + } +} diff --git a/crates/render/tests/motion_blur_probe.rs b/crates/render/tests/motion_blur_probe.rs new file mode 100644 index 000000000..df1e0cf74 --- /dev/null +++ b/crates/render/tests/motion_blur_probe.rs @@ -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> { + 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) -> (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::>()); + 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 +} diff --git a/crates/script/Cargo.toml b/crates/script/Cargo.toml index ef756107c..093d0c1c4 100644 --- a/crates/script/Cargo.toml +++ b/crates/script/Cargo.toml @@ -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] diff --git a/crates/script/src/include.rs b/crates/script/src/include.rs new file mode 100644 index 000000000..3c397f53c --- /dev/null +++ b/crates/script/src/include.rs @@ -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, +} + +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> { + 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, + /// The latest `#includepath`. + search: Vec, + seen: HashSet, + 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, 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)) +} diff --git a/crates/script/src/lib.rs b/crates/script/src/lib.rs index d1c9568c9..0b612d7b9 100644 --- a/crates/script/src/lib.rs +++ b/crates/script/src/lib.rs @@ -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; diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index f50a968c5..240be8ed1 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -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 () {}, diff --git a/crates/script/src/runtime.rs b/crates/script/src/runtime.rs index 712999ee0..dba967683 100644 --- a/crates/script/src/runtime.rs +++ b/crates/script/src/runtime.rs @@ -65,18 +65,18 @@ pub(crate) struct Active { pub(crate) output: Vec, /// Open undo groups: (name, project snapshot when the outermost group began). pub(crate) groups: Vec<(String, Arc)>, - /// `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, } 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) { 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 { 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 { +pub(crate) fn project_dir(s: &Session) -> Option { 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 { (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 { .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 { +/// 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 { 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, result: Result) -> 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) } diff --git a/crates/script/src/scriptfile.rs b/crates/script/src/scriptfile.rs new file mode 100644 index 000000000..2b6afcfbe --- /dev/null +++ b/crates/script/src/scriptfile.rs @@ -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 { + 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 { + if Path::new(name).is_absolute() { + return Ok(normalize(name)); + } + let candidates: Vec = 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 { + 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()) +} diff --git a/crates/script/src/tasks.rs b/crates/script/src/tasks.rs new file mode 100644 index 000000000..563f3bbc0 --- /dev/null +++ b/crates/script/src/tasks.rs @@ -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, + /// 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> = 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 { + 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 { + 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 { + 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) { + 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, + errors: Vec, + next: Option, +} + +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) { + 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 = 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}) +} diff --git a/crates/script/src/tests_callable.rs b/crates/script/src/tests_callable.rs new file mode 100644 index 000000000..13d6f787f --- /dev/null +++ b/crates/script/src/tests_callable.rs @@ -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" })); +} diff --git a/crates/script/src/tests_include.rs b/crates/script/src/tests_include.rs new file mode 100644 index 000000000..3c7d10241 --- /dev/null +++ b/crates/script/src/tests_include.rs @@ -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); +} diff --git a/crates/script/src/tests_tasks.rs b/crates/script/src/tests_tasks.rs new file mode 100644 index 000000000..4ee79984d --- /dev/null +++ b/crates/script/src/tests_tasks.rs @@ -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 { + 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(); +} diff --git a/crates/script/src/ui.rs b/crates/script/src/ui.rs index 609b767b9..082a16cac 100644 --- a/crates/script/src/ui.rs +++ b/crates/script/src/ui.rs @@ -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, } /// The host thread's end of the hand-off. @@ -164,6 +171,12 @@ pub(crate) struct Link { struct HostEnd { tx: std::sync::mpsc::Sender, rx: std::sync::mpsc::Receiver, + /// When the host's next scheduled task is due. + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + next_due: Option, + /// [`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> { @@ -177,38 +190,57 @@ const _: fn() = || { send::(); }; +/// 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, done: bool) -> Option { + 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, done: bool, next_due: Option) -> Option { 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 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)); +} diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 2f7311c58..1df8b337d 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -58,6 +58,8 @@ pub struct FaceInfo { /// be requested the way After Effects scripts name fonts. pub full_name: String, pub postscript: Option, + /// 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 { 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 { + 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 diff --git a/crates/text/src/sfnt.rs b/crates/text/src/sfnt.rs index 7acfbd387..ec9763bfa 100644 --- a/crates/text/src/sfnt.rs +++ b/crates/text/src/sfnt.rs @@ -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, + /// 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 { @@ -66,12 +69,14 @@ fn read_face(r: &mut R, off: u64, index: u32) -> Option 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: &mut R, off: u64, index: u32) -> Option= 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>) -> Vec { + let u16s = |v: &[u16]| -> Vec { v.iter().flat_map(|x| x.to_be_bytes()).collect() }; + let strings: Vec> = [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)> = 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 = 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); } } diff --git a/crates/text/src/variable.rs b/crates/text/src/variable.rs index 6594e2dc2..a673578e7 100644 --- a/crates/text/src/variable.rs +++ b/crates/text/src/variable.rs @@ -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::() }; - 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); + } } } diff --git a/crates/ui-egui/src/color_picker.rs b/crates/ui-egui/src/color_picker.rs index cd24dbc0e..35d8e4eb4 100644 --- a/crates/ui-egui/src/color_picker.rs +++ b/crates/ui-egui/src/color_picker.rs @@ -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::(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 = 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 { diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 1260c9c9e..ca4cea157 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -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. diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index da7c28496..dcc3b5298 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -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"), diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index d4fefc924..cec42d88c 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -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")), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a0e05e007..914bf487c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.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 ▸ . 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::*; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5c61b4977..37abc11c5 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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::(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 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::(system_paste_id())); } } } diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 81fa6bab9..8312cbdc8 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -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), } } diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 501adf898..2e6017592 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -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); diff --git a/crates/ui-egui/src/panels/effects_presets.rs b/crates/ui-egui/src/panels/effects_presets.rs index e60354b15..da6812bea 100644 --- a/crates/ui-egui/src/panels/effects_presets.rs +++ b/crates/ui-egui/src/panels/effects_presets.rs @@ -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 = 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) { diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index 039ebb69b..b04e017bd 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -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) = 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(), diff --git a/crates/ui-egui/src/panels/fx_editors.rs b/crates/ui-egui/src/panels/fx_editors.rs index a426f5a18..1ae74caa2 100644 --- a/crates/ui-egui/src/panels/fx_editors.rs +++ b/crates/ui-egui/src/panels/fx_editors.rs @@ -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::>().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 = 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"); diff --git a/crates/ui-egui/src/panels/gradient_editor.rs b/crates/ui-egui/src/panels/gradient_editor.rs new file mode 100644 index 000000000..bb6923025 --- /dev/null +++ b/crates/ui-egui/src/panels/gradient_editor.rs @@ -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.`, `dialog.gradient.colorStop.` (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 { + 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, + pub colors: Vec>, + pub opacities: Vec>, + 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(v: &mut [Stop]) { + // 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, 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 { + 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 { + 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 { + 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 = 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 = 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![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"); + } +} diff --git a/crates/ui-egui/src/panels/graph.rs b/crates/ui-egui/src/panels/graph.rs index 2dbb98268..77e39c620 100644 --- a/crates/ui-egui/src/panels/graph.rs +++ b/crates/ui-egui/src/panels/graph.rs @@ -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::(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 = 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); 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)); + } +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 2f0ca804c..bc07c2587 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -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; diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 21f842976..64bc43b6e 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -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::(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 { + 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::(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::(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::(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, 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::(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::(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::(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"); diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 385febefe..ae881a374 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -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 = 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 { diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index a32d74b43..69db87b0c 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -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::(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| { diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 61b2d22d4..983b424d5 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -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::(), 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() { diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 5fe7697c9..26b680d1b 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1113,6 +1113,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let context_layers: Vec = 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::>(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::(&ctx).as_deref() + && ui.rect_contains_pointer(rect) + { + let items: Vec = 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. diff --git a/crates/ui-egui/src/prefs_live.rs b/crates/ui-egui/src/prefs_live.rs index b18d3e874..321e432e9 100644 --- a/crates/ui-egui/src/prefs_live.rs +++ b/crates/ui-egui/src/prefs_live.rs @@ -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); } diff --git a/crates/ui-egui/tests/ui_drag_drop.rs b/crates/ui-egui/tests/ui_drag_drop.rs index ce09d7fd8..77a3995d6 100644 --- a/crates/ui-egui/tests/ui_drag_drop.rs +++ b/crates/ui-egui/tests/ui_drag_drop.rs @@ -15,6 +15,7 @@ use effectcraft_ui_egui::dock::PanelKind; use effectcraft_ui_egui::panels::viewer::{comp_to_screen, last_fit}; use egui::{Event, Modifiers, Pos2, Rect, pos2, vec2}; use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; use serde_json::json; /// Comp "Main" (4 s at 30 fps, current time 1 s) with solids Top, Middle and Bottom, and comp @@ -252,6 +253,48 @@ fn multiple_selected_footage_dropped_on_empty_timeline_asks_how_to_create_comps( assert!(h.state().session.active_comp().is_none(), "no composition until the user chooses"); } +/// #684: dropping imported footage onto the empty Composition viewer creates +/// a composition that matches the source, with that source as its first layer. +#[test] +fn project_footage_dropped_on_empty_viewer_creates_first_comp() { + let mut s = Session::default(); + let footage = + s.execute("file.importPlaceholder", json!({"name": "Landscape.png", "width": 640, "height": 360, "frameRate": 24, "duration": 5})).unwrap()["item"] + .as_u64() + .unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + assert!(h.state().session.active_comp().is_none()); + let from = rect(&h, &format!("project.item.{footage}.name")).center(); + let viewer = rect(&h, "panel.Composition"); + drag(&mut h, from, pos2(viewer.min.x + 40.0, viewer.min.y + 70.0), Modifiers::NONE); + let comp = h.state().session.active_comp().expect("created a composition"); + assert_eq!((comp.width, comp.height), (640, 360)); + assert_eq!(comp.frame_rate, effectcraft_engine::time::FrameRate::from_f64(24.0)); + assert_eq!(comp.duration.seconds(), 5.0); + assert_eq!(comp.layers.len(), 1); + assert_eq!(comp.layers[0].name, "Landscape.png"); + assert!(h.state().dialog.is_none()); +} + +/// Several dragged sources must ask whether to create one or multiple comps, +/// rather than silently discarding the other selected items. +#[test] +fn multiple_selected_footage_dropped_on_empty_viewer_asks_how_to_create_comps() { + let mut s = Session::default(); + let first = s.execute("file.importPlaceholder", json!({"name": "First.png", "width": 160, "height": 90})).unwrap()["item"].as_u64().unwrap(); + let second = s.execute("file.importPlaceholder", json!({"name": "Second.png", "width": 300, "height": 200})).unwrap()["item"].as_u64().unwrap(); + s.state.project_selection = vec![effectcraft_engine::project::ItemId(first), effectcraft_engine::project::ItemId(second)]; + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + let from = rect(&h, &format!("project.item.{first}.name")).center(); + let viewer = rect(&h, "panel.Composition"); + drag(&mut h, from, pos2(viewer.min.x + 40.0, viewer.min.y + 70.0), Modifiers::NONE); + assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Form)); + assert!(h.state().auto.find("form.field.single").is_some()); + assert!(h.state().session.active_comp().is_none(), "no composition until the user chooses"); +} + /// A file dropped on the window (egui hands it over as a dropped file). #[derive(Debug)] struct Dropped(std::path::PathBuf); @@ -379,6 +422,35 @@ fn dropped_file_on_viewer_imports_and_adds_layer_at_pointer() { assert!((p[0] - 80.0).abs() <= tol && (p[1] - 45.0).abs() <= tol, "{p:?}"); } +/// #678: a dropped Photoshop file asks how to import it, as File ▸ Import ▸ File… does; the +/// Import Kind starts at Settings ▸ Import ▸ Default Drag Import As, and OK imports the drop. +#[test] +fn dropped_photoshop_file_shows_the_import_photoshop_file_dialog() { + let (mut h, _) = harness(); + h.state_mut().session.importer = Some(Arc::new(Gated { started: AtomicUsize::new(0), allow: Arc::new(AtomicUsize::new(usize::MAX)) })); + h.state_mut().session.prefs.import.drag_import_as = "compLayerSizes".into(); + h.event(Event::PointerMoved(rect(&h, "panel.Project").center())); + h.step(); + h.input_mut().dropped_files.push(Arc::new(Dropped(std::env::temp_dir().join("Layers.PSD")))); + h.run_steps(2); + assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Form), "the drop asks first"); + h.run_steps(2); + assert!(h.query_by_label("Import Photoshop File").is_some()); + assert!(h.query_by_value("Composition - Retain Layer Sizes").is_some(), "Import Kind starts at Default Drag Import As"); + assert!(!h.state().session.project.items.values().any(|i| i.name == "Layers.PSD"), "nothing imported before OK"); + click_id(&mut h, "form.cancel"); + assert_eq!(h.state().dialog, None); + + // With Default Drag Import As at Footage, OK imports the file as one item. + h.state_mut().session.prefs.import.drag_import_as = "footage".into(); + h.input_mut().dropped_files.push(Arc::new(Dropped(std::env::temp_dir().join("Layers.PSD")))); + h.run_steps(4); + assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Form)); + assert!(h.query_by_value("Footage").is_some()); + click_id(&mut h, "form.ok"); + step_until(&mut h, |h| h.state().session.project.items.values().any(|i| i.name == "Layers.PSD")); +} + #[test] fn an_unsupported_drop_reports_the_error_and_keeps_valid_files() { let (mut h, _) = harness(); diff --git a/crates/ui-egui/tests/ui_font_search.rs b/crates/ui-egui/tests/ui_font_search.rs index e3da6ef91..6be69cb7a 100644 --- a/crates/ui-egui/tests/ui_font_search.rs +++ b/crates/ui-egui/tests/ui_font_search.rs @@ -105,3 +105,74 @@ fn font_search_preserves_edited_text_and_applies_only_to_selected_characters() { assert_eq!(doc.style_at(0).font, "Noto Serif"); assert_eq!(doc.style_at(8).font, "Inter"); } + +/// A font file of just `name` (the family, Regular) and an `fvar` table with one axis pinned at +/// 400: a variable font as far as the font menu's filter is concerned. +fn pinned_variable_font(family: &str) -> Vec { + let u16s = |v: &[u16]| -> Vec { v.iter().flat_map(|x| x.to_be_bytes()).collect() }; + let strings: Vec> = [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 = 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): the checkbox keeps only variable +/// families, works with the typed search (which keeps the keyboard), and stays on when the menu +/// is reopened until it is turned off. +#[test] +fn font_menu_filters_variable_fonts() { + const FAMILY: &str = "Inter Variable Menu Test"; + assert_eq!(effectcraft_engine::text::fonts::add_font_data(pinned_variable_font(FAMILY)).len(), 1); + let variable = effectcraft_engine::text::fonts::variable_families(); + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 320, "height": 180})).unwrap(); + let lid = s.execute("layer.newText", json!({"text": "Var", "font": "Inter"})).unwrap()["layer"].as_u64().unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(move |_| EffectcraftApp::new(s)); + h.state_mut().show_panel(PanelKind::Character); + h.run_steps(3); + let families = |h: &Harness<'_, EffectcraftApp>| -> Vec { + h.state().auto.query("character.font.option.").iter().map(|e| e.id.trim_start_matches("character.font.option.").to_string()).collect() + }; + click(&mut h, "character.font"); + type_text(&mut h, "inter"); + let shown = families(&h); + assert!(shown.iter().any(|f| f == "Inter") && shown.iter().any(|f| f == FAMILY), "unfiltered at first: {shown:?}"); + click(&mut h, "character.font.variable"); + let shown = families(&h); + assert!(shown.iter().any(|f| f == FAMILY), "{shown:?}"); + assert!(shown.iter().all(|f| variable.contains(f)), "only variable families: {shown:?}"); + // The search field keeps the keyboard and narrows the filtered list further. + type_text(&mut h, " variable menu"); + assert_eq!(families(&h), vec![FAMILY.to_string()]); + type_text(&mut h, "x"); + assert!(families(&h).is_empty()); + assert!(h.state().auto.find("character.font.empty").is_some()); + key(&mut h, Key::Escape); + // Reopened: still filtered; turning it off lists the static families again. + click(&mut h, "character.font"); + h.run_steps(2); + let shown = families(&h); + assert!(!shown.is_empty() && shown.iter().all(|f| variable.contains(f)), "the filter stays on: {shown:?}"); + click(&mut h, "character.font.variable"); + type_text(&mut h, "inter"); + assert!(families(&h).iter().any(|f| f == "Inter"), "{:?}", families(&h)); + key(&mut h, Key::Escape); + assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "Inter", "filtering changes nothing"); +} diff --git a/crates/ui-egui/tests/ui_fx_editors.rs b/crates/ui-egui/tests/ui_fx_editors.rs index 5334cf07a..90a5af15d 100644 --- a/crates/ui-egui/tests/ui_fx_editors.rs +++ b/crates/ui-egui/tests/ui_fx_editors.rs @@ -250,6 +250,36 @@ fn reshape_correspondence_points_on_the_viewer() { assert_eq!(h.state().session.history.undo.len(), undo_len + 1); } +/// #303: with Mesh Warp selected, the viewer shows its grid; dragging a vertex warps the mesh +/// (one undo step), and the other vertices stay put. +#[test] +fn mesh_warp_vertices_drag_on_the_viewer() { + let (mut s0, l, fx) = with_effect("Mesh Warp"); + let g = s0.active_comp().unwrap().layer(LayerId(l)).unwrap().props.find_group(fx).unwrap().clone(); + s0.execute("prop.set", json!({"layer": l, "prop": g.get("rows").unwrap().uid, "value": 2})).unwrap(); + s0.execute("prop.set", json!({"layer": l, "prop": g.get("columns").unwrap().uid, "value": 2})).unwrap(); + let mut h = harness(s0); + h.state_mut().toggle_maximize(PanelKind::EffectControls); + h.state_mut().session.state.selected_props = vec![(LayerId(l), fx)]; + for _ in 0..6 { + h.step(); + } + let v = |r: usize, c: usize| format!("viewer.meshWarp.{fx}.{r}.{c}"); + let (tl, mid, br) = (rect(&h, &v(0, 0)).center(), rect(&h, &v(1, 1)).center(), rect(&h, &v(2, 2)).center()); + assert!(((tl.x + br.x) / 2.0 - mid.x).abs() < 1.0 && ((tl.y + br.y) / 2.0 - mid.y).abs() < 1.0, "{tl:?} {mid:?} {br:?}"); + assert!(br.x - tl.x > 100.0, "the grid spans the layer: {tl:?} {br:?}"); + assert_eq!(s(value(&h, l, fx, "mesh")), ""); + let undo_len = h.state().session.history.undo.len(); + drag(&mut h, mid, mid + egui::vec2(40.0, 0.0)); + let mesh = effectcraft_engine::effects::parse_mesh(&s(value(&h, l, fx, "mesh"))); + assert_eq!(mesh.len(), 9, "{mesh:?}"); + assert!(mesh[4].0 > 10.0 && mesh[4].1.abs() < 1.0, "the centre vertex moved right: {mesh:?}"); + assert!(mesh.iter().enumerate().all(|(i, o)| i == 4 || *o == (0.0, 0.0)), "{mesh:?}"); + assert_eq!(h.state().session.history.undo.len(), undo_len + 1); + let moved = rect(&h, &v(1, 1)).center(); + assert!((moved.x - mid.x - 40.0).abs() < 2.0, "the handle follows the vertex: {mid:?} → {moved:?}"); +} + /// UI fixes of M13.10: the Composition / Timeline tabs' close button, swatch and lock; the /// Preview panel scrolls in the default workspace (where it shows just the transport). #[test] diff --git a/crates/ui-egui/tests/ui_gradient_editor.rs b/crates/ui-egui/tests/ui_gradient_editor.rs new file mode 100644 index 000000000..fa8c7b73f --- /dev/null +++ b/crates/ui-egui/tests/ui_gradient_editor.rs @@ -0,0 +1,493 @@ +//! The Gradient Editor (#612): the Timeline's "Edit Gradient…" opens it on a gradient property +//! (here a shape layer's Gradient Fill ▸ Colors). OK sets the edited gradient with one undo step +//! at the composition time it opened at (offset and stretched layers too); OK with no change, Cancel +//! and Escape change nothing; on an animated gradient only the keyframe at that time changes, with +//! a warning when its stop counts no longer match a neighbour's. + +use effectcraft_engine::Session; +use effectcraft_engine::keyframe::{Gradient, Value}; +use effectcraft_engine::project::{LayerId, PropGroup}; +use effectcraft_engine::time::Tick; +use effectcraft_ui_egui::panels::gradient_editor::Track; +use effectcraft_ui_egui::{Dialog, EffectcraftApp}; +use egui::accesskit::Role; +use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +const WARNING: &str = "These adjacent keyframes cannot interpolate because their stop counts differ."; + +/// The uids from the layer's root down to the Gradient Fill group, and its Colors property. +fn gradient_fill(g: &PropGroup) -> Option<(Vec, u64)> { + for x in g.groups() { + if x.match_id == "gfill" { + return Some((vec![x.uid], x.get("colors")?.uid)); + } + if let Some((mut path, colors)) = gradient_fill(x) { + path.insert(0, x.uid); + return Some((path, colors)); + } + } + None +} + +/// A 20 s comp with a rectangle shape layer holding a Gradient Fill, its rows open in the +/// Timeline. `timing` runs on the layer first (start, stretch). Returns (harness, layer, Colors uid). +fn setup(timing: &[(&str, serde_json::Value)]) -> (Harness<'static, EffectcraftApp>, u64, u64) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 20})).unwrap(); + let l = s.execute("layer.newShape", json!({"kind": "rect"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.addShapeItem", json!({"layer": l, "kind": "gfill"})).unwrap(); + for (cmd, p) in timing { + let mut p = p.clone(); + p["layers"] = json!([l]); + s.execute(cmd, p).unwrap(); + } + let (groups, colors) = gradient_fill(&s.active_comp().unwrap().layer(LayerId(l)).unwrap().props).expect("a Gradient Fill"); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.state_mut().ui.timeline.open_layers.insert(l); + h.state_mut().ui.timeline.open_groups.extend(groups); + h.run_steps(3); + (h, l, colors) +} + +fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> [f32; 4] { + h.state().auto.find(id).unwrap_or_else(|| panic!("no automation id {id}")).rect +} + +fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 { + let r = rect(h, id); + pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0) +} + +/// The point at `f` (0..1) along the bar, at the height of automation id `row`. +fn along(h: &Harness<'_, EffectcraftApp>, row: &str, f: f32) -> Pos2 { + let bar = rect(h, "dialog.gradient.bar"); + pos2(bar[0] + bar[2] * f, center(h, row).y) +} + +fn click_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) { + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let p = center(h, id); + click_at(h, p); +} + +fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) { + h.input_mut().events.push(Event::PointerMoved(from)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=6 { + let f = k as f32 / 6.0; + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * f)); + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +fn key(h: &mut Harness<'_, EffectcraftApp>, k: Key) { + h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +fn colors_prop(h: &Harness<'_, EffectcraftApp>, l: u64, uid: u64) -> effectcraft_engine::project::Property { + h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.find(uid).unwrap().clone() +} + +fn gradient(v: &Value) -> Gradient { + match v { + Value::Gradient(g) => g.clone(), + other => panic!("not a gradient: {other:?}"), + } +} + +fn open_editor(h: &mut Harness<'_, EffectcraftApp>, colors: u64) { + let id = format!("timeline.prop.{colors}.value"); + assert!(h.state().auto.find(&id).is_some(), "no automation id {id}: the Timeline's gradient value isn't clickable"); + click(h, &id); + h.run_steps(2); +} + +fn set_time(h: &mut Harness<'_, EffectcraftApp>, t: f64) { + h.state_mut().session.execute("time.set", json!({"time": t})).unwrap(); + h.run_steps(2); +} + +fn add_key(h: &mut Harness<'_, EffectcraftApp>, l: u64, colors: u64, comp_t: f64, g: &Gradient) { + h.state_mut() + .session + .execute("prop.addKey", json!({"layer": l, "prop": colors, "time": comp_t, "timeBase": "comp", "value": Value::Gradient(g.clone()).to_json()})) + .unwrap(); +} + +fn near(a: [f32; 4], b: [f32; 4]) -> bool { + a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-4) +} + +/// The shared colour picker is showing (its colour field is in the accessibility tree). +fn picker_open(h: &Harness<'_, EffectcraftApp>) -> bool { + h.query_by_label("Color field").is_some() +} + +/// Type `hex` into the open picker's Hex field, over the code it shows. +fn type_hex(h: &mut Harness<'_, EffectcraftApp>, hex: &str) { + let at = h.get_by_role_and_label(Role::TextInput, "#").rect().center(); + click_at(h, at); + for pressed in [true, false] { + h.input_mut().events.push(Event::Key { key: Key::A, physical_key: None, pressed, repeat: false, modifiers: Modifiers::COMMAND }); + } + h.step(); + h.input_mut().events.push(Event::Text(hex.into())); + h.run_steps(3); +} + +fn stop_color(h: &Harness<'_, EffectcraftApp>, id: u64) -> [f32; 4] { + h.state().gradient_draft().colors.iter().find(|s| s.id == id).expect("the stop").value +} + +/// The numbers the editor's fields show (their accessibility values). +fn shown_numbers(h: &Harness<'_, EffectcraftApp>) -> Vec { + h.query_all_by(|n| n.numeric_value().is_some()).filter_map(|n| n.accesskit_node().numeric_value()).collect() +} + +fn undo_len(h: &Harness<'_, EffectcraftApp>) -> usize { + h.state().session.history.undo.len() +} + +#[test] +fn stop_edits_ok_sets_the_gradient_in_one_undo_step() { + let (mut h, l, colors) = setup(&[]); + let before = colors_prop(&h, l, colors).value.clone(); + let undo = undo_len(&h); + open_editor(&mut h, colors); + assert_eq!(h.state().dialog, Some(Dialog::GradientEditor)); + // Add a colour stop a quarter along, recolour it, and drag the right opacity stop to the middle. + let p = along(&h, "dialog.gradient.addColor", 0.25); + click_at(&mut h, p); + let (track, id) = h.state().gradient_draft().selected.expect("the new stop is selected"); + assert_eq!(track, Track::Color); + let sampled = stop_color(&h, id); + // The swatch opens the shared colour picker over the modal editor. A click in its colour field + // reaches it (a modal blocks the layers below it), and so does typing a hex code. + click(&mut h, "dialog.gradient.color"); + assert!(picker_open(&h), "the colour picker opened"); + let field = h.get_by_label("Color field").rect().center(); + click_at(&mut h, field + egui::vec2(20.0, -20.0)); + assert_ne!(stop_color(&h, id), sampled, "a click in the picker's colour field changed the stop"); + type_hex(&mut h, "336699"); + let picked = stop_color(&h, id); + assert!(near(picked, [0.2, 0.4, 0.6, sampled[3]]), "the hex code's colour, and the stop kept its alpha: {picked:?}"); + // Clicking outside the picker keeps the colour and closes it; the editor stays. + let title = h.get_by_label("Gradient Editor").rect().center(); + click_at(&mut h, title); + assert!(!picker_open(&h), "the picker closed"); + assert_eq!(h.state().dialog, Some(Dialog::GradientEditor)); + assert_eq!(stop_color(&h, id), picked); + let from = center(&h, "dialog.gradient.opacityStop.1"); + let to = along(&h, "dialog.gradient.opacityStop.1", 0.5); + drag(&mut h, from, to); + assert_eq!(undo_len(&h), undo, "nothing is set while editing"); + click(&mut h, "dialog.gradient.ok"); + assert_eq!(h.state().dialog, None); + let g = gradient(&colors_prop(&h, l, colors).value); + assert_eq!(g.colors.len(), 3, "{g:?}"); + assert!((g.colors[1].0 - 0.25).abs() < 0.02 && g.colors[1].1 == picked, "{g:?}"); + assert!((g.opacities[1].0 - 0.5).abs() < 0.02, "{g:?}"); + assert_eq!(undo_len(&h), undo + 1, "one undo step"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(colors_prop(&h, l, colors).value, before, "one undo restores it"); +} + +#[test] +fn cancel_and_escape_change_nothing() { + for cancel in ["button", "escape"] { + let (mut h, _, colors) = setup(&[]); + let project = h.state().session.project.clone(); + let undo = undo_len(&h); + open_editor(&mut h, colors); + let p = along(&h, "dialog.gradient.addOpacity", 0.4); + click_at(&mut h, p); + assert_eq!(h.state().gradient_draft().opacities.len(), 3); + if cancel == "button" { + click(&mut h, "dialog.gradient.cancel"); + } else { + key(&mut h, Key::Escape); + } + assert_eq!(h.state().dialog, None, "{cancel}"); + assert_eq!(h.state().session.project, project, "{cancel} leaves the project as it was"); + assert_eq!(undo_len(&h), undo); + } +} + +/// OK without a change adds no keyframe between two (prop.set would add one). +#[test] +fn ok_without_changes_adds_no_keyframe() { + let (mut h, l, colors) = setup(&[]); + add_key(&mut h, l, colors, 1.0, &Gradient::default()); + add_key(&mut h, l, colors, 3.0, &Gradient::default()); + set_time(&mut h, 2.0); + let undo = undo_len(&h); + open_editor(&mut h, colors); + click(&mut h, "dialog.gradient.ok"); + assert_eq!(h.state().dialog, None); + assert_eq!(colors_prop(&h, l, colors).keys.len(), 2, "still the two keyframes"); + assert_eq!(undo_len(&h), undo, "no undo step"); +} + +/// On an animated gradient OK sets the keyframe at the edit time (adding one between keys) and no +/// other. +#[test] +fn animated_edits_change_only_the_keyframe_at_the_edit_time() { + let (mut h, l, colors) = setup(&[]); + let red = Gradient { colors: vec![(0.0, [1.0, 0.0, 0.0, 1.0]), (1.0, [0.0, 0.0, 0.0, 1.0])], ..Gradient::default() }; + add_key(&mut h, l, colors, 1.0, &Gradient::default()); + add_key(&mut h, l, colors, 3.0, &red); + let first = colors_prop(&h, l, colors).keys[0].clone(); + for (t, keys) in [(3.0, 2), (2.0, 3)] { + set_time(&mut h, t); + open_editor(&mut h, colors); + let d = &h.state().gradient_draft(); + let id = d.id_at(Track::Color, 1).unwrap(); + h.state_mut().gradient_draft_mut().set_rgb(id, [0.0, 0.0, 1.0]); + click(&mut h, "dialog.gradient.ok"); + let pr = colors_prop(&h, l, colors); + assert_eq!(pr.keys.len(), keys, "at {t} s"); + assert_eq!(pr.keys[0], first, "the keyframe at 1 s is untouched"); + let layer = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().clone(); + // The edit time snapped to the nearest frame (29.97 fps), as prop.set snaps it. + let at = h.state().session.active_comp().unwrap().frame_rate.snap_nearest(Tick::from_seconds_f64(t)).seconds(); + let k = pr.keys.iter().find(|k| (layer.comp_time(k.time).seconds() - at).abs() < 1e-6).expect("a keyframe at the edit time"); + assert_eq!(gradient(&k.value).colors[1].1, [0.0, 0.0, 1.0, 1.0], "at {t} s"); + } +} + +/// The key goes at the composition time the editor opened at, on a layer that starts late or is +/// stretched (layer time differs from composition time). +#[test] +fn offset_and_stretched_layers_get_the_keyframe_at_the_comp_time() { + for timing in + [vec![("layer.timing", json!({"start": 5.0}))], vec![("layer.timing", json!({"start": 5.0})), ("layer.timeStretch", json!({"percent": 200.0}))]] + { + let (mut h, l, colors) = setup(&timing); + set_time(&mut h, 5.5); + h.state_mut().session.execute("prop.toggleAnimation", json!({"layer": l, "prop": colors})).unwrap(); + set_time(&mut h, 6.0); + open_editor(&mut h, colors); + let p = along(&h, "dialog.gradient.addColor", 0.5); + click_at(&mut h, p); + click(&mut h, "dialog.gradient.ok"); + let layer = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().clone(); + let pr = layer.props.find(colors).unwrap(); + let comp_times: Vec = pr.keys.iter().map(|k| layer.comp_time(k.time).seconds()).collect(); + assert_eq!(pr.keys.len(), 2, "{timing:?}: {comp_times:?}"); + // Composition time 6 s snapped to the nearest frame (29.97 fps: 6.006 s), as prop.set snaps + // it: the layer's start and stretch don't move it. + let at = h.state().session.active_comp().unwrap().frame_rate.snap_nearest(Tick::from_seconds_f64(6.0)).seconds(); + let k = pr.keys.iter().find(|k| (layer.comp_time(k.time).seconds() - at).abs() < 1e-6); + let k = k.unwrap_or_else(|| panic!("{timing:?}: no keyframe at comp time {at} s: {comp_times:?}")); + assert_eq!(gradient(&k.value).colors.len(), 3, "{timing:?}"); + } +} + +/// Adding a stop next to keyframes with fewer stops warns that they can't interpolate; it still +/// saves, the neighbour keeps its stops, and the animation holds until the edited keyframe. +#[test] +fn mismatched_stop_counts_warn_and_neighbours_stay() { + let (mut h, l, colors) = setup(&[]); + add_key(&mut h, l, colors, 1.0, &Gradient::default()); + add_key(&mut h, l, colors, 3.0, &Gradient::default()); + set_time(&mut h, 3.0); + open_editor(&mut h, colors); + assert!(h.query_by_label(WARNING).is_none(), "same counts: no warning"); + let p = along(&h, "dialog.gradient.addColor", 0.5); + click_at(&mut h, p); + assert!(h.query_by_label(WARNING).is_some(), "three colour stops next to a keyframe with two"); + click(&mut h, "dialog.gradient.ok"); + let pr = colors_prop(&h, l, colors); + assert_eq!(pr.keys.len(), 2); + assert_eq!(gradient(&pr.keys[0].value), Gradient::default(), "the neighbour keeps its stops"); + assert_eq!(gradient(&pr.keys[1].value).colors.len(), 3); + let layer = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().clone(); + let mid = pr.value_at(layer.layer_time(Tick::from_seconds_f64(2.0))); + assert_eq!(gradient(&mid), Gradient::default(), "it holds the first keyframe instead of blending"); +} + +/// Delete removes the selected stop (never a layer, and never the last stop of a track), but not +/// while typing in a field. +#[test] +fn delete_removes_the_selected_stop_but_not_the_last_or_while_typing() { + let (mut h, _, colors) = setup(&[]); + let layers = h.state().session.active_comp().unwrap().layers.len(); + open_editor(&mut h, colors); + click(&mut h, "dialog.gradient.colorStop.0"); + key(&mut h, Key::Delete); + assert_eq!(h.state().gradient_draft().colors.len(), 1, "Delete removed the selected stop"); + click(&mut h, "dialog.gradient.colorStop.0"); + key(&mut h, Key::Delete); + click(&mut h, "dialog.gradient.delete"); + assert_eq!(h.state().gradient_draft().colors.len(), 1, "the last colour stop stays"); + assert_eq!(h.state().session.active_comp().unwrap().layers.len(), layers, "Delete never reaches the layer"); + assert_eq!(h.state().dialog, Some(Dialog::GradientEditor)); + // Typing in Location: Delete edits the text, not the stops. + click(&mut h, "dialog.gradient.opacityStop.0"); + click(&mut h, "dialog.gradient.location"); + assert!(h.ctx.egui_wants_keyboard_input(), "the Location field takes the keyboard"); + key(&mut h, Key::Delete); + assert_eq!(h.state().gradient_draft().opacities.len(), 2, "typing doesn't delete a stop"); +} + +/// One colour stop saves (the typed form; plain JSON needs two) and one undo brings both back. +#[test] +fn a_single_colour_stop_saves_and_undoes() { + let (mut h, l, colors) = setup(&[]); + let before = colors_prop(&h, l, colors).value.clone(); + let undo = undo_len(&h); + open_editor(&mut h, colors); + click(&mut h, "dialog.gradient.colorStop.1"); + click(&mut h, "dialog.gradient.delete"); + assert_eq!(h.state().gradient_draft().colors.len(), 1); + click(&mut h, "dialog.gradient.ok"); + let g = gradient(&colors_prop(&h, l, colors).value); + assert_eq!(g.colors, gradient(&before).colors[..1].to_vec(), "{g:?}"); + assert_eq!(g.opacities, gradient(&before).opacities, "opacity stops unchanged"); + assert_eq!(undo_len(&h), undo + 1); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(colors_prop(&h, l, colors).value, before); +} + +/// Typing a Location that moves a stop past its neighbour keeps the selection on that stop, and +/// in the same frame the Opacity field shows its value, not the neighbour's (now at its old index). +#[test] +fn location_moves_past_a_neighbour_and_the_fields_follow_the_stop() { + let (mut h, _, colors) = setup(&[]); + open_editor(&mut h, colors); + // Opacity 100% at 0 and 40% at 1, plus a stop at 0.5 sampled from them: 70%. + let right = h.state().gradient_draft().opacities[1].id; + h.state_mut().gradient_draft_mut().set_opacity(right, 0.4); + let p = along(&h, "dialog.gradient.addOpacity", 0.5); + click_at(&mut h, p); + let middle = h.state().gradient_draft().selected.expect("the new stop").1; + let i = h.state().gradient_draft().index_of(Track::Opacity, middle).unwrap(); + assert!((h.state().gradient_draft().opacities[i].value - 0.7).abs() < 0.02, "it sampled 70%"); + // Select the 100% stop at 0 and type 80 into Location: it passes the stop at 0.5. + let first = h.state().gradient_draft().opacities[0].id; + click(&mut h, "dialog.gradient.opacityStop.0"); + click(&mut h, "dialog.gradient.location"); + assert!(h.ctx.egui_wants_keyboard_input(), "the Location field is being typed in"); + h.input_mut().events.push(Event::Key { key: Key::A, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }); + h.input_mut().events.push(Event::Text("80".into())); + h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.step(); + let d = h.state().gradient_draft(); + assert_eq!(d.selected, Some((Track::Opacity, first)), "the moved stop stays selected"); + assert_eq!(d.index_of(Track::Opacity, first), Some(1), "it is now after the stop at 0.5"); + // The frame that moved it: Opacity already shows this stop's 100%, never the neighbour's 70% + // that is now at its old index. (Location itself was drawn before the move: 0 until the next.) + let shown = shown_numbers(&h); + assert!(shown.contains(&100.0), "this stop's 100%: {shown:?}"); + assert!(!shown.iter().any(|v| (v - 70.0).abs() < 1.0), "never the neighbour's 70%: {shown:?}"); + h.run_steps(2); + let shown = shown_numbers(&h); + assert!(shown.contains(&80.0) && shown.contains(&100.0), "Location 80 and Opacity 100%: {shown:?}"); + assert!(!shown.iter().any(|v| (v - 70.0).abs() < 1.0), "{shown:?}"); +} + +/// A layer deleted while the editor is open: OK reports it, without a crash. +#[test] +fn a_layer_deleted_while_editing_is_reported() { + let (mut h, l, colors) = setup(&[]); + open_editor(&mut h, colors); + let p = along(&h, "dialog.gradient.addColor", 0.5); + click_at(&mut h, p); + h.state_mut().session.execute("edit.clear", json!({"layers": [l]})).unwrap(); + h.run_steps(2); + click(&mut h, "dialog.gradient.ok"); + assert_eq!(h.state().dialog, None); + assert!(!h.state().ui.status.is_empty(), "the failed set is reported"); +} + +/// `prop.editGradient` by id (agents, MCP, the control channel) is an error for a target that +/// isn't a gradient property, from the engine and through the UI, and opens nothing. +#[test] +fn edit_gradient_by_id_rejects_bad_targets() { + let (mut h, l, colors) = setup(&[]); + let ctx = h.ctx.clone(); + let opacity = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("transform/opacity").unwrap().uid; + for (p, err) in [(json!({"layer": 9999, "prop": colors}), "no layer"), (json!({"layer": l, "prop": opacity}), "not a gradient")] { + let e = h.state_mut().session.execute_checked("prop.editGradient", p.clone()).expect_err("the engine refuses it"); + assert!(e.to_string().contains(err), "{p}: {e}"); + let e = effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "prop.editGradient", p.clone()).expect_err("so does the UI"); + assert!(e.contains(err), "{p}: {e}"); + h.run_steps(2); + assert_eq!(h.state().dialog, None, "{p}"); + } + let r = effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "prop.editGradient", json!({"layer": l, "prop": colors})); + assert!(r.is_ok(), "{r:?}"); + h.run_steps(2); + assert_eq!(h.state().dialog, Some(Dialog::GradientEditor)); +} + +/// In the editor, the colour picker's Escape and Cancel go back to the stop's colour and close only +/// the picker: the editor and its other edits stay (dialogs leave Escape to an open picker). +#[test] +fn picker_escape_and_cancel_restore_the_stop_and_keep_the_editor() { + let (mut h, _, colors) = setup(&[]); + open_editor(&mut h, colors); + let added = along(&h, "dialog.gradient.addOpacity", 0.3); + click_at(&mut h, added); + click(&mut h, "dialog.gradient.colorStop.0"); + let id = h.state().gradient_draft().selected.expect("a selected stop").1; + let original = stop_color(&h, id); + for how in ["escape", "cancel"] { + click(&mut h, "dialog.gradient.color"); + assert!(picker_open(&h), "{how}: the picker opened"); + type_hex(&mut h, "ff8000"); + assert!(near(stop_color(&h, id), [1.0, 128.0 / 255.0, 0.0, original[3]]), "{how}: the picker previews as it goes"); + if how == "escape" { + key(&mut h, Key::Escape); + } else { + // The picker's Cancel, not the editor's. + let editor_cancel = rect(&h, "dialog.gradient.cancel"); + let cancel = + h.query_all_by_label("Cancel").map(|n| n.rect()).find(|r| (r.min.x - editor_cancel[0]).abs() > 1.0 || (r.min.y - editor_cancel[1]).abs() > 1.0); + click_at(&mut h, cancel.expect("the picker's Cancel").center()); + } + assert!(!picker_open(&h), "{how}: the picker closed"); + assert_eq!(h.state().dialog, Some(Dialog::GradientEditor), "{how}: the editor stays open"); + assert_eq!(stop_color(&h, id), original, "{how}: the stop's colour is back"); + assert_eq!(h.state().gradient_draft().opacities.len(), 3, "{how}: the editor's other edits stay"); + } +} + +/// The Escape that closes the picker is its own: the next Escape, even in the very next pass, +/// cancels the editor (a closed picker doesn't keep the dialog from it). +#[test] +fn the_escape_after_the_picker_closes_cancels_the_editor() { + let (mut h, _, colors) = setup(&[]); + let project = h.state().session.project.clone(); + open_editor(&mut h, colors); + click(&mut h, "dialog.gradient.colorStop.0"); + click(&mut h, "dialog.gradient.color"); + assert!(picker_open(&h), "the picker opened"); + let escape = |h: &mut Harness<'_, EffectcraftApp>| { + h.input_mut().events.push(Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::Key { key: Key::Escape, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.step(); + }; + escape(&mut h); + assert_eq!(h.state().dialog, Some(Dialog::GradientEditor), "the first Escape closed only the picker"); + escape(&mut h); + assert_eq!(h.state().dialog, None, "the next pass's Escape cancels the editor"); + assert_eq!(h.state().session.project, project, "and changes nothing"); +} diff --git a/crates/ui-egui/tests/ui_gradient_value.rs b/crates/ui-egui/tests/ui_gradient_value.rs new file mode 100644 index 000000000..84c3844c0 --- /dev/null +++ b/crates/ui-egui/tests/ui_gradient_value.rs @@ -0,0 +1,49 @@ +//! #612: a gradient property's "Edit Gradient…" in the Timeline opens an editor. Before the fix +//! the value only painted its label: no automation id, nothing to click. This test uses only APIs +//! that existed before the Gradient Editor, so it runs (and fails) on the code without it; the +//! editor itself is tested in `ui_gradient_editor`. + +use effectcraft_engine::Session; +use effectcraft_engine::project::{LayerId, PropGroup}; +use effectcraft_ui_egui::EffectcraftApp; +use egui::{Event, Modifiers, PointerButton, pos2}; +use egui_kittest::Harness; +use serde_json::json; + +/// The uids from the layer's root down to the Gradient Fill group, and its Colors property. +fn gradient_fill(g: &PropGroup) -> Option<(Vec, u64)> { + for x in g.groups() { + if x.match_id == "gfill" { + return Some((vec![x.uid], x.get("colors")?.uid)); + } + if let Some((mut path, colors)) = gradient_fill(x) { + path.insert(0, x.uid); + return Some((path, colors)); + } + } + None +} + +#[test] +fn the_timeline_gradient_value_opens_an_editor() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap(); + let l = s.execute("layer.newShape", json!({"kind": "rect"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.addShapeItem", json!({"layer": l, "kind": "gfill"})).unwrap(); + let (groups, colors) = gradient_fill(&s.active_comp().unwrap().layer(LayerId(l)).unwrap().props).expect("a Gradient Fill"); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.state_mut().ui.timeline.open_layers.insert(l); + h.state_mut().ui.timeline.open_groups.extend(groups); + h.run_steps(3); + assert!(h.state().dialog.is_none()); + let id = format!("timeline.prop.{colors}.value"); + let r = h.state().auto.find(&id).unwrap_or_else(|| panic!("no automation id {id}: the Timeline's Edit Gradient… isn't clickable")).rect; + let p = pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0); + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(4); + assert!(h.state().dialog.is_some(), "clicking Edit Gradient… opens an editor"); +} diff --git a/crates/ui-egui/tests/ui_panels.rs b/crates/ui-egui/tests/ui_panels.rs index a71bebfeb..e3fee868c 100644 --- a/crates/ui-egui/tests/ui_panels.rs +++ b/crates/ui-egui/tests/ui_panels.rs @@ -2,7 +2,9 @@ //! registers its automation ids; the Footage panel's buttons edit into the comp; the Progress //! panel lists and cancels a job; Lumetri Scopes switch scope through their dropdown. -use effectcraft_engine::Session; +use std::sync::Arc; + +use effectcraft_engine::{Session, config::MemoryConfig}; use effectcraft_ui_egui::EffectcraftApp; use effectcraft_ui_egui::dock::PanelKind; use egui::{Event, Pos2, pos2}; @@ -237,46 +239,147 @@ fn leaving_the_learn_workspace_closes_its_tutorials() { } } -/// #284: Settings ▸ Appearance ▸ UI Scale sizes the whole interface as soon as it is chosen, -/// Cancel puts it back, OK keeps it; Ctrl+0 (Window ▸ Project) doesn't reset it. +fn open_settings(h: &mut Harness<'_, EffectcraftApp>) { + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "app.settings", json!({"page": "appearance"})).unwrap(); + h.run_steps(3); +} + +fn key(h: &mut Harness<'_, EffectcraftApp>, key: egui::Key, modifiers: egui::Modifiers) { + for pressed in [true, false] { + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }); + h.step(); + } +} + +/// The UI Scale setting and the zoom it gives. +fn scale(h: &Harness<'_, EffectcraftApp>) -> (u32, f32) { + (h.state().session.prefs.appearance.ui_scale, h.ctx.zoom_factor()) +} + +/// Types `text` into Settings' UI Scale a key at a time: nothing applies (and the field keeps +/// the keyboard) until Enter. +fn type_scale(h: &mut Harness<'_, EffectcraftApp>, text: &str) { + let before = scale(h); + click(h, "settings.appearance.uiScale"); + for c in text.chars() { + h.input_mut().events.push(Event::Text(c.to_string())); + h.run_steps(2); + assert_eq!(scale(h), before, "{text}: not while typing"); + assert!(h.ctx.memory(|m| m.focused().is_some()), "{text}: the field keeps the keyboard"); + } + key(h, egui::Key::Enter, egui::Modifiers::NONE); + // (Settings re-centres a frame after the zoom changes.) + h.run_steps(4); +} + +/// #284, #682: Settings ▸ Appearance ▸ UI Scale takes any typed percentage from 75 to 200 and +/// sizes the whole interface once it is entered, Cancel puts it back, OK keeps it and later +/// sessions start with it; Ctrl+0 (Window ▸ Project) doesn't reset it. #[test] fn ui_scale_setting_sizes_the_interface() { let mut h = harness(); let ctx = h.ctx.clone(); - let choose = |h: &mut Harness<'_, EffectcraftApp>, label: &str| { - let ctx = h.ctx.clone(); - effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "app.settings", json!({"page": "appearance"})).unwrap(); - h.run_steps(3); - click(h, "settings.appearance.uiScale"); - let option = h.query_by_label(label).unwrap_or_else(|| panic!("the {label} choice")).rect().center(); - click_at(h, option); - h.run_steps(2); - }; assert_eq!(ctx.zoom_factor(), 1.0); - choose(&mut h, "150%"); - assert_eq!(h.state().session.prefs.appearance.ui_scale, 150); - assert_eq!(ctx.zoom_factor(), 1.5, "live"); + open_settings(&mut h); + type_scale(&mut h, "85"); + assert_eq!(scale(&h), (85, 0.85), "live"); click(&mut h, "settings.cancel"); h.run_steps(2); - assert_eq!((h.state().session.prefs.appearance.ui_scale, ctx.zoom_factor()), (100, 1.0), "Cancel put it back"); - choose(&mut h, "125%"); + assert_eq!(scale(&h), (100, 1.0), "Cancel put it back"); + open_settings(&mut h); + type_scale(&mut h, "300"); + assert_eq!(scale(&h), (200, 2.0), "at most 200"); + click(&mut h, "settings.cancel"); + h.run_steps(2); + assert_eq!(scale(&h), (100, 1.0), "Cancel put it back from 200 %"); + open_settings(&mut h); + type_scale(&mut h, "50"); + assert_eq!(scale(&h), (75, 0.75), "at least 75"); + type_scale(&mut h, "90"); click(&mut h, "settings.ok"); h.run_steps(2); assert_eq!(h.state().dialog, None); - assert_eq!((h.state().session.prefs.appearance.ui_scale, ctx.zoom_factor()), (125, 1.25), "OK kept it"); + assert_eq!(scale(&h), (90, 0.9), "OK kept it"); // Ctrl+0 opens the Project panel; egui's own Ctrl+0 zoom reset stays out of it. - for pressed in [true, false] { - h.input_mut().events.push(Event::Key { key: egui::Key::Num0, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::COMMAND }); - h.step(); - } + key(&mut h, egui::Key::Num0, egui::Modifiers::COMMAND); h.run_steps(2); - assert_eq!(ctx.zoom_factor(), 1.25); + assert_eq!(ctx.zoom_factor(), 0.9); // A saved scale applies from the first frame. - let mut s = Session::default(); - s.prefs.set("appearance.uiScale", json!(175)).unwrap(); + let s = Session { prefs: effectcraft_engine::prefs::Prefs::from_json(&h.state().session.prefs.to_json()), ..Default::default() }; let mut h2 = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); h2.run_steps(3); - assert_eq!(h2.ctx.zoom_factor(), 1.75); + assert_eq!(h2.ctx.zoom_factor(), 0.9); +} + +/// Settings' Previous / Next / Cancel / OK stay whole and in view at 200 % in a 1600×1000 +/// window (they scrolled out of the dialog with its pages): the page scrolls under them, Cancel +/// restores the settings and OK saves them. +#[test] +fn settings_buttons_stay_in_view_at_200_percent() { + let store = Arc::new(MemoryConfig::default()); + let saved = || { + let mut s = Session { config: Some(store.clone()), ..Default::default() }; + s.prefs.disk.disk_cache_enabled = false; + s.load_settings(); + s.prefs.appearance.ui_scale + }; + let mut s = Session { config: Some(store.clone()), ..Default::default() }; + s.prefs.disk.disk_cache_enabled = false; + s.prefs.set("appearance.uiScale", json!(200)).unwrap(); + s.save_prefs(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + assert_eq!(scale(&h), (200, 2.0)); + let rect = |h: &Harness<'_, EffectcraftApp>, id: &str| { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")); + egui::Rect::from_min_size(pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3])) + }; + // Whole: inside the window, and painted with none of them clipped away. + let buttons = |h: &Harness<'_, EffectcraftApp>| { + ["previous", "next", "cancel", "ok"].map(|b| { + let r = rect(h, &format!("settings.{b}")); + let clip = h + .output() + .shapes + .iter() + .find(|c| matches!(&c.shape, egui::Shape::Rect(s) if (s.rect.center() - r.center()).length() < 0.5)) + .map(|c| c.clip_rect); + assert!(h.ctx.content_rect().contains_rect(r) && clip.is_some_and(|c| c.contains_rect(r)), "{b} is cut off: {r:?} in {clip:?}"); + r + }) + }; + let scroll_page = |h: &mut Harness<'_, EffectcraftApp>| { + let at = rect(h, "settings.appearance.appearanceMode").center(); + h.input_mut().events.push(Event::PointerMoved(at)); + h.input_mut().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -1000.0), + modifiers: Default::default(), + phase: egui::TouchPhase::Move, + }); + h.run_steps(5); + }; + open_settings(&mut h); + let footer = buttons(&h); + let field = rect(&h, "settings.appearance.uiScale"); + scroll_page(&mut h); + assert_eq!(buttons(&h), footer, "the buttons don't scroll with the page"); + let scrolled = rect(&h, "settings.appearance.uiScale"); + assert!(scrolled.top() < field.top() && scrolled.bottom() < footer[2].top(), "the page scrolled to UI Scale: {field:?} -> {scrolled:?}"); + for (button, kept) in [("settings.cancel", 200), ("settings.ok", 150)] { + if h.state().dialog.is_none() { + open_settings(&mut h); + scroll_page(&mut h); + } + type_scale(&mut h, "150"); + assert_eq!(scale(&h), (150, 1.5)); + buttons(&h); + click(&mut h, button); + h.run_steps(2); + assert_eq!(h.state().dialog, None, "{button} closed Settings"); + assert_eq!((scale(&h), saved()), ((kept, kept as f32 / 100.0), kept), "{button}"); + } } /// Headless look at the panels (wgpu offscreen; needs a GPU adapter). Run with @@ -362,3 +465,31 @@ fn essential_graphics_scale_keeps_constrain_proportions() { let unlinked = scale(&h); assert!(unlinked[0] > linked[0] && unlinked[1] == linked[1], "unlinked, X moves alone: {unlinked:?}"); } + +/// #757: Effects & Presets lists the built-in Transitions presets under * Animation Presets; +/// double-clicking one applies it to the selected layer (effect plus keyframes). +#[test] +fn transition_presets_apply_from_effects_and_presets() { + let mut h = harness(); + let layer = h.state().session.active_comp().unwrap().layers[0].id.0; + h.state_mut().session.execute("layer.select", json!({"layers": [layer]})).unwrap(); + open(&mut h, "effectsPresets"); + h.state_mut().ui.effects_search = "clock reveal".into(); + h.run_steps(3); + assert!(h.state().auto.find("effects.category.presets/Transitions").is_some(), "the Transitions folder is listed"); + let e = h.state().auto.find("effects.preset.builtin.clockReveal").expect("Clock Reveal is listed").clone(); + let p: Pos2 = pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0); + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + // Both clicks in one frame: the harness' frame time is longer than a double-click's. + for _ in 0..2 { + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + } + } + h.run_steps(3); + let comp = h.state().session.active_comp().unwrap(); + let fx = comp.layers[0].effects().unwrap().groups().next().expect("an effect was added").clone(); + assert_eq!(fx.name, "Radial Wipe"); + assert_eq!(fx.prop("completion").unwrap().keys.len(), 2); +} diff --git a/crates/ui-egui/tests/ui_project_keyboard.rs b/crates/ui-egui/tests/ui_project_keyboard.rs new file mode 100644 index 000000000..af2a796fe --- /dev/null +++ b/crates/ui-egui/tests/ui_project_keyboard.rs @@ -0,0 +1,224 @@ +//! Project panel keyboard navigation and accessibility (#776): the arrow keys move and extend the +//! selection and open / close folders, as in After Effects; the rows, sort headers and row buttons +//! reach screen readers with names, roles and states; a row focused with Tab keeps the arrows, and +//! keyboard focus follows the selection. + +use effectcraft_engine::Session; +use effectcraft_engine::project::{ItemId, ItemKind}; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; +use egui::accesskit::Role; +use egui::{Event, Key, Modifiers, Pos2, pos2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +/// A closed folder holding a comp, and a solid at the project root: (harness, folder, comp, solid). +fn harness() -> (Harness<'static, EffectcraftApp>, ItemId, ItemId, ItemId) { + let mut s = Session::default(); + let comp = ItemId(s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap()["comp"].as_u64().unwrap()); + s.execute("layer.newSolid", json!({"name": "Solid", "color": "#406080"})).unwrap(); + let solid = s.project.items.values().find(|i| matches!(i.kind, ItemKind::Solid(_))).unwrap().id; + let folder = ItemId(s.execute("project.newFolder", json!({"name": "Folder"})).unwrap()["item"].as_u64().unwrap()); + s.execute("project.move", json!({"items": [comp.0], "folder": folder.0})).unwrap(); + s.execute("project.move", json!({"items": [solid.0], "folder": null})).unwrap(); + s.execute("project.select", json!({"items": []})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + (h, folder, comp, solid) +} + +fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone(); + pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0) +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let p = center(h, id); + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(3); +} + +/// Press and release `key` with `modifiers` (one frame each), then let the UI settle. +fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key, modifiers: Modifiers) { + for pressed in [true, false] { + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }); + h.step(); + } + h.run_steps(2); +} + +/// The item rows on screen, top to bottom. +fn rows(h: &Harness<'_, EffectcraftApp>) -> Vec { + let mut rows: Vec<(f32, ItemId)> = h + .state() + .auto + .query("project.item.") + .into_iter() + .filter_map(|e| e.id.strip_prefix("project.item.").and_then(|n| n.parse().ok()).map(|n| (e.rect[1], ItemId(n)))) + .collect(); + rows.sort_by(|a, b| a.0.total_cmp(&b.0)); + rows.into_iter().map(|(_, id)| id).collect() +} + +fn selection(h: &Harness<'_, EffectcraftApp>) -> Vec { + h.state().session.state.project_selection.clone() +} + +#[test] +fn arrow_keys_move_and_extend_the_project_selection() { + let (mut h, _, _, _) = harness(); + let rows = rows(&h); + assert!(rows.len() >= 2, "rows: {rows:?}"); + let (first, second) = (rows[0], rows[1]); + click(&mut h, &format!("project.item.{}.name", first.0)); + assert_eq!(h.state().ui.focused, PanelKind::Project); + assert_eq!(selection(&h), vec![first]); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(selection(&h), vec![second], "Down Arrow selects the next item"); + key(&mut h, Key::ArrowUp, Modifiers::NONE); + assert_eq!(selection(&h), vec![first], "Up Arrow selects the previous item"); + key(&mut h, Key::ArrowUp, Modifiers::NONE); + assert_eq!(selection(&h), vec![first], "the first item stays selected at the top"); + for _ in 0..rows.len() + 1 { + key(&mut h, Key::ArrowDown, Modifiers::SHIFT); + } + assert_eq!(selection(&h), rows, "Shift+Down Arrow extends the selection to the last item"); + key(&mut h, Key::ArrowUp, Modifiers::SHIFT); + assert_eq!(selection(&h), rows[..rows.len() - 1], "Shift+Up Arrow shrinks it back toward the anchor"); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(selection(&h), vec![rows[rows.len() - 1]], "a plain arrow selects one item again"); + // With nothing selected, Down selects the first item and Up the last. + h.state_mut().session.state.project_selection.clear(); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(selection(&h), vec![first]); + h.state_mut().session.state.project_selection.clear(); + key(&mut h, Key::ArrowUp, Modifiers::NONE); + assert_eq!(selection(&h), vec![rows[rows.len() - 1]]); +} + +#[test] +fn right_and_left_arrows_open_and_close_the_selected_folder() { + let (mut h, folder, comp, _) = harness(); + click(&mut h, &format!("project.item.{}.name", folder.0)); + assert!(!rows(&h).contains(&comp), "the folder starts closed"); + key(&mut h, Key::ArrowRight, Modifiers::NONE); + assert!(h.state().ui.project_open_folders.contains(&folder.0), "Right Arrow opens the selected folder"); + assert!(rows(&h).contains(&comp)); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(selection(&h), vec![comp], "Down Arrow walks into the open folder"); + key(&mut h, Key::ArrowLeft, Modifiers::NONE); + assert_eq!(selection(&h), vec![folder], "Left Arrow on an item in a folder selects the folder"); + key(&mut h, Key::ArrowLeft, Modifiers::NONE); + assert!(!h.state().ui.project_open_folders.contains(&folder.0), "Left Arrow closes the selected folder"); + assert_eq!(selection(&h), vec![folder]); +} + +#[test] +fn arrows_leave_the_project_selection_alone_when_another_panel_has_focus() { + let (mut h, folder, _, _) = harness(); + click(&mut h, &format!("project.item.{}.name", folder.0)); + h.state_mut().ui.focused = PanelKind::Timeline; + key(&mut h, Key::ArrowDown, Modifiers::NONE); + key(&mut h, Key::ArrowRight, Modifiers::NONE); + assert_eq!(selection(&h), vec![folder]); + assert!(!h.state().ui.project_open_folders.contains(&folder.0)); +} + +#[test] +fn rows_and_headers_have_accessible_names_roles_and_states() { + let (mut h, folder, _, _) = harness(); + click(&mut h, &format!("project.item.{}.name", folder.0)); + let row = h.get_by_label("Folder, Folder"); + let node = row.accesskit_node(); + assert_eq!(node.role(), Role::TreeItem); + assert_eq!(node.is_selected(), Some(true), "the selected row says so"); + assert_eq!(node.data().is_expanded(), Some(false), "a closed folder says so"); + assert_eq!(node.data().level(), Some(1)); + let solid = h.get_by_label("Solid, Solid"); + assert_eq!(solid.accesskit_node().is_selected(), Some(false)); + assert_eq!(solid.accesskit_node().data().is_expanded(), None, "only folders open"); + assert_eq!(h.get_by_label("Sort by Name, ascending").accesskit_node().role(), Role::Button); + assert_eq!(h.get_by_label("Open folder Folder").accesskit_node().role(), Role::Button); + assert_eq!(h.get_by_label("Delete selected project items").accesskit_node().role(), Role::Button); + assert!(h.query_by_label_contains("Label of Folder: ").is_some(), "the label swatch is named"); + assert!(h.query_by_label("Create a new Folder").is_some()); + key(&mut h, Key::ArrowRight, Modifiers::NONE); + assert_eq!(h.get_by_label("Folder, Folder").accesskit_node().data().is_expanded(), Some(true)); + assert_eq!(h.get_by_label("Main, Composition").accesskit_node().data().level(), Some(2), "the comp sits one level down"); +} + +#[test] +fn a_focused_row_keeps_the_arrows_and_focus_follows_the_selection() { + let (mut h, _, _, _) = harness(); + let rows = rows(&h); + let names: Vec = rows + .iter() + .map(|id| { + let it = h.state().session.project.item(*id).unwrap(); + format!("{}, {}", it.name, it.type_name()) + }) + .collect(); + // Focus the first row the way a screen reader (or Tab) does, without a click: the panel + // takes focus with it. + h.state_mut().ui.focused = PanelKind::Timeline; + h.get_by_label(&names[0]).focus(); + h.run_steps(3); + assert!(h.get_by_label(&names[0]).is_focused()); + assert_eq!(h.state().ui.focused, PanelKind::Project, "keyboard focus on a row focuses the panel"); + // A plain arrow moved egui's focus to the widget below; here it moves the selection and the + // focus with it. + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(selection(&h), vec![rows[0]], "nothing was selected: Down selects the first item"); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(selection(&h), vec![rows[1]]); + assert!(h.get_by_label(&names[1]).is_focused(), "keyboard focus follows the selection"); + key(&mut h, Key::ArrowUp, Modifiers::NONE); + assert_eq!(selection(&h), vec![rows[0]]); + assert!(h.get_by_label(&names[0]).is_focused()); +} + +/// A mouse click on a row gives no keyboard focus to the row, so the app's shortcuts run as +/// before the arrow keys came: Delete / Backspace delete, Cmd+D duplicates, Enter renames, also +/// after an arrow key moved the selection. +#[test] +fn shortcuts_still_run_after_a_mouse_click_and_the_arrows() { + let focus = |h: &Harness<'_, EffectcraftApp>| h.ctx.memory(|m| m.focused()); + for delete_key in [Key::Delete, Key::Backspace] { + let (mut h, folder, _, _) = harness(); + let empty = ItemId(h.state_mut().session.execute("project.newFolder", json!({"name": "Empty"})).unwrap()["item"].as_u64().unwrap()); + h.run_steps(2); + click(&mut h, &format!("project.item.{}.name", empty.0)); + assert_eq!(selection(&h), vec![empty]); + assert_eq!(focus(&h), None, "a click gives the row no keyboard focus"); + key(&mut h, delete_key, Modifiers::NONE); + assert!(h.state().session.project.item(empty).is_none(), "{delete_key:?} deletes the clicked item"); + // Click, an arrow, then the shortcut. + let empty2 = ItemId(h.state_mut().session.execute("project.newFolder", json!({"name": "Empty 2"})).unwrap()["item"].as_u64().unwrap()); + h.run_steps(2); + let rows = rows(&h); + let at = rows.iter().position(|r| *r == empty2).unwrap(); + let from = if at > 0 { rows[at - 1] } else { rows[at + 1] }; + click(&mut h, &format!("project.item.{}.name", from.0)); + key(&mut h, if at > 0 { Key::ArrowDown } else { Key::ArrowUp }, Modifiers::NONE); + assert_eq!(selection(&h), vec![empty2]); + assert_eq!(focus(&h), None, "the arrows don't take keyboard focus after a click"); + key(&mut h, delete_key, Modifiers::NONE); + assert!(h.state().session.project.item(empty2).is_none(), "{delete_key:?} deletes the item the arrow moved to"); + assert!(h.state().session.project.item(folder).is_some()); + } + let (mut h, folder, _, solid) = harness(); + // Folders can't be duplicated (engine rule): duplicate the solid. + click(&mut h, &format!("project.item.{}.name", solid.0)); + let n = h.state().session.project.items.len(); + key(&mut h, Key::D, Modifiers::COMMAND); + assert_eq!(h.state().session.project.items.len(), n + 1, "Cmd+D duplicates the clicked item"); + click(&mut h, &format!("project.item.{}.name", folder.0)); + key(&mut h, Key::Enter, Modifiers::NONE); + assert!(h.state().auto.find(&format!("project.item.{}.nameEdit", folder.0)).is_some(), "Enter renames the clicked item"); +} diff --git a/crates/ui-egui/tests/ui_project_rows.rs b/crates/ui-egui/tests/ui_project_rows.rs index 4c255cadd..7d0ebf667 100644 --- a/crates/ui-egui/tests/ui_project_rows.rs +++ b/crates/ui-egui/tests/ui_project_rows.rs @@ -127,16 +127,21 @@ fn footage_context_menu_interprets_the_footage() { } h.run_steps(2); }; + // The menu's Interpret Footage entry, not the panel's footer button of the same name. + let menu_entry = |h: &Harness<'_, EffectcraftApp>| { + let footer = center(h, "project.interpret"); + h.query_all_by_label_contains("Interpret Footage").find(|n| !n.rect().contains(footer)).map(|n| n.rect()) + }; let at = center(&h, &format!("project.item.{}.name", solid.0)); right_click(&mut h, at); assert_eq!(h.state().session.state.project_selection, vec![solid]); - assert!(h.query_by_label_contains("Interpret Footage").is_none(), "a solid isn't footage"); + assert!(menu_entry(&h).is_none(), "a solid isn't footage"); h.input_mut().events.push(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); h.run_steps(2); let at = center(&h, &format!("project.item.{}.name", clip.0)); right_click(&mut h, at); assert_eq!(h.state().session.state.project_selection, vec![clip], "right-click selects the item"); - let sub = h.query_by_label_contains("Interpret Footage").expect("Interpret Footage in the menu").rect(); + let sub = menu_entry(&h).expect("Interpret Footage in the menu"); h.input_mut().events.push(Event::PointerMoved(sub.center())); h.run_steps(3); let main = h.query_by_label_contains("Main...").expect("Interpret Footage ▸ Main...").rect(); diff --git a/crates/ui-egui/tests/ui_settings_info.rs b/crates/ui-egui/tests/ui_settings_info.rs index 64f088fe5..f110ce0f7 100644 --- a/crates/ui-egui/tests/ui_settings_info.rs +++ b/crates/ui-egui/tests/ui_settings_info.rs @@ -47,11 +47,15 @@ fn check_info_transaction(via_escape: bool) { let edited_brightness = h.state().session.prefs.appearance.brightness; assert!((edited_brightness - original_brightness).abs() > 0.01, "real brightness slider changed"); assert_eq!(saved(&store).prefs.appearance.brightness, original_brightness, "unconfirmed edit is not saved"); - // Use the upstream percent-valued Scale choice; keyboard zoom is deliberately disabled. + // Type a UI Scale; keyboard zoom is deliberately disabled. let at = rect(&h, "settings.appearance.uiScale").center(); click(&mut h, at); - let at = h.get_by_label("125%").rect().center(); - click(&mut h, at); + h.event(Event::Text("125".into())); + for pressed in [true, false] { + h.event(Event::Key { key: Key::Enter, physical_key: Some(Key::Enter), pressed, repeat: false, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(2); assert_eq!(h.state().session.prefs.appearance.ui_scale, 125); assert_eq!(saved(&store).prefs.appearance.ui_scale, 100, "unconfirmed scale edit is not saved"); // Let UI Scale settle the centered dialog before reading its next hit target. @@ -111,3 +115,22 @@ fn gpu_information_ok_returns_to_settings_and_cancel_restores_preferences() { fn gpu_information_escape_returns_to_settings_and_ok_saves_preferences() { check_info_transaction(true); } + +/// GPU Information names the adapter the window got and says why the compositor is off, so a +/// card that the compositor could not use does not look undetected (#596). +#[test] +fn gpu_information_names_the_adapter_and_why_the_gpu_is_off() { + let session = Session { + accel_note: Some("GPU compositor unavailable: no compute shaders".into()), + window_adapter: Some("NVIDIA GeForce RTX 4070 (Dx12, DiscreteGpu)".into()), + ..Default::default() + }; + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(session)); + h.run_steps(4); + let ctx = h.ctx.clone(); + menus::invoke(h.state_mut(), &ctx, "app.gpuInfo", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(h.state().dialog, Some(Dialog::Info)); + assert!(h.query_by_label_contains("Window graphics: NVIDIA GeForce RTX 4070").is_some(), "the window's adapter is shown"); + assert!(h.query_by_label_contains("GPU compositing is off: GPU compositor unavailable").is_some(), "the reason is shown"); +} diff --git a/crates/ui-egui/tests/ui_timeline_shapes.rs b/crates/ui-egui/tests/ui_timeline_shapes.rs index dd9232d77..411bb22a0 100644 --- a/crates/ui-egui/tests/ui_timeline_shapes.rs +++ b/crates/ui-egui/tests/ui_timeline_shapes.rs @@ -1,7 +1,7 @@ //! Timeline rows for shape layers, masks and keyframing: the Contents "Add:" menu and the Classic //! 3D "Change Renderer…" row (#206), Alt+Shift+P revealing the key it adds and clicks below the //! layers deselecting them (#205), Mask Feather's linked values (#203), and copying shape items -//! between shape layers (#227). +//! between shape layers (#227), and Ctrl+V of SVG markup making a shape layer (#738). use effectcraft_engine::Session; use effectcraft_engine::project::LayerId; @@ -243,3 +243,31 @@ fn timeline_colour_eyedropper_samples_the_comp() { assert!((c[k] - want[k]).abs() < 0.02, "{c:?} vs {want:?}"); } } + +/// Ctrl+V with SVG markup on the system clipboard pastes it as a shape layer (After Effects +/// 26.3), even with layers on the app's clipboard: copying in the app replaces the system +/// clipboard, so the SVG was copied later (#738). +#[test] +fn ctrl_v_pastes_svg_markup_as_a_shape_layer() { + let (mut h, a, b, _) = two_shape_layers(); + click(&mut h, &format!("timeline.layer.{b}.row")); + h.input_mut().events.push(Event::Copy); + h.step(); + assert_eq!(h.state().session.state.clipboard.len(), 1, "a layer on the app's clipboard"); + let svg = r##""##; + h.input_mut().events.push(Event::Paste(svg.into())); + h.run_steps(3); + let c = h.state().session.active_comp().unwrap().clone(); + assert_eq!(c.layers.len(), 3, "{:?}", h.state().ui.status); + let top = &c.layers[0]; + assert!(top.id != LayerId(a) && top.id != LayerId(b)); + assert_eq!(top.name, "Shape Layer 3"); + assert_eq!(top.props.sub("contents").unwrap().groups().count(), 2); + assert_eq!(h.state().session.state.selected_layers, vec![top.id]); + // The app's own clipboard note pastes the copied layer as before. + h.input_mut().events.push(Event::Paste("EffectCraft: 1 layer".into())); + h.run_steps(3); + let c = h.state().session.active_comp().unwrap(); + assert_eq!(c.layers.len(), 4); + assert_eq!(c.layers[0].name, "Oval 2"); +} diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index bdb4189d7..b7c732ce7 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -601,6 +601,32 @@ fn fill_options_paint_the_next_shape() { assert_eq!(gfill.get("opacity").unwrap().value.as_f64(), 40.0); } +/// With a shape layer selected, Fill Options' None switches its fill off and Solid Color turns +/// it back on, and the Fill swatch shows the layer's kind (#732). +#[test] +fn fill_options_change_the_selected_shape_layer() { + let mut h = harness(); + let l = h.state_mut().session.execute("layer.newShape", json!({"kind": "rect", "size": [100, 100], "position": [320, 180]})).unwrap()["layer"] + .as_u64() + .unwrap(); + h.run_steps(2); + let fill_on = |h: &Harness<'_, EffectcraftApp>| { + let layer = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().clone(); + layer.props.group("contents/group/contents/fill").unwrap().enabled + }; + assert!(fill_on(&h)); + for (kind, on) in [("none", false), ("solid", true)] { + let at = rect(&h, "header.fillOptions").center(); + click(&mut h, at); + let at = rect(&h, &format!("header.fillOptions.{kind}")).center(); + click(&mut h, at); + h.input_mut().events.push(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }); + h.run_steps(3); + assert_eq!(fill_on(&h), on, "{kind}"); + assert_eq!(h.state().session.state.shape_tool.fill.kind.id(), kind); + } +} + #[test] fn shape_pen_draws_a_closed_shape_layer() { let mut h = harness(); diff --git a/docs/agents.md b/docs/agents.md index 6a3b9e85e..d983c6c54 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -107,7 +107,7 @@ See [MCP conventions](mcp.md) for core tools, resources, strict arguments, and r | `set_property {layer, path, value?, time?, timeBase?, expression?, comp?}` | Sets a static value. With `time` it sets a keyframe (`timeBase: "comp"` for comp seconds); with `expression` it sets an expression. | | `add_keyframe {layer, path, time+value \| keys:[...], timeBase?, interpolation?, comp?}` | Adds keys (layer seconds, or comp seconds with `timeBase: "comp"`), then optionally applies linear/bezier/hold/easyEase. | | `list_effects {filter?}` | Effect ids, names, categories, GPU / 32-bpc support and parameters. | -| `list_fonts {query?, rescan?}` | Font families text layers can use, bundled and installed, with their styles, origin and own-language name; `rescan` picks up fonts installed since launch. | +| `list_fonts {query?, variable?, rescan?}` | Font families text layers can use, bundled and installed, with their styles, origin, whether each is a variable font and own-language name; `variable` lists only variable fonts; `rescan` picks up fonts installed since launch. | | `add_effect {layer, effect, values?, comp?}` | Apply an effect and set its parameters in one call and one undo step (a failing value leaves nothing applied); returns the instance path (`effects/#n`) and its parameter paths. | | `render_frame {comp?, time?, max_side?, path?, inline?, transparent?}` | Returns a PNG image of a frame; `transparent: true` keeps the alpha (as an RGB + Alpha render writes it) instead of compositing over the comp background. | | `open_project {path \| demo \| new}` / `save_project {path?}` | Open and save files. | @@ -491,8 +491,9 @@ whole layer, or only characters `range: [start, end]` (character indices): chara work on the selected text; `text.endEdit` commits (an empty Type-tool layer is removed). Fonts: `text.fonts` (MCP `list_fonts`) lists every family a text layer can use, bundled and installed, with its -styles, origin (`bundled`, `system`, `user`) and own-language name (`{"query":"gothic"}` filters -by name; `{"rescan":true}` first picks up fonts installed since launch). Pass a listed `family` and +styles, origin (`bundled`, `system`, `user`), `variable` (it has variation axes) and own-language name +(`{"query":"gothic"}` filters by name, `{"variable":true}` keeps only variable fonts, like the font +menu's Variable Fonts filter; `{"rescan":true}` first picks up fonts installed since launch). Pass a listed `family` and one of its `styles` to `layer.setText {"font", "style"}`; `text.fontFeatures` says which OpenType options a font draws with its own glyphs. A tool with its own font library can hand it over without installing it: folders listed in diff --git a/docs/effects-parity.md b/docs/effects-parity.md index b57b84b59..5f26587cd 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -16,7 +16,7 @@ After Effects', and does the preset library exist. The per-effect catalogue is t | On the GPU | **280** (92%) | Measured: registry flags; GPU vs CPU agreement tested per effect | | 32-bit float, no clamping | 306 | Measured: registry flags | | **Rendered output matches After Effects** | **unmeasured** | No effect is compared with an After Effects render yet. `effectcraft_effects::fidelity::compare_frames` exists for the corpus ([fidelity/README.md](fidelity/README.md)); simulation effects are pinned by golden hashes of our own output (M13.27) | -| Animation presets | **8** original text-animator presets against After Effects' **621** `.ffx` presets | Measured: `crates/engine/presets/text_animators.json`; the After Effects `Presets` folder | +| Animation presets | **31** original presets (8 text-animator, 23 Transitions) against After Effects' **621** `.ffx` presets | Measured: `crates/engine/presets/text_animators.json`, `crates/engine/presets/transitions.json`; the After Effects `Presets` folder | | 26.x additions | Curl Noise (26.3) present (#463 open); effect colour labels and the rebuilt Effect Controls (26.5) missing | Release notes | **Breadth ≈ 85%** (presets weigh in; the effects themselves are ≈ 100%). **Ready ≈ 40%**: until a diff --git a/docs/effects.md b/docs/effects.md index e498cf1ae..0fac41ed9 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -306,7 +306,7 @@ Effects are addressed by id (`ec..`) or display name in commands | 3D Glasses | `ec.perspective.3dglasses` | 8 | GPU | 32 | Implemented | | Bevel Alpha | `ec.perspective.bevelalpha` | 4 | GPU | 32 | Implemented | | Bevel Edges | `ec.perspective.beveledges` | 4 | GPU | 32 | Implemented | -| CC Cylinder | `ec.perspective.cccylinder` | 12 | GPU | 32 | Implemented | +| CC Cylinder | `ec.perspective.cccylinder` | 15 | GPU | 32 | Implemented | | CC Environment | `ec.perspective.ccenvironment` | 4 | GPU | 32 | Implemented | | CC Sphere | `ec.perspective.ccsphere` | 14 | GPU | 32 | Implemented | | CC Spotlight | `ec.perspective.ccspotlight` | 8 | GPU | 32 | Implemented | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 66f18b47e..ccc9fbccb 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -44,7 +44,7 @@ trips and renders, checked by tests, not against After Effects), **partial** (na | OpenEXR (multi-layer, Cryptomatte) | R/W | yes | yes (32-bit sequence) | good; HTJ2K compression fails (#482); EXRs without an unnamed RGB layer decode twice (#474) | | Photoshop PSD / PSB | R (footage, composition) / W (layers, sequence) | yes (layers, groups, masks, styles, smart objects) | Save Frame As ▸ Photoshop Layers; no Photoshop sequence output | good, from Adobe's published spec | | Illustrator `.ai`, PDF, EPS | R | yes (vector, Continuously Rasterize, Create Shapes) | — | partial: no JPX / JBIG2 images, no Illustrator procset EPS | -| SVG | R | yes | — | good; pasting SVG/AI as shape layers (26.3) missing | +| SVG | R | yes | — | good; SVG markup on the clipboard pastes as a shape layer (26.3, #738); Illustrator's own clipboard data (PDF/AI) doesn't | | GIF | R/W | yes | yes (animated) | good | | BMP | R | yes | — | good | | WebP | — | yes | — | beyond After Effects | @@ -80,7 +80,7 @@ trips and renders, checked by tests, not against After Effects), **partial** (na | OBJ / MTL | R | yes | good | | FBX | R (`usdFbx`) | none | gap 13 | | USD / USDZ | R (`usd_plugins`) | none | gap 13 | -| STL | R (`usdStl`) | none | gap 13 | +| STL | R (`usdStl`) | yes (binary and ASCII, VisCAM and Magics facet colours; Z-up turned to Y-up) | good | | Substance 3D materials `.sbsar` | R (26.0) | none | gap 13 | ## Interchange and data @@ -107,7 +107,7 @@ encoders ([hardware-parity.md](hardware-parity.md)), and rendering safely over a ## Estimate **≈ 130–230 h**: `.aep` / `.aepx` import 80–160 h (after the owner decision), wiring TGA / DPX / -HDR / MXF / AVI / DNx import and DPX/TGA output 10–20 h, FBX / USD / STL and Substance 25–45 h, +HDR / MXF / AVI / DNx import and DPX/TGA output 10–20 h, FBX / USD and Substance 25–45 h, relinking 6–12 h, `.mogrt`-compatible templates 15–30 h (partly parallel with the rest). ## Revision history diff --git a/docs/gaps.md b/docs/gaps.md index acb22a630..c261e0055 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -63,13 +63,13 @@ are grouped by area; the issue tracker has the rest. | 4 | **No CI gate on pull requests** | `.github/workflows` has release, packaging-lint, FreeBSD and Windows ARM64 jobs only; `cargo xtask ci` runs locally | Regressions reach main unseen; 49 community PRs landed in one batch (#598) | 4–8 h | G3 · this file | | 5 | **Playback and scrubbing feel** | #595 stutter on cached frames, #304, #329 slow scrubbing, #554 / #504 / #335 audio late or crackling, #586 puppet lag | Preview is where motion designers spend their day | 25–50 h | G5 · [hardware-parity.md](hardware-parity.md) | | 6 | **Linux and Windows interaction bugs** | #491 multi-layer edits, #488 rename arrows, #493, #492, #467 shortcuts, #268 drag and drop, #320 workspaces not persisting, #302 docking, #613 Direct3D 12 compositor, #596 GPU not detected | Many reports come from Linux; some Windows GPUs fail to start the compositor | 25–45 h | G2 · [ui-parity.md](ui-parity.md) | -| 7 | **Animation presets: 8 against 621** | After Effects ships 621 `.ffx` presets (Behaviors, Backgrounds, Shapes, Synthetics, Text, Transitions…); we ship 8 original text-animator presets and read only our `.ecpreset` | Presets are how many designers start; tutorials assume them | 20–40 h (original presets; `.ffx` reading is out of clean-room scope) | G8 · [effects-parity.md](effects-parity.md) | +| 7 | **Animation presets: 31 against 621** | After Effects ships 621 `.ffx` presets (Behaviors, Backgrounds, Shapes, Synthetics, Text, Transitions…); we ship 31 original presets (8 text-animator, 23 Transitions: wipes, clock and fan reveals, blinds, dissolves, card flips, CC wipes, fades; #757) and read only our `.ecpreset`. Missing: Behaviors, Backgrounds, Shapes, Synthetics, Image and more Text | Presets are how many designers start; tutorials assume them | 15–35 h (original presets; `.ffx` reading is out of clean-room scope) | G8 · [effects-parity.md](effects-parity.md) | | 8 | **Mask and shape bugs** | #510, #513, #527 (first Intersect/Darken mask), #509 Roto Brush size, #303 Mesh Warp, #553 pen tools on motion paths | Masks are core compositing | 10–20 h | G2 · [target-app-parity.md](target-app-parity.md) | | 9 | **Effect behaviour bugs** | #580, #526 Fractal reset, #514 Apply Color LUT, #463 Curl Noise, #369 Motion Tile output size, #473 Cryptomatte selection, #474 multi-layer EXR decoded twice, #534–#536 community fixes pending | Each one breaks a look someone built | 10–20 h | G1/G2 · [effects-parity.md](effects-parity.md) | -| 10 | **Colour pipeline differences** | #494 32-bpc blend modes clamp and apply opacity differently; #495 no Working Gamma 2.4; #340 ProRes 4444 tagged bt709 but sRGB-encoded; ACES 2.0 / OCIO 2.5 configs (26.5) missing; #546 / #547 viewer colour | Wrong colour in delivery is a client-visible failure | 15–30 h | G1 · [file-format-parity.md](file-format-parity.md) | +| 10 | **Colour pipeline differences** | #494 32-bpc blend modes clamp and apply opacity differently; #495 Working Gamma 2.4 (in, also for float footage: #750); #340 ProRes 4444 tagged bt709 but sRGB-encoded; ACES 2.0 / OCIO 2.5 configs (26.5) missing; #546 / #547 viewer colour | Wrong colour in delivery is a client-visible failure | 15–30 h | G1 · [file-format-parity.md](file-format-parity.md) | | 11 | **Text rendering and layout** | #453 CJK text in comps, #316 paragraph alignment inverted, #483 squeezed at 4:3, #417 soft at Fit, #583 font search, #550 toolbar font options | Text is in almost every motion graphic | 10–20 h | G2 · [ui-parity.md](ui-parity.md) | | 12 | **3D interaction and depth** | No 3D transform gizmos (#392, #347); Z position (#489); camera aperture (#260); reflections (#261); bevel options (#315); 3D issue reopened (#307) | Advanced 3D is After Effects' growth area since 2023 | 20–35 h | G2 · [ui-parity.md](ui-parity.md) | -| 13 | **3D model formats and materials** | After Effects 26 imports FBX, USD/USDZ, STL besides glTF and OBJ (`usd_plugins`), and Substance 3D materials with mesh displacement (26.0–26.2); we read glTF and OBJ | Product shots and 3D scenes come as FBX/USDZ | 25–45 h | — · [file-format-parity.md](file-format-parity.md) | +| 13 | **3D model formats and materials** | After Effects 26 imports FBX, USD/USDZ, STL besides glTF and OBJ (`usd_plugins`), and Substance 3D materials with mesh displacement (26.0–26.2); we read glTF, OBJ and STL (binary and ASCII, with facet colours), not FBX, USD/USDZ or Substance | Product shots and 3D scenes come as FBX/USDZ | 25–45 h | — · [file-format-parity.md](file-format-parity.md) | | 14 | **Object Matte** (26.2, disk cache in 26.5) | Not present; MobileSAM is already in `effectcraft-segment` and could back it | The new one-click roto; Roto Brush covers part of it | 15–30 h | G7 · [target-app-parity.md](target-app-parity.md) | | 15 | **Hardware video decode and encode** | No VideoToolbox, NVENC, Quick Sync or VA-API; #498 asks for it; FilmCraft has VideoToolbox / NVENC / VA-API paths to borrow | 4K playback and export speed | 25–45 h | G5/G6 · [hardware-parity.md](hardware-parity.md) | | 16 | **Localization depth** | Menus in 6 languages; panels in 4; effect names and ~1,650 effect parameter names untranslated; French, German, Korean, Italian, Russian (After Effects ships them) absent; #599 Hangul boxes on macOS | After Effects is used heavily in Japan, China, Korea, Brazil, Europe | 60–110 h + native review | G9 · [localization-parity.md](localization-parity.md) | @@ -77,11 +77,11 @@ are grouped by area; the issue tracker has the rest. | 18 | **Relative paths and relinking** | Projects store absolute footage paths; no relink when a project moves | Moving or sharing a project breaks it | 6–12 h | G4 · [file-format-parity.md](file-format-parity.md) | | 19 | **Plug-in ecosystem** | No After Effects SDK hosting (#600, #327 OpenFX, #334, #376 plug-in manager); our WebAssembly API has no third-party plug-ins | Many pro projects depend on Trapcode, Element, Saber… | 40–80 h for API growth; SDK hosting needs an owner decision | G8 · [scripting-parity.md](scripting-parity.md) | | 20 | **Multi-monitor and video output** | #486 multi-screen, floating panels on other screens, Mercury Transmit to video hardware (we have a second preview window only) | Two-screen setups are standard for professionals | 10–20 h | G2 · [hardware-parity.md](hardware-parity.md) | -| 21 | **Small 26.x features** | Proportional Scrubbing (26.2), copy frame to clipboard and paste SVG/AI as shapes (26.3), variable-font filter (26.3), percentage guides and effect colour labels (26.5), the rebuilt Effect Controls (26.5) | Users of the current version expect them | 15–25 h | — · [ui-parity.md](ui-parity.md) | +| 21 | **Small 26.x features** | Proportional Scrubbing (26.2), pasting Illustrator's own clipboard data as shapes (26.3; SVG markup on the clipboard pastes as a shape layer, #738), variable-font filter (26.3), percentage guides and effect colour labels (26.5), the rebuilt Effect Controls (26.5) | Users of the current version expect them | 15–25 h | — · [ui-parity.md](ui-parity.md) | | 22 | **Encoder efficiency** | HEVC / AV1 have no B-frames, multi-reference or SAO / CDEF; Opus no FEC / DTX; #372 uncompressed single-file output; #325 render over existing file | Larger files than After Effects + Media Encoder | 20–40 h | G6 · [file-format-parity.md](file-format-parity.md) | | 23 | **Essential Graphics interchange** | Templates are `.ectemplate`, not `.mogrt`; #338 Scale exposes X and Y; #611 FilmCraft rejects exported `.fcgt` | Premiere editors can't use our templates | 15–30 h | — · [file-format-parity.md](file-format-parity.md) | | 24 | **Timeline depth requests** | #601 expand precomps in place, #602 reorderable render stack, #604 guide-only effects, #355 bulk keyframes, #425 keyframe confusion, #428 more shortcuts | Power-user speed | 15–30 h | G2 · [ui-parity.md](ui-parity.md) | -| 25 | **Accessibility** | AccessKit is enabled in eframe and tests read focus labels, but no screen-reader pass, keyboard navigation audit or contrast check | Excludes users; After Effects supports screen readers poorly too | 15–30 h | G9 · [ui-parity.md](ui-parity.md) | +| 25 | **Accessibility** | AccessKit is enabled in eframe and tests read focus labels; the Project panel has After Effects' arrow-key navigation and named, stateful AccessKit nodes (#776), but the other panels have had no screen-reader pass or keyboard navigation audit, and there is no contrast check | Excludes users; After Effects supports screen readers poorly too | 15–30 h | G9 · [ui-parity.md](ui-parity.md) | ## Where we're going: workstreams in priority order @@ -129,6 +129,19 @@ The most important missing piece: it turns every other estimate here into a meas the prompt, with their layers, in one undo step, for single items, multiple selections and folder contents. Nothing happens with an empty selection, in other panels, while typing or with a dialog open, and the next Delete still asks. +- Regression evidence for [#612](https://github.com/storytold/effectcraft/issues/612): the + `ui_gradient_value` and `ui_gradient_editor` input tests, checked headlessly on Windows, open + the Gradient Editor from the Timeline's "Edit Gradient…" on a shape layer's Gradient Fill. They + check that OK sets the edited stops (added, dragged, recoloured in the picker, down to a single + colour stop) in one undo step, at the composition time the editor opened (also on offset and + stretched layers), and that OK without a change, Cancel and Escape change nothing; Escape with + the picker open closes only the picker. On an animated gradient only the keyframe at that time + changes, with a warning when its stop counts no longer match a neighbour's. Delete never + removes the last stop, a layer or a stop while typing; a stop moved past another by its + Location keeps its selection and fields; a layer deleted while editing is reported; and + `prop.editGradient` is an error for a target that isn't a gradient. Unit tests in `keyframe` + and `gradient_editor` cover per-track sampling (no double opacity), the typed one-stop round + trip, and stops staying in order, and selected, while they move. - Regression evidence for the reports of 6 October, checked headlessly on Windows: real pointer drags in `ui_drag_drop` (Project items into the Timeline between layers and at a time, and onto the viewer; an effect onto a layer in the viewer: #85, #88, #89), `ui_render_queue` (a template diff --git a/docs/plugins.md b/docs/plugins.md index d24c078b8..8583d8a5c 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -14,7 +14,7 @@ Guide): | Effect plug-ins (C/C++ SDK, `.aex` / `.plugin`) | [Effect plug-ins](#effect-plug-ins): WebAssembly modules (plug-in API v1) or Rust; the `Plug-ins` folder next to the settings loads at start-up | Different ABI: After Effects SDK plug-ins can't run (G8) | | Scripts folder: its scripts are listed in File ▸ Scripts | `Scripts/` in the settings folder (File ▸ Scripts ▸ Install Script File… copies a script there), listed in File ▸ Scripts with the bundled samples | Same | | File ▸ Scripts ▸ Run Script File… (`.jsx`, `.jsxbin`) | `.jsx` / `.js` | Gap: `.jsxbin` (an undocumented binary encoding) | -| ExtendScript preprocessor directives (`#include`, `#target`, `#targetengine`) | Not understood: a script that starts with one doesn't parse | Gap | +| ExtendScript preprocessor directives (`#include`, `#target`, `#targetengine`) | `#include` / `//@include` / `#includepath` inline files relative to the script (once each, depth-limited, file gate applies); `#target`, `#targetengine`, `#strict` are ignored | Same | | `Scripts/Startup` and `Scripts/Shutdown`: run in alphabetical order when the app starts and quits | Not run | Gap | | `Scripts/ScriptUI Panels`: listed at the bottom of the Window menu, dockable like the app's own panels, `this` is the panel | `Scripts/ScriptUI Panels/` in the settings folder (File ▸ Scripts ▸ Install ScriptUI Panel… copies one there), listed at the bottom of the Window menu, docked like any panel, `this` is the panel; the panels open when the app quits open again at the next launch | Same. Where a panel was docked isn't kept: workspace layouts aren't saved between launches | | Keyboard shortcuts for scripts (the Keyboard Shortcuts editor lists the scripts of the Scripts folder) | Shortcuts bind to commands and menu entries; the File ▸ Scripts and Window menu entries of scripts aren't offered | Gap | diff --git a/docs/preferences.md b/docs/preferences.md index 157eca99f..42acd9318 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -59,8 +59,9 @@ Commands (CLI, MCP, control channel): - `appearance.lightTheme`: Light Theme (`light`), shown in light appearance. Settings ▸ Appearance shows the mode above a light and a dark theme card, each with a preview of the EffectCraft window in that theme. - `appearance.theme`: the single theme from before appearance modes, kept for older settings files and automation. A saved file with only `theme` loads as a fixed Dark or Light mode showing it; setting it (`prefs.set`, `ui.set {"theme": …}`, `view.theme.dark|darker|light`) selects that theme and fixes the mode to its family. While the mode is Dark or Light it mirrors the theme in use. - `appearance.brightness`: Brightness -- `appearance.uiScale`: UI Scale, the size of the whole interface (75–200 %, on top of the - display's own scaling), applied as soon as it is chosen +- `appearance.uiScale`: UI Scale, the size of the whole interface (any whole percentage from + 75 to 200, on top of the display's own scaling), dragged or typed; it applies as soon as it is + dragged or entered - `appearance.useLabelColorForHandles`: Use Label Color for Layer Handles and Paths - `grids.gridColor`: Color - `grids.gridSpacing`: Gridline Every diff --git a/docs/scripting-parity.md b/docs/scripting-parity.md index fef88efbc..d95c12450 100644 --- a/docs/scripting-parity.md +++ b/docs/scripting-parity.md @@ -18,11 +18,12 @@ our own scripts; no real-world script library has been run against both apps. | Expression object model | `thisComp`, `thisLayer`, `wiggle`, `loopOut`, `valueAtTime`, `sampleImage`, `footage()`, text style API… | Same members, from the reference | good | `crates/expr/src/tests.rs`; behaviour of edge cases unmeasured (G1) | | Expression editing | Inline field, error bar, pick whip, Expression Language menu, external editors | Inline field that grows, error bar, pick whips (property and value), language menu | partial | External editor support (#364) | | Expression performance | Cached, multi-frame | Per-thread boa contexts | unknown | No benchmark | -| Scripting object model | `app`, `Project`, items, layers, properties, keys, eases, render queue, `TextDocument`, `Shape`, markers, `File`/`Folder`, `Socket`, `app.settings` | Same classes, edits run through undoable engine commands; match names table | good | `crates/script` tests (≈ 10 test files) | +| Scripting object model | `app`, `Project`, items, layers, properties, keys, eases, render queue, `TextDocument`, `Shape`, markers, `File`/`Folder`, `Socket`, `app.settings` | Same classes, edits run through undoable engine commands; match names table; layers, groups and properties are callable (`layer("Effects")(1)` is `.property(...)`) | good | `crates/script` tests (≈ 10 test files) | | ScriptUI | Dialogs, palettes, dockable panels, resource strings, `onDraw` / ScriptUIGraphics | Same, dockable panels reopen at launch | good | `ui_scriptui`, `tests_ui*` | +| `app.scheduleTask` / `cancelTask` | One-shot and repeating tasks (polling, panel refresh) until `cancelTask` | Same: repeating tasks run every `delay` ms (at least 50 ms, at most 64 pending per script) until `cancelTask`, until they throw (reported once, then cancelled), or until their owner goes: a script's last window closing, or the project closing for scripts without windows; frontends tick them every frame, agents with `script.runTasks` | good | `tests_tasks.rs` (#773); one-shot tasks of a script file still run right after it, not after their delay | | Scripts menu, ScriptUI Panels folder | yes | yes | same | [plugins.md](plugins.md) | | Startup / Shutdown folders | yes | not run | missing | plugins.md | -| `#include`, `#target` directives | yes | a script starting with one doesn't parse | missing | plugins.md; common in real scripts | +| `#include`, `#target` directives | yes | `#include` / `//@include` / `#includepath` inline files relative to the including script (once each, nested at most 32 deep, under the scripts' file gate; errors name the included file and line); `#target`, `#targetengine`, `#strict` are ignored | good | `tests_include.rs` (#759); installed scripts and panels have no folder of their own, so they include from the project's folder or absolute paths | | `.jsxbin` | yes | no | out of scope | No public specification | | Keyboard shortcuts for scripts | yes | no | missing | plugins.md | | Command-line rendering `aerender` | yes | `effectcraft-cli render`, `script`, `exec` | same purpose | `apps/effectcraft-cli` | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index fd807ebc3..d5337a417 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -18,7 +18,7 @@ feel and platform differences, and the 26.x interface changes are not in yet. | Menu bar | After Effects' menus, order, submenus and labels; native on macOS; keyboard-operable in-window bar | 95% | 587 entries in `crates/engine/src/menus.rs`; Adobe-service entries omitted by design; `ui_menus` tests (#279) | | Panels and workspaces | Every After Effects panel; all standard workspaces; drag-to-dock, tabs, floating panels, maximise | 75% | `ui_tabs`, `ui_panels`; dropping a panel on a stacked panel doesn't make a tab (#302); stacked-panel headers (#301); saved workspaces don't persist (#320); no panels on a second monitor (#486) | | Timeline | Switches, modes, parent & link, twirl-downs, reveal shortcuts (U, UU, P, S, R, T, A, E, M, F, Alt+Shift…), pick whips, graph editor, work area, markers | 75% | `ui_keyframes`, `ui_timeline_*`; Graph Editor jitter and hidden buttons (#456, #457); rotation can't go negative by dragging (#569); bulk keyframing (#355); expand precomps in place (#601); Proportional Scrubbing (26.2) missing | -| Composition viewer | Selection, handles with direction-aware cursors, rulers, guides, grids, snapping, channels, exposure, region of interest, snapshots, 3D views, Spacebar hand | 70% | `ui_viewer`, `ui_viewers`; Cmd/Ctrl +/− doesn't zoom (#570); inaccurate snapping (#352); anchor point grab on Linux (#492); no 3D transform gizmos (#392, #347); percentage guides (26.5); copy frame to clipboard (26.3) | +| Composition viewer | Selection, handles with direction-aware cursors, rulers, guides, grids, snapping, channels, exposure, region of interest, snapshots, 3D views, Spacebar hand | 70% | `ui_viewer`, `ui_viewers`; Cmd/Ctrl +/− doesn't zoom (#570); inaccurate snapping (#352); anchor point grab on Linux (#492); no 3D transform gizmos (#392, #347); percentage guides (26.5); Copy Frame to Clipboard (26.3) is in the Composition menu and the viewer's context menu | | Tools panel | All After Effects tools; long-press flyouts; Tool Creates Shape / Mask; Fill / Stroke options | 80% | #213, #227 fixed; pen and vertex tools on motion paths (#553) | | Nudging and modifiers | Arrow nudge (1 px, Shift 10 px), Alt/Option duplicates and slips, Shift constrains | 75% | #290 nudging fixed; Linux multi-layer edits (#491) | | Numeric entry | Scrubbable values, typed entry with Tab / Shift+Tab, angle revolutions, Escape cancels, non-finite rejected | 85% | `ui_angles`, `ui_inline_edit` (#93, #141, #361); rename arrows on Linux (#488) | @@ -26,11 +26,11 @@ feel and platform differences, and the 26.x interface changes are not in yet. | Context menus | Layer, property, Project, Timeline empty area, work area, labels with colours | 80% | `ui_blank_context_menus`; #227, #290, #407 fixed | | Keyboard shortcuts | After Effects' defaults; Keyboard Shortcuts editor that rebinds and saves sets | 75% | Shortcut editor in Settings; adding shortcuts fails on Linux (#467); requests for more (#428, #336); no shortcuts for scripts | | Drag and drop | Project → Timeline / viewer / New Comp / Render Queue; effects onto layers; files from the OS | 65% | `ui_drag_drop`; OS drops fail on Wayland (winit) and some Linux setups (#268); multi-file drops (#552, #351); crash dragging over the import dialog (#511) | -| Text editing on canvas | Point and paragraph text, per-character styles, Character / Paragraph panels, OpenType and variable axes | 70% | `ui_text_edit`; paragraph alignment inverted (#316); font search by typing (#583); toolbar font options (#550); variable-font filter (26.3) | +| Text editing on canvas | Point and paragraph text, per-character styles, Character / Paragraph panels, OpenType and variable axes | 70% | `ui_text_edit`, `ui_font_search`; paragraph alignment inverted (#316); font search by typing (#583); toolbar font options (#550); the font menus' variable-font filter (26.3) landed (#741) | | Preview feel | Spacebar / Numpad 0 previews, RAM preview, cache bars, adaptive resolution | 55% | `ui_ram_preview`, `ui_live_preview`; stutter (#595, #304), scrubbing lag (#329), Shift+Space default (#551) | | Home screen, Learn, templates | Home with recent projects, template gallery, interactive Learn tutorials | 70% | Layout nitpicks (#581); Home literals untranslated (#574) | | Quick Apply (26.2) | Search to apply effects, presets or commands | partial | A command palette exists; not After Effects' Quick Apply | -| Accessibility | AccessKit enabled; focus labels tested | 20% | No screen-reader pass, keyboard-navigation audit or contrast check | +| Accessibility | AccessKit enabled; focus labels tested; Project panel keyboard navigation and accessible names (#776) | 20% | No screen-reader pass or keyboard-navigation audit of the other panels; no contrast check | | UI scale | Settings ▸ Appearance ▸ UI Scale 75–200% | beyond | After Effects follows the system's scaling (#284) | ## Estimate