Merge upstream/main into cs-ui

Keep every entry in contributors/people.toml, and translate the new menu
item (Copy Frame to Clipboard) and the five gradient-editor strings into
Czech.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Losminek
2026-10-12 09:19:44 +02:00
co-authored by Claude Opus 5.5
120 changed files with 6057 additions and 420 deletions
+8 -4
View File
@@ -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:
+5 -7
View File
@@ -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:
+2
View File
@@ -65,6 +65,8 @@ Screenshots of EffectCraft itself, rendered headlessly, showing only procedurall
`crates/engine/src/templates.rs`.
- Text animator presets (Typewriter, Fade Up Characters, Bounce In Words, Tracking In, Scramble, Blur In, Jitter,
Drop In Lines): `crates/engine/presets/text_animators.json` (sidecar `.attribution`).
- Transition animation presets (23: wipes, clock and fan reveals, blinds, dissolves, card flips, CC wipes, fades):
`crates/engine/presets/transitions.json` (sidecar `.attribution`).
- Sample scripts (File ▸ Scripts) and ScriptUI panel: `crates/engine/scripts/Rename Layers.jsx`,
`crates/engine/scripts/Sort Layers by In Point.jsx`, `crates/engine/scripts/Create Null at Selected Layers.jsx`,
`crates/engine/scripts/Render Queue Batch.jsx`, `crates/engine/scripts/ScriptUI Panels/Layer Tools.jsx`
Generated
+1
View File
@@ -1560,6 +1560,7 @@ dependencies = [
"effectcraft-render",
"effectcraft-time",
"serde_json",
"web-time",
]
[[package]]
+4
View File
@@ -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
+6
View File
@@ -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
+3 -2
View File
@@ -210,7 +210,7 @@ fn list_effects(b: &mut Backend, a: &Value) -> Result<Reply> {
}
fn list_fonts(b: &mut Backend, a: &Value) -> Result<Reply> {
json_reply(b.exec("text.fonts", obj(&[("query", get(a, "query")), ("rescan", get(a, "rescan"))]))?)
json_reply(b.exec("text.fonts", obj(&[("query", get(a, "query")), ("variable", get(a, "variable")), ("rescan", get(a, "rescan"))]))?)
}
/// `effect.apply` on one layer, then set parameters on the new instance by param id: one batch,
@@ -703,12 +703,13 @@ static TOOLS: &[ToolDef] = &[
},
ToolDef {
name: "list_fonts",
description: "The font families text layers can use, bundled and installed on this machine: family, styles, origin (bundled / system / user) and the name in the font's own language. Narrow with `query`; `rescan` picks up fonts installed since launch. Set one with execute_command `layer.setText {\"layer\", \"font\", \"style\"}`.",
description: "The font families text layers can use, bundled and installed on this machine: family, styles, origin (bundled / system / user), whether it is a variable font and the name in the font's own language. Narrow with `query` and `variable`; `rescan` picks up fonts installed since launch. Set one with execute_command `layer.setText {\"layer\", \"font\", \"style\"}`.",
bridge_only: false,
schema: || {
schema(
json!({
"query": {"type": "string", "description": "Only families whose name (English or own-language) contains this text."},
"variable": {"type": "boolean", "description": "Only variable fonts (families with variation axes)."},
"rescan": {"type": "boolean", "description": "Look for fonts installed since launch first."}
}),
&[],
+51 -3
View File
@@ -323,11 +323,40 @@ fn wild(pat: &str, name: &str) -> bool {
}
}
/// Parse a selection: comma-separated names (optionally quoted) or `<hex>` hashes / wildcards.
/// Split a selection at its commas. A name in double or single quotes stays whole, commas
/// included (Blender allows `Green, Glossy`), and a backslash takes the next character
/// literally (`Green\, Glossy`, `\"`). Unquoted text is trimmed; the quotes are dropped.
fn selection_names(sel: &str) -> Vec<String> {
let mut out = vec![];
let mut cur = String::new();
let mut quote: Option<char> = None;
let mut chars = sel.chars();
while let Some(c) = chars.next() {
match (c, quote) {
('\\', _) => {
if let Some(next) = chars.next() {
cur.push(next);
}
}
(c, Some(q)) if c == q => quote = None,
('"' | '\'', None) if cur.trim().is_empty() => {
cur.clear();
quote = Some(c);
}
(',', None) => out.push(std::mem::take(&mut cur).trim().to_string()),
(c, _) => cur.push(c),
}
}
out.push(cur.trim().to_string());
out
}
/// Parse a selection: comma-separated names (optionally quoted, see [`selection_names`]) or
/// `<hex>` hashes / wildcards.
pub fn selection_hashes(sel: &str, manifest: Option<&[(String, u32)]>) -> Vec<u32> {
let mut out = vec![];
for tok in sel.split(',') {
let t = tok.trim().trim_matches('"').trim_matches('\'');
for t in selection_names(sel) {
let t = t.as_str();
if t.is_empty() {
continue;
}
@@ -677,6 +706,25 @@ mod tests {
assert_eq!(selection_hashes(&hex, None), vec![crypto_hash("left")]);
}
/// #473: a name with a comma in it (Blender allows `Green, Glossy`) is selected when it is
/// quoted or its comma escaped, instead of being cut into two names that match nothing.
#[test]
fn cryptomatte_selection_keeps_quoted_names_with_commas_whole() {
let manifest = vec![("Green, Glossy".to_string(), 7), ("Green".to_string(), 1), ("Glossy".to_string(), 2), ("ring".to_string(), 3)];
let m = Some(manifest.as_slice());
assert_eq!(selection_hashes(r#""Green, Glossy""#, m), vec![7]);
assert_eq!(selection_hashes("'Green, Glossy', ring", m), vec![7, 3]);
assert_eq!(selection_hashes(r"Green\, Glossy", m), vec![7]);
assert_eq!(selection_hashes(r#"ring , "Green, Glossy""#, m), vec![3, 7]);
// Without a manifest the whole name is hashed.
assert_eq!(selection_hashes(r#""Green, Glossy""#, None), vec![crypto_hash("Green, Glossy")]);
// Plain lists, apostrophes inside names and an unclosed quote still parse.
assert_eq!(selection_hashes("Green, Glossy", m), vec![1, 2]);
assert_eq!(selection_names("Bob's mat, ring"), vec!["Bob's mat", "ring"]);
assert_eq!(selection_names(r#""Green, Glossy"#), vec!["Green, Glossy"]);
assert_eq!(selection_names(r#"say \"hi\""#), vec![r#"say "hi""#]);
}
#[test]
fn fog_and_depth_of_field_follow_depth() {
let h = scene();
+45 -6
View File
@@ -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];
+1 -1
View File
@@ -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).
+75 -12
View File
@@ -219,28 +219,63 @@ fn cylinder(ctx: &EffectCtx, mut b: Buf) -> Buf {
let (_, _, w, h) = rect;
let r = (ctx.params.f("radius") / 100.0 * w / std::f64::consts::TAU).max(0.5);
let c = b.to_px(ctx.params.v2("position"));
// Keep the pre-existing Rotation control as an additional texture spin.
let spin = ctx.params.f("rotation").to_radians();
let (rx, ry, rz) = (ctx.params.f("rotationX"), ctx.params.f("rotationY"), ctx.params.f("rotationZ"));
let tilted = rx != 0.0 || ry != 0.0 || rz != 0.0;
let m = rot_xyz(rx, ry, rz);
// The cylinder's local Y axis after rotation, in viewer coordinates.
let axis = [m[0][1], m[1][1], m[2][1]];
let a = 1.0 - axis[2] * axis[2];
let mode = ctx.params.e("render");
let sh = Shading::from(ctx);
let src = b.img.clone();
b.img.rows_mut().for_each(|(y, row)| {
let v = (y as f64 + 0.5 - c.1) / h + 0.5;
let dy = y as f64 + 0.5 - c.1;
for (x, px) in row.iter_mut().enumerate() {
let s = (x as f64 + 0.5 - c.0) / r;
if s.abs() >= 1.0 {
let dx = x as f64 + 0.5 - c.0;
if !tilted {
// Preserve exactly the mapping of projects saved before axis rotations.
let v = dy / h + 0.5;
let s = dx / r;
if s.abs() >= 1.0 {
*px = [0.0; 4];
continue;
}
let th = s.asin();
let cz = th.cos();
let front = || sh.shade(texel(&src, rect, (th + spin) / std::f64::consts::TAU + 0.5, v), [s, 0.0, cz]);
let back = || sh.shade(texel(&src, rect, (std::f64::consts::PI - th + spin) / std::f64::consts::TAU + 0.5, v), [-s, 0.0, cz]);
*px = match mode {
1 => front(),
2 => back(),
_ => over(front(), back()),
};
continue;
}
// Intersect the viewing ray (dx,dy,z) with the rotated cylindrical
// side wall: |q - dot(q,axis)*axis|^2 = radius^2.
let da = dx * axis[0] + dy * axis[1];
let bq = -da * axis[2];
let discriminant = bq * bq - a * (dx * dx + dy * dy - da * da - r * r);
if a <= 1e-10 || !discriminant.is_finite() || discriminant <= 0.0 {
*px = [0.0; 4];
continue;
}
let th = s.asin();
let cz = th.cos();
let front = || {
let u = (th + spin) / std::f64::consts::TAU + 0.5;
sh.shade(texel(&src, rect, u, v), [s, 0.0, cz])
};
let back = || {
let u = (std::f64::consts::PI - th + spin) / std::f64::consts::TAU + 0.5;
sh.shade(texel(&src, rect, u, v), [-s, 0.0, cz])
let root = discriminant.sqrt();
let face = |z: f64, back: bool| {
// Rᵀ turns the intersection into cylinder-local coordinates.
let local = [m[0][0] * dx + m[1][0] * dy + m[2][0] * z, m[0][1] * dx + m[1][1] * dy + m[2][1] * z, m[0][2] * dx + m[1][2] * dy + m[2][2] * z];
let u = (local[0].atan2(local[2]) + spin) / std::f64::consts::TAU + 0.5;
let v = local[1] / h + 0.5;
let sign = if back { -1.0 } else { 1.0 };
let nx = local[0] / r * sign;
let nz = local[2] / r * sign;
let normal = [m[0][0] * nx + m[0][2] * nz, m[1][0] * nx + m[1][2] * nz, m[2][0] * nx + m[2][2] * nz];
sh.shade(texel(&src, rect, u, v), normal)
};
let front = || face((-bq + root) / a, false);
let back = || face((-bq - root) / a, true);
*px = match mode {
1 => front(),
2 => back(),
@@ -362,6 +397,9 @@ pub fn specs() -> Vec<EffectSpec> {
p("radius", "Radius", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)),
p("position", "Position", pt(0.5, 0.5), ParamUi::Point),
p("rotation", "Rotation", num(0.0), ParamUi::Angle),
p("rotationX", "Rotation X", num(0.0), ParamUi::Angle),
p("rotationY", "Rotation Y", num(0.0), ParamUi::Angle),
p("rotationZ", "Rotation Z", num(0.0), ParamUi::Angle),
p("render", "Render", Value::Enum(0), render()),
];
cyl.extend(shading());
@@ -527,6 +565,31 @@ mod tests {
assert_eq!(out.get(50 + 18, 10)[3], 0.0);
}
#[test]
fn cylinder_axis_rotations_affect_geometry_and_texture() {
let img = Image::filled(100, 100, [0.5, 0.5, 0.5, 1.0]);
let default = run("ec.perspective.cccylinder", &img, &[]).img;
let x_tilt = run("ec.perspective.cccylinder", &img, &[("rotationX", num(70.0))]).img;
// Tilting the finite cylinder shortens its vertical projection.
assert!(default.get(50, 10)[3] > 0.99);
assert_eq!(x_tilt.get(50, 10)[3], 0.0);
assert!(x_tilt.get(50, 50)[3] > 0.99);
let z_roll = run("ec.perspective.cccylinder", &img, &[("rotationZ", num(90.0))]).img;
// A quarter turn moves the side wall from vertical to horizontal.
assert_eq!(default.get(5, 50)[3], 0.0);
assert!(z_roll.get(5, 50)[3] > 0.99);
let mut striped = Image::filled(100, 100, [0.0, 0.0, 0.0, 1.0]);
for y in 0..100 {
for x in 0..100 {
striped.set(x, y, [x as f32 / 100.0, 0.2, 0.3, 1.0]);
}
}
let baseline = run("ec.perspective.cccylinder", &striped, &[]).img;
let spun = run("ec.perspective.cccylinder", &striped, &[("rotationY", num(90.0))]).img;
assert!((baseline.get(50, 50)[0] - spun.get(50, 50)[0]).abs() > 0.05);
}
#[test]
fn spotlight_darkens_outside_cone() {
let img = Image::filled(80, 80, [1.0, 1.0, 1.0, 1.0]);
+70 -4
View File
@@ -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, &times)
}
fn pixel_motion_blur(ctx: &EffectCtx, b: Buf) -> Buf {
fn shutter_times(ctx: &EffectCtx) -> Vec<f64> {
let angle = if ctx.params.e("shutterControl") == 1 { ctx.params.f("shutterAngle") } else { 180.0 };
let n = ctx.params.f("shutterSamples").round().clamp(1.0, 64.0) as usize;
let span = angle.clamp(0.0, 720.0) / 360.0 / ctx.fps();
let times: Vec<f64> = (0..n).map(|i| ctx.time - span * 0.5 + if n > 1 { span * i as f64 / (n - 1) as f64 } else { span * 0.5 }).collect();
mean_of(ctx, b, &times)
(0..n).map(|i| ctx.time - span * 0.5 + if n > 1 { span * i as f64 / (n - 1) as f64 } else { span * 0.5 }).collect()
}
fn pixel_motion_blur(ctx: &EffectCtx, b: Buf) -> Buf {
match pmb_plan(ctx).as_ref().map(TimewarpPlan::render_cpu) {
Some(blur) => blur,
None => mean_of(ctx, b, &shutter_times(ctx)),
}
}
/// Pixel Motion Blur as a [`TimewarpPlan`]: each shutter instant is decoded between the two
/// source frames it falls between along a measured motion field (the same synthesis Timewarp
/// uses), and the instants average into the blur. Vector Detail sets the spacing of the motion
/// vectors, so a coarse grid follows one big motion and a fine grid separates neighbouring
/// ones (#535). `None` without a host — the caller then falls back. Shared by the CPU effect
/// and the GPU compositor (`fx_time`), which renders the plan like Timewarp's.
pub fn pmb_plan(ctx: &EffectCtx) -> Option<TimewarpPlan> {
ctx.env.host?;
let fps = ctx.fps();
let samples = shutter_times(ctx);
let n = samples.len();
let mut times = Vec::new();
for &s in &samples {
let (f0, _) = tw_split(s, fps);
times.push(f0 / fps);
times.push((f0 + 1.0) / fps);
}
let fr = fetch(ctx, &times)?;
let idx = |t: f64| times.iter().position(|q| (q - t).abs() < 1e-9).unwrap_or(0);
let mut pm = PixelMotion::from(ctx, fr.grid.scale);
// PMB's own Vector Detail: 0 → one vector per 32 px, 100 → one per 2 px.
let detail = (ctx.params.f("vectorDetail") / 100.0).clamp(0.0, 1.0);
pm.tile = (32.0 * (1.0f64 / 16.0).powf(detail) * fr.grid.scale).round().max(2.0) as u32;
let mut flows: Vec<FlowEntry> = vec![];
let mut steps = vec![];
let w = 1.0 / n as f32;
for &s in &samples {
let (f0, frac) = tw_split(s, fps);
let (i0, i1) = (idx(f0 / fps), idx((f0 + 1.0) / fps));
let step = if frac < 1e-4 {
TwStep::Whole(i0)
} else {
let flow = match flows.iter().position(|fl| fl.key == f0 as i64) {
Some(p) => p,
None => {
let reach = (fr.grid.img.width.max(fr.grid.img.height) / 4).clamp(8, 64);
flows.push(FlowEntry { key: f0 as i64, flow: timewarp_flow_search(&fr.imgs[i0], &fr.imgs[i1], &pm, reach as i32) });
flows.len() - 1
}
};
TwStep::Motion { a: i0, b: i1, w: frac, flow }
};
steps.push((step, w));
}
Some(TimewarpPlan { grid: fr.grid, frames: fr.imgs, steps, flows: flows.into_iter().map(|f| f.flow).collect(), pm })
}
/// A measured flow with the source frame pair it came from (plans cache one per pair).
struct FlowEntry {
key: i64,
flow: Flow,
}
#[cfg(test)]
+255
View File
@@ -0,0 +1,255 @@
{
"format": "effectcraft.animationPresets/1",
"notes": "Original EffectCraft transition presets (MIT OR Apache-2.0), written from scratch on EffectCraft's own Transition effects. Key times are seconds from the current time (layer time); effect paths are the effect's parameter ids; layer paths are property paths such as transform/opacity.",
"presets": [
{
"id": "fadeIn",
"name": "Fade In",
"category": "Transitions",
"direction": "in",
"description": "The layer fades up from transparent over half a second.",
"layer": { "keys": { "transform/opacity": [[0, 0], [0.5, 100]] }, "ease": true }
},
{
"id": "fadeOut",
"name": "Fade Out",
"category": "Transitions",
"direction": "out",
"description": "The layer fades down to transparent over half a second.",
"layer": { "keys": { "transform/opacity": [[0, 100], [0.5, 0]] }, "ease": true }
},
{
"id": "wipeInFromLeft",
"name": "Wipe In from Left",
"category": "Transitions",
"direction": "in",
"description": "A soft edge sweeps across from the left and reveals the layer.",
"effects": [
{ "effect": "ec.transition.linearwipe", "set": { "angle": 270, "feather": 40 }, "keys": { "completion": [[0, 100], [1, 0]] }, "ease": true }
]
},
{
"id": "wipeInFromRight",
"name": "Wipe In from Right",
"category": "Transitions",
"direction": "in",
"description": "A soft edge sweeps across from the right and reveals the layer.",
"effects": [
{ "effect": "ec.transition.linearwipe", "set": { "angle": 90, "feather": 40 }, "keys": { "completion": [[0, 100], [1, 0]] }, "ease": true }
]
},
{
"id": "wipeInFromTop",
"name": "Wipe In from Top",
"category": "Transitions",
"direction": "in",
"description": "The layer is revealed from the top edge downwards.",
"effects": [
{ "effect": "ec.transition.linearwipe", "set": { "angle": 0, "feather": 30 }, "keys": { "completion": [[0, 100], [0.9, 0]] }, "ease": true }
]
},
{
"id": "wipeInFromBottom",
"name": "Wipe In from Bottom",
"category": "Transitions",
"direction": "in",
"description": "The layer is revealed from the bottom edge upwards.",
"effects": [
{ "effect": "ec.transition.linearwipe", "set": { "angle": 180, "feather": 30 }, "keys": { "completion": [[0, 100], [0.9, 0]] }, "ease": true }
]
},
{
"id": "diagonalSweepIn",
"name": "Diagonal Sweep In",
"category": "Transitions",
"direction": "in",
"description": "A wide, very soft edge sweeps in from the bottom-left corner.",
"effects": [
{ "effect": "ec.transition.linearwipe", "set": { "angle": 45, "feather": 160 }, "keys": { "completion": [[0, 100], [1.2, 0]] }, "ease": true }
]
},
{
"id": "wipeOutToRight",
"name": "Wipe Out to Right",
"category": "Transitions",
"direction": "out",
"description": "A soft edge travels left to right and takes the layer away.",
"effects": [
{ "effect": "ec.transition.linearwipe", "set": { "angle": 90, "feather": 40 }, "keys": { "completion": [[0, 0], [1, 100]] }, "ease": true }
]
},
{
"id": "clockReveal",
"name": "Clock Reveal",
"category": "Transitions",
"direction": "in",
"description": "A clock hand sweeps once around the centre and reveals the layer.",
"effects": [
{ "effect": "ec.transition.radialwipe", "set": { "wipe": 1, "feather": 6 }, "keys": { "completion": [[0, 100], [1.2, 0]] }, "ease": true }
]
},
{
"id": "fanOpen",
"name": "Fan Open",
"category": "Transitions",
"direction": "in",
"description": "The layer opens like a fan, from the top down both sides.",
"effects": [
{ "effect": "ec.transition.radialwipe", "set": { "wipe": 2, "startAngle": 180, "feather": 10 }, "keys": { "completion": [[0, 100], [1, 0]] }, "ease": true }
]
},
{
"id": "clockWipeOut",
"name": "Clock Wipe Out",
"category": "Transitions",
"direction": "out",
"description": "A clock hand sweeps once around the centre and removes the layer.",
"effects": [
{ "effect": "ec.transition.radialwipe", "set": { "feather": 6 }, "keys": { "completion": [[0, 0], [1.2, 100]] }, "ease": true }
]
},
{
"id": "blindsOpen",
"name": "Blinds Open",
"category": "Transitions",
"direction": "in",
"description": "Vertical slats widen until the layer is whole.",
"effects": [
{ "effect": "ec.transition.venetian", "set": { "direction": 0, "width": 60, "feather": 4 }, "keys": { "completion": [[0, 100], [0.9, 0]] }, "ease": true }
]
},
{
"id": "slatsDown",
"name": "Slats Down",
"category": "Transitions",
"direction": "in",
"description": "Horizontal slats thicken until the layer is whole.",
"effects": [
{ "effect": "ec.transition.venetian", "set": { "direction": 90, "width": 36, "feather": 3 }, "keys": { "completion": [[0, 100], [0.8, 0]] } }
]
},
{
"id": "blindsClose",
"name": "Blinds Close",
"category": "Transitions",
"direction": "out",
"description": "Vertical slats narrow until the layer is gone.",
"effects": [
{ "effect": "ec.transition.venetian", "set": { "direction": 0, "width": 60, "feather": 4 }, "keys": { "completion": [[0, 0], [0.9, 100]] }, "ease": true }
]
},
{
"id": "pixelDissolveIn",
"name": "Pixel Dissolve In",
"category": "Transitions",
"direction": "in",
"description": "Small hard-edged blocks pop in at random until the layer is whole.",
"effects": [
{
"effect": "ec.transition.blockdissolve",
"set": { "blockWidth": 6, "blockHeight": 6, "softEdges": false },
"keys": { "completion": [[0, 100], [1.2, 0]] }
}
]
},
{
"id": "mosaicFadeIn",
"name": "Mosaic Fade In",
"category": "Transitions",
"direction": "in",
"description": "Large soft tiles fade in at random.",
"effects": [
{
"effect": "ec.transition.blockdissolve",
"set": { "blockWidth": 48, "blockHeight": 48, "feather": 16 },
"keys": { "completion": [[0, 100], [1.2, 0]] },
"ease": true
}
]
},
{
"id": "pixelDissolveOut",
"name": "Pixel Dissolve Out",
"category": "Transitions",
"direction": "out",
"description": "Small hard-edged blocks drop out at random until the layer is gone.",
"effects": [
{
"effect": "ec.transition.blockdissolve",
"set": { "blockWidth": 6, "blockHeight": 6, "softEdges": false },
"keys": { "completion": [[0, 0], [1.2, 100]] }
}
]
},
{
"id": "cardFlipIn",
"name": "Card Flip In",
"category": "Transitions",
"direction": "in",
"description": "A grid of cards turns over, left to right, to show the layer.",
"effects": [
{
"effect": "ec.transition.cardwipe",
"set": { "rows": 4, "columns": 8, "flipAxis": 1, "flipOrder": 1, "transitionWidth": 70 },
"keys": { "completion": [[0, 100], [1.4, 0]] },
"ease": true
}
]
},
{
"id": "cardFlipOut",
"name": "Card Flip Out",
"category": "Transitions",
"direction": "out",
"description": "A grid of cards turns over, left to right, and the layer is gone.",
"effects": [
{
"effect": "ec.transition.cardwipe",
"set": { "rows": 4, "columns": 8, "flipAxis": 1, "transitionWidth": 70 },
"keys": { "completion": [[0, 0], [1.4, 100]] },
"ease": true
}
]
},
{
"id": "gridDoorsIn",
"name": "Grid Doors In",
"category": "Transitions",
"direction": "in",
"description": "Tiles open like small doors from the centre outwards.",
"effects": [
{ "effect": "ec.transition.ccgridwipe", "set": { "shape": 0, "tiles": 12, "reverse": true }, "keys": { "completion": [[0, 100], [1, 0]] }, "ease": true }
]
},
{
"id": "lensPopIn",
"name": "Lens Pop In",
"category": "Transitions",
"direction": "in",
"description": "The layer grows out of a circle that stretches the picture outwards.",
"effects": [
{ "effect": "ec.transition.ccradialscalewipe", "set": { "reverse": true }, "keys": { "completion": [[0, 100], [1, 0]] }, "ease": true }
]
},
{
"id": "lineSweepIn",
"name": "Line Sweep In",
"category": "Transitions",
"direction": "in",
"description": "A stepped, slanted edge sweeps in from the lower left.",
"effects": [
{ "effect": "ec.transition.cclinesweep", "set": { "direction": 90, "thickness": 30, "slant": 30 }, "keys": { "completion": [[0, 100], [1, 0]] } }
]
},
{
"id": "jawsCloseIn",
"name": "Jaws Close In",
"category": "Transitions",
"direction": "in",
"description": "Toothed edges close in from the top and bottom until the layer is whole.",
"effects": [
{ "effect": "ec.transition.ccjaws", "set": { "shape": 2, "height": 30, "width": 40 }, "keys": { "completion": [[0, 100], [1, 0]] }, "ease": true }
]
}
]
}
@@ -0,0 +1,7 @@
asset: transitions.json
title: EffectCraft Transitions animation presets (Fade In/Out, Wipe In from Left/Right/Top/Bottom, Diagonal Sweep In, Wipe Out to Right, Clock Reveal, Fan Open, Clock Wipe Out, Blinds Open/Close, Slats Down, Pixel Dissolve In/Out, Mosaic Fade In, Card Flip In/Out, Grid Doors In, Lens Pop In, Line Sweep In, Jaws Close In)
author: EffectCraft contributors
source: original work
license: MIT OR Apache-2.0
added: 2026-10-11 by vangel76 (Claude Code agent, #757)
notes: Effect choices, values and key timings written from scratch for EffectCraft on its own Transition effects; not derived from any Adobe preset (.ffx) or its values.
+177
View File
@@ -0,0 +1,177 @@
//! EffectCraft's own animation preset library (Effects & Presets ▸ * Animation Presets): the
//! Transitions category, original presets built on our Transition effects and layer opacity.
//!
//! Each preset adds effects (with values and keyframes) and/or keys layer properties, starting
//! at the current time, on every selected layer. The library is data
//! (`presets/transitions.json`), written from scratch; nothing comes from After Effects' `.ffx`
//! presets.
use effectcraft_keyframe::Keyframe;
use effectcraft_project::build::Ids;
use effectcraft_project::{Node, Property};
use effectcraft_time::Tick;
use serde_json::{Map, Value, json};
use super::text_anim::json_value;
use super::{CommandSpec, bad, has_layers, layer_mut, layers_p, str_p};
use crate::{EngineError, Result, Session, cmd, query};
/// The original transition presets (`presets/transitions.json`).
pub const TRANSITIONS_JSON: &str = include_str!("../../presets/transitions.json");
/// Keys per animated property, so a hostile inline preset can't allocate without bound.
const MAX_KEYS: usize = 256;
fn presets() -> Vec<Value> {
serde_json::from_str::<Value>(TRANSITIONS_JSON).ok().and_then(|v| v.get("presets").and_then(Value::as_array).cloned()).unwrap_or_default()
}
fn text<'a>(v: &'a Value, key: &str) -> Option<&'a str> {
v.get(key).and_then(Value::as_str)
}
/// (id, name, category) of every built-in animation preset, in library order.
pub fn preset_names() -> Vec<(String, String, String)> {
presets()
.iter()
.filter_map(|p| Some((text(p, "id")?.to_string(), text(p, "name")?.to_string(), text(p, "category").unwrap_or("Transitions").to_string())))
.collect()
}
fn find_preset(want: &str) -> Option<Value> {
presets().into_iter().find(|x| text(x, "id") == Some(want) || text(x, "name").is_some_and(|n| n.eq_ignore_ascii_case(want)))
}
/// Set `pr` to `v` (when given) and replace its keys with `keys` (`[[seconds, value], …]`,
/// seconds from `at`). Returns false when nothing in the request fits the property.
fn set_prop(pr: &mut Property, v: Option<&Value>, keys: Option<&Value>, at: Tick, ease: bool) -> bool {
let mut ok = true;
if let Some(v) = v {
match json_value(v, &pr.value) {
Some(val) => pr.value = val,
None => ok = false,
}
}
let Some(list) = keys else { return ok };
let Some(list) = list.as_array() else { return false };
let mut ks: Vec<Keyframe> = Vec::new();
for k in list.iter().take(MAX_KEYS) {
let t = k.get(0).and_then(Value::as_f64).filter(|t| t.is_finite() && t.abs() <= 86_400.0);
let (Some(t), Some(val)) = (t, k.get(1).and_then(|x| json_value(x, &pr.value))) else { return false };
let mut key = Keyframe::new(at + Tick::from_seconds_f64(t), val);
if pr.hold_only {
key = key.hold();
} else if ease {
key = key.eased();
}
ks.push(key);
}
ks.sort_by_key(|k| k.time);
ks.dedup_by_key(|k| k.time);
let Some(first) = ks.first() else { return false };
pr.value = first.value.clone();
pr.keys = ks;
ok
}
/// Apply `set` / `keys` maps to the properties under `root`; returns the paths that don't exist
/// or don't take the given values.
fn apply_maps(root: &mut effectcraft_project::PropGroup, spec: &Value, at: Tick) -> Vec<String> {
let ease = spec.get("ease").and_then(Value::as_bool).unwrap_or(false);
let empty = Map::new();
let set = spec.get("set").and_then(Value::as_object).unwrap_or(&empty);
let keys = spec.get("keys").and_then(Value::as_object).unwrap_or(&empty);
let mut skipped = Vec::new();
for (path, v) in set {
if !root.prop_mut(path).is_some_and(|pr| set_prop(pr, Some(v), None, at, false)) {
skipped.push(path.clone());
}
}
for (path, list) in keys {
if !root.prop_mut(path).is_some_and(|pr| !pr.static_only && set_prop(pr, None, Some(list), at, ease)) {
skipped.push(path.clone());
}
}
skipped
}
/// `anim.applyBuiltinPreset`: add a built-in preset's effects and keyframes to the layers.
fn apply(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "anim.applyBuiltinPreset";
let want = str_p(p, "preset").ok_or_else(|| bad(C, "`preset` id or name required"))?;
let preset = find_preset(want).ok_or_else(|| bad(C, format!("no animation preset `{want}`")))?;
let name = text(&preset, "name").unwrap_or("Preset").to_string();
let effects: Vec<(&'static effectcraft_effects::EffectSpec, Value)> = preset
.get("effects")
.and_then(Value::as_array)
.into_iter()
.flatten()
.map(|e| {
let id = text(e, "effect").unwrap_or_default();
effectcraft_effects::lookup(id).map(|spec| (spec, e.clone())).ok_or_else(|| bad(C, format!("preset `{name}` uses unknown effect `{id}`")))
})
.collect::<Result<_>>()?;
let layer_spec = preset.get("layer").cloned();
let (cid, ids) = layers_p(s, p)?;
if ids.is_empty() {
return Err(bad(C, "select a layer"));
}
let sizes: Vec<_> = {
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
ids.iter().filter_map(|id| comp.layer(*id)).map(|l| (l.id, effectcraft_render::effect_bounds(&s.project, comp, l).0)).collect()
};
let now = s.time();
let (added, skipped) = s.edit(&format!("Apply Animation Preset {name}"), None, |proj, _| {
let mut added: Vec<u64> = Vec::new();
let mut skipped: Vec<String> = Vec::new();
for (lid, size) in &sizes {
let mut next = proj.next_id;
let l = layer_mut(proj, cid, *lid)?;
let at = l.layer_time(now);
if !effects.is_empty() {
let fx = l.props.sub_mut("effects").ok_or_else(|| bad(C, "this layer type has no effects"))?;
for (spec, ej) in &effects {
let same = fx.groups().filter(|g| g.match_id == spec.id).count();
let inst = if same == 0 { spec.name.to_string() } else { format!("{} {}", spec.name, same.saturating_add(1)) };
let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), &inst, *size);
skipped.extend(apply_maps(&mut g, ej, at).into_iter().map(|path| format!("{}/{path}", spec.id)));
added.push(g.uid);
fx.children.push(Node::Group(g));
}
}
if let Some(ls) = &layer_spec {
skipped.extend(apply_maps(&mut l.props, ls, at));
}
proj.next_id = next;
}
Ok((added, skipped))
})?;
Ok(json!({"preset": text(&preset, "id"), "effects": added, "skipped": skipped}))
}
/// `anim.builtinPresets`: the library, optionally one category.
fn list(_: &mut Session, p: &Value) -> Result<Value> {
let cat = str_p(p, "category");
Ok(Value::Array(
presets()
.iter()
.filter(|x| cat.is_none_or(|c| text(x, "category").is_some_and(|k| k.eq_ignore_ascii_case(c))))
.map(|x| {
json!({
"id": x.get("id"),
"name": x.get("name"),
"category": x.get("category"),
"direction": x.get("direction"),
"description": x.get("description"),
})
})
.collect(),
))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("anim.applyBuiltinPreset", "Apply Built-in Animation Preset", [], None, "{preset: id|name (see anim.builtinPresets), layers?}", has_layers, apply),
query!("anim.builtinPresets", "Built-in Animation Presets", "{category?}", list),
]
}
+1
View File
@@ -316,6 +316,7 @@ pub const IMPORT_FORMATS: &[&str] = &[
"Opus",
"glTF/GLB",
"OBJ",
"STL",
"Lottie JSON",
];
+22 -1
View File
@@ -1,5 +1,5 @@
//! Composition menu, part two: crop to region of interest / layer bounds, Save Frame As ▸ File,
//! Responsive Design — Time.
//! Copy Frame to Clipboard, Responsive Design — Time.
use effectcraft_project::Marker;
use effectcraft_time::Tick;
@@ -120,6 +120,26 @@ fn save_frame(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"path": path, "width": img.width, "height": img.height, "time": t.seconds()}))
}
/// Composition ▸ Copy Frame to Clipboard (After Effects 26.3): render the frame as Save Frame As
/// ▸ File does and hand it to the frontend, which puts it on the system clipboard.
fn copy_frame(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let t = f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(s.time());
let scale = f_p(p, "scale").unwrap_or(1.0).clamp(0.01, 4.0);
let bg = comp.background;
let img = s.render(cid, t, effectcraft_render::RenderOpts { scale, backend: effectcraft_render::Backend::Auto, ..Default::default() });
let (width, height) = (img.width, img.height);
let rgba = img.to_rgba8_over(bg);
let expected = (width as usize).checked_mul(height as usize).and_then(|n| n.checked_mul(4));
if width == 0 || height == 0 || expected != Some(rgba.len()) {
return Err(EngineError::Other(format!("cannot copy an empty or malformed {width}×{height} frame")));
}
s.events.push(crate::Event::CopyImage { width, height, rgba });
s.toast("Copied frame to the clipboard");
Ok(json!({"width": width, "height": height, "time": t.seconds()}))
}
/// Responsive Design — Time: protected comp markers for an intro, an outro or the work area.
fn responsive_time(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
@@ -156,6 +176,7 @@ pub fn specs() -> Vec<CommandSpec> {
has_comp,
save_frame
),
cmd!("comp.copyFrame", "Copy Frame to Clipboard", ["Composition"], None, "{comp?, time?, scale?}", has_comp, copy_frame),
cmd!("comp.responsiveTime", "Responsive Design — Time", [], None, "{op: intro|outro|workArea}", has_comp, responsive_time),
]
}
+45
View File
@@ -531,6 +531,42 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!(new.iter().map(|l| l.0).collect::<Vec<_>>()))
}
/// Largest SVG text `edit.pasteSvg` reads (a clipboard can hold anything).
const MAX_SVG_PASTE: usize = 64 << 20;
/// `edit.pasteSvg`: SVG markup (the system clipboard's, copied from a browser, Illustrator or a
/// design tool) as a new shape layer above the selected layer, its canvas centred in the comp:
/// After Effects 26.3 pastes copied vector artwork as shape layers. Edit ▸ Paste runs it when
/// the system clipboard holds SVG copied after anything copied in the app.
fn paste_svg(s: &mut Session, p: &Value) -> Result<Value> {
let c = "edit.pasteSvg";
let text = str_p(p, "svg").ok_or_else(|| super::bad(c, "missing `svg` (SVG markup)"))?;
if text.len() > MAX_SVG_PASTE {
return Err(super::bad(c, format!("the SVG is larger than {} MB", MAX_SVG_PASTE >> 20)));
}
if !crate::vector::is_svg_text(text) {
return Err(super::bad(c, "the text is not SVG markup"));
}
let doc = effectcraft_svg::parse(text.as_bytes()).map_err(|e| super::bad(c, format!("cannot read the SVG: {e}")))?;
if doc.flatten().is_empty() {
return Err(super::bad(c, "the SVG has no shapes"));
}
let cid = super::comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let name = match str_p(p, "name") {
Some(n) => n.to_string(),
None => {
let count = comp.layers.iter().filter(|l| matches!(l.source, LayerSource::Shape)).count();
comp.unique_layer_name(&format!("Shape Layer {}", count.saturating_add(1)))
}
};
let id = s.edit("Paste", None, |proj, st| {
let l = crate::vector::shapes_from_svg(proj, &comp, &doc, &name);
super::layer::insert_layer(proj, st, cid, l)
})?;
Ok(json!({"layer": id.0, "skipped": doc.skipped}))
}
fn split(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let t = s.time();
@@ -818,6 +854,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_clip,
paste
),
cmd!(
"edit.pasteSvg",
"Paste SVG as Shapes",
[],
None,
"{svg: SVG markup, name?, comp?} → {layer, skipped} (Edit ▸ Paste runs it for SVG on the system clipboard)",
has_comp,
paste_svg
),
cmd!("edit.pasteReversedKeyframes", "Paste Reversed Keyframes", ["Edit"], None, "{layers?, prop?|path?, time?}", has_key_clip, paste_reversed),
cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{layers?}", layers_or_keys, delete),
cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+D"), "{layers?} (effects or shape items when they are selected)", has_layers, duplicate),
+27 -2
View File
@@ -3,8 +3,10 @@
//! control channel and MCP all see them); running one emits [`crate::Event::Frontend`] and the UI
//! performs it. Headless sessions accept and ignore them.
use super::{CommandSpec, always, frontend, has_comp, has_layers};
use crate::cmd;
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, frontend, has_comp, has_layers};
use crate::{Result, Session, cmd};
macro_rules! fe {
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr) => {
@@ -116,5 +118,28 @@ pub fn specs() -> Vec<CommandSpec> {
"{} — the Project panel's selection, with the layers that use it; one undo step",
always
),
// A gradient property's "Edit Gradient…" (the Timeline's Colors value): opens the
// Gradient Editor on it at the current time. OK sets it with prop.set (one undo step).
cmd!(
"prop.editGradient",
"Edit Gradient...",
[],
None,
"{layer?, path|prop, comp?} — a gradient property; opens the Gradient Editor at the current time",
has_layers,
edit_gradient
),
]
}
/// `prop.editGradient`: check the target is a gradient property before the UI opens the editor,
/// so agents get an error (not success) for a wrong one, and hand the UI the resolved ids.
fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
let c = "prop.editGradient";
let (cid, lid, uid) = super::prop::prop_ref(s, p, c)?;
let pr = s.project.comp(cid).and_then(|comp| comp.layer(lid)).and_then(|l| l.props.find(uid)).ok_or_else(|| bad(c, format!("no property @{uid}")))?;
if !matches!(pr.value, effectcraft_keyframe::Value::Gradient(_)) {
return Err(bad(c, format!("{} is not a gradient property", pr.name)));
}
frontend(s, c, &json!({"comp": cid.0, "layer": lid.0, "prop": uid}))
}
+7
View File
@@ -4,6 +4,7 @@
mod align;
mod align_data;
mod anim;
mod anim_presets;
pub mod anim_tools;
mod animation;
pub(crate) mod app_more;
@@ -162,6 +163,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(layer_menu::specs());
v.extend(create::specs());
v.extend(animation::specs());
v.extend(anim_presets::specs());
v.extend(view::specs());
v.extend(viewer_cmds::specs());
v.extend(shape_stroke::specs());
@@ -221,6 +223,11 @@ pub fn text_preset_list() -> Vec<(String, String)> {
text_anim::preset_names()
}
/// (id, name, category) of the built-in animation presets (Transitions…).
pub fn builtin_preset_list() -> Vec<(String, String, String)> {
anim_presets::preset_names()
}
pub fn find(id: &str) -> Option<&'static CommandSpec> {
// Every command execution looks its spec up: index the ~2,000 ids once.
static INDEX: OnceLock<std::collections::HashMap<&'static str, usize>> = OnceLock::new();
+2 -2
View File
@@ -1,4 +1,4 @@
//! Advanced 3D: model layers (File ▸ Import of glTF/OBJ, Layer ▸ New ▸ 3D primitives), the
//! Advanced 3D: model layers (File ▸ Import of glTF/OBJ/STL, Layer ▸ New ▸ 3D primitives), the
//! comp renderer switch, Layer ▸ Environment Layer, Geometry Options for extruded text and
//! shapes, and Layer ▸ Material commands.
@@ -376,7 +376,7 @@ pub fn specs() -> Vec<CommandSpec> {
"3D Model Layer",
[],
None,
"{item?: id or name of a 3D model footage item, path?: a .gltf, .glb or .obj file to import, name?, time? (s), index? (1-based stack position), position? ([x, y] comp px)}; switches a Classic 3D comp to Advanced 3D",
"{item?: id or name of a 3D model footage item, path?: a .gltf, .glb, .obj or .stl file to import, name?, time? (s), index? (1-based stack position), position? ([x, y] comp px)}; switches a Classic 3D comp to Advanced 3D",
has_comp,
new_model
),
+9
View File
@@ -384,6 +384,15 @@ pub fn specs() -> Vec<CommandSpec> {
always,
settings_save
),
cmd!(
"script.runTasks",
"Run Scheduled Script Tasks",
[],
None,
"{} → runs the app.scheduleTask tasks that are due (frontends do it every frame) → {ran, output, errors, next (ms until the next task, or null)}",
always,
scriptui::run_tasks
),
crate::query!("scriptui.list", "List Script Windows", "{} → [{window, title, kind: dialog|palette|window|panel, script, modal, size}]", scriptui::list),
crate::query!(
"scriptui.get",
+174 -17
View File
@@ -3,13 +3,14 @@
//! commands here set several of them in one undo step and add the groups to strokes saved before
//! they existed. Also the toolbar's Fill / Stroke options on selected shape layers.
use effectcraft_keyframe::Value as KV;
use effectcraft_keyframe::{Gradient, Value as KV};
use effectcraft_project::build::{self, EXTRA_DASHES, Ids};
use effectcraft_project::{ItemId, Layer, LayerId, LayerSource, Node, PropGroup, Uid};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::layer::color_p;
use super::shape_tool::{self, PaintKind};
use super::{CommandSpec, bad, f_p, has_layers, layer_mut, layer_p, layers_p, merge_p};
use crate::{EngineError, Result, Session, cmd};
@@ -224,15 +225,157 @@ pub fn first_paint(layer: &Layer, lt: Tick) -> (Option<[f64; 4]>, Option<[f64; 4
(fill, stroke, width)
}
/// Paint one contents group and its sub-groups: Fills take `fill`, Strokes `stroke`, Strokes and
/// Gradient Strokes `width`. With `add_stroke`, a group with a path and no stroke gets one (before
/// its fills, as new shapes have it).
#[allow(clippy::too_many_arguments)]
fn paint(c: &mut PropGroup, ids: &mut Ids, lt: Tick, fill: Option<[f64; 4]>, stroke: Option<[f64; 4]>, width: Option<f64>, add_stroke: bool, depth: usize) {
/// What the toolbar's Fill and Stroke set on the selected shape layers.
#[derive(Clone, Copy, Debug, Default)]
struct PaintEdit {
fill: Option<[f64; 4]>,
stroke: Option<[f64; 4]>,
width: Option<f64>,
/// A group with a path and no stroke gets one.
add_stroke: bool,
fill_kind: Option<PaintKind>,
stroke_kind: Option<PaintKind>,
}
/// The kind of the first Fill and the first Stroke in `layer`'s contents at layer time `lt`: a
/// switched-off or missing one is None (what the toolbar shows for a selected shape layer).
pub fn first_paint_kinds(layer: &Layer, lt: Tick) -> (PaintKind, PaintKind) {
let (mut fill, mut stroke) = (None, None);
let kind = |g: &PropGroup| match g.match_id.as_str() {
_ if !g.enabled => PaintKind::None,
"fill" | "stroke" => PaintKind::Solid,
_ if g.get("type").is_some_and(|p| p.value_at(lt).as_enum() == 1) => PaintKind::Radial,
_ => PaintKind::Linear,
};
if let Some(c) = layer.props.sub("contents") {
walk_groups(c, &mut |g| match g.match_id.as_str() {
"fill" | "gfill" if fill.is_none() => fill = Some(kind(g)),
"stroke" | "gstroke" if stroke.is_none() => stroke = Some(kind(g)),
_ => {}
});
}
(fill.unwrap_or(PaintKind::None), stroke.unwrap_or(PaintKind::None))
}
/// The bounds (`[x, y, w, h]`) of the path items of one contents level at layer time `lt`, where
/// a new gradient's Start and End Points go; a 100 px square at the origin without paths.
fn path_bounds(c: &PropGroup, lt: Tick) -> [f64; 4] {
let mut acc: Option<[f64; 4]> = None;
for g in c.groups() {
let at = |id: &str| g.get(id).map(|p| p.value_at(lt));
let b = match g.match_id.as_str() {
"rect" | "ellipse" => {
let [w, h] = at("size").map(|v| v.as_vec2()).unwrap_or([100.0; 2]);
let [x, y] = at("position").map(|v| v.as_vec2()).unwrap_or([0.0; 2]);
Some([x - w.abs() / 2.0, y - h.abs() / 2.0, x + w.abs() / 2.0, y + h.abs() / 2.0])
}
"star" => {
let r = at("outerRadius").map(|v| v.as_f64().abs()).unwrap_or(100.0);
let [x, y] = at("position").map(|v| v.as_vec2()).unwrap_or([0.0; 2]);
Some([x - r, y - r, x + r, y + r])
}
"path" => {
let v = at("path");
let bez = v.as_ref().and_then(|v| v.as_path()).map(effectcraft_path::to_kurbo);
bez.and_then(|b| effectcraft_path::bounds(&[b])).map(|r| [r.x0, r.y0, r.x1, r.y1])
}
_ => None,
};
let Some(b) = b.filter(|b| b.iter().all(|v| v.is_finite())) else { continue };
acc = Some(match acc {
None => b,
Some(a) => [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])],
});
}
acc.map(|[x0, y0, x1, y1]| [x0, y0, x1 - x0, y1 - y0]).unwrap_or([-50.0, -50.0, 100.0, 100.0])
}
/// Fill Options / Stroke Options on one contents level (as in After Effects, with shape layers
/// selected they change the layers' paint): None switches its Fills and Gradient Fills (or
/// Strokes and Gradient Strokes) off; another kind switches them on, turns them into that kind
/// (keeping the properties both kinds have: blend mode, opacity, width, dashes…) and gives a
/// level with paths and no such item one.
fn set_kind(c: &mut PropGroup, ids: &mut Ids, lt: Tick, stroke: bool, kind: PaintKind, e: &PaintEdit) {
let (solid, grad) = if stroke { ("stroke", "gstroke") } else { ("fill", "gfill") };
let is_item = |g: &PropGroup| g.match_id == solid || g.match_id == grad;
if kind == PaintKind::None {
for n in &mut c.children {
if let Node::Group(g) = n
&& is_item(g)
{
g.enabled = false;
}
}
return;
}
let radial = kind == PaintKind::Radial;
let color = (if stroke { e.stroke } else { e.fill }).unwrap_or([1.0; 4]);
let width = e.width.filter(|w| *w > 0.0).unwrap_or(2.0);
let (start, end) = shape_tool::gradient_ends(radial, path_bounds(c, lt));
let fresh = |ids: &mut Ids| match (kind, stroke) {
(PaintKind::Solid, false) => build::shape_fill(ids, color),
(PaintKind::Solid, true) => build::shape_stroke(ids, color, width),
(_, false) => build::shape_gradient_fill(ids, radial, start, end, Gradient::default()),
(_, true) => build::shape_gradient_stroke(ids, radial, start, end, Gradient::default(), width),
};
let want = if kind == PaintKind::Solid { solid } else { grad };
let mut found = false;
for i in 0..c.children.len() {
let Some(Node::Group(g)) = c.children.get(i) else { continue };
if !is_item(g) {
continue;
}
found = true;
let replacement = (g.match_id != want).then(|| {
let mut new = fresh(ids);
new.uid = g.uid;
new.name = super::effect::unique_name(c, &new.name);
for n in &mut new.children {
if let Some(old) = g.children.iter().find(|o| o.match_id() == n.match_id()) {
*n = old.clone();
}
}
new
});
let Some(Node::Group(g)) = c.children.get_mut(i) else { continue };
match replacement {
Some(new) => *g = new,
None => {
g.enabled = true;
if let Some(t) = g.get_mut("type").filter(|_| want == grad) {
t.set_value_at(lt, KV::Enum(radial as u32));
}
}
}
}
if !found && c.groups().any(|g| PATHS.contains(&g.match_id.as_str())) {
let mut new = fresh(ids);
new.name = super::effect::unique_name(c, &new.name);
// Paths, strokes, then fills, as new shapes have them.
let at = if stroke {
c.children.iter().position(|n| matches!(n.match_id(), "fill" | "gfill")).unwrap_or(c.children.len())
} else {
c.children.iter().rposition(|n| PATHS.contains(&n.match_id()) || matches!(n.match_id(), "stroke" | "gstroke")).map_or(c.children.len(), |i| i + 1)
};
c.children.insert(at, new.into());
}
}
/// Paint one contents group and its sub-groups: the kinds first, then Fills take `fill`, Strokes
/// `stroke`, Strokes and Gradient Strokes `width`. With `add_stroke`, a group with a path and no
/// stroke gets one (before its fills, as new shapes have it).
fn paint(c: &mut PropGroup, ids: &mut Ids, lt: Tick, e: &PaintEdit, depth: usize) {
if depth > MAX_DEPTH {
return;
}
if add_stroke && c.groups().any(|g| PATHS.contains(&g.match_id.as_str())) && !c.groups().any(is_stroke) {
if let Some(k) = e.stroke_kind {
set_kind(c, ids, lt, true, k, e);
}
if let Some(k) = e.fill_kind {
set_kind(c, ids, lt, false, k, e);
}
let (fill, stroke, width) = (e.fill, e.stroke, e.width);
if e.add_stroke && c.groups().any(|g| PATHS.contains(&g.match_id.as_str())) && !c.groups().any(is_stroke) {
let at = c.children.iter().position(|n| matches!(n, Node::Group(g) if matches!(g.match_id.as_str(), "fill" | "gfill"))).unwrap_or(c.children.len());
c.children.insert(at, build::shape_stroke(ids, stroke.unwrap_or([1.0; 4]), width.unwrap_or(2.0)).into());
}
@@ -252,7 +395,7 @@ fn paint(c: &mut PropGroup, ids: &mut Ids, lt: Tick, fill: Option<[f64; 4]>, str
"gstroke" => set(g, "width", width.map(KV::Scalar)),
"group" => {
if let Some(sub) = g.sub_mut("contents") {
paint(sub, ids, lt, fill, stroke, width, add_stroke, depth + 1);
paint(sub, ids, lt, e, depth + 1);
}
}
_ => {}
@@ -260,10 +403,20 @@ fn paint(c: &mut PropGroup, ids: &mut Ids, lt: Tick, fill: Option<[f64; 4]>, str
}
}
/// `p`'s `fillType` / `strokeType` (none|solid|linear|radial).
fn kind_p(p: &Value, key: &str, c: &str) -> Result<Option<PaintKind>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => v.as_str().and_then(PaintKind::from_name).map(Some).ok_or_else(|| bad(c, format!("`{key}`: none|solid|linear|radial"))),
}
}
/// The toolbar's Fill and Stroke options with shape layers selected (as in After Effects, they
/// change the layers' fills and strokes): `fill` sets every Fill's colour, `stroke` every Stroke's,
/// `strokeWidth` every stroke's width. A stroke colour or a width above 0 gives the paths that
/// have no stroke one. Without values, reports the first layer's paint.
/// change the layers' fills and strokes): `fillType` / `strokeType` switch every Fill / Stroke
/// off (none) or turn them into Solid Color, Linear or Radial Gradient ones (adding one where a
/// path has none), `fill` sets every Fill's colour, `stroke` every Stroke's, `strokeWidth` every
/// stroke's width. A stroke colour or a width above 0 gives the paths that have no stroke one.
/// Without values, reports the first layer's paint.
fn fill_stroke(s: &mut Session, p: &Value) -> Result<Value> {
let c = "shape.fillStroke";
let (cid, lids) = layers_p(s, p)?;
@@ -275,8 +428,10 @@ fn fill_stroke(s: &mut Session, p: &Value) -> Result<Value> {
let rgba = |k: &str| color_p(p, k).map(|c| [c[0] as f64, c[1] as f64, c[2] as f64, 1.0]);
let (fill, stroke) = (rgba("fill"), rgba("stroke"));
let width = f_p(p, "strokeWidth").filter(|w| w.is_finite()).map(|w| w.max(0.0));
if fill.is_some() || stroke.is_some() || width.is_some() {
let add_stroke = width.map_or(stroke.is_some(), |w| w > 0.0);
let (fill_kind, stroke_kind) = (kind_p(p, "fillType", c)?, kind_p(p, "strokeType", c)?);
let add_stroke = stroke_kind != Some(PaintKind::None) && width.map_or(stroke.is_some(), |w| w > 0.0);
let e = PaintEdit { fill, stroke, width, add_stroke, fill_kind, stroke_kind };
if fill.is_some() || stroke.is_some() || width.is_some() || fill_kind.is_some() || stroke_kind.is_some() {
let t = s.time();
s.edit("Fill and Stroke", merge_p(p), |proj, _| {
let mut next = proj.next_id;
@@ -284,7 +439,7 @@ fn fill_stroke(s: &mut Session, p: &Value) -> Result<Value> {
let l = layer_mut(proj, cid, *lid)?;
let lt = l.layer_time(t);
if let Some(contents) = l.props.sub_mut("contents") {
paint(contents, &mut Ids(&mut next), lt, fill, stroke, width, add_stroke, 0);
paint(contents, &mut Ids(&mut next), lt, &e, 0);
}
}
proj.next_id = next;
@@ -292,8 +447,10 @@ fn fill_stroke(s: &mut Session, p: &Value) -> Result<Value> {
})?;
}
let l = shapes.first().and_then(|id| s.project.comp(cid)?.layer(*id)).ok_or(EngineError::NoComp)?;
let (fill, stroke, width) = first_paint(l, l.layer_time(s.time()));
Ok(json!({"fill": fill, "stroke": stroke, "strokeWidth": width}))
let lt = l.layer_time(s.time());
let (fill, stroke, width) = first_paint(l, lt);
let (fill_kind, stroke_kind) = first_paint_kinds(l, lt);
Ok(json!({"fill": fill, "stroke": stroke, "strokeWidth": width, "fillType": fill_kind.id(), "strokeType": stroke_kind.id()}))
}
pub fn specs() -> Vec<CommandSpec> {
@@ -303,7 +460,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Fill and Stroke",
[],
None,
"{layers?, fill?: color, stroke?: color, strokeWidth?, merge?} → {fill, stroke, strokeWidth} (first layer)",
"{layers?, fillType?: none|solid|linear|radial, strokeType?, fill?: color, stroke?: color, strokeWidth?, merge?} → {fill, stroke, strokeWidth, fillType, strokeType} (first layer)",
has_layers,
fill_stroke
),
+9 -3
View File
@@ -139,12 +139,18 @@ fn blended(mut g: PropGroup, paint: &ToolPaint) -> PropGroup {
g
}
/// A gradient's Start and End Points across `bounds` (`[x, y, w, h]`): left to right, or out
/// from the centre.
pub(crate) fn gradient_ends(radial: bool, bounds: [f64; 4]) -> ([f64; 2], [f64; 2]) {
let [x, y, w, h] = bounds;
let mid = [x + w / 2.0, y + h / 2.0];
if radial { (mid, [mid[0] + w.max(h).max(2.0) / 2.0, mid[1]]) } else { ([x, mid[1]], [x + w.max(1.0), mid[1]]) }
}
/// The Stroke and Fill a new shape gets from `paint`; gradients run across `bounds` (`[x, y, w,
/// h]` in the group's space): left to right, or out from the centre.
pub(crate) fn paint_items(ids: &mut Ids, paint: &ShapeTool, bounds: [f64; 4]) -> Vec<PropGroup> {
let [x, y, w, h] = bounds;
let mid = [x + w / 2.0, y + h / 2.0];
let ends = |radial: bool| if radial { (mid, [mid[0] + w.max(h).max(2.0) / 2.0, mid[1]]) } else { ([x, mid[1]], [x + w.max(1.0), mid[1]]) };
let ends = |radial: bool| gradient_ends(radial, bounds);
let mut items = vec![];
let (s, width) = (&paint.stroke, paint.stroke_width);
let stroke = match s.kind {
+1 -1
View File
@@ -246,7 +246,7 @@ fn enable_per_char(s: &mut Session, p: &Value) -> Result<Value> {
}
/// JSON → property value shaped like `like`.
fn json_value(v: &Value, like: &KV) -> Option<KV> {
pub(super) fn json_value(v: &Value, like: &KV) -> Option<KV> {
Some(match v {
Value::Bool(b) => match like {
KV::Bool(_) => KV::Bool(*b),
+5 -2
View File
@@ -506,7 +506,9 @@ pub fn fonts(_s: &mut Session, p: &Value) -> Result<Value> {
use effectcraft_text::fonts;
let added = if p.get("rescan").and_then(Value::as_bool).unwrap_or(false) { fonts::rescan_system() } else { 0 };
let query = p.get("query").and_then(Value::as_str).map(str::to_lowercase).unwrap_or_default();
let only_variable = p.get("variable").and_then(Value::as_bool).unwrap_or(false);
let native = fonts::native_families();
let variable = fonts::variable_families();
let mut origin: std::collections::BTreeMap<String, &'static str> = Default::default();
for f in fonts::all_faces() {
origin.entry(f.info.family.clone()).or_insert(f.info.origin);
@@ -514,8 +516,9 @@ pub fn fonts(_s: &mut Session, p: &Value) -> Result<Value> {
let list: Vec<Value> = effectcraft_text::families()
.into_iter()
.filter(|(f, _)| query.is_empty() || f.to_lowercase().contains(&query) || native.get(f).is_some_and(|n| n.to_lowercase().contains(&query)))
.filter(|(f, _)| !only_variable || variable.contains(f))
.map(|(f, styles)| {
let mut v = json!({"family": f, "styles": styles, "origin": origin.get(&f).copied().unwrap_or("system")});
let mut v = json!({"family": f, "styles": styles, "origin": origin.get(&f).copied().unwrap_or("system"), "variable": variable.contains(&f)});
if let Some(n) = native.get(&f) {
v["nativeName"] = json!(n);
}
@@ -559,7 +562,7 @@ pub fn specs() -> Vec<CommandSpec> {
"List Fonts",
[],
None,
"{query?, rescan?} → {count, families: [{family, styles, origin: bundled|system|user, nativeName?}], added} (rescan picks up fonts installed since launch)",
"{query?, variable?, rescan?} → {count, families: [{family, styles, origin: bundled|system|user, variable, nativeName?}], added} (variable: only variable fonts; rescan picks up fonts installed since launch)",
always_ok,
fonts
),
+7 -1
View File
@@ -210,7 +210,13 @@ mod tests {
let mut paths: Vec<String> =
o.project.items.values().filter_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.path.clone()) } else { None }).collect();
paths.sort();
assert_eq!(paths, ["/nonexistent/ec/abs.png", clip.to_str().unwrap(), "media/ec-relative-gone.png"]);
// Windows joins the stored "media/..." onto the project's folder and leaves a path with mixed
// separators; `Path` equality compares components, so both spellings are the same file.
let expected = ["/nonexistent/ec/abs.png", clip.to_str().unwrap(), "media/ec-relative-gone.png"];
assert_eq!(paths.len(), expected.len(), "{paths:?}");
for (got, want) in paths.iter().zip(expected) {
assert_eq!(std::path::Path::new(got), std::path::Path::new(want), "{paths:?}");
}
assert!(!o.is_dirty(), "finding the files is no edit");
let r = o.execute("footage.check", json!({"wait": true})).unwrap();
assert_eq!(r["missing"], 2);
+29 -3
View File
@@ -350,6 +350,15 @@ pub enum Event {
},
/// Drop cached frames / renders (Edit ▸ Purge).
PurgeCaches,
/// Put an image on the system clipboard (Composition ▸ Copy Frame to Clipboard): `rgba` is
/// `width` × `height` straight-alpha RGBA8 rows. Headless sessions ignore it; the pixels are
/// not serialized.
CopyImage {
width: u32,
height: u32,
#[serde(skip)]
rgba: Vec<u8>,
},
}
pub struct Session {
@@ -980,6 +989,7 @@ impl Session {
self.roto_job = None;
self.roto_pending.clear();
self.drop_tasks();
self.script_ui.project_epoch = self.script_ui.project_epoch.wrapping_add(1);
p.fix_next_id();
upgrade_effects(&mut p);
self.project = Arc::new(p);
@@ -1046,6 +1056,8 @@ mod tests;
#[cfg(test)]
mod tests_3d;
#[cfg(test)]
mod tests_anim_presets;
#[cfg(test)]
mod tests_anim_tools;
#[cfg(test)]
mod tests_camera_track;
@@ -1124,6 +1136,11 @@ pub fn text_presets() -> Vec<(String, String)> {
commands::text_preset_list()
}
/// Built-in animation presets (Effects & Presets ▸ * Animation Presets): (id, name, category).
pub fn animation_presets() -> Vec<(String, String, String)> {
commands::builtin_preset_list()
}
pub fn text_families() -> Vec<String> {
effectcraft_text::families().into_iter().map(|(f, _)| f).collect()
}
@@ -1153,6 +1170,8 @@ pub struct FontRow {
/// Settings ▸ Type ▸ Show Font Names in English is off.
pub display: String,
pub recent: bool,
/// A variable font (the menu's Variable Fonts filter keeps only these).
pub variable: bool,
}
/// Outline polylines of "Sample" set in `family` at `size` px (baseline at y = 0, y down), for
@@ -1193,14 +1212,21 @@ pub fn font_preview(family: &str, size: f64) -> Vec<Vec<[f32; 2]>> {
}
/// The Character panel's font menu: the recent fonts (Settings ▸ Type ▸ Number of Recent Fonts
/// to Display), a separator, then every family.
/// to Display), a separator, then every family. Each row says whether the family is variable,
/// for the menu's Variable Fonts filter.
pub fn font_menu(prefs: &prefs::Prefs) -> Vec<FontRow> {
let all = text_families();
let native = if prefs.type_.font_names_in_english { Default::default() } else { effectcraft_text::fonts::native_families() };
let row = |f: &String, recent: bool| FontRow { family: f.clone(), display: native.get(f).cloned().unwrap_or_else(|| f.clone()), recent };
let variable = effectcraft_text::fonts::variable_families();
let row = |f: &String, recent: bool| FontRow {
family: f.clone(),
display: native.get(f).cloned().unwrap_or_else(|| f.clone()),
recent,
variable: variable.contains(f),
};
let mut out: Vec<FontRow> = prefs.recent_fonts_shown().iter().filter(|f| all.contains(f)).map(|f| row(f, true)).collect();
if !out.is_empty() {
out.push(FontRow { family: String::new(), display: "-".into(), recent: false });
out.push(FontRow { family: String::new(), display: "-".into(), recent: false, variable: false });
}
out.extend(all.iter().map(|f| row(f, false)));
out
+1 -1
View File
@@ -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> {
+1
View File
@@ -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
---
+1 -4
View File
@@ -35,9 +35,6 @@ pub const LANGUAGES: &[(&str, &str)] = &[
("Čeština", "cs"),
];
/// Settings ▸ Appearance ▸ UI Scale: (label, `appearance.uiScale` percent).
pub const UI_SCALES: &[(&str, &str)] = &[("75%", "75"), ("100%", "100"), ("125%", "125"), ("150%", "150"), ("175%", "175"), ("200%", "200")];
/// Settings ▸ Startup & Repair ▸ Window Graphics: (label, `startup.windowGraphics` value).
pub const WINDOW_GRAPHICS: &[(&str, &str)] = &[("Automatic", "auto"), ("OpenGL (compatibility)", "gl")];
@@ -1171,7 +1168,7 @@ pub fn pages() -> Vec<Page> {
s("appearance.lightTheme", "Light Theme", Kind::Choice(LIGHT_THEMES), true),
s("appearance.darkTheme", "Dark Theme", Kind::Choice(DARK_THEMES), true),
s("appearance.brightness", "Brightness", Kind::Slider(-1.0, 1.0), true),
s("appearance.uiScale", "UI Scale", Kind::Choice(UI_SCALES), true),
s("appearance.uiScale", "UI Scale", Kind::Int(75, 200, "%"), true),
Section("Labels and Colors"),
s("appearance.useLabelColorForHandles", "Use Label Color for Layer Handles and Paths", B, true),
s("appearance.useLabelColorForTabs", "Use Label Color for Related Tabs", B, true),
+5 -5
View File
@@ -67,11 +67,11 @@ fn add_configure_render() {
let r = s.execute("renderQueue.setOutputModule", json!({"item": id, "format": "png", "channels": "rgba"})).unwrap();
assert!(r["outputPath"].as_str().unwrap().ends_with("_[#####].png"), "{r}");
assert!(s.execute("renderQueue.setOutputModule", json!({"item": id, "format": "jpeg", "channels": "rgba"})).is_err());
let r = s.execute("renderQueue.setOutput", json!({"item": id, "path": "/tmp/rq/out.mov"})).unwrap();
let r = s.execute("renderQueue.setOutput", json!({"item": id, "path": crate::tests_stubs::tmp_out("rq/out.mov")})).unwrap();
assert_eq!(r["output"]["format"], "ProRes", "Output To with .mov switches to ProRes");
assert_eq!(std::path::Path::new(r["outputPath"].as_str().unwrap()), std::path::absolute("/tmp/rq/out.mov").unwrap());
assert_eq!(std::path::Path::new(r["outputPath"].as_str().unwrap()), std::path::PathBuf::from(crate::tests_stubs::tmp_out("rq/out.mov")));
// A second item that fails, a third that is unqueued.
let b = s.execute("renderQueue.add", json!({"format": "gif", "output": "/tmp/rq/fail.gif"})).unwrap();
let b = s.execute("renderQueue.add", json!({"format": "gif", "output": crate::tests_stubs::tmp_out("rq/fail.gif")})).unwrap();
let c = s.execute("renderQueue.add", json!({"output": "/tmp/rq/skip.mp4"})).unwrap();
s.execute("renderQueue.setRender", json!({"item": c["item"], "render": false})).unwrap();
assert_eq!(s.project.render_queue[2].status.label(), "Unqueued");
@@ -79,12 +79,12 @@ fn add_configure_render() {
assert_eq!(r["items"].as_array().unwrap().len(), 2);
assert_eq!(
ex.log.lock().unwrap().iter().map(std::path::PathBuf::from).collect::<Vec<_>>(),
[std::path::absolute("/tmp/rq/out.mov").unwrap(), std::path::absolute("/tmp/rq/fail.gif").unwrap()]
[std::path::PathBuf::from(crate::tests_stubs::tmp_out("rq/out.mov")), std::path::PathBuf::from(crate::tests_stubs::tmp_out("rq/fail.gif"))]
);
let q = &s.project.render_queue;
assert_eq!(q[0].status.label(), "Done");
assert!(q[0].started.is_some() && q[0].render_time.is_some());
assert_eq!(std::path::Path::new(q[0].last_output.as_deref().unwrap()), std::path::absolute("/tmp/rq/out.mov").unwrap());
assert_eq!(std::path::Path::new(q[0].last_output.as_deref().unwrap()), std::path::PathBuf::from(crate::tests_stubs::tmp_out("rq/out.mov")));
assert_eq!(q[1].status, RenderStatus::Failed("boom".into()));
assert_eq!(q[2].status.label(), "Unqueued");
assert!(s.drain_events().iter().any(|e| matches!(e, Event::Toast { error: true, .. })));
+32
View File
@@ -374,6 +374,11 @@ pub struct ScriptUiEvent {
/// (`{ok, output, error}`) and updates [`Session::script_ui`].
pub type ScriptUiDispatch = fn(&mut Session, &ScriptUiEvent) -> std::result::Result<Value, String>;
/// Runs the scripts' `app.scheduleTask` tasks that are due; returns
/// `{ran, output, errors: [{message, line, file}], next (ms until the next task is due, or null)}`
/// and updates [`Session::script_ui`]. Frontends call it every frame (see [`ScriptUi::run_tasks`]).
pub type ScriptTaskRunner = fn(&mut Session) -> Value;
/// The open script windows.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(default)]
@@ -384,6 +389,16 @@ pub struct ScriptUi {
/// The scripting engine's event hand-off (set with [`Session::script`]).
#[serde(skip)]
pub dispatch: Option<ScriptUiDispatch>,
/// The scripting engine's scheduled-task runner (set with [`Session::script`]).
#[serde(skip)]
pub tasks: Option<ScriptTaskRunner>,
/// Bumped whenever the project is closed or replaced (New / Open / Close Project): repeating
/// tasks of scripts without an open window stop then.
#[serde(skip)]
pub project_epoch: u64,
/// The script contexts of this session with scheduled tasks (kept by the scripting engine).
#[serde(skip)]
pub task_hosts: Vec<u32>,
}
impl ScriptUi {
@@ -391,6 +406,17 @@ impl ScriptUi {
self.windows.iter().find(|w| w.id == id)
}
/// Run the scripts' due `app.scheduleTask` tasks ([`ScriptTaskRunner`]); `None` without a
/// scripting engine.
pub fn run_tasks(s: &mut Session) -> Option<Value> {
let f = s.script_ui.tasks?;
// A task may close its ScriptUI panel: forget it then.
let open = crate::commands::scripts::open_panel_names(s);
let r = f(s);
crate::commands::scripts::forget_closed_panels(s, &open);
Some(r)
}
/// Replace the windows a script host published (laying them out).
pub fn publish(&mut self, host: u32, mut windows: Vec<ScriptWindow>) {
for w in &mut windows {
@@ -687,6 +713,12 @@ pub(crate) fn set(s: &mut Session, p: &Value) -> Result<Value> {
dispatch(s, ScriptUiEvent { window, widget, kind: kind.into(), value })
}
/// `script.runTasks`: run the scripts' `app.scheduleTask` tasks that are due now (frontends do
/// it every frame; headless hosts and agents call this).
pub(crate) fn run_tasks(s: &mut Session, _: &Value) -> Result<Value> {
ScriptUi::run_tasks(s).ok_or_else(|| EngineError::Other("scripting is not available in this build".into()))
}
/// `scriptui.close`: close a window (a dialog's `show()` returns `result`, default 2 = Cancel).
pub(crate) fn close(s: &mut Session, p: &Value) -> Result<Value> {
let window = window_p(s, p, "scriptui.close")?;
+19
View File
@@ -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::*;
+51
View File
@@ -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]
+74
View File
@@ -0,0 +1,74 @@
//! Built-in animation presets (Effects & Presets ▸ * Animation Presets ▸ Transitions).
use effectcraft_time::Tick;
use serde_json::json;
use crate::Session;
use crate::render::RenderOpts;
/// A 160×90 comp with one white full-frame solid; returns (session, solid).
fn session() -> (Session, u64) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "T", "width": 160, "height": 90, "frameRate": 30, "duration": 5})).unwrap();
let solid = s.execute("layer.newSolid", json!({"name": "S", "color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
(s, solid)
}
/// Share of the comp's pixels that are mostly opaque at `secs`.
fn coverage(s: &Session, secs: f64) -> f64 {
let cid = s.active_comp_id().unwrap();
let img = s.render(cid, Tick::from_seconds_f64(secs), RenderOpts { scale: 1.0, ..Default::default() });
img.data.iter().filter(|p| p[3] > 0.5).count() as f64 / img.data.len().max(1) as f64
}
#[test]
fn every_transition_preset_applies_and_transitions() {
let (mut s, solid) = session();
let list = s.execute("anim.builtinPresets", json!({"category": "Transitions"})).unwrap();
let list = list.as_array().unwrap().clone();
assert!(list.len() >= 20, "{}", list.len());
assert_eq!(crate::animation_presets().len(), list.len());
let start = 0.5;
for p in &list {
let id = p["id"].as_str().unwrap();
let incoming = match p["direction"].as_str() {
Some("in") => true,
Some("out") => false,
d => panic!("{id}: direction {d:?}"),
};
s.execute("time.set", json!({"time": start})).unwrap();
let r = s.execute("anim.applyBuiltinPreset", json!({"layers": [solid], "preset": id})).unwrap();
assert_eq!(r["skipped"], json!([]), "{id}: every path in the preset exists and takes its values");
let (a, b) = (coverage(&s, start), coverage(&s, start + 2.0));
let (hidden, shown) = if incoming { (a, b) } else { (b, a) };
assert!(hidden < 0.15, "{id}: hidden end covers {hidden:.3}");
assert!(shown > 0.97, "{id}: shown end covers {shown:.3}");
// Keys start at the current time: before it the layer holds the first key's state.
assert!((coverage(&s, 0.0) - a).abs() < 0.02, "{id}");
s.execute("edit.undo", json!({})).unwrap();
let l = &s.active_comp().unwrap().layers[0];
assert_eq!(l.effects().map_or(0, |fx| fx.groups().count()), 0, "{id}: undo removes the effects");
assert!(l.props.prop("transform/opacity").unwrap().keys.is_empty(), "{id}: undo removes the keys");
}
}
#[test]
fn transition_preset_by_name_and_bad_input() {
let (mut s, solid) = session();
// By name; a second application numbers the effect like Effect ▸ apply does.
s.execute("anim.applyBuiltinPreset", json!({"layers": [solid], "preset": "Wipe In from Left"})).unwrap();
let r = s.execute("anim.applyBuiltinPreset", json!({"layers": [solid], "preset": "wipeInFromLeft"})).unwrap();
assert_eq!(r["effects"].as_array().unwrap().len(), 1);
let names: Vec<String> = s.active_comp().unwrap().layers[0].effects().unwrap().groups().map(|g| g.name.clone()).collect();
assert_eq!(names, ["Linear Wipe", "Linear Wipe 2"]);
let fx = s.active_comp().unwrap().layers[0].effects().unwrap().groups().next().unwrap().clone();
let completion = fx.prop("completion").unwrap();
assert_eq!(completion.keys.len(), 2);
assert_eq!(completion.keys[0].time, Tick::ZERO);
// Unknown presets and missing params are errors, not panics.
assert!(s.execute("anim.applyBuiltinPreset", json!({"layers": [solid], "preset": "nope"})).is_err());
assert!(s.execute("anim.applyBuiltinPreset", json!({"layers": [solid]})).is_err());
// A layer without effects (a camera) refuses effect presets.
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
assert!(s.execute("anim.applyBuiltinPreset", json!({"layers": [cam], "preset": "clockReveal"})).is_err());
}
+31 -1
View File
@@ -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")));
}
+44 -5
View File
@@ -88,7 +88,7 @@ fn prefs_set_validates_keys_types_and_ranges() {
}
/// Settings ▸ Appearance ▸ UI Scale (#284): a percentage, 100 by default, kept within 75–200;
/// the dialog's choices set it as a number.
/// the dialog types or drags any whole percentage in that range (#682).
#[test]
fn ui_scale_is_a_percentage_from_75_to_200() {
let mut p = Prefs::default();
@@ -101,10 +101,8 @@ fn ui_scale_is_a_percentage_from_75_to_200() {
p.set("appearance.uiScale", json!(10)).unwrap();
assert_eq!(p.appearance.ui_scale, 75);
let page = crate::prefs::pages().into_iter().find(|pg| pg.id == "appearance").unwrap();
let listed = page.items.iter().any(|i| {
matches!(i, crate::prefs::Item::Setting { key: "appearance.uiScale", kind: crate::prefs::Kind::Choice(c), live: true, .. } if c.contains(&("200%", "200")))
});
assert!(listed, "Settings ▸ Appearance offers it");
let field = crate::prefs::Item::Setting { key: "appearance.uiScale", label: "UI Scale", kind: crate::prefs::Kind::Int(75, 200, "%"), live: true };
assert!(page.items.contains(&field), "Settings ▸ Appearance offers it");
// Older settings files without it get the default.
assert_eq!(Prefs::from_json(r#"{"version": 2, "appearance": {"theme": "light"}}"#).appearance.ui_scale, 100);
}
@@ -658,3 +656,44 @@ fn render_backend_says_why_it_renders_on_the_cpu() {
assert_eq!(why(&mut s), json!("headless: renders on the CPU unless started with --gpu"));
assert_eq!(s.execute("render.backend", json!({"backend": "cpu"})).unwrap()["why"], json!("the project's renderer is Mercury Software Only"));
}
/// Settings ▸ GPU Information names what the GPU compositor runs on, or why it is off, and the
/// adapter the window got, so a card the compositor couldn't use doesn't look undetected (#596).
#[test]
fn gpu_summary_names_the_adapter_or_says_why_there_is_none() {
use crate::sysinfo::gpu_summary;
use effectcraft_render::{Accelerator, FxStep, Renderer};
struct Named;
impl Accelerator for Named {
fn name(&self) -> String {
"NVIDIA GeForce RTX 4070 (Vulkan)".into()
}
fn comp_frame(&self, _: &Renderer, _: crate::project::ItemId, _: effectcraft_time::Tick) -> Option<effectcraft_raster::Image> {
None
}
fn supports_effect(&self, _: &str) -> bool {
false
}
fn effects(&self, _: &[FxStep], _: &effectcraft_effects::Buf, _: Option<f32>) -> Option<effectcraft_effects::Buf> {
None
}
}
let mut s = Session::default();
let off = gpu_summary(&s);
assert!(off.contains("Compositing: CPU"), "{off}");
assert!(off.contains("GPU compositing is off: no reason was recorded"), "{off}");
assert!(off.contains("Window graphics: not known yet"), "{off}");
s.accel_note = Some("GPU compositor unavailable: no compute shaders".into());
s.window_adapter = Some("NVIDIA GeForce RTX 4070 (Dx12, DiscreteGpu)".into());
let declined = gpu_summary(&s);
assert!(declined.contains("GPU compositing is off: GPU compositor unavailable: no compute shaders"), "{declined}");
assert!(declined.contains("Window graphics: NVIDIA GeForce RTX 4070 (Dx12, DiscreteGpu)"), "the card the window got is listed: {declined}");
s.accel = Some(std::sync::Arc::new(Named));
let on = gpu_summary(&s);
assert!(on.starts_with("Compositing: GPU, NVIDIA GeForce RTX 4070 (Vulkan)."), "{on}");
assert!(!on.contains("compositing is off"), "{on}");
}
+21 -13
View File
@@ -270,18 +270,26 @@ fn rq() -> (Session, Arc<MockExporter>) {
(s, ex)
}
/// An output path that is absolute on every platform. `/tmp/...` has no drive letter on Windows,
/// where it means "the current drive" to the OS but resolves against the home folder's drive in
/// the render queue, so a test cannot know which drive to expect.
pub(crate) fn tmp_out(rel: &str) -> String {
let base = std::env::temp_dir().join("effectcraft-rq-tests");
rel.split('/').fold(base, |p, part| p.join(part)).to_string_lossy().into_owned()
}
#[test]
fn add_output_module_encodes_twice() {
let (mut s, ex) = rq();
assert!(!s.is_enabled("render.addOutputModule"));
s.execute("renderQueue.add", json!({"output": "/tmp/om/main.mp4"})).unwrap();
s.execute("renderQueue.add", json!({"output": tmp_out("om/main.mp4")})).unwrap();
let r = s.execute("render.addOutputModule", json!({"format": "png"})).unwrap();
assert_eq!(r["module"], 2);
assert_eq!(r["outputModules"], 2);
let extra = r["extraOutputs"][0]["outputPath"].as_str().unwrap().to_string();
assert!(extra.ends_with(".png"), "{extra}");
// Module 2 is editable on its own.
s.execute("renderQueue.setOutput", json!({"index": 1, "module": 2, "path": "/tmp/om/second.mov"})).unwrap();
s.execute("renderQueue.setOutput", json!({"index": 1, "module": 2, "path": tmp_out("om/second.mov")})).unwrap();
assert!(s.execute("renderQueue.setOutput", json!({"index": 1, "module": 3, "path": "/tmp/x.mov"})).is_err());
s.execute("renderQueue.render", json!({})).unwrap();
assert_eq!(
@@ -294,10 +302,10 @@ fn add_output_module_encodes_twice() {
(format.to_string(), std::path::PathBuf::from(path))
})
.collect::<Vec<_>>(),
[("H264".to_string(), std::path::absolute("/tmp/om/main.mp4").unwrap()), ("ProRes".to_string(), std::path::absolute("/tmp/om/second.mov").unwrap())]
[("H264".to_string(), std::path::PathBuf::from(tmp_out("om/main.mp4"))), ("ProRes".to_string(), std::path::PathBuf::from(tmp_out("om/second.mov")))]
);
assert_eq!(s.project.render_queue[0].status.label(), "Done");
assert_eq!(std::path::Path::new(s.project.render_queue[0].last_output.as_deref().unwrap()), std::path::absolute("/tmp/om/main.mp4").unwrap());
assert_eq!(std::path::Path::new(s.project.render_queue[0].last_output.as_deref().unwrap()), std::path::PathBuf::from(tmp_out("om/main.mp4")));
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project.render_queue[0].extra_outputs.len(), 1, "undo the Output To edit");
}
@@ -311,7 +319,7 @@ fn pre_render_imports_and_replaces() {
let outer = s.active_comp_id().unwrap();
s.execute("layer.addItem", json!({"item": inner.0})).unwrap();
s.open_comp(inner);
let r = s.execute("render.preRender", json!({"output": "/tmp/pre/inner.mov"})).unwrap();
let r = s.execute("render.preRender", json!({"output": tmp_out("pre/inner.mov")})).unwrap();
assert_eq!(r["postRenderAction"], "Import & Replace Usage");
assert_eq!(s.project.render_queue[0].post_render, PostRenderAction::ImportAndReplace);
s.execute("renderQueue.render", json!({})).unwrap();
@@ -319,7 +327,7 @@ fn pre_render_imports_and_replaces() {
let l = &s.project.comp(outer).unwrap().layers[0];
let LayerSource::Footage { item } = l.source else { panic!("not replaced: {:?}", l.source) };
assert!(
matches!(&s.project.item(item).unwrap().kind, effectcraft_project::ItemKind::Footage(f) if std::path::Path::new(&f.path) == std::path::absolute("/tmp/pre/inner.mov").unwrap())
matches!(&s.project.item(item).unwrap().kind, effectcraft_project::ItemKind::Footage(f) if std::path::Path::new(&f.path) == std::path::PathBuf::from(tmp_out("pre/inner.mov")))
);
s.execute("edit.undo", json!({})).unwrap();
assert!(matches!(s.project.comp(outer).unwrap().layers[0].source, LayerSource::Comp { .. }));
@@ -331,23 +339,23 @@ fn post_render_actions_import_and_set_proxy() {
let cid = s.active_comp_id().unwrap();
let items_before = s.project.items.len();
// Import: the rendered file becomes a footage item.
let a = s.execute("renderQueue.add", json!({"output": "/tmp/post/a.mov"})).unwrap();
let a = s.execute("renderQueue.add", json!({"output": tmp_out("post/a.mov")})).unwrap();
s.execute("renderQueue.setOutputModule", json!({"item": a["item"], "postRenderAction": "import"})).unwrap();
assert_eq!(s.project.render_queue[0].post_render, PostRenderAction::Import);
s.execute("renderQueue.render", json!({})).unwrap();
assert_eq!(s.project.items.len(), items_before + 1);
assert!(s.project.items.values().any(
|i| matches!(&i.kind, effectcraft_project::ItemKind::Footage(f) if std::path::Path::new(&f.path) == std::path::absolute("/tmp/post/a.mov").unwrap())
|i| matches!(&i.kind, effectcraft_project::ItemKind::Footage(f) if std::path::Path::new(&f.path) == std::path::PathBuf::from(tmp_out("post/a.mov")))
));
// Set Proxy: the comp gets the render as its proxy.
let b = s.execute("renderQueue.add", json!({"output": "/tmp/post/b.mov"})).unwrap();
let b = s.execute("renderQueue.add", json!({"output": tmp_out("post/b.mov")})).unwrap();
s.execute("renderQueue.setOutputModule", json!({"item": b["item"], "postRenderAction": "Set Proxy"})).unwrap();
s.execute("renderQueue.render", json!({})).unwrap();
let px = s.project.item(cid).unwrap().proxy.clone().expect("proxy set");
assert_eq!(std::path::Path::new(&px.footage.path), std::path::absolute("/tmp/post/b.mov").unwrap());
assert_eq!(std::path::Path::new(&px.footage.path), std::path::PathBuf::from(tmp_out("post/b.mov")));
// None: nothing happens.
let n = s.project.items.len();
let c = s.execute("renderQueue.add", json!({"output": "/tmp/post/c.mov"})).unwrap();
let c = s.execute("renderQueue.add", json!({"output": tmp_out("post/c.mov")})).unwrap();
s.execute("renderQueue.setOutputModule", json!({"item": c["item"], "postRenderAction": "none"})).unwrap();
s.execute("renderQueue.render", json!({})).unwrap();
assert_eq!(s.project.items.len(), n);
@@ -426,9 +434,9 @@ fn save_frame_as_queues_with_the_frame_defaults() {
#[test]
fn log_notify_and_overflow_settings() {
let (mut s, _) = rq();
let a = s.execute("renderQueue.add", json!({"output": "/tmp/n/a.mp4", "log": "plusPerFrameInfo"})).unwrap();
let a = s.execute("renderQueue.add", json!({"output": tmp_out("n/a.mp4"), "log": "plusPerFrameInfo"})).unwrap();
assert_eq!(a["logLabel"], "Plus Per Frame Info");
assert_eq!(std::path::Path::new(a["logPath"].as_str().unwrap()), std::path::absolute("/tmp/n/a_RenderLog.txt").unwrap());
assert_eq!(std::path::Path::new(a["logPath"].as_str().unwrap()), std::path::PathBuf::from(tmp_out("n/a_RenderLog.txt")));
s.execute("renderQueue.setLog", json!({"item": a["item"], "log": "plusSettings"})).unwrap();
assert_eq!(s.project.render_queue[0].log, effectcraft_project::render_queue::RenderLog::PlusSettings);
s.execute("renderQueue.setOverflowFolders", json!({"folders": ["/tmp/o1", " ", "/tmp/o2"]})).unwrap();
+57
View File
@@ -260,6 +260,63 @@ fn fonts_lists_bundled_and_installed_families() {
assert_eq!(crate::font_styles("No Such Family").len(), 5);
}
/// A font file of just `name` (the family, Regular) and an `fvar` table with one axis pinned at
/// 400 (so the helpers that look for an installed font with a real axis range never pick it).
fn pinned_variable_font(family: &str) -> Vec<u8> {
let u16s = |v: &[u16]| -> Vec<u8> { v.iter().flat_map(|x| x.to_be_bytes()).collect() };
let strings: Vec<Vec<u8>> = [family, "Regular"].iter().map(|s| s.encode_utf16().flat_map(|u| u.to_be_bytes()).collect()).collect();
let mut name = u16s(&[0, 2, 30]);
let mut off = 0;
for (id, st) in strings.iter().enumerate() {
name.extend(u16s(&[3, 1, 0x409, id as u16 + 1, st.len() as u16, off]));
off += st.len() as u16;
}
strings.iter().for_each(|st| name.extend(st));
let mut fvar = u16s(&[1, 0, 16, 2, 1, 20, 0, 8]);
fvar.extend(b"wght".iter().copied().chain(u16s(&[0x190, 0, 0x190, 0, 0x190, 0, 0, 256])));
let mut out = u16s(&[1, 0, 2, 0, 0, 0]);
let mut body: Vec<u8> = vec![];
for (tag, data) in [(b"fvar", fvar), (b"name", name)] {
out.extend(tag);
out.extend([0u8; 4]);
out.extend(((12 + 32 + body.len()) as u32).to_be_bytes());
out.extend((data.len() as u32).to_be_bytes());
body.extend(data);
}
out.extend(body);
out
}
/// The font menu's Variable Fonts filter (After Effects 26.3): `text.fonts {variable: true}`
/// lists only families with variation axes, every listing says which families are variable, and
/// the font menu's rows carry the flag the filter uses.
#[test]
fn fonts_filter_variable_families() {
const FAMILY: &str = "EffectCraft Variable Filter Test";
assert_eq!(effectcraft_text::fonts::add_font_data(pinned_variable_font(FAMILY)).len(), 1);
let mut s = Session::default();
let all = s.execute("text.fonts", json!({})).unwrap();
let fams = all["families"].as_array().unwrap();
let flag = |name: &str| fams.iter().find(|f| f["family"] == name).map(|f| f["variable"].clone());
assert_eq!(flag("Inter"), Some(json!(false)), "the bundled fonts are static");
assert_eq!(flag(FAMILY), Some(json!(true)));
let var = s.execute("text.fonts", json!({"variable": true})).unwrap();
let listed = var["families"].as_array().unwrap();
assert_eq!(var["count"].as_u64(), Some(listed.len() as u64));
assert!(listed.iter().all(|f| f["variable"] == true), "{var}");
assert!(listed.iter().any(|f| f["family"] == FAMILY), "{var}");
assert!(listed.len() < fams.len(), "the filter drops the static families");
// Combined with the name query.
let both = s.execute("text.fonts", json!({"variable": true, "query": "variable filter"})).unwrap();
assert_eq!(both["families"].as_array().map(Vec::len), Some(1), "{both}");
let none = s.execute("text.fonts", json!({"variable": true, "query": "jetbrains mono"})).unwrap();
assert!(!none["families"].as_array().unwrap().iter().any(|f| f["family"] == "JetBrains Mono"), "{none}");
// The font menu's rows.
let menu = crate::font_menu(&s.prefs);
assert!(menu.iter().any(|r| r.family == FAMILY && r.variable));
assert!(menu.iter().any(|r| r.family == "Inter" && !r.variable));
}
/// `layer.setText`'s schema lists every OpenType key as optional, so checked execution (control
/// channel, MCP) accepts them and requires none (#520).
#[test]
+48
View File
@@ -235,3 +235,51 @@ fn create_masks_from_text() {
assert!(s.execute_checked("layer.create", json!({"op": "shapesFromVector", "layers": [lid]})).is_err());
assert!(s.execute_checked("layer.create", json!({"op": "nullTracing", "layers": [lid]})).is_err());
}
/// Edit ▸ Paste of SVG markup (After Effects 26.3): a shape layer with the SVG's shapes, its
/// canvas centred in the comp, above the selected layer, one undo step.
#[test]
fn paste_svg_markup_as_a_shape_layer() {
let (mut s, lid) = text_session();
let cid = s.active_comp_id().unwrap();
s.execute("layer.select", json!({"layers": [lid]})).unwrap();
let svg = r##"<?xml version="1.0"?>
<!-- copied from a design tool -->
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20" viewBox="0 0 40 20">
<rect x="0" y="0" width="20" height="20" fill="#ff0000"/>
<rect x="20" y="0" width="20" height="20" fill="#0000ff"/>
</svg>"##;
let r = s.execute_checked("edit.pasteSvg", json!({"svg": svg})).unwrap();
let sid = r["layer"].as_u64().unwrap();
let c = s.active_comp().unwrap();
assert_eq!(c.layers.len(), 2);
assert_eq!(c.layers[0].id.0, sid, "above the selected layer");
assert!(matches!(c.layers[0].source, LayerSource::Shape));
assert_eq!(c.layers[0].name, "Shape Layer 1");
assert_eq!(s.state.selected_layers, [effectcraft_project::LayerId(sid)]);
assert_eq!(c.layers[0].props.sub("contents").unwrap().groups().count(), 2);
// Hide the text so only the pasted shapes draw: the 40×20 canvas is centred in the
// 320×120 comp (x 140–180, y 50–70), red on the left, blue on the right.
s.execute("layer.setSwitch", json!({"layers": [lid], "switch": "video", "value": false})).unwrap();
let img = s.render(cid, s.time(), RenderOpts::default());
let px = |x: usize, y: usize| img.data[y * img.width as usize + x];
let red = px(150, 60);
assert!(red[3] > 0.99 && red[0] > 0.99 && red[2] < 0.01, "{red:?}");
let blue = px(170, 60);
assert!(blue[3] > 0.99 && blue[2] > 0.99 && blue[0] < 0.01, "{blue:?}");
assert!(px(130, 60)[3] < 0.01 && px(160, 45)[3] < 0.01, "nothing outside the canvas");
// One undo step removes it.
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active_comp().unwrap().layers.len(), 1);
}
#[test]
fn paste_svg_rejects_text_that_is_not_usable_svg() {
let (mut s, _) = text_session();
for bad in ["hello", "<svg", "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"10\" height=\"10\"></svg>", "<html><svg></html>"] {
assert!(s.execute_checked("edit.pasteSvg", json!({"svg": bad})).is_err(), "{bad}");
}
assert!(s.execute_checked("edit.pasteSvg", json!({})).is_err());
assert_eq!(s.active_comp().unwrap().layers.len(), 1, "nothing pasted");
}
+32 -4
View File
@@ -250,6 +250,12 @@ pub fn vector_clip_masks(doc: &Doc) -> (Vec<BezPath>, Doc) {
(masks, out)
}
/// Whether text (the system clipboard's, say) is SVG markup: Edit ▸ Paste makes it a shape
/// layer (`edit.pasteSvg`).
pub fn is_svg_text(text: &str) -> bool {
effectcraft_svg::looks_like_svg(text.as_bytes())
}
/// Whether footage is a vector file (SVG / PDF / AI / EPS).
pub fn is_vector(f: &effectcraft_project::Footage) -> bool {
effectcraft_render::is_vector_footage(f) || f.path.to_ascii_lowercase().ends_with(".svg")
@@ -471,6 +477,32 @@ pub fn image_layer(proj: &mut Project, comp: &Comp, item: ItemId, img: &VectorIm
/// are kept with Merge Paths (see [`svg_contents`]).
pub fn shapes_from_vector(proj: &mut Project, comp: &Comp, src: &Layer, doc: &Doc) -> Layer {
let mut l = build::layer(proj, comp, &format!("{} Outlines", src.name), LayerSource::Shape, (0, 0), None);
add_doc_shapes(proj, &mut l, doc);
copy_transform(proj, src, &mut l);
like_source(&mut l, src);
l.name = comp.unique_layer_name(&format!("{} Outlines", src.name));
l
}
/// Edit ▸ Paste of SVG markup (After Effects 26.3 pastes copied vector artwork as shape layers):
/// a shape layer named `name` with the document's shapes, its Anchor Point at the centre of the
/// SVG canvas and its Position at the comp centre, so the canvas lands centred in the comp.
pub fn shapes_from_svg(proj: &mut Project, comp: &Comp, doc: &Doc, name: &str) -> Layer {
let mut l = build::layer(proj, comp, name, LayerSource::Shape, (0, 0), None);
add_doc_shapes(proj, &mut l, doc);
let (w, h) = (doc.width, doc.height);
if w.is_finite()
&& h.is_finite()
&& let Some(a) = l.props.sub_mut("transform").and_then(|t| t.get_mut("anchor"))
{
a.value = Value::Vec3([w / 2.0, h / 2.0, 0.0]);
}
l
}
/// The shapes of `doc` into shape layer `l`'s Contents; clips enclosing the whole document
/// become its masks (Add, then Intersect).
fn add_doc_shapes(proj: &mut Project, l: &mut Layer, doc: &Doc) {
let (clips, doc) = vector_clip_masks(doc);
let mut ids = Ids(&mut proj.next_id);
let items = svg_contents(&mut ids, &doc);
@@ -488,10 +520,6 @@ pub fn shapes_from_vector(proj: &mut Project, comp: &Comp, src: &Layer, doc: &Do
}
}
}
copy_transform(proj, src, &mut l);
like_source(&mut l, src);
l.name = comp.unique_layer_name(&format!("{} Outlines", src.name));
l
}
/// Layer ▸ Create ▸ Create Shapes from Text: one group per character with its outline, fill and
+16 -1
View File
@@ -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);
}
}
+12 -7
View File
@@ -45,13 +45,12 @@ pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<G
let fd = 1.0 / ctx.fps();
mean_of(e, ctx, b, &(-back..=fwd).map(|k| ctx.time + k as f64 * fd).collect::<Vec<_>>())
}
"ec.time.pixelmotionblur" => {
let angle = if ctx.params.e("shutterControl") == 1 { ctx.params.f("shutterAngle") } else { 180.0 };
let n = ctx.params.f("shutterSamples").round().clamp(1.0, 64.0) as usize;
let span = angle.clamp(0.0, 720.0) / 360.0 / ctx.fps();
let times: Vec<f64> = (0..n).map(|i| ctx.time - span * 0.5 + if n > 1 { span * i as f64 / (n - 1) as f64 } else { span * 0.5 }).collect();
mean_of(e, ctx, b, &times)
}
// Pixel Motion Blur synthesizes along measured motion vectors (#535); a plan that
// cannot be built (no host) declines to the CPU effect.
"ec.time.pixelmotionblur" => match effectcraft_effects::pmb_plan(ctx) {
Some(plan) => render_plan(e, &plan),
None => None,
},
"ec.time.timewarp" => timewarp(e, ctx, b),
_ => None,
}
@@ -179,6 +178,12 @@ fn timewarp(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
return Some(b);
}
let Some(plan) = effectcraft_effects::timewarp_plan(ctx) else { return Some(b) };
render_plan(e, &plan)
}
/// Render a prepared Timewarp plan (also Pixel Motion Blur's, `effectcraft_effects::pmb_plan`):
/// every step to an image on the GPU, then the weighted average.
fn render_plan(e: &mut Enc, plan: &effectcraft_effects::TimewarpPlan) -> Option<GBuf> {
// Pixel Motion's in-between frame of frames a, b along motion field `flow`.
let motion = |e: &mut Enc, a: usize, bi: usize, w: f32, flow: usize| -> Option<GpuImage> {
let pa = e.g.upload_image(&effectcraft_effects::timewarp_crop(&plan.frames[a], &plan.pm))?;
+7
View File
@@ -448,6 +448,13 @@ fn cylinder(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
p.u[0][0] = pr.e("render");
p.f[0] = f4([c.0, c.1, r, h]);
p.f[2][0] = pr.f("rotation").to_radians() as f32;
let (rx, ry, rz) = (pr.f("rotationX"), pr.f("rotationY"), pr.f("rotationZ"));
p.u[0][1] = (rx != 0.0 || ry != 0.0 || rz != 0.0) as u32;
let m = rot_xyz(rx, ry, rz);
// Matrix rows, as for CC Sphere; the kernel uses Rᵀ for mapping.
for (i, row) in m.iter().enumerate() {
p.f[7 + i] = f4([row[0], row[1], row[2], 0.0]);
}
run(e, "ftr_cylinder", &p, b, None, None)
}
+44 -10
View File
@@ -666,7 +666,24 @@ fn ftr_faces(mode: u32, front: vec4<f32>, back: vec4<f32>) -> vec4<f32> {
return ftr_over(front, back);
}
// Cylinder: u[0].x = render; f[0] = (cx, cy, radius, layer height); f[2].x = rotation.
// Cylinder: u[0].x = render, u[0].y = axis rotation enabled;
// f[0] = (cx, cy, radius, layer height); f[2].x = legacy texture spin;
// f[7..10] = rows of the local-to-view rotation matrix.
fn ftr_cylinder_face(d: vec2<f32>, z: f32, back: bool) -> vec4<f32> {
let m0 = P.f[7].xyz;
let m1 = P.f[8].xyz;
let m2 = P.f[9].xyz;
let q = vec3<f32>(d, z);
let local = vec3<f32>(m0.x * q.x + m1.x * q.y + m2.x * q.z,
m0.y * q.x + m1.y * q.y + m2.y * q.z,
m0.z * q.x + m1.z * q.y + m2.z * q.z);
let u = (atan2(local.x, local.z) + P.f[2].x) / 6.283185307179586 + 0.5;
let v = local.y / P.f[0].w + 0.5;
let flip = select(1.0, -1.0, back);
let n = vec3<f32>(local.x / P.f[0].z * flip, 0.0, local.z / P.f[0].z * flip);
return ftr_shade(ftr_texel(u, v), vec3<f32>(dot(m0, n), dot(m1, n), dot(m2, n)));
}
@compute @workgroup_size(16, 16)
fn ftr_cylinder(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
@@ -674,18 +691,35 @@ fn ftr_cylinder(@builtin(global_invocation_id) gid: vec3<u32>) {
return;
}
let d = ftr_centre(p) - P.f[0].xy;
let v = d.y / P.f[0].w + 0.5;
let s = d.x / P.f[0].z;
if (abs(s) >= 1.0) {
if (P.u[0].y == 0u) {
// Original path is unchanged for old projects and default parameters.
let v = d.y / P.f[0].w + 0.5;
let s = d.x / P.f[0].z;
if (abs(s) >= 1.0) {
textureStore(out, p, vec4<f32>(0.0));
return;
}
let tau = 6.283185307179586;
let th = asin_p(s);
let cz = cos(th);
let spin = P.f[2].x;
let front = ftr_shade(ftr_texel((th + spin) / tau + 0.5, v), vec3<f32>(s, 0.0, cz));
let back = ftr_shade(ftr_texel((3.141592653589793 - th + spin) / tau + 0.5, v), vec3<f32>(-s, 0.0, cz));
textureStore(out, p, ftr_faces(P.u[0].x, front, back));
return;
}
let axis = vec3<f32>(P.f[7].y, P.f[8].y, P.f[9].y);
let a = 1.0 - axis.z * axis.z;
let da = dot(d, axis.xy);
let bq = -da * axis.z;
let disc = bq * bq - a * (dot(d, d) - da * da - P.f[0].z * P.f[0].z);
if (a <= 1e-10 || !(disc > 0.0)) {
textureStore(out, p, vec4<f32>(0.0));
return;
}
let tau = 6.283185307179586;
let th = asin_p(s);
let cz = cos(th);
let spin = P.f[2].x;
let front = ftr_shade(ftr_texel((th + spin) / tau + 0.5, v), vec3<f32>(s, 0.0, cz));
let back = ftr_shade(ftr_texel((3.141592653589793 - th + spin) / tau + 0.5, v), vec3<f32>(-s, 0.0, cz));
let root = sqrt(disc);
let front = ftr_cylinder_face(d, (-bq + root) / a, false);
let back = ftr_cylinder_face(d, (-bq - root) / a, true);
textureStore(out, p, ftr_faces(P.u[0].x, front, back));
}
+15 -8
View File
@@ -621,18 +621,25 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3<u32>) {
if (P.u[0].y == 1u) {
px = vec4<f32>(max((c + br - 0.5) * k + 0.5, vec3<f32>(0.0)) * a, a);
} else if (P.u[0].y == 2u) {
// Brightness & Contrast tone curves (effects crate bc_modern).
// Brightness & Contrast tone curves (effects crate bc_modern, AE-fitted #681).
var o = c;
let pb = exp2(-br);
let g = exp2(2.0 * ct);
let g = max(1.0 + 0.53 * ct, 0.05);
for (var i = 0; i < 3; i++) {
if (c[i] < 1.0) {
let v = powz(max(c[i], 0.0), pb);
if (v < 0.5) {
o[i] = 0.5 * powz(2.0 * v, g);
} else {
o[i] = 1.0 - 0.5 * powz(max(2.0 - 2.0 * v, 0.0), g);
var v = max(c[i], 0.0);
if (br > 0.0) {
v = 1.0 - powz(1.0 - v, exp2(1.24 * br));
} else if (br < 0.0) {
v = powz(v, exp2(-1.32 * br));
}
if (ct != 0.0) {
if (v < 0.5) {
v = 0.5 * powz(2.0 * v, g);
} else {
v = 1.0 - 0.5 * powz(max(2.0 - 2.0 * v, 0.0), g);
}
}
o[i] = v;
}
}
px = vec4<f32>(o * a, a);
+18
View File
@@ -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");
}
+5
View File
@@ -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))]);
+58
View File
@@ -0,0 +1,58 @@
//! Animated GIF footage through the wired session (#651): the imported item is a movie whose
//! frames follow the file's own delays in preview rendering, not one frozen still.
use effectcraft_engine::render::RenderOpts;
use effectcraft_project::{ItemId, ItemKind};
use effectcraft_time::Tick;
use serde_json::json;
fn tmp(name: &str) -> String {
let d = std::env::temp_dir().join(format!("effectcraft-host-gif-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
d.join(name).to_string_lossy().to_string()
}
/// A 3-frame 16×16 GIF: red, green, blue, one tenth of a second each.
fn animated_gif() -> Vec<u8> {
let mut out = Vec::new();
{
let mut enc = gif::Encoder::new(&mut out, 16, 16, &[]).unwrap();
for rgb in [[255u8, 0, 0], [0, 255, 0], [0, 0, 255]] {
let mut f = gif::Frame::from_rgb(16, 16, &rgb.repeat(256));
f.delay = 10;
enc.write_frame(&f).unwrap();
}
}
out
}
fn centre(s: &effectcraft_engine::Session, comp: ItemId, t: f64) -> [f32; 3] {
let img = s.render(comp, Tick::from_seconds_f64(t), RenderOpts::default());
let p = img.data[8 * 16 + 8];
[p[0], p[1], p[2]]
}
#[test]
fn an_imported_animated_gif_plays_its_frames() {
let mut s = effectcraft_host::session();
let path = tmp("anim.gif");
std::fs::write(&path, animated_gif()).unwrap();
s.execute("comp.new", json!({"name": "Gif", "width": 16, "height": 16, "frameRate": 10, "duration": 1})).unwrap();
let before = s.project.items.len();
s.execute("file.import", json!({"paths": [path.clone()], "addToComp": true})).unwrap();
let f = match s.project.items.values().find_map(|i| match &i.kind {
ItemKind::Footage(f) if f.path == path => Some(f),
_ => None,
}) {
Some(f) => f.clone(),
None => panic!("no footage item for the GIF"),
};
assert_eq!(s.project.items.len(), before + 1, "one footage item, not a comp");
assert_eq!((f.codec.as_str(), f.has_video), ("GIF", true), "probed as an animated movie");
let cid = s.active_comp_id().unwrap();
let (r, g, b) = (centre(&s, cid, 0.05), centre(&s, cid, 0.15), centre(&s, cid, 0.25));
let _ = std::fs::remove_file(&path);
assert!(r[0] > 0.9 && r[1] < 0.1, "frame at 0.05 s is red, got {r:?}");
assert!(g[1] > 0.9 && g[0] < 0.1, "frame at 0.15 s is green, got {g:?}");
assert!(b[2] > 0.9 && b[0] < 0.1, "frame at 0.25 s is blue, got {b:?}");
}
+32
View File
@@ -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))];
+18
View File
@@ -169,6 +169,24 @@ impl Gradient {
let a = sample_stops(&self.opacities, t, |a, b, f| a + (b - a) * f as f32).unwrap_or(1.0);
[col[0], col[1], col[2], col[3] * a]
}
/// The colour stops alone at `t` (0..1), RGBA as stored: unlike [`Gradient::sample`], the
/// opacity stops are not applied. A new colour stop takes this value.
pub fn color_at(&self, t: f64) -> [f32; 4] {
sample_stops(&self.colors, t.clamp(0.0, 1.0), |a, b, f| {
let mut o = [0.0; 4];
for i in 0..4 {
o[i] = a[i] + (b[i] - a[i]) * f as f32;
}
o
})
.unwrap_or([1.0; 4])
}
/// The opacity stops alone at `t` (0..1). A new opacity stop takes this value.
pub fn opacity_at(&self, t: f64) -> f32 {
sample_stops(&self.opacities, t.clamp(0.0, 1.0), |a, b, f| a + (b - a) * f as f32).unwrap_or(1.0)
}
}
fn sample_stops<T: Copy>(stops: &[(f64, T)], t: f64, lerp: impl Fn(T, T, f64) -> T) -> Option<T> {
+20 -10
View File
@@ -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)]
+73 -22
View File
@@ -75,34 +75,21 @@ pub fn read_exr_channels(bytes: &[u8]) -> Option<AuxChannels> {
}
};
collect_meta(&img.attributes.other);
for layer in img.layer_data.iter() {
let n = w.checked_mul(h)?;
// The decoded image is consumed channel by channel, so each channel's samples are freed (or
// moved) as its plane is made instead of the whole file being held twice (#474).
for layer in img.layer_data {
collect_meta(&layer.attributes.other);
let prefix = layer.attributes.layer_name.as_ref().map(|t| t.to_string()).filter(|s| !s.is_empty());
let (lw, lh) = (layer.size.0, layer.size.1);
let size = (layer.size.0, layer.size.1);
let pos = layer.attributes.layer_position;
let (ox, oy) = (pos.0 as i64 - dw.position.0 as i64, pos.1 as i64 - dw.position.1 as i64);
for ch in layer.channel_data.list.iter() {
let offset = (i64::from(pos.0) - i64::from(dw.position.0), i64::from(pos.1) - i64::from(dw.position.1));
for ch in layer.channel_data.list {
let name = channel_name(prefix.as_deref(), &ch.name.to_string());
let fill = if is_depth(&name) { BACKGROUND_DEPTH } else { 0.0 };
let mut plane = vec![fill; w * h];
let vals: Vec<f32> = ch.sample_data.values_as_f32().collect();
if vals.len() < lw * lh {
continue;
if let Some(plane) = place(ch.sample_data, size, offset, (w, h), n, fill) {
aux.channels.push((name, plane));
}
for y in 0..lh {
let ty = y as i64 + oy;
if ty < 0 || ty >= h as i64 {
continue;
}
for x in 0..lw {
let tx = x as i64 + ox;
if tx < 0 || tx >= w as i64 {
continue;
}
plane[ty as usize * w + tx as usize] = vals[y * lw + x];
}
}
aux.channels.push((name, plane));
}
}
for (_, (name, manifest)) in crypto {
@@ -114,6 +101,45 @@ pub fn read_exr_channels(bytes: &[u8]) -> Option<AuxChannels> {
Some(aux)
}
/// One channel's samples (`size`, at `offset` in the `display` window of `n` pixels) as a
/// display-window plane, `fill` outside its data window. A channel covering exactly the display
/// window is converted in one pass, and an f32 one is moved without a copy. `None` when the
/// channel has fewer samples than its size.
fn place(samples: exr::image::FlatSamples, size: (usize, usize), offset: (i64, i64), display: (usize, usize), n: usize, fill: f32) -> Option<Vec<f32>> {
use exr::image::FlatSamples;
let (lw, lh) = size;
if samples.len() < lw.checked_mul(lh)? {
return None;
}
if size == display && offset == (0, 0) {
return Some(match samples {
FlatSamples::F32(mut v) => {
v.truncate(n);
v
}
FlatSamples::F16(v) => v.iter().take(n).map(|s| s.to_f32()).collect(),
FlatSamples::U32(v) => v.iter().take(n).map(|s| *s as f32).collect(),
});
}
let value = |i: usize| match &samples {
FlatSamples::F32(v) => v.get(i).copied(),
FlatSamples::F16(v) => v.get(i).map(|s| s.to_f32()),
FlatSamples::U32(v) => v.get(i).map(|s| *s as f32),
};
let (w, h) = display;
let mut plane = vec![fill; n];
for y in 0..lh {
let Some(ty) = usize::try_from(y as i64 + offset.1).ok().filter(|ty| *ty < h) else { continue };
for x in 0..lw {
let Some(tx) = usize::try_from(x as i64 + offset.0).ok().filter(|tx| *tx < w) else { continue };
if let (Some(v), Some(p)) = (value(y * lw + x), plane.get_mut(ty * w + tx)) {
*p = v;
}
}
}
Some(plane)
}
/// Names of the beauty pass, most preferred first, matched against the last part of a layer's
/// name (Blender's `ViewLayer.Combined` is `Combined`): Blender's Combined pass and its
/// compositor's File Output default `Image`, the beauty / rgba / rgb / color of Nuke, Arnold and
@@ -262,6 +288,31 @@ mod tests {
assert!(read_exr_channels(b"not an exr").is_none());
}
/// #474: a full-window f32 channel (Cryptomatte ranks, …) becomes its plane without a copy,
/// so a decode no longer holds the file's samples twice; other channels are placed in the
/// display window as before.
#[test]
fn channels_become_planes_without_an_extra_copy() {
use exr::image::FlatSamples;
let v = vec![0.25f32; 6];
let ptr = v.as_ptr();
let plane = place(FlatSamples::F32(v), (3, 2), (0, 0), (3, 2), 6, 0.0).unwrap();
assert_eq!(plane.as_ptr(), ptr, "moved, not copied");
assert_eq!(plane, [0.25; 6]);
let half = |x: f32| exr::prelude::f16::from_f32(x);
let full = place(FlatSamples::F16(vec![half(0.5); 6]), (3, 2), (0, 0), (3, 2), 6, 0.0).unwrap();
assert_eq!(full, [0.5; 6]);
// A 2×1 data window at (1, 1) of a 3×2 display window; the rest is the fill.
let part = place(FlatSamples::F16(vec![half(1.0), half(2.0)]), (2, 1), (1, 1), (3, 2), 6, 9.0).unwrap();
assert_eq!(part, [9.0, 9.0, 9.0, 9.0, 1.0, 2.0]);
// Partly outside the display window: clipped, never out of bounds.
let off = place(FlatSamples::U32(vec![7, 8]), (2, 1), (2, -1), (3, 2), 6, 0.0).unwrap();
assert_eq!(off, [0.0; 6]);
let edge = place(FlatSamples::U32(vec![7, 8]), (2, 1), (2, 0), (3, 2), 6, 0.0).unwrap();
assert_eq!(edge, [0.0, 0.0, 7.0, 0.0, 0.0, 0.0]);
assert!(place(FlatSamples::F32(vec![1.0; 5]), (3, 2), (0, 0), (3, 2), 6, 0.0).is_none(), "too few samples");
}
/// #295: a multi-layer EXR whose colour is only in named layers (`diffuse.R`, as Blender and
/// Nuke write them) failed to import ("does not contain non-deep rgb channels"); it now
/// shows its colour layer and keeps every channel for EXtractoR.
+1 -1
View File
@@ -58,7 +58,7 @@ pub const AUDIO_EXTENSIONS: &[&str] = filmcraft_media::AUDIO_EXTENSIONS;
pub const VIDEO_EXTENSIONS: &[&str] = &["mp4", "m4v", "mov", "mkv", "webm"];
/// File extensions recognised as 3D models (Advanced 3D model layers).
pub const MODEL_EXTENSIONS: &[&str] = &["gltf", "glb", "obj"];
pub const MODEL_EXTENSIONS: &[&str] = &["gltf", "glb", "obj", "stl"];
/// Whether `path` has an extension we can import.
pub fn is_importable(path: &std::path::Path) -> bool {
+67 -25
View File
@@ -6,7 +6,7 @@ use std::sync::{Arc, Condvar, Mutex, OnceLock};
use effectcraft_project::{AlphaMode, Footage, FootageKind, ItemId};
use effectcraft_raster::{Image, Px};
use effectcraft_render::FootageSource;
use effectcraft_render::{FloatCurve, FootageSource};
use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
use filmcraft_media::{FrameRequest, SharedSource};
@@ -38,8 +38,8 @@ fn colour_bars(w: u32, h: u32) -> Image {
struct Key {
path: Arc<str>,
frame: i64,
/// The alpha interpretation (bits 0–1) and Preserve RGB (bit 2): they change the decoded
/// pixels.
/// The alpha interpretation (bits 0–1) and how float values are encoded (bits 2–3: sRGB,
/// as stored, a 2.4 power curve): they change the decoded pixels.
interp: u8,
matte: [u32; 3],
/// A movie frame made at this size for a reduced-resolution render ((0, 0): as decoded).
@@ -233,6 +233,8 @@ struct Loc {
key: Key,
/// Media time for movies (`None`: an image file, `key.path`).
media_t: Option<Tick>,
/// How float values are encoded (also in `key.interp`).
float: FloatCurve,
}
/// Removes an in-flight key and wakes waiters, also when decoding panics.
@@ -345,15 +347,15 @@ impl MediaPool {
/// Decode (or fetch from cache) the frame of `footage` at source time `t`.
pub fn frame_at(&self, footage: &Footage, t: Tick) -> Result<Arc<Image>> {
Inner::frame_at(&self.inner, footage, t, true, None)
Inner::frame_at(&self.inner, footage, t, true, None, FloatCurve::Srgb)
}
/// [`FootageSource::frame`] / [`FootageSource::frame_at_size`]: `None` (logged) on errors.
fn frame_sized(&self, _item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>) -> Option<Arc<Image>> {
fn frame_sized(&self, _item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) -> Option<Arc<Image>> {
if footage.missing {
return None;
}
match Inner::frame_at(&self.inner, footage, t, true, size) {
match Inner::frame_at(&self.inner, footage, t, true, size, float) {
Ok(img) => Some(img),
Err(e) => {
log::warn!("media: frame of {} at {:?}: {e}", footage.path, t);
@@ -520,7 +522,7 @@ impl MediaPool {
/// A thumbnail of `footage` (its first frame) fitting in `max_side` × `max_side`, aspect kept.
pub fn thumbnail(&self, footage: &Footage, max_side: u32) -> Result<Image> {
let img = Inner::frame_at(&self.inner, footage, Tick::ZERO, false, None)?;
let img = Inner::frame_at(&self.inner, footage, Tick::ZERO, false, None, FloatCurve::Srgb)?;
let (w, h) = (img.width.max(1), img.height.max(1));
let s = (max_side.max(1) as f64 / w.max(h) as f64).min(1.0);
if s >= 1.0 {
@@ -618,22 +620,28 @@ impl Inner {
((d.0 as i128 * rate.num as i128 + den / 2) / den) as i64
}
fn locate(footage: &Footage, t: Tick) -> Loc {
fn locate(footage: &Footage, t: Tick, float: FloatCurve) -> Loc {
let alpha = match footage.alpha {
AlphaMode::Straight => 0,
AlphaMode::Premultiplied => 1,
AlphaMode::Ignore => 2,
};
let interp = alpha | (u8::from(footage.preserve_rgb) << 2);
let float = AlphaOp::of(footage, float).float_curve();
let interp = alpha
| (match float {
FloatCurve::Srgb => 0,
FloatCurve::Linear => 1,
FloatCurve::Gamma24 => 2,
} << 2);
let matte = if footage.alpha == AlphaMode::Premultiplied { footage.premul_color.map(f32::to_bits) } else { [0; 3] };
let key = |path: &str, frame| Key { path: path.into(), frame, interp, matte, size: (0, 0) };
match footage.kind {
FootageKind::Sequence if !footage.sequence.is_empty() => {
let i = Self::frame_index(footage.frame_rate, t, footage.sequence_frames(), footage.loop_count);
match footage.sequence_file(i) {
Some(path) => Loc { key: key(path, 0), media_t: None },
Some(path) => Loc { key: key(path, 0), media_t: None, float },
// A gap in the numbering: colour bars, as in After Effects.
None => Loc { key: key(PLACEHOLDER, ((footage.width as i64) << 32) | footage.height as i64), media_t: None },
None => Loc { key: key(PLACEHOLDER, ((footage.width as i64) << 32) | footage.height as i64), media_t: None, float },
}
}
// A layer of a layered still is keyed by its layer index and size mode (smart objects
@@ -641,9 +649,9 @@ impl Inner {
FootageKind::Still if footage.layer.is_some() || footage.page != 0 => {
let l = footage.layer.as_ref().map_or(0, |l| 1 + l.index as i64 * 2 + l.layer_size as i64 + if l.embedded.is_some() { 1 << 40 } else { 0 })
+ ((footage.page as i64) << 42);
Loc { key: key(&footage.path, l), media_t: None }
Loc { key: key(&footage.path, l), media_t: None, float }
}
FootageKind::Still | FootageKind::Sequence | FootageKind::Model | FootageKind::Data => Loc { key: key(&footage.path, 0), media_t: None },
FootageKind::Still | FootageKind::Sequence | FootageKind::Model | FootageKind::Data => Loc { key: key(&footage.path, 0), media_t: None, float },
FootageKind::Video | FootageKind::Audio => {
let rate = footage.frame_rate;
let n = Self::frame_count(rate, footage.duration);
@@ -651,18 +659,18 @@ impl Inner {
// Middle of the frame in the file's own timing (robust to timestamp rounding).
let native = footage.native_rate.unwrap_or(rate);
let mt = native.tick_of(i) + Tick(native.frame_duration().0 / 2);
Loc { key: key(&footage.path, i), media_t: Some(mt) }
Loc { key: key(&footage.path, i), media_t: Some(mt), float }
}
}
}
/// The frame of `footage` at `t`; a movie's resampled to `size` when it can be made at that
/// size directly (see [`FootageSource::frame_at_size`]).
fn frame_at(self: &Arc<Self>, footage: &Footage, t: Tick, playback: bool, size: Option<(u32, u32)>) -> Result<Arc<Image>> {
fn frame_at(self: &Arc<Self>, footage: &Footage, t: Tick, playback: bool, size: Option<(u32, u32)>, float: FloatCurve) -> Result<Arc<Image>> {
if !footage.has_video {
return Err(MediaError::Unsupported(format!("{}: no video", footage.path)));
}
let mut loc = Self::locate(footage, t);
let mut loc = Self::locate(footage, t, float);
let movie = loc.media_t.is_some();
if movie && let Some(size) = size {
loc.key.size = size;
@@ -673,10 +681,10 @@ impl Inner {
let prev = c.last.insert(loc.key.path.clone(), loc.key.frame);
if prev == Some(loc.key.frame - 1) && self.read_ahead.load(Ordering::Relaxed) {
let next_t = t + footage.frame_rate.frame_duration();
let mut next = Self::locate(footage, next_t);
let mut next = Self::locate(footage, next_t, float);
next.key.size = loc.key.size;
if next.key != loc.key && !c.lru.map.contains_key(&next.key) && !c.inflight.contains(&next.key) {
self.prefetch(footage, next_t, size);
self.prefetch(footage, next_t, size, float);
}
}
}
@@ -709,20 +717,20 @@ impl Inner {
}
#[cfg(not(target_arch = "wasm32"))]
fn prefetch(self: &Arc<Self>, footage: &Footage, t: Tick, size: Option<(u32, u32)>) {
fn prefetch(self: &Arc<Self>, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) {
let (me, f) = (self.clone(), footage.clone());
self.prefetched.fetch_add(1, Ordering::Relaxed);
// A plain thread, not a rayon job: the decode below parallelises with rayon itself.
std::thread::spawn(move || {
let _ = me.frame_at(&f, t, false, size);
let _ = me.frame_at(&f, t, false, size, float);
});
}
#[cfg(target_arch = "wasm32")]
fn prefetch(self: &Arc<Self>, _footage: &Footage, _t: Tick, _size: Option<(u32, u32)>) {}
fn prefetch(self: &Arc<Self>, _footage: &Footage, _t: Tick, _size: Option<(u32, u32)>, _float: FloatCurve) {}
fn decode(&self, loc: &Loc, footage: &Footage) -> Result<Image> {
let op = AlphaOp::of(footage);
let op = AlphaOp::of(footage, loc.float);
let path = &loc.key.path;
match loc.media_t {
None if &**path == PLACEHOLDER => Ok(colour_bars(footage.width, footage.height)),
@@ -792,10 +800,13 @@ impl FootageSource for MediaPool {
Some(self.budget())
}
fn frame(&self, item: ItemId, footage: &Footage, t: Tick) -> Option<Arc<Image>> {
self.frame_sized(item, footage, t, None)
self.frame_sized(item, footage, t, None, FloatCurve::Srgb)
}
fn frame_at_size(&self, item: ItemId, footage: &Footage, t: Tick, width: u32, height: u32) -> Option<Arc<Image>> {
self.frame_sized(item, footage, t, Some((width, height)))
self.frame_sized(item, footage, t, Some((width, height)), FloatCurve::Srgb)
}
fn frame_with(&self, item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) -> Option<Arc<Image>> {
self.frame_sized(item, footage, t, size, float)
}
fn vector_frame(&self, _item: ItemId, footage: &Footage, scale: f64) -> Option<Arc<Image>> {
@@ -810,7 +821,7 @@ impl FootageSource for MediaPool {
if footage.missing || !footage.has_video {
return None;
}
let loc = Inner::locate(footage, t);
let loc = Inner::locate(footage, t, FloatCurve::Srgb);
if loc.media_t.is_some() || !loc.key.path.to_ascii_lowercase().ends_with(".exr") {
return None;
}
@@ -1018,4 +1029,35 @@ mod tests {
assert_eq!(raw, [0.18, 0.5, 2.0, 1.0]);
assert_eq!(pool.frame_at(&f, Tick::ZERO).unwrap().get(0, 0), encoded, "the default interpretation is cached apart");
}
/// #750: float footage is handed over as the renderer asks: sRGB-encoded, with Working
/// Gamma 2.4's pure power curve, or as stored (Interpret As Linear Light encodes it then);
/// Preserve RGB reads it as stored whatever is asked. Each decodes and caches apart.
#[test]
fn float_exr_is_encoded_with_the_requested_curve() {
use exr::prelude::*;
let (w, h) = (2usize, 2usize);
let ch = |n: &str, v: f32| AnyChannel::new(n, FlatSamples::F32(vec![v; w * h]));
let channels = AnyChannels::sort(vec![ch("R", 0.051), ch("G", 0.18), ch("B", 2.0), ch("A", 1.0)].into());
let image = exr::image::Image::from_layer(Layer::new((w, h), LayerAttributes::default(), Encoding::FAST_LOSSLESS, channels));
let mut bytes = std::io::Cursor::new(Vec::new());
image.write().to_buffered(&mut bytes).unwrap();
let b: Arc<[u8]> = bytes.into_inner().into();
let pool = MediaPool::new();
pool.add_bytes("/linear.exr", b.clone());
let f = crate::probe_bytes("/linear.exr", b).unwrap();
let px = |f: &Footage, float| pool.frame_with(ItemId(1), f, Tick::ZERO, None, float).unwrap().get(0, 0);
let near = |o: [f32; 4], want: [f32; 3]| (0..3).all(|i| (o[i] - want[i]).abs() < 1e-3);
let srgb = px(&f, FloatCurve::Srgb);
assert!(near(srgb, [0.2503, 0.4614, 1.3532]), "sRGB: {srgb:?}");
assert_eq!(pool.frame(ItemId(1), &f, Tick::ZERO).unwrap().get(0, 0), srgb, "frame() is sRGB");
let g24 = px(&f, FloatCurve::Gamma24);
assert!(near(g24, [0.2898, 0.4898, 2.0f32.powf(1.0 / 2.4)]), "2.4: {g24:?}");
assert_eq!(px(&f, FloatCurve::Linear), [0.051, 0.18, 2.0, 1.0]);
let preserve = Footage { preserve_rgb: true, ..f.clone() };
assert_eq!(px(&preserve, FloatCurve::Gamma24), [0.051, 0.18, 2.0, 1.0]);
// Each curve is cached apart: asking again returns the same pixels.
assert_eq!(px(&f, FloatCurve::Srgb), srgb);
assert_eq!(px(&f, FloatCurve::Gamma24), g24);
}
}
+2 -1
View File
@@ -23,7 +23,7 @@ pub fn probe(path: impl AsRef<Path>) -> Result<Footage> {
probe_bytes(&path.to_string_lossy(), bytes.into())
}
/// Probe a 3D model (glTF 2.0 / OBJ): a [`FootageKind::Model`] item whose duration is its
/// Probe a 3D model (glTF 2.0 / OBJ / STL): a [`FootageKind::Model`] item whose duration is its
/// longest animation. `resolve` reads sibling resources.
pub fn probe_model(path: &str, bytes: &[u8], resolve: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<Footage> {
let m = effectcraft_model::load(path, bytes, resolve).map_err(|e| MediaError::Decode(format!("{path}: {e}")))?;
@@ -31,6 +31,7 @@ pub fn probe_model(path: &str, bytes: &[u8], resolve: &dyn Fn(&str) -> Option<Ve
let dur = s.animations.iter().map(|a| a.1).fold(0.0f64, f64::max);
let codec = match effectcraft_model::format_of(path) {
Some("obj") => "OBJ",
Some("stl") => "STL",
Some("glb") => "GLB",
_ => "glTF",
};
+10
View File
@@ -674,3 +674,13 @@ fn conformed_flac_audio_reads_back_like_the_decoder() {
assert_eq!(pool.audio_samples(&f, at, 4800, 48_000), decoded);
let _ = std::fs::remove_dir_all(&folder);
}
/// File ▸ Import of an STL part: importable by extension and probed as a 3D model item.
#[test]
fn stl_imports_as_a_model() {
assert!(effectcraft_media::is_importable(Path::new("part.stl")));
let ascii = "solid part\nfacet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid part\n";
let f = effectcraft_media::probe_bytes("part.stl", Arc::from(ascii.as_bytes())).expect("probe");
assert_eq!(f.kind, FootageKind::Model);
assert_eq!(f.codec, "STL (1 meshes, 1 triangles)");
}
+1 -1
View File
@@ -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
+6 -2
View File
@@ -5,6 +5,7 @@
//! metallic-roughness, normal, occlusion and emissive maps), the node hierarchy, skins and
//! animations.
//! - [`obj`]: Wavefront OBJ geometry with MTL materials (mapped onto the metallic-roughness model).
//! - [`stl`]: STL meshes (binary and ASCII, with the binary per-facet and part colours).
//! - [`prim`]: parametric primitives (cube, sphere, plane, torus, cone, cylinder).
//! - [`extrude`]: extruded, bevelled meshes from 2D outlines (text and shape layers), with
//! [`triangulate`] for the caps.
@@ -18,6 +19,7 @@ pub mod extrude;
pub mod gltf;
pub mod obj;
pub mod prim;
pub mod stl;
pub mod triangulate;
use std::sync::Arc;
@@ -720,13 +722,14 @@ pub fn skin_point(inst: &Instance, p: &Primitive, k: usize, v: [f32; 3]) -> [f64
[q.x, q.y, q.z]
}
/// Which loader handles a file name (`gltf`, `glb`, `obj`), by extension.
/// Which loader handles a file name (`gltf`, `glb`, `obj`, `stl`), by extension.
pub fn format_of(path: &str) -> Option<&'static str> {
let ext = path.rsplit('.').next()?.to_ascii_lowercase();
match ext.as_str() {
"gltf" => Some("gltf"),
"glb" => Some("glb"),
"obj" => Some("obj"),
"stl" => Some("stl"),
_ => None,
}
}
@@ -736,9 +739,10 @@ pub fn format_of(path: &str) -> Option<&'static str> {
pub fn load(path: &str, bytes: &[u8], resolve: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<Model> {
match format_of(path) {
Some("obj") => obj::parse(bytes, resolve),
Some("stl") => stl::parse(bytes),
Some(_) => gltf::parse(bytes, resolve),
None if bytes.starts_with(b"glTF") => gltf::parse(bytes, resolve),
None => Err(ModelError::Unsupported(format!("{path}: not a glTF or OBJ file"))),
None => Err(ModelError::Unsupported(format!("{path}: not a glTF, OBJ or STL file"))),
}
}
+203
View File
@@ -0,0 +1,203 @@
//! STL (stereolithography) meshes, binary and ASCII.
//!
//! Written from the public description of the format (3D Systems' StereoLithography Interface
//! Specification, 1989, and its widely documented colour extensions):
//!
//! - Binary: an 80-byte header, a little-endian `u32` facet count, then 50 bytes per facet: a
//! normal and three vertices (12 `f32`) and a 16-bit attribute word. Bytes after the last
//! facet are ignored.
//! - ASCII: `solid name`, then `facet normal nx ny nz` / `outer loop` / `vertex x y z`… /
//! `endloop` / `endfacet`, closed by `endsolid`; several solids may follow each other.
//! Loops with more than three vertices are fan-triangulated.
//! - Colour (binary only): with `COLOR=r g b a` (four bytes) in the header (Materialise Magics),
//! that is the part's colour and a facet whose attribute has bit 15 clear has its own colour
//! (red in bits 0–4, green 5–9, blue 10–14); otherwise (VisCAM / SolidView) a facet with bit 15
//! set has its own colour (blue in bits 0–4, green 5–9, red 10–14). Each colour becomes a
//! plain dielectric material; uncoloured files get none (the renderer's default material).
//!
//! STL has no units, no UVs and no shared vertices: every facet keeps its own three vertices
//! with the face normal from its counter-clockwise winding (the stored normal is only used when
//! the triangle is degenerate), which gives the faceted look of an STL part. STL files are
//! Z-up (3D printing and CAD); they are turned to the +Y-up convention of [`crate::Model`]
//! (`(x, y, z)` → `(x, z, −y)`, a proper rotation, so winding is kept). Facets with non-finite
//! coordinates are skipped.
use std::collections::HashMap;
use crate::{Material, Mesh, Model, Primitive, Result, cross, normalize, obj::srgb_to_linear, perr, sub};
/// Header (80) plus facet count (4).
const HEADER: usize = 84;
/// Bytes per binary facet.
const FACET: usize = 50;
/// Parse an STL file (binary or ASCII, detected from the contents).
pub fn parse(bytes: &[u8]) -> Result<Model> {
let count = bytes.get(80..HEADER).and_then(|b| <[u8; 4]>::try_from(b).ok()).map(u32::from_le_bytes);
let needed = count.and_then(|n| (n as usize).checked_mul(FACET)).and_then(|n| n.checked_add(HEADER));
// A binary file's size is exact; many binary headers still begin with `solid`, so the size
// decides first, then the ASCII keyword.
if needed.is_some_and(|n| n == bytes.len()) {
return binary(bytes);
}
if looks_ascii(bytes) {
return ascii(bytes);
}
if needed.is_some_and(|n| n <= bytes.len()) {
return binary(bytes);
}
Err(perr("not an STL file (truncated binary facets or no `solid` keyword)"))
}
fn looks_ascii(bytes: &[u8]) -> bool {
let head = bytes.get(..bytes.len().min(512)).unwrap_or(&[]);
let text = String::from_utf8_lossy(head);
let t = text.trim_start();
t.get(..5).is_some_and(|k| k.eq_ignore_ascii_case("solid")) && (t.contains("facet") || t.contains("endsolid"))
}
/// The Z-up → Y-up turn.
fn y_up(v: [f32; 3]) -> [f32; 3] {
[v[0], v[2], -v[1]]
}
/// Facets grouped into primitives by colour, in order of first use.
#[derive(Default)]
struct Builder {
/// (colour key, primitive); `None` = uncoloured.
prims: Vec<(Option<[u8; 3]>, Primitive)>,
/// Colour → index into `prims` (up to 32 768 colours, so not a linear search).
slots: HashMap<Option<[u8; 3]>, usize>,
facets: usize,
}
impl Builder {
/// Add one triangle (file coordinates); `stored` is the file's facet normal.
fn tri(&mut self, color: Option<[u8; 3]>, stored: [f32; 3], v: [[f32; 3]; 3]) {
if v.iter().flatten().any(|c| !c.is_finite()) {
return;
}
let v = v.map(y_up);
let n = normalize(cross(sub(v[1], v[0]), sub(v[2], v[0])))
.or_else(|| stored.iter().all(|c| c.is_finite()).then(|| normalize(y_up(stored))).flatten())
.unwrap_or([0.0, 1.0, 0.0]);
let slot = *self.slots.entry(color).or_insert_with(|| {
self.prims.push((color, Primitive::default()));
self.prims.len() - 1
});
let Some((_, p)) = self.prims.get_mut(slot) else { return };
for c in v {
p.indices.push(p.positions.len() as u32);
p.positions.push(c);
p.normals.push(n);
}
self.facets += 1;
}
fn finish(self, name: String) -> Result<Model> {
if self.facets == 0 {
return Err(perr("STL file without facets"));
}
let mut materials = vec![];
let mut mesh = Mesh { name, primitives: vec![] };
for (color, mut p) in self.prims {
if let Some(c) = color {
let lin = c.map(|x| srgb_to_linear(x as f32 / 255.0));
let name = format!("#{:02X}{:02X}{:02X}", c[0], c[1], c[2]);
materials.push(Material { name, ..Material::plain([lin[0], lin[1], lin[2], 1.0], 0.0, 0.5) });
p.material = Some(materials.len() - 1);
}
mesh.primitives.push(p);
}
// Uncoloured facets next to coloured ones: a neutral grey of their own.
if !materials.is_empty() {
for p in mesh.primitives.iter_mut().filter(|p| p.material.is_none()) {
materials.push(Material { name: "STL".into(), ..Material::plain([0.6, 0.6, 0.6, 1.0], 0.0, 0.5) });
p.material = Some(materials.len() - 1);
}
}
Ok(Model::single(mesh, materials))
}
}
fn f32_at(b: &[u8], at: usize) -> f32 {
b.get(at..at + 4).and_then(|s| <[u8; 4]>::try_from(s).ok()).map_or(f32::NAN, f32::from_le_bytes)
}
/// A 5-bit channel expanded to 8 bits.
fn five(x: u16) -> u8 {
let x = (x & 31) as u8;
(x << 3) | (x >> 2)
}
/// The Magics `COLOR=` part colour in the header (RGB; alpha is ignored).
fn header_color(header: &[u8]) -> Option<[u8; 3]> {
let at = header.windows(6).position(|w| w == b"COLOR=")?;
let c = header.get(at + 6..at + 10)?;
Some([*c.first()?, *c.get(1)?, *c.get(2)?])
}
fn binary(bytes: &[u8]) -> Result<Model> {
let header = bytes.get(..80).ok_or_else(|| perr("STL header truncated"))?;
let magics = header_color(header);
let body = bytes.get(HEADER..).unwrap_or(&[]);
let count = bytes.get(80..HEADER).and_then(|b| <[u8; 4]>::try_from(b).ok()).map_or(0, u32::from_le_bytes) as usize;
let mut b = Builder::default();
for f in body.as_chunks::<FACET>().0.iter().take(count) {
let n = [f32_at(f, 0), f32_at(f, 4), f32_at(f, 8)];
let v = [0usize, 1, 2].map(|k| [f32_at(f, 12 + 12 * k), f32_at(f, 16 + 12 * k), f32_at(f, 20 + 12 * k)]);
let attr = f.get(48..50).and_then(|a| <[u8; 2]>::try_from(a).ok()).map_or(0, u16::from_le_bytes);
let color = match magics {
Some(part) => Some(if attr & 0x8000 == 0 { [five(attr), five(attr >> 5), five(attr >> 10)] } else { part }),
None => (attr & 0x8000 != 0).then(|| [five(attr >> 10), five(attr >> 5), five(attr)]),
};
b.tri(color, n, v);
}
let text = String::from_utf8_lossy(header);
let name = text.trim_matches(|c: char| c == '\0' || c.is_whitespace());
b.finish(name.strip_prefix("solid").unwrap_or(name).trim().to_string())
}
/// Three numbers from the rest of an ASCII line.
fn three<'a>(it: &mut impl Iterator<Item = &'a str>) -> Option<[f32; 3]> {
Some([it.next()?.parse().ok()?, it.next()?.parse().ok()?, it.next()?.parse().ok()?])
}
fn ascii(bytes: &[u8]) -> Result<Model> {
let text = String::from_utf8_lossy(bytes);
let mut b = Builder::default();
let mut name: Option<String> = None;
let mut normal = [0.0f32; 3];
let mut loop_v: Vec<[f32; 3]> = vec![];
for (ln, line) in text.lines().enumerate() {
let line = line.trim();
let mut it = line.split_whitespace();
let Some(key) = it.next() else { continue };
let bad = || perr(format!("STL line {}: expected three numbers", ln + 1));
match key.to_ascii_lowercase().as_str() {
"solid" => {
if name.is_none() {
name = Some(line.get(5..).unwrap_or("").trim().to_string());
}
}
"facet" => {
// `facet normal nx ny nz`
normal = it.next().and_then(|_| three(&mut it)).unwrap_or([0.0; 3]);
loop_v.clear();
}
"vertex" => loop_v.push(three(&mut it).ok_or_else(bad)?),
"endloop" | "endfacet" => {
if let Some((&first, rest)) = loop_v.split_first() {
for w in rest.windows(2) {
if let [a, c] = w {
b.tri(None, normal, [first, *a, *c]);
}
}
}
loop_v.clear();
}
_ => {}
}
}
b.finish(name.unwrap_or_default())
}
+90
View File
@@ -373,3 +373,93 @@ fn stacked_extrusions_move_towards_the_camera() {
assert_eq!((a[0], a[1]), (b[0], b[1]));
}
}
/// A binary STL: `header` (padded to 80 bytes) and facets of (normal, vertices, attribute).
fn binary_stl(header: &[u8], facets: &[([f32; 3], [[f32; 3]; 3], u16)]) -> Vec<u8> {
let mut b = header.to_vec();
b.resize(80, 0);
b.extend((facets.len() as u32).to_le_bytes());
for (n, v, attr) in facets {
for c in n.iter().chain(v.iter().flatten()) {
b.extend(c.to_le_bytes());
}
b.extend(attr.to_le_bytes());
}
b
}
/// The unit square in the XY plane (Z-up file), facing +Z, as two facets.
const SQUARE: [[[f32; 3]; 3]; 2] = [[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0]], [[0.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]]];
#[test]
fn stl_binary_is_faceted_and_turned_y_up() {
// A header that begins with `solid`, as many binary exporters write it.
let bytes = binary_stl(b"solid part", &[([0.0, 0.0, 1.0], SQUARE[0], 0), ([0.0, 0.0, 1.0], SQUARE[1], 0)]);
let m = load("part.STL", &bytes, &|_| None).unwrap();
assert_eq!(format_of("part.STL"), Some("stl"));
assert_eq!(m.meshes[0].name, "part");
assert!(m.materials.is_empty());
let p = &m.meshes[0].primitives[0];
assert_eq!((p.triangle_count(), p.positions.len()), (2, 6));
assert!(p.uvs.is_empty());
// Z-up +Z becomes Y-up +Y; file Y becomes −Z.
assert_eq!(p.normals, vec![[0.0, 1.0, 0.0]; 6]);
assert_eq!(p.positions[2], [1.0, 0.0, -1.0]);
// Counter-clockwise from outside after the turn.
let (a, b, c) = (p.positions[0], p.positions[1], p.positions[2]);
assert!(dot(cross(sub(b, a), sub(c, a)), p.normals[0]) > 0.0);
// Trailing bytes after the last facet are ignored.
let mut longer = bytes.clone();
longer.extend([0u8; 7]);
assert_eq!(stl::parse(&longer).unwrap().meshes, m.meshes);
}
#[test]
fn stl_ascii_with_polygon_loops_and_several_solids() {
let src = "solid Bracket\n facet normal 0 0 1\n outer loop\n vertex 0 0 0\n vertex 1 0 0\n vertex 1 1 0\n vertex 0 1 0\n endloop\n endfacet\nendsolid Bracket\n\
solid second\nfacet normal 0 0 0\nouter loop\nvertex 0 0 1\nvertex 1 0 1\nvertex 1E0 1 1\nendloop\nendfacet\nendsolid\n";
let m = load("bracket.stl", src.as_bytes(), &|_| None).unwrap();
assert_eq!(m.meshes[0].name, "Bracket");
// The quad fans into two triangles; the second solid adds one.
assert_eq!(m.triangle_count(), 3);
let (lo, hi) = m.bounds().unwrap();
assert_eq!((lo, hi), ([0.0, 0.0, -1.0], [1.0, 1.0, 0.0]));
assert!(stl::parse(b"solid x\nfacet normal 0 0 1\nouter loop\nvertex 0 zero 0\n").is_err());
}
#[test]
fn stl_binary_colours() {
// VisCAM / SolidView: bit 15 set, red in bits 10–14.
let red = 0x8000 | (31 << 10);
let m = stl::parse(&binary_stl(b"", &[([0.0; 3], SQUARE[0], red), ([0.0; 3], SQUARE[1], 0)])).unwrap();
assert_eq!(m.meshes[0].primitives.len(), 2);
assert_eq!(m.materials.len(), 2);
assert!(m.materials[0].base_color.iter().zip([1.0, 0.0, 0.0, 1.0]).all(|(x, y)| (x - y).abs() < 1e-5));
assert_eq!(m.materials[0].metallic, 0.0);
assert_eq!(m.meshes[0].primitives[1].material, Some(1));
// Materialise Magics: a part colour in the header; bit 15 clear = the facet's own colour
// with red in bits 0–4.
let mut header = b"COLOR=".to_vec();
header.extend([0, 255, 0, 255]);
let m = stl::parse(&binary_stl(&header, &[([0.0; 3], SQUARE[0], 31), ([0.0; 3], SQUARE[1], 0x8000)])).unwrap();
let close = |a: [f32; 4], b: [f32; 4]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5);
assert!(close(m.materials[0].base_color, [1.0, 0.0, 0.0, 1.0]), "{:?}", m.materials[0]);
assert!(close(m.materials[1].base_color, [0.0, 1.0, 0.0, 1.0]), "{:?}", m.materials[1]);
}
#[test]
fn stl_hostile_input_is_an_error() {
// Claims four billion facets but has one: truncated.
let mut bytes = binary_stl(b"", &[([0.0; 3], SQUARE[0], 0)]);
bytes.splice(80..84, u32::MAX.to_le_bytes());
assert!(stl::parse(&bytes).is_err());
assert!(stl::parse(b"").is_err());
assert!(stl::parse(&[0u8; 83]).is_err());
// No facets, or only non-finite ones.
assert!(stl::parse(&binary_stl(b"", &[])).is_err());
assert!(stl::parse(&binary_stl(b"", &[([0.0; 3], [[f32::NAN, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], 0)])).is_err());
assert!(stl::parse(b"solid empty\nendsolid empty\n").is_err());
// A degenerate facet keeps its stored normal.
let m = stl::parse(&binary_stl(b"", &[([1.0, 0.0, 0.0], [[0.0; 3]; 3], 0)])).unwrap();
assert_eq!(m.meshes[0].primitives[0].normals[0], [1.0, 0.0, 0.0]);
}
+17 -1
View File
@@ -15,7 +15,9 @@
//! are linearised just before they are transformed and blended, the comp accumulates in linear
//! and is encoded back to the working space when the comp is done (so precomps hand encoded
//! pixels to their parent). Without a working space the Working Gamma's curve is used (sRGB
//! at 2.2, a pure 2.4 power curve at 2.4); so is it for Interpret As Linear Light footage.
//! at 2.2, a pure 2.4 power curve at 2.4); so is it for Interpret As Linear Light footage
//! and for float footage (OpenEXR, float TIFF), whose linear values are encoded once
//! ([`Pipe::float_curve`]).
use effectcraft_color::{ColorSpace, Conversion};
use effectcraft_project::{Footage, HdrMode, ProjectSettings};
@@ -68,6 +70,20 @@ impl Pipe {
f.color_profile.unwrap_or(if self.space.is_some() { ColorSpace::Srgb } else { self.curve() })
}
/// How float footage `f` (OpenEXR, float TIFF; linear light) is decoded: as stored when
/// Interpret As Linear Light encodes it afterwards (once, with
/// [`Pipe::linear_footage_space`]), else with the Working Gamma's curve when unmanaged and
/// the sRGB curve otherwise (the working space converts from there).
pub fn float_curve(&self, f: &Footage) -> crate::FloatCurve {
if f.linear_light_applies() { crate::FloatCurve::Linear } else { self.float_encoding() }
}
/// The curve float footage is encoded with when nothing interprets it afterwards: the
/// Working Gamma's when unmanaged (sRGB at 2.2, a pure 2.4 power curve at 2.4), else sRGB.
pub fn float_encoding(&self) -> crate::FloatCurve {
if self.space.is_none() && self.gamma24 { crate::FloatCurve::Gamma24 } else { crate::FloatCurve::Srgb }
}
/// Footage `f` from its colour profile (`None` = sRGB) → working space; nothing for
/// Preserve RGB, whose values enter the working space as the file stores them.
pub fn media_in(&self, f: &Footage) -> Option<Conversion> {
+32 -9
View File
@@ -36,6 +36,21 @@ use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
pub use eval::{EvalCtx, ExprHost, effect_bounds, source_size};
use rayon::prelude::*;
/// How a [`FootageSource`] hands over float footage (OpenEXR, float TIFF), whose values are
/// linear light: encoded with a curve, as the compositor takes footage, or as stored. Other
/// footage is decoded the same way whatever it says. Interpret Footage ▸ Preserve RGB always
/// reads the values as stored.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum FloatCurve {
/// The sRGB curve (managed projects, and Working Gamma 2.2).
#[default]
Srgb,
/// A pure 2.4 power curve (no working space, Working Gamma 2.4).
Gamma24,
/// The linear values as stored (Interpret As Linear Light encodes them afterwards).
Linear,
}
/// Supplies decoded footage frames (implemented by the media layer).
pub trait FootageSource: Send + Sync {
/// The frame of `item` at source time `t`, straight from the file (any size).
@@ -47,6 +62,16 @@ pub trait FootageSource: Send + Sync {
fn frame_at_size(&self, item: ItemId, footage: &Footage, t: Tick, _width: u32, _height: u32) -> Option<Arc<Image>> {
self.frame(item, footage, t)
}
/// [`FootageSource::frame`] (`size: None`) or [`FootageSource::frame_at_size`] with float
/// footage handed over as `float` says ([`FootageSource::frame`] uses [`FloatCurve::Srgb`]).
/// Sources without float footage can leave the default, which ignores `float`.
fn frame_with(&self, item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) -> Option<Arc<Image>> {
let _ = float;
match size {
Some((w, h)) => self.frame_at_size(item, footage, t, w, h),
None => self.frame(item, footage, t),
}
}
/// `frames` interleaved stereo sample frames of `item`'s audio from source time `t` at
/// `rate` Hz (`None` when unavailable). The first sample is `t.to_units_floor(rate)`;
/// a sample boundary uses the earliest representable tick that selects that sample.
@@ -1109,7 +1134,7 @@ impl<'a> Renderer<'a> {
if let Some(pf) = self.proxy_for(*item)
&& let Some(nc) = self.project.comp(*item)
{
let img = self.footage.frame(*item, pf, lt)?;
let img = self.footage.frame_with(*item, pf, lt, None, self.pipe.float_encoding())?;
return Some(self.footage_buf(&img, pf, nc.width, nc.height));
}
// Essential Properties overrides render the nested comp with this instance's
@@ -1220,7 +1245,7 @@ impl<'a> Renderer<'a> {
if f.fields != effectcraft_project::FieldOrder::Off && matches!(f.kind, FootageKind::Video | FootageKind::Sequence) {
let field_rate = FrameRate::new(f.frame_rate.num * 2, f.frame_rate.den);
let i = field_rate.frame_at(t).max(0);
let img = self.footage.frame(item, f, f.frame_rate.tick_of(i / 2))?;
let img = self.footage.frame_with(item, f, f.frame_rate.tick_of(i / 2), None, self.pipe.float_curve(f))?;
let dominant_upper = f.fields == effectcraft_project::FieldOrder::UpperFirst;
// Upper field = even lines (0, 2, …).
let parity = if (i % 2 == 0) == dominant_upper { 0 } else { 1 };
@@ -1234,6 +1259,7 @@ impl<'a> Renderer<'a> {
fn footage_frame_raw(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick, size: Option<(u32, u32)>) -> Option<Arc<Image>> {
let mode = frame_blend_mode(ctx, layer);
let float = self.pipe.float_curve(f);
if mode != FrameBlend::Off && matches!(f.kind, FootageKind::Video | FootageKind::Sequence) {
let (i, w) = frame_position(t, f.frame_rate);
if w > 1e-6 {
@@ -1241,7 +1267,7 @@ impl<'a> Renderer<'a> {
let key = self.cache.map(|_| {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
(0x00f8_a3e5_u64, item, i, (w as f32).to_bits(), mode as u8, f.frame_rate.num, f.frame_rate.den, &f.path).hash(&mut h);
(0x00f8_a3e5_u64, item, i, (w as f32).to_bits(), mode as u8, f.frame_rate.num, f.frame_rate.den, &f.path, float).hash(&mut h);
h.finish()
});
if let (Some(c), Some(k)) = (self.cache, key)
@@ -1249,8 +1275,8 @@ impl<'a> Renderer<'a> {
{
return Some(Arc::new(b.img.clone()));
}
let a = self.footage.frame(item, f, f.frame_rate.tick_of(i));
let b = self.footage.frame(item, f, f.frame_rate.tick_of(i + 1));
let a = self.footage.frame_with(item, f, f.frame_rate.tick_of(i), None, float);
let b = self.footage.frame_with(item, f, f.frame_rate.tick_of(i + 1), None, float);
if let (Some(a), Some(b)) = (a, b) {
let img = blend_frames(mode, &a, &b, w as f32);
if let (Some(c), Some(k)) = (self.cache, key) {
@@ -1260,10 +1286,7 @@ impl<'a> Renderer<'a> {
}
}
}
match size {
Some((w, h)) => self.footage.frame_at_size(item, f, t, w, h),
None => self.footage.frame(item, f, t),
}
self.footage.frame_with(item, f, t, size, float)
}
/// Source → masks, clamped/quantised to the project depth.
+46 -1
View File
@@ -7,7 +7,7 @@ use effectcraft_project::build;
use effectcraft_project::{AlphaMode, BitDepth, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid};
use effectcraft_time::{FrameRate, Tick};
use crate::{FootageSource, Image, LayerCache, RenderOpts, Renderer, render_frame};
use crate::{FloatCurve, FootageSource, Image, LayerCache, RenderOpts, Renderer, render_frame};
fn setup(depth: BitDepth) -> (Project, ItemId, Comp) {
let mut p = Project::default();
@@ -294,3 +294,48 @@ fn working_gamma_24_encodes_linear_footage_with_a_power_curve() {
crate::color::Pipe::of(&s).key()
});
}
/// Float footage (an OpenEXR) the way `effectcraft-media` decodes it: linear values handed over
/// as the renderer asks ([`FloatCurve`]).
struct FloatFile([f32; 3]);
impl FootageSource for FloatFile {
fn frame(&self, item: ItemId, f: &Footage, t: Tick) -> Option<Arc<Image>> {
self.frame_with(item, f, t, None, FloatCurve::Srgb)
}
fn frame_with(&self, _: ItemId, f: &Footage, _: Tick, _: Option<(u32, u32)>, float: FloatCurve) -> Option<Arc<Image>> {
let c = self.0.map(|v| match float {
FloatCurve::Srgb => ColorSpace::Srgb.encode(v),
FloatCurve::Gamma24 => v.powf(1.0 / 2.4),
FloatCurve::Linear => v,
});
Some(Arc::new(Image::filled(f.width, f.height, [c[0], c[1], c[2], 1.0])))
}
}
/// #750: with no working space, float footage is encoded with the Working Gamma's curve (as
/// After Effects converts a linear EXR), and Interpret As Linear Light encodes it once, not on
/// top of the decode's own encoding. A working space ignores Working Gamma.
#[test]
fn float_footage_is_encoded_once_with_the_working_gamma() {
let render = |gamma: f64, ws: Option<ColorSpace>, linear_light: bool| {
let (mut p, cid, comp) = setup(BitDepth::Bpc32);
p.settings.working_gamma = gamma;
p.settings.working_space = ws;
let f = Footage { path: "x.exr".into(), linear_light, ..footage(None) };
let fid = p.add_item("F", Label::Aqua, None, ItemKind::Footage(f));
let l = build::layer(&mut p, &comp, "F", LayerSource::Footage { item: fid }, (40, 20), None);
p.comp_mut(cid).unwrap().layers.push(l);
let src = FloatFile([0.051, 0.18, 0.5]);
Renderer::new(&p, &src, RenderOpts::default()).comp_frame(cid, Tick::ZERO).get(5, 5)
};
let near = |o: [f32; 4], want: [f32; 3]| (0..3).all(|i| (o[i] - want[i]).abs() < 2e-3);
for linear_light in [false, true] {
let o = render(2.4, None, linear_light);
assert!(near(o, [0.290, 0.490, 0.749]), "2.4, linear light {linear_light}: {o:?}");
let o = render(2.2, None, linear_light);
assert!(near(o, [0.250, 0.461, 0.735]), "2.2, linear light {linear_light}: {o:?}");
let srgb = render(2.2, Some(ColorSpace::Srgb), linear_light);
assert!(near(srgb, [0.250, 0.461, 0.735]), "sRGB working space, linear light {linear_light}: {srgb:?}");
assert_eq!(render(2.4, Some(ColorSpace::Srgb), linear_light), srgb);
}
}
+112
View File
@@ -0,0 +1,112 @@
//! Pixel Motion Blur must synthesize blur from motion vectors, not average decoded frames
//! (#535). Two bars move in opposite directions between discrete source frames; the gap
//! pixels between their old and new positions must fill in, and Vector Detail must matter.
use effectcraft_color::Label;
use effectcraft_keyframe::Value;
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{AlphaMode, BitDepth, Comp, Footage, FootageKind, FrameBlend, ItemId, ItemKind, LayerSource, Project};
use effectcraft_render::{FootageSource, Image, RenderOpts, Renderer};
use effectcraft_time::{FrameRate, Tick};
use std::sync::Arc;
// A 12×8 block with a horizontal brightness gradient slides right by 12 px between source
// frames 9 and 10. The gradient gives the block matcher real texture, so Pixel Motion can find
// the +12 vector and smear the block across the gap — unlike a flat bar, whose motion is
// undefined (the aperture problem) and which no synthesizer can invent coverage for.
struct Clip;
impl FootageSource for Clip {
fn frame(&self, _: ItemId, f: &Footage, t: Tick) -> Option<Arc<Image>> {
let k = f.frame_rate.frame_at(t).clamp(0, 19);
let dx = 12 * (k - 10);
let mut image = Image::filled(64, 48, [0.0, 0.0, 0.0, 1.0]);
for y in 0..48 {
for x in 0..64 {
if (16..32).contains(&y) && (20 + dx..32 + dx).contains(&x) {
let g = (x - (20 + dx)) as f32 / 12.0;
image.set(x as u32, y, [0.4 + 0.6 * g, 0.3, 0.2, 1.0]);
}
}
}
Some(Arc::new(image))
}
}
fn scene(detail: Option<f64>) -> (Project, ItemId) {
let mut p = Project::default();
p.settings.bit_depth = BitDepth::Bpc32;
let comp = Comp::new(64, 48, FrameRate::FPS_25, Tick::from_seconds_f64(0.8));
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
let footage = Footage {
path: "generated.mov".into(),
kind: FootageKind::Video,
width: 64,
height: 48,
pixel_aspect: 1.0,
frame_rate: FrameRate::FPS_25,
duration: Tick::from_seconds_f64(0.8),
has_video: true,
has_audio: false,
alpha: AlphaMode::Ignore,
missing: false,
..Default::default()
};
let fid = p.add_item("Clip", Label::Aqua, None, ItemKind::Footage(footage));
let mut layer = build::layer(&mut p, &comp, "Clip", LayerSource::Footage { item: fid }, (64, 48), None);
layer.switches.frame_blend = FrameBlend::Off;
layer.switches.motion_blur = false;
if let Some(detail) = detail {
let spec = effectcraft_effects::find("ec.time.pixelmotionblur").unwrap();
let mut next = p.next_id;
let mut effect = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [64.0, 48.0]);
for (name, value) in [
("shutterControl", Value::Enum(1)),
("shutterAngle", Value::Scalar(180.0)),
("shutterSamples", Value::Scalar(16.0)),
("vectorDetail", Value::Scalar(detail)),
] {
effect.prop_mut(name).unwrap().value = value;
}
p.next_id = next;
layer.props.sub_mut("effects").unwrap().children.push(effect.into());
}
p.comp_mut(cid).unwrap().layers.push(layer);
(p, cid)
}
fn render(p: &Project, cid: ItemId, time: f64) -> Image {
Renderer::new(p, &Clip, RenderOpts::default()).comp_frame(cid, Tick::from_seconds_f64(time))
}
fn difference(a: &Image, b: &Image) -> f64 {
a.data.iter().zip(&b.data).flat_map(|(p, q)| (0..4).map(move |c| (p[c] - q[c]).abs() as f64)).sum()
}
#[test]
fn pixel_motion_blur_synthesizes_from_motion_vectors() {
let (plain, cid) = scene(None);
let still = render(&plain, cid, 0.4);
let average = average_of(&plain, cid);
let (low, low_id) = scene(Some(0.0));
let (high, high_id) = scene(Some(100.0));
let a = render(&low, low_id, 0.4);
let b = render(&high, high_id, 0.4);
assert!(difference(&b, &still) > 1.0, "the blur equals the still frame");
assert!(difference(&b, &average) > 1.0, "the blur is just the plain overlay of the two decoded positions");
assert!((24..32).any(|x| b.get(x, 24)[0] > 0.2), "no motion trail behind the moving block: {:?}", (18..46).map(|x| b.get(x, 24)[0]).collect::<Vec<f32>>());
assert!(difference(&a, &b) > 0.5, "Vector Detail is inert: the motion field does not change with it");
}
fn average_of(p: &Project, cid: ItemId) -> Image {
let mut average = Image::new(64, 48);
for i in 0..16 {
let time = ((0.39 + 0.02 * i as f64 / 15.0) * 1e6).round() / 1e6;
let frame = render(p, cid, time);
for (p, q) in average.data.iter_mut().zip(&frame.data) {
for c in 0..4 {
p[c] += q[c] / 16.0;
}
}
}
average
}
+1
View File
@@ -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]
+173
View File
@@ -0,0 +1,173 @@
//! ExtendScript's preprocessor directives, from the JavaScript Tools Guide's description of their
//! behaviour: `#include "file"` (and `//@include "file"`) inlines a file in place of the line,
//! resolved against the including file's folder, then the folders the latest `#includepath "a;b"`
//! (or `//@includepath`) named; `#target`, `#targetengine`, `#strict`, `#script` and `#engine`
//! are dropped. Each file is inlined once (which also breaks include cycles), nesting is limited
//! to [`MAX_DEPTH`], files and their total size are capped, and reads follow the scripts' file
//! gate ([`crate::scriptfile::check_read`]). Directives are recognised on lines of their own.
//!
//! Errors in the expanded code are mapped back to the file and line they came from.
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use effectcraft_engine::Session;
use crate::runtime::ScriptError;
use crate::scriptfile::{self, MAX_DEPTH, MAX_FILE_BYTES};
/// The most text a script and the files it includes may add up to.
const MAX_TOTAL_BYTES: usize = 4 * MAX_FILE_BYTES;
/// A script with its includes inlined.
pub(crate) struct Expanded {
pub(crate) code: String,
/// Per line of `code`: the file (index into `files`) and 1-based line it came from.
lines: Vec<(usize, u32)>,
/// The script itself, then each included file.
files: Vec<String>,
}
impl Expanded {
/// Point an error in the expanded code at the file and line it came from.
pub(crate) fn locate(&self, e: &mut ScriptError) {
if self.files.first() != Some(&e.file) {
return;
}
let Some(&(file, line)) = e.line.and_then(|l| l.checked_sub(1)).and_then(|l| self.lines.get(l as usize)) else { return };
if let Some(name) = self.files.get(file) {
e.file = name.clone();
e.line = Some(line);
}
}
}
enum Directive<'a> {
Include(&'a str),
IncludePath(&'a str),
Ignored,
}
/// The preprocessor directive on `line`, if it is one.
fn directive(line: &str) -> Option<Directive<'_>> {
let t = line.trim();
let (rest, comment) = match t.strip_prefix("//@") {
Some(r) => (r, true),
None => (t.strip_prefix('#')?, false),
};
let end = rest.find(|c: char| !c.is_ascii_alphabetic()).unwrap_or(rest.len());
let (word, arg) = rest.split_at(end);
if !(arg.is_empty() || arg.starts_with(char::is_whitespace) || arg.starts_with(['"', '\''])) {
return None;
}
let arg = unquote(arg);
match word {
"include" => Some(Directive::Include(arg)),
"includepath" => Some(Directive::IncludePath(arg)),
// `//@target aftereffects` and friends are plain comments already.
"target" | "targetengine" | "strict" | "script" | "engine" if !comment => Some(Directive::Ignored),
_ => None,
}
}
/// A directive's argument: `"x"`, `'x'` or `x`, with an optional trailing `;`.
fn unquote(arg: &str) -> &str {
let a = arg.trim();
let a = a.strip_suffix(';').map_or(a, str::trim_end);
for q in ['"', '\''] {
if let Some(inner) = a.strip_prefix(q).and_then(|r| r.split(q).next()) {
return inner;
}
}
a
}
struct Walk<'a> {
s: &'a Session,
/// The running script's folder: the file gate lets it load files below it.
root: Option<PathBuf>,
/// The latest `#includepath`.
search: Vec<PathBuf>,
seen: HashSet<PathBuf>,
total: usize,
out: Expanded,
}
impl Walk<'_> {
fn push(&mut self, line: &str, file: usize, n: usize) {
if !self.out.lines.is_empty() {
self.out.code.push('\n');
}
self.out.code.push_str(line);
self.out.lines.push((file, u32::try_from(n + 1).unwrap_or(u32::MAX)));
}
/// Inline `code` (file `file`, in folder `dir`), `depth` includes deep.
fn walk(&mut self, code: &str, file: usize, dir: Option<&Path>, depth: usize) -> Result<(), ScriptError> {
for (n, line) in code.split('\n').enumerate() {
let err = |w: &Self, message: String| ScriptError {
message,
line: Some(u32::try_from(n + 1).unwrap_or(u32::MAX)),
column: None,
file: w.out.files.get(file).cloned().unwrap_or_default(),
};
match directive(line) {
None => self.push(line, file, n),
Some(Directive::Ignored) => self.push("", file, n),
Some(Directive::IncludePath(paths)) => {
self.search =
paths.split(';').map(str::trim).filter(|p| !p.is_empty()).filter_map(|p| scriptfile::resolve(self.s, p, dir, &[]).ok()).collect();
self.push("", file, n);
}
Some(Directive::Include(name)) => {
if name.is_empty() {
return Err(err(self, "#include needs a file name".into()));
}
let path = scriptfile::resolve(self.s, name, dir, &self.search).map_err(|e| err(self, format!("#include {e}")))?;
if !self.seen.insert(path.clone()) {
// Already inlined (or the file including it): once is enough.
self.push("", file, n);
continue;
}
if depth >= MAX_DEPTH {
return Err(err(self, format!("#include \"{name}\": includes are nested too deeply (at most {MAX_DEPTH})")));
}
scriptfile::check_read(self.s, &path, self.root.as_deref()).map_err(|e| err(self, e))?;
let text = scriptfile::read(self.s, &path).map_err(|e| err(self, e))?;
self.total = self.total.saturating_add(text.len());
if self.total > MAX_TOTAL_BYTES {
return Err(err(self, format!("#include \"{name}\": the included files exceed {} MiB", MAX_TOTAL_BYTES >> 20)));
}
// The included lines take the directive's place.
self.out.files.push(path.to_string_lossy().to_string());
let idx = self.out.files.len().saturating_sub(1);
self.walk(&text, idx, path.parent(), depth + 1)?;
}
}
}
Ok(())
}
}
/// `code` (the script `name`) with its directives applied, or `None` when it has none.
pub(crate) fn expand(s: &Session, code: &str, name: &str) -> Result<Option<Expanded>, ScriptError> {
if !code.split('\n').any(|l| directive(l).is_some()) {
return Ok(None);
}
let root = scriptfile::script_dir(s, name);
let mut w = Walk {
s,
root: root.clone(),
search: vec![],
seen: HashSet::new(),
total: code.len(),
out: Expanded { code: String::with_capacity(code.len()), lines: vec![], files: vec![if name.is_empty() { "script" } else { name }.to_string()] },
};
if root.is_some() {
w.seen.insert(crate::runtime::normalize(name));
}
// A script without a folder (the Script Console, code run by name) looks in the project's.
let dir = root.or_else(|| crate::runtime::project_dir(s).map(|d| crate::runtime::normalize(&d)));
w.walk(code, 0, dir.as_deref(), 0)?;
Ok(Some(w.out))
}
+13 -1
View File
@@ -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;
+24 -5
View File
@@ -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 () {},
+50 -29
View File
@@ -65,18 +65,18 @@ pub(crate) struct Active {
pub(crate) output: Vec<String>,
/// Open undo groups: (name, project snapshot when the outermost group began).
pub(crate) groups: Vec<(String, Arc<Project>)>,
/// `app.scheduleTask` queue: (id, code, repeat).
pub(crate) tasks: Vec<(u32, String, bool)>,
pub(crate) next_task: u32,
pub(crate) name: String,
/// The script context running (its `app.scheduleTask` tasks are kept under this id, see
/// [`crate::tasks`]).
pub(crate) host: u32,
/// Running on a script-host thread: modal dialogs hand the session back to the caller
/// through this link while they wait.
pub(crate) link: Option<crate::ui::Link>,
}
impl Active {
pub(crate) fn new(session: Session, name: &str) -> Active {
Active { session, output: vec![], groups: vec![], tasks: vec![], next_task: 0, name: name.to_string(), link: None }
pub(crate) fn new(session: Session, name: &str, host: u32) -> Active {
Active { session, output: vec![], groups: vec![], name: name.to_string(), host, link: None }
}
}
@@ -268,36 +268,42 @@ pub(crate) fn collapse(s: &mut Session, name: &str, snap: &Arc<Project>) {
h.merge_key = None;
}
/// `__task("schedule", code, repeat)` → id; `__task("cancel", id)`.
/// `__task("schedule", code, delayMs, repeat)` → id; `__task("cancel", id)` (see
/// [`crate::tasks`]).
fn native_task(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let op = arg_string(args, 0, ctx)?;
if op == "cancel" {
let id = args.get(1).cloned().unwrap_or_default().to_number(ctx)? as u32;
let _ = with_active(|a| {
a.tasks.retain(|t| t.0 != id);
Ok(())
});
let id = args.get(1).cloned().unwrap_or_default().to_number(ctx)?;
if id.is_finite() && id >= 0.0 && id <= f64::from(u32::MAX) {
let _ = with_active(|a| {
crate::tasks::cancel(a.host, id as u32);
Ok(())
});
}
return Ok(JsValue::undefined());
}
let code = arg_string(args, 1, ctx)?;
let repeat = args.get(2).cloned().unwrap_or_default().to_boolean();
let delay = args.get(2).cloned().unwrap_or_default().to_number(ctx)?;
let repeat = args.get(3).cloned().unwrap_or_default().to_boolean();
let now = web_time::Instant::now();
let id = with_active(|a| {
a.next_task += 1;
let id = a.next_task;
a.tasks.push((id, code, repeat));
let id = crate::tasks::schedule(a.host, &a.name, a.session.script_ui.project_epoch, code, delay, repeat, now)?;
if !a.session.script_ui.task_hosts.contains(&a.host) {
a.session.script_ui.task_hosts.push(a.host);
}
Ok(id)
})
.map_err(throw)?;
Ok(JsValue::from(id))
}
fn project_dir(s: &Session) -> Option<String> {
pub(crate) fn project_dir(s: &Session) -> Option<String> {
let p = s.path.as_deref()?;
std::path::Path::new(p).parent().map(|d| d.to_string_lossy().to_string())
}
/// Lexically normalized absolute path (`a/./b/../c` → `a/c`).
fn normalize(p: &str) -> std::path::PathBuf {
pub(crate) fn normalize(p: &str) -> std::path::PathBuf {
use std::path::{Component, Path, PathBuf};
let path = Path::new(p);
let abs = if path.is_absolute() { path.to_path_buf() } else { std::env::current_dir().unwrap_or_default().join(path) };
@@ -415,7 +421,7 @@ pub(crate) fn new_context() -> Result<Context, String> {
(js_string!("__query"), 2, native_query),
(js_string!("__print"), 1, native_print),
(js_string!("__undo"), 2, native_undo),
(js_string!("__task"), 3, native_task),
(js_string!("__task"), 4, native_task),
(js_string!("__file"), 3, native_file),
(js_string!("__sock"), 3, crate::socket::native_sock),
(js_string!("__uiNewId"), 0, crate::ui::native_new_id),
@@ -553,14 +559,11 @@ pub(crate) fn settle() -> Vec<String> {
.unwrap_or_default()
}
/// Evaluate a script file's code, then the one-shot tasks it scheduled (`app.scheduleTask`).
pub(crate) fn eval_with_tasks(ctx: &mut Context, code: &str, file: &str) -> Result<J, ScriptError> {
/// Evaluate a script file's code, then the one-shot tasks it scheduled (`app.scheduleTask`;
/// repeating ones run later, see [`crate::tasks`]).
pub(crate) fn eval_with_tasks(ctx: &mut Context, code: &str, file: &str, host: u32) -> Result<J, ScriptError> {
let mut r = eval_in(ctx, code, file);
let tasks = with_active(|a| Ok(std::mem::take(&mut a.tasks))).unwrap_or_default();
for (_, code, repeat) in tasks {
if repeat {
continue;
}
for code in crate::tasks::take_one_shots(host) {
if let Err(e) = eval_in(ctx, &code, "scheduled task")
&& r.is_ok()
{
@@ -587,7 +590,23 @@ pub(crate) fn outcome(output: Vec<String>, result: Result<J, ScriptError>) -> Ou
/// events reach the host through [`crate::dispatch_ui`]); palettes and panels keep their host
/// alive for their handlers. The Script Console (and the web build) run inline, where dialogs
/// don't block.
///
/// ExtendScript's `#include` / `#includepath` are inlined first, and errors point at the file
/// and line they came from ([`crate::include`]).
pub fn run(session: &mut Session, req: &ScriptRequest) -> Outcome {
let expanded = match crate::include::expand(session, req.code, req.name) {
Ok(x) => x,
Err(e) => return Outcome { error: Some(e), ..Default::default() },
};
let Some(x) = expanded else { return run_expanded(session, req) };
let mut out = run_expanded(session, &ScriptRequest { code: &x.code, name: req.name, console: req.console });
if let Some(e) = out.error.as_mut() {
x.locate(e);
}
out
}
fn run_expanded(session: &mut Session, req: &ScriptRequest) -> Outcome {
if let Err(message) = check_nesting(req.code) {
return Outcome { error: Some(ScriptError { message, file: req.name.into(), ..Default::default() }), ..Default::default() };
}
@@ -660,7 +679,7 @@ pub fn check_nesting(code: &str) -> Result<(), String> {
pub(crate) fn run_inline(session: &mut Session, req: &ScriptRequest) -> Outcome {
let host = if req.console { crate::ui::CONSOLE_HOST } else { crate::ui::new_host_id() };
let s = std::mem::take(session);
ACTIVE.with(|a| *a.borrow_mut() = Some(Active::new(s, req.name)));
ACTIVE.with(|a| *a.borrow_mut() = Some(Active::new(s, req.name, host)));
let guard = Restore { target: session };
let file = if req.name.is_empty() { "script" } else { req.name };
let (result, windows) = if req.console {
@@ -681,10 +700,11 @@ pub(crate) fn run_inline(session: &mut Session, req: &ScriptRequest) -> Outcome
} else {
match new_context() {
Ok(mut ctx) => {
let r = eval_with_tasks(&mut ctx, req.code, file);
let r = eval_with_tasks(&mut ctx, req.code, file, host);
let windows = crate::ui::snapshot(&mut ctx);
if windows.iter().any(|w| w.visible) {
// Palettes and panels outlive the run: keep their context for the handlers.
if windows.iter().any(|w| w.visible) || crate::tasks::has(host) {
// Palettes, panels and repeating tasks outlive the run: keep their context
// for the handlers and tasks.
crate::ui::keep_inline(host, ctx);
}
(r, windows)
@@ -694,6 +714,7 @@ pub(crate) fn run_inline(session: &mut Session, req: &ScriptRequest) -> Outcome
};
let output = settle();
drop(guard);
crate::tasks::set_epoch(host, session.script_ui.project_epoch);
crate::ui::publish(session, host, req.name, windows);
outcome(output, result)
}
+65
View File
@@ -0,0 +1,65 @@
//! Script files a running script loads (`#include`, `$.evalFile`): where a name resolves, whether
//! the script may read it, and the limits that keep hostile, huge or cyclic files from running
//! away.
use std::path::{Path, PathBuf};
use effectcraft_engine::Session;
use crate::runtime::{GATE, normalize, project_dir};
/// How deep loaded files may nest (a loaded file loading another…).
pub(crate) const MAX_DEPTH: usize = 32;
/// The largest single file a script may load.
pub(crate) const MAX_FILE_BYTES: usize = 16 << 20;
/// The folder of the script file `name` runs from: `None` for the Script Console and for scripts
/// run by name or from code, which have no file.
pub(crate) fn script_dir(s: &Session, name: &str) -> Option<PathBuf> {
let parent = Path::new(name).parent().filter(|p| !p.as_os_str().is_empty())?;
s.services.exists(name).then(|| normalize(&parent.to_string_lossy()))
}
/// Resolve `name`: absolute paths as they are; relative ones against `dir` (the loading script's
/// folder), then each of `search`. The first existing file wins; when none exists, the first
/// candidate, so the read error names it.
pub(crate) fn resolve(s: &Session, name: &str, dir: Option<&Path>, search: &[PathBuf]) -> Result<PathBuf, String> {
if Path::new(name).is_absolute() {
return Ok(normalize(name));
}
let candidates: Vec<PathBuf> = dir.into_iter().chain(search.iter().map(PathBuf::as_path)).map(|d| normalize(&d.join(name).to_string_lossy())).collect();
candidates
.iter()
.find(|c| s.services.exists(&c.to_string_lossy()))
.or(candidates.first())
.cloned()
.ok_or_else(|| format!("cannot find \"{name}\": the script has no folder to look in (use an absolute path or #includepath)"))
}
/// The read gate for loaded script files: files under the running script's own folder (`root`) or
/// the project's folder; anywhere with the Allow Scripts to Write Files preference.
pub(crate) fn check_read(s: &Session, path: &Path, root: Option<&Path>) -> Result<(), String> {
if s.prefs.scripting.allow_scripts_write_files {
return Ok(());
}
let project = project_dir(s).map(|d| normalize(&d));
if root.into_iter().chain(project.as_deref()).any(|d| path.starts_with(d)) {
return Ok(());
}
Err(format!("Unable to read {}: scripts can only load files from their own or the project's folder unless {GATE} is on", path.display()))
}
/// Read a script file as text (a UTF-8 byte order mark dropped), refusing files over
/// [`MAX_FILE_BYTES`].
pub(crate) fn read(s: &Session, path: &Path) -> Result<String, String> {
let too_big = || format!("Unable to read {}: script files are limited to {} MiB", path.display(), MAX_FILE_BYTES >> 20);
if std::fs::metadata(path).is_ok_and(|m| m.len() > MAX_FILE_BYTES as u64) {
return Err(too_big());
}
let bytes = s.services.read_file(&path.to_string_lossy()).map_err(|e| format!("Unable to read {}: {e}", path.display()))?;
if bytes.len() > MAX_FILE_BYTES {
return Err(too_big());
}
let text = String::from_utf8_lossy(&bytes);
Ok(text.strip_prefix('\u{feff}').unwrap_or(&text).to_string())
}
+217
View File
@@ -0,0 +1,217 @@
//! `app.scheduleTask(code, delay, repeat)` / `app.cancelTask(id)`.
//!
//! Tasks belong to the script context (host, see [`crate::ui`]) that scheduled them and run in
//! it. A script file's one-shot tasks run right after the script; everything else (repeating
//! tasks, and one-shot tasks scheduled later by handlers or other tasks) runs when it is due, as
//! frontends call [`run_tasks`] every frame (`ScriptUi::run_tasks`, `script.runTasks`).
//!
//! Lifetime: a repeating task runs every `delay` ms until `app.cancelTask(id)`, until it throws
//! (the error is reported once and the task cancelled), or until its owner goes away: a script
//! whose windows (ScriptUI panel, palette) were open stops when the last one closes; a script
//! without windows (and the Script Console) keeps its tasks until the project is closed or
//! replaced. Bounds: repeating tasks run at most every [`MIN_REPEAT_MS`], a script has at most
//! [`MAX_TASKS`] pending tasks, every run has the script engine's usual limits, and a due task
//! runs once per pass (no catch-up bursts after a long frame).
//!
//! Each thread keeps the tasks of the hosts it runs: a script-host thread its own, the caller's
//! thread those of inline hosts (the Script Console, web runs).
use std::cell::RefCell;
use std::collections::BTreeMap;
use boa_engine::Context;
use effectcraft_engine::Session;
use serde_json::{Value as J, json};
use web_time::{Duration, Instant};
use crate::runtime::{ScriptError, eval_in};
/// The shortest interval of a repeating task (milliseconds).
pub const MIN_REPEAT_MS: u64 = 50;
/// The most tasks one script may have pending.
pub const MAX_TASKS: usize = 64;
/// The longest delay (milliseconds), as in browsers' `setTimeout`.
const MAX_DELAY_MS: f64 = 2_147_483_647.0;
struct Task {
id: u32,
code: String,
interval: Duration,
due: Instant,
repeat: bool,
}
#[derive(Default)]
struct HostTasks {
next_id: u32,
list: Vec<Task>,
/// The script's name (inline hosts without windows run under it).
name: String,
/// [`effectcraft_engine::scriptui::ScriptUi::project_epoch`] when the host last ran (inline
/// hosts; the caller tracks script-host threads).
epoch: u64,
}
thread_local! {
static TASKS: RefCell<BTreeMap<u32, HostTasks>> = const { RefCell::new(BTreeMap::new()) };
}
/// Queue a task for `host`; its id.
pub(crate) fn schedule(host: u32, name: &str, epoch: u64, code: String, delay_ms: f64, repeat: bool, now: Instant) -> Result<u32, String> {
crate::runtime::check_nesting(&code).map_err(|e| format!("app.scheduleTask: {}", e.trim_start_matches("Error: ")))?;
let ms = if delay_ms.is_finite() { delay_ms.clamp(0.0, MAX_DELAY_MS) } else { 0.0 } as u64;
let interval = Duration::from_millis(if repeat { ms.max(MIN_REPEAT_MS) } else { ms });
let due = now.checked_add(interval).ok_or("app.scheduleTask: the delay is too long")?;
TASKS.with(|t| {
let mut t = t.borrow_mut();
let h = t.entry(host).or_default();
if h.list.len() >= MAX_TASKS {
return Err(format!("app.scheduleTask: a script can have at most {MAX_TASKS} scheduled tasks"));
}
h.next_id = h.next_id.wrapping_add(1).max(1);
let id = h.next_id;
h.list.push(Task { id, code, interval, due, repeat });
h.name = name.to_string();
h.epoch = epoch;
Ok(id)
})
}
/// `app.cancelTask(id)`.
pub(crate) fn cancel(host: u32, id: u32) {
TASKS.with(|t| {
if let Some(h) = t.borrow_mut().get_mut(&host) {
h.list.retain(|task| task.id != id);
}
});
}
/// Take the one-shot tasks of `host` (run right after a script file).
pub(crate) fn take_one_shots(host: u32) -> Vec<String> {
TASKS.with(|t| {
let mut t = t.borrow_mut();
let Some(h) = t.get_mut(&host) else { return vec![] };
let (once, keep) = std::mem::take(&mut h.list).into_iter().partition(|task| !task.repeat);
h.list = keep;
once.into_iter().map(|task: Task| task.code).collect()
})
}
/// Take the tasks due at `now`: one-shot tasks leave the queue, repeating ones are due again an
/// interval from now. (id, code, repeat).
fn take_due(host: u32, now: Instant) -> Vec<(u32, String, bool)> {
TASKS.with(|t| {
let mut t = t.borrow_mut();
let Some(h) = t.get_mut(&host) else { return vec![] };
let mut due = vec![];
h.list.retain_mut(|task| {
if task.due > now {
return true;
}
due.push((task.id, task.code.clone(), task.repeat));
match now.checked_add(task.interval) {
Some(next) if task.repeat => {
task.due = next;
true
}
_ => false,
}
});
due
})
}
/// Whether `host` has pending tasks.
pub(crate) fn has(host: u32) -> bool {
TASKS.with(|t| t.borrow().get(&host).is_some_and(|h| !h.list.is_empty()))
}
/// When `host`'s next task is due.
pub(crate) fn next_due(host: u32) -> Option<Instant> {
TASKS.with(|t| t.borrow().get(&host).and_then(|h| h.list.iter().map(|task| task.due).min()))
}
/// Forget `host`'s tasks.
pub(crate) fn drop_host(host: u32) {
TASKS.with(|t| t.borrow_mut().remove(&host));
}
/// The hosts on this thread with tasks: (host, script name, epoch).
pub(crate) fn hosts() -> Vec<(u32, String, u64)> {
TASKS.with(|t| t.borrow().iter().filter(|(_, h)| !h.list.is_empty()).map(|(id, h)| (*id, h.name.clone(), h.epoch)).collect())
}
pub(crate) fn set_epoch(host: u32, epoch: u64) {
TASKS.with(|t| {
if let Some(h) = t.borrow_mut().get_mut(&host) {
h.epoch = epoch;
}
});
}
/// Run `host`'s due tasks in `ctx` (the session is active): how many ran, and the first error.
/// A task that throws is cancelled (a repeating one would only repeat the error).
pub(crate) fn run_due(ctx: &mut Context, host: u32, now: Instant) -> (usize, Option<ScriptError>) {
let (mut ran, mut first) = (0, None);
for (id, code, repeat) in take_due(host, now) {
ran += 1;
if let Err(mut e) = eval_in(ctx, &code, "scheduled task") {
if repeat {
cancel(host, id);
e.message = format!("{} (repeating task {id} cancelled)", e.message);
}
let line = e.line.map(|l| format!(" (line {l})")).unwrap_or_default();
let _ = crate::runtime::with_active(|a| {
a.output.push(format!("Error in scheduled task {id}{line}: {}", e.message));
Ok(())
});
first.get_or_insert(e);
}
}
(ran, first)
}
/// What a pass over the scheduled tasks did.
#[derive(Default)]
pub(crate) struct Report {
ran: usize,
output: Vec<String>,
errors: Vec<ScriptError>,
next: Option<Instant>,
}
impl Report {
pub(crate) fn add(&mut self, out: crate::Outcome) {
self.ran += out.result.as_u64().unwrap_or(0) as usize;
self.output.extend(out.output);
self.errors.extend(out.error);
}
pub(crate) fn due(&mut self, at: Option<Instant>) {
if let Some(at) = at {
self.next = Some(self.next.map_or(at, |n| n.min(at)));
}
}
}
/// The [`effectcraft_engine::scriptui::ScriptTaskRunner`] this crate provides: run the tasks
/// that are due now.
pub fn run_tasks(session: &mut Session) -> J {
run_tasks_at(session, Instant::now())
}
/// [`run_tasks`] at `now`.
pub(crate) fn run_tasks_at(session: &mut Session, now: Instant) -> J {
let mut report = Report::default();
// Not from inside a script (`script.runTasks` through `app.executeCommand`).
let running = crate::runtime::ACTIVE.with(|a| a.try_borrow().map(|g| g.is_some()).unwrap_or(true));
if !running {
#[cfg(not(target_arch = "wasm32"))]
crate::ui::tick_threaded(session, now, &mut report);
crate::ui::tick_inline(session, now, &mut report);
crate::ui::prune_task_hosts(session);
}
let errors: Vec<J> = report.errors.iter().map(|e| json!({"message": e.message, "line": e.line, "column": e.column, "file": e.file})).collect();
let next = report.next.map(|n| n.saturating_duration_since(now).as_millis() as u64);
json!({"ran": report.ran, "output": report.output, "errors": errors, "next": next})
}
+96
View File
@@ -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" }));
}
+94
View File
@@ -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);
}
+180
View File
@@ -0,0 +1,180 @@
//! `app.scheduleTask` / `app.cancelTask` (#773): repeating tasks run whenever they are due (frontends
//! tick [`crate::run_tasks`] every frame) until they are cancelled, throw, or their owner goes
//! away; one-shot tasks scheduled later run when due too.
use std::sync::Arc;
use effectcraft_engine::{ScriptRequest, Session};
use serde_json::{Value, json};
use web_time::{Duration, Instant};
use crate::run_code;
use crate::tasks::run_tasks_at;
fn session() -> Session {
let mut s = Session { expr: Some(Arc::new(effectcraft_expr::Expressions)), ..Default::default() };
crate::install(&mut s);
s.config = Some(Arc::new(effectcraft_engine::config::MemoryConfig::default()));
s
}
fn ok(s: &mut Session, code: &str) {
let o = run_code(s, code, "tasks.jsx");
assert!(o.error.is_none(), "script failed: {:?}\noutput: {:?}", o.error, o.output);
}
fn console(s: &mut Session, code: &str) -> crate::Outcome {
let o = crate::run(s, &ScriptRequest { code, name: "console", console: true });
assert!(o.error.is_none(), "console failed: {:?}", o.error);
o
}
fn ms(n: u64) -> Duration {
Duration::from_millis(n)
}
fn output(r: &Value) -> Vec<String> {
r["output"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect()
}
fn comps(s: &Session) -> usize {
s.project.items.values().filter(|i| i.as_comp().is_some()).count()
}
#[test]
fn a_repeating_task_runs_every_interval_until_cancelled() {
let mut s = session();
ok(
&mut s,
"var n = 0;\nvar id = app.scheduleTask(\"n++; writeLn('tick ' + n); app.project.items.addComp('C' + n, 64, 64, 1, 1, 25); if (n == 3) app.cancelTask(id);\", 1000, true);",
);
let t0 = Instant::now();
// Not due yet: nothing runs, and the frontend learns when to come back.
let r = run_tasks_at(&mut s, t0);
assert_eq!(r["ran"], json!(0));
assert!(r["next"].as_u64().is_some_and(|n| n <= 1000), "{r}");
let r = run_tasks_at(&mut s, t0 + ms(1500));
assert_eq!((r["ran"].clone(), output(&r)), (json!(1), vec!["tick 1".to_string()]), "{r}");
assert_eq!(comps(&s), 1, "the task edits the project through engine commands");
// Due again an interval after it ran, once per pass (no catch-up burst).
assert_eq!(run_tasks_at(&mut s, t0 + ms(1500))["ran"], json!(0));
assert_eq!(run_tasks_at(&mut s, t0 + ms(2600))["ran"], json!(1));
let r = run_tasks_at(&mut s, t0 + ms(9000));
assert_eq!(output(&r), vec!["tick 3"]);
// Cancelled: the script (no windows, no tasks) has ended.
let r = run_tasks_at(&mut s, t0 + ms(20_000));
assert_eq!((r["ran"].clone(), r["next"].clone()), (json!(0), Value::Null), "{r}");
assert_eq!(comps(&s), 3);
assert!(s.history.undo.len() >= 3, "every run is undoable");
}
#[test]
fn a_panels_task_refreshes_it_until_the_panel_closes() {
let mut s = session();
ok(
&mut s,
r#"
var w = new Window("palette", "Clock");
var label = w.add("statictext", undefined, "0");
var n = 0;
app.scheduleTask("n++; label.text = String(n);", 200, true);
w.show();
"#,
);
let win = s.script_ui.windows[0].id;
let text = |s: &Session| s.script_ui.window(win).map(|w| w.root.children[0].text.clone());
let t0 = Instant::now();
run_tasks_at(&mut s, t0 + ms(500));
run_tasks_at(&mut s, t0 + ms(1000));
assert_eq!(text(&s).as_deref(), Some("2"), "the window shows what the task set");
// A project change doesn't stop a script whose window is open.
s.execute("file.closeProject", json!({})).unwrap();
assert_eq!(run_tasks_at(&mut s, t0 + ms(1500))["ran"], json!(1));
assert_eq!(text(&s).as_deref(), Some("3"));
// Closing the window ends the script and its tasks.
s.execute("scriptui.close", json!({"window": win})).unwrap();
let r = run_tasks_at(&mut s, t0 + ms(5000));
assert_eq!((r["ran"].clone(), r["next"].clone()), (json!(0), Value::Null), "{r}");
}
#[test]
fn a_throwing_task_is_reported_once_and_cancelled() {
let mut s = session();
ok(&mut s, "app.scheduleTask('undefinedThing.go()', 100, true); app.scheduleTask('writeLn(\"fine\")', 100, true);");
let t0 = Instant::now();
let r = run_tasks_at(&mut s, t0 + ms(500));
assert_eq!(r["ran"], json!(2));
let errors = r["errors"].as_array().cloned().unwrap_or_default();
assert_eq!(errors.len(), 1, "{r}");
assert!(errors[0]["message"].as_str().is_some_and(|m| m.contains("undefinedThing") && m.contains("cancelled")), "{r}");
// The other task goes on; the failing one doesn't run again.
let r = run_tasks_at(&mut s, t0 + ms(1000));
assert_eq!((r["ran"].clone(), output(&r), r["errors"].clone()), (json!(1), vec!["fine".to_string()], json!([])), "{r}");
}
#[test]
fn tasks_are_bounded() {
let mut s = session();
// Repeating tasks run at most every MIN_REPEAT_MS.
ok(&mut s, "var hits = 0; app.scheduleTask('hits++', 0, true);");
let t0 = Instant::now();
assert_eq!(run_tasks_at(&mut s, t0 + ms(crate::MIN_REPEAT_MS) + ms(20))["ran"], json!(1));
assert_eq!(run_tasks_at(&mut s, t0 + ms(crate::MIN_REPEAT_MS) + ms(30))["ran"], json!(0));
// At most MAX_TASKS pending tasks per script.
let o = run_code(&mut s, "for (var i = 0; i < 1000; i++) app.scheduleTask('1', 1000, true);", "many.jsx");
assert!(o.error.as_ref().is_some_and(|e| e.message.contains(&format!("at most {}", crate::MAX_TASKS))), "{:?}", o.error);
// Task code nested too deeply for the parser is refused up front.
let deep = format!("app.scheduleTask('{}', 10, false);", "(".repeat(10_000));
let o = run_code(&mut s, &deep, "deep.jsx");
assert!(o.error.as_ref().is_some_and(|e| e.message.contains("nested too deeply")), "{:?}", o.error);
// Odd delays and ids are harmless.
ok(
&mut s,
"app.cancelTask(-1); app.cancelTask(NaN); app.cancelTask(1e20); app.scheduleTask('1', NaN, false); app.scheduleTask('1', -5, false); app.scheduleTask('1', 1e300, true);",
);
}
#[test]
fn closing_the_project_stops_tasks_of_scripts_without_windows() {
let mut s = session();
ok(&mut s, "app.scheduleTask('writeLn(1)', 100, true);");
console(&mut s, "var cid = app.scheduleTask('writeLn(2)', 100, true);");
let t0 = Instant::now();
assert_eq!(run_tasks_at(&mut s, t0 + ms(500))["ran"], json!(2));
s.execute("file.closeProject", json!({})).unwrap();
let r = run_tasks_at(&mut s, t0 + ms(5000));
assert_eq!((r["ran"].clone(), r["next"].clone()), (json!(0), Value::Null), "{r}");
}
#[test]
fn console_tasks_run_in_the_console_and_cancel_there() {
let mut s = session();
console(&mut s, "var hits = 0; var tid = app.scheduleTask('hits++', 100, true); app.scheduleTask('writeLn(\"once \" + hits)', 300, false);");
let t0 = Instant::now();
assert_eq!(run_tasks_at(&mut s, t0 + ms(200))["ran"], json!(1));
let r = run_tasks_at(&mut s, t0 + ms(400));
assert_eq!((r["ran"].clone(), output(&r)), (json!(2), vec!["once 2".to_string()]), "{r}");
assert_eq!(console(&mut s, "app.cancelTask(tid); hits").result, json!(2));
let r = run_tasks_at(&mut s, t0 + ms(5000));
assert_eq!((r["ran"].clone(), r["next"].clone()), (json!(0), Value::Null), "{r}");
}
#[test]
fn handlers_schedule_one_shot_tasks_that_run_when_due() {
let mut s = session();
ok(
&mut s,
r#"
var w = new Window("palette", "Later");
var b = w.add("button", undefined, "Go", { name: "go" });
b.onClick = function () { app.scheduleTask("writeLn('later')", 100, false); };
w.show();
"#,
);
s.execute("scriptui.click", json!({"widget": "go"})).unwrap();
let r = run_tasks_at(&mut s, Instant::now() + ms(500));
assert_eq!(output(&r), vec!["later"], "{r}");
// The engine command runs the same pass (headless hosts, agents).
assert_eq!(s.execute("script.runTasks", json!({})).unwrap()["ran"], json!(0));
s.execute("scriptui.close", json!({})).unwrap();
}
+167 -28
View File
@@ -19,6 +19,7 @@ use boa_engine::{Context, JsResult, JsValue, js_string};
use effectcraft_engine::Session;
use effectcraft_engine::scriptui::{ScriptUiEvent, ScriptWindow, layout};
use serde_json::{Value as J, json};
use web_time::Instant;
use crate::runtime::{ACTIVE, Active, CONSOLE, Restore, ScriptError, arg_string, js_str, outcome, script_error, settle, throw, with_active};
@@ -132,6 +133,10 @@ pub(crate) enum ToHost {
#[cfg(not(target_arch = "wasm32"))]
Start(Session),
Event(Session, ScriptUiEvent),
/// Run the scheduled tasks due at this time (see [`crate::tasks`]; the web has no
/// script-host threads).
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
Tick(Session, Instant),
}
impl ToHost {
@@ -140,7 +145,7 @@ impl ToHost {
match self {
#[cfg(not(target_arch = "wasm32"))]
ToHost::Start(s) => s,
ToHost::Event(s, _) => s,
ToHost::Event(s, _) | ToHost::Tick(s, _) => s,
}
}
}
@@ -152,6 +157,8 @@ pub(crate) struct FromHost {
name: String,
/// The host thread has ended (no windows left).
done: bool,
/// When its next scheduled task is due.
next_due: Option<Instant>,
}
/// The host thread's end of the hand-off.
@@ -164,6 +171,12 @@ pub(crate) struct Link {
struct HostEnd {
tx: std::sync::mpsc::Sender<ToHost>,
rx: std::sync::mpsc::Receiver<FromHost>,
/// When the host's next scheduled task is due.
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
next_due: Option<Instant>,
/// [`effectcraft_engine::scriptui::ScriptUi::project_epoch`] when the host last ran.
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
epoch: u64,
}
fn hosts() -> &'static std::sync::Mutex<BTreeMap<u32, HostEnd>> {
@@ -177,38 +190,57 @@ const _: fn() = || {
send::<Session>();
};
/// What wakes a script-host thread.
enum Wake {
Event(ScriptUiEvent),
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
Tick(Instant),
}
/// On the host thread: hand the session (with the windows' state and what the script printed)
/// back to the caller, and wait for the next event. `None` when the caller has gone.
/// back to the caller, and wait for the next event. `None` when the caller has gone. Scheduled
/// tasks wait while a modal dialog does.
fn yield_wait(outcome: crate::Outcome, windows: Vec<ScriptWindow>, done: bool) -> Option<ScriptUiEvent> {
let mut out = outcome;
loop {
match yield_wake(out, windows.clone(), done, None)? {
Wake::Event(ev) => return Some(ev),
Wake::Tick(_) => out = crate::Outcome { waiting: true, ..Default::default() },
}
}
}
/// [`yield_wait`] that also wakes for the scheduled tasks due at `next_due`.
fn yield_wake(outcome: crate::Outcome, windows: Vec<ScriptWindow>, done: bool, next_due: Option<Instant>) -> Option<Wake> {
let (session, link, name) = ACTIVE.with(|a| {
let mut a = a.borrow_mut();
let a = a.as_mut()?;
Some((std::mem::take(&mut a.session), a.link.take()?, a.name.clone()))
})?;
if link.tx.send(FromHost { session, outcome, windows, name, done }).is_err() {
if link.tx.send(FromHost { session, outcome, windows, name, done, next_due }).is_err() {
return None;
}
if done {
return None;
}
match link.rx.recv() {
Ok(ToHost::Event(s, ev)) => {
ACTIVE.with(|a| {
if let Some(a) = a.borrow_mut().as_mut() {
a.session = s;
a.link = Some(link);
}
});
Some(ev)
let (s, wake) = match link.rx.recv() {
Ok(ToHost::Event(s, ev)) => (s, Wake::Event(ev)),
Ok(ToHost::Tick(s, now)) => (s, Wake::Tick(now)),
_ => return None,
};
ACTIVE.with(|a| {
if let Some(a) = a.borrow_mut().as_mut() {
a.session = s;
a.link = Some(link);
}
_ => None,
}
});
Some(wake)
}
#[cfg(not(target_arch = "wasm32"))]
fn host_main(name: String, code: String, link: Link) {
fn host_main(host: u32, name: String, code: String, link: Link) {
let Ok(ToHost::Start(session)) = link.rx.recv() else { return };
let mut a = Active::new(session, &name);
let mut a = Active::new(session, &name, host);
a.link = Some(link);
ACTIVE.with(|c| *c.borrow_mut() = Some(a));
let file = if name.is_empty() { "script".to_string() } else { name.clone() };
@@ -220,19 +252,29 @@ fn host_main(name: String, code: String, link: Link) {
return;
}
};
let r = crate::runtime::eval_with_tasks(&mut ctx, &code, &file);
let r = crate::runtime::eval_with_tasks(&mut ctx, &code, &file, host);
let mut out = outcome(settle(), r);
let mut had_windows = false;
loop {
let windows = snapshot(&mut ctx);
let open = windows.iter().any(|w| w.visible);
match yield_wait(out, windows, !open) {
Some(ev) => {
had_windows |= open;
// The host lives while its windows are open, or (a script that never showed one)
// while it has scheduled tasks.
let done = !open && (had_windows || !crate::tasks::has(host));
match yield_wake(out, windows, done, crate::tasks::next_due(host)) {
Some(Wake::Event(ev)) => {
let r = dispatch_js(&mut ctx, &ev);
out = outcome(settle(), r);
}
None => return,
Some(Wake::Tick(now)) => {
let (ran, error) = crate::tasks::run_due(&mut ctx, host, now);
out = crate::Outcome { result: json!(ran), output: settle(), error, waiting: false };
}
None => break,
}
}
crate::tasks::drop_host(host);
}));
// A panic: give the session back all the same.
let left = ACTIVE.with(|c| c.borrow_mut().take());
@@ -241,21 +283,21 @@ fn host_main(name: String, code: String, link: Link) {
{
let message = if body.is_err() { "the script engine stopped unexpectedly" } else { "the script ended" };
let out = crate::Outcome { error: Some(ScriptError { message: message.into(), file, ..Default::default() }), ..Default::default() };
let _ = link.tx.send(FromHost { session: std::mem::take(&mut a.session), outcome: out, windows: vec![], name, done: true });
let _ = link.tx.send(FromHost { session: std::mem::take(&mut a.session), outcome: out, windows: vec![], name, done: true, next_due: None });
}
}
/// Wait for the host to hand the session back; keep its link while it has windows open.
fn receive(session: &mut Session, host: u32, end: HostEnd) -> crate::Outcome {
match end.rx.recv() {
Ok(FromHost { session: s, outcome, windows, name, done }) => {
Ok(FromHost { session: s, outcome, windows, name, done, next_due }) => {
*session = s;
publish(session, host, &name, windows);
if done {
session.script_ui.windows.retain(|w| w.host != host);
session.script_ui.revision += 1;
} else if let Ok(mut h) = hosts().lock() {
h.insert(host, end);
h.insert(host, HostEnd { next_due, epoch: session.script_ui.project_epoch, ..end });
}
outcome
}
@@ -271,8 +313,10 @@ pub(crate) fn run_threaded(session: &mut Session, req: &effectcraft_engine::Scri
let (to_tx, to_rx) = std::sync::mpsc::channel();
let (from_tx, from_rx) = std::sync::mpsc::channel();
let (name, code) = (req.name.to_string(), req.code.to_string());
let spawned =
std::thread::Builder::new().name(format!("script-{host}")).stack_size(64 << 20).spawn(move || host_main(name, code, Link { rx: to_rx, tx: from_tx }));
let spawned = std::thread::Builder::new()
.name(format!("script-{host}"))
.stack_size(64 << 20)
.spawn(move || host_main(host, name, code, Link { rx: to_rx, tx: from_tx }));
if spawned.is_err() {
return crate::runtime::run_inline(session, req);
}
@@ -280,7 +324,7 @@ pub(crate) fn run_threaded(session: &mut Session, req: &effectcraft_engine::Scri
*session = msg.into_session();
return crate::Outcome { error: Some(ScriptError { message: "the script host did not start".into(), ..Default::default() }), ..Default::default() };
}
receive(session, host, HostEnd { tx: to_tx, rx: from_rx })
receive(session, host, HostEnd { tx: to_tx, rx: from_rx, next_due: None, epoch: 0 })
}
/// Record the windows a host published (named after the script that made them).
@@ -295,8 +339,11 @@ pub(crate) fn publish(session: &mut Session, host: u32, name: &str, mut windows:
return;
}
session.script_ui.publish(host, windows);
if host != CONSOLE_HOST && !session.script_ui.windows.iter().any(|w| w.host == host) {
// Its last window closed: the context and its scheduled tasks go (a script that never showed
// a window keeps them, see [`crate::tasks`]).
if host != CONSOLE_HOST && !session.script_ui.windows.iter().any(|w| w.host == host) && (had || !crate::tasks::has(host)) {
drop_inline(host);
crate::tasks::drop_host(host);
}
}
@@ -330,7 +377,7 @@ pub fn dispatch_ui(session: &mut Session, ev: &ScriptUiEvent) -> Result<J, Strin
fn dispatch_inline(session: &mut Session, host: u32, name: &str, ev: &ScriptUiEvent) -> crate::Outcome {
let s = std::mem::take(session);
ACTIVE.with(|a| *a.borrow_mut() = Some(Active::new(s, name)));
ACTIVE.with(|a| *a.borrow_mut() = Some(Active::new(s, name, host)));
let guard = Restore { target: session };
let (r, windows) = if host == CONSOLE_HOST {
CONSOLE.with(|c| match c.borrow_mut().as_mut() {
@@ -354,3 +401,95 @@ fn dispatch_inline(session: &mut Session, host: u32, name: &str, ev: &ScriptUiEv
publish(session, host, name, windows);
outcome(output, r)
}
// ---------------------------------------------------------------- scheduled tasks
/// Run the due tasks of the script-host threads; hosts without windows whose project was closed
/// end.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn tick_threaded(session: &mut Session, now: Instant, report: &mut crate::tasks::Report) {
let epoch = session.script_ui.project_epoch;
let (mut due, mut stale) = (vec![], vec![]);
if let Ok(mut h) = hosts().lock() {
for host in session.script_ui.task_hosts.clone() {
let Some(end) = h.get(&host) else { continue };
let windowless = !session.script_ui.windows.iter().any(|w| w.host == host);
if windowless && end.epoch != epoch {
// Dropping its end lets the host thread finish.
stale.extend(h.remove(&host));
} else if end.next_due.is_some_and(|d| d <= now) {
due.push(host);
}
}
}
drop(stale);
for host in due {
let Some(end) = hosts().lock().ok().and_then(|mut h| h.remove(&host)) else { continue };
if let Err(std::sync::mpsc::SendError(msg)) = end.tx.send(ToHost::Tick(std::mem::take(session), now)) {
*session = msg.into_session();
session.script_ui.windows.retain(|w| w.host != host);
session.script_ui.revision += 1;
continue;
}
report.add(receive(session, host, end));
}
if let Ok(h) = hosts().lock() {
for host in &session.script_ui.task_hosts {
report.due(h.get(host).and_then(|end| end.next_due));
}
}
}
/// Run the due tasks of the inline hosts on this thread (the Script Console, web runs); those
/// without windows whose project was closed stop.
pub(crate) fn tick_inline(session: &mut Session, now: Instant, report: &mut crate::tasks::Report) {
for (host, name, epoch) in crate::tasks::hosts() {
if !session.script_ui.task_hosts.contains(&host) {
continue;
}
let windowless = !session.script_ui.windows.iter().any(|w| w.host == host);
let alive = host == CONSOLE_HOST || INLINE.with(|m| m.borrow().contains_key(&host));
if !alive || (windowless && epoch != session.script_ui.project_epoch) {
crate::tasks::drop_host(host);
if host != CONSOLE_HOST && windowless {
drop_inline(host);
}
continue;
}
if crate::tasks::next_due(host).is_some_and(|d| d <= now) {
report.add(tick_inline_host(session, host, &name, now));
}
if host != CONSOLE_HOST && !crate::tasks::has(host) && !session.script_ui.windows.iter().any(|w| w.host == host) {
// A script without windows whose tasks have all run.
drop_inline(host);
}
report.due(crate::tasks::next_due(host));
}
}
fn tick_inline_host(session: &mut Session, host: u32, name: &str, now: Instant) -> crate::Outcome {
let s = std::mem::take(session);
ACTIVE.with(|a| *a.borrow_mut() = Some(Active::new(s, name, host)));
let guard = Restore { target: session };
let run = |ctx: &mut Context| {
let r = crate::tasks::run_due(ctx, host, now);
(r, snapshot(ctx))
};
let ((ran, error), windows) = if host == CONSOLE_HOST {
CONSOLE.with(|c| c.borrow_mut().as_mut().map(|ctx| run(ctx)))
} else {
INLINE.with(|m| m.borrow_mut().get_mut(&host).map(|ctx| run(ctx)))
}
.unwrap_or(((0, None), vec![]));
let output = settle();
drop(guard);
crate::tasks::set_epoch(host, session.script_ui.project_epoch);
publish(session, host, name, windows);
crate::Outcome { result: json!(ran), output, error, waiting: false }
}
/// Forget the session's task hosts that have ended (inline ones: that have no tasks left).
pub(crate) fn prune_task_hosts(session: &mut Session) {
let threaded = |host: &u32| hosts().lock().ok().is_some_and(|h| h.contains_key(host));
session.script_ui.task_hosts.retain(|host| crate::tasks::has(*host) || threaded(host));
}
+10
View File
@@ -58,6 +58,8 @@ pub struct FaceInfo {
/// be requested the way After Effects scripts name fonts.
pub full_name: String,
pub postscript: Option<String>,
/// The face has variation axes (an `fvar` table), read from the table directory at scan time.
pub variable: bool,
}
/// Something that can list fonts (system folders, a web font picker, a project's font folder…).
@@ -150,6 +152,7 @@ fn info_from(n: FaceNames, data: FaceData, origin: &'static str) -> FaceInfo {
native_family: n.native_family,
full_name: n.full_name,
postscript: n.postscript,
variable: n.variable,
}
}
@@ -403,6 +406,13 @@ pub fn native_families() -> std::collections::BTreeMap<String, String> {
all_faces().into_iter().filter_map(|f| Some((f.info.family.clone(), f.info.native_family.clone()?))).collect()
}
/// Families with at least one variable face (variation axes), for the font menu's Variable
/// Fonts filter. Scans the system fonts first if that hasn't happened yet.
pub fn variable_families() -> std::collections::BTreeSet<String> {
scan_system();
all_faces().into_iter().filter(|f| f.info.variable).map(|f| f.info.family.clone()).collect()
}
/// Families with their style names, sorted by family; styles in weight order. This is what font
/// menus list, so it scans the system fonts first if that hasn't happened yet (before, only a
/// project asking for a font the bundled ones lack started the scan, and the menus offered the
+60 -1
View File
@@ -19,6 +19,9 @@ pub struct FaceNames {
/// The PostScript name (name id 6, e.g. `YuGothic-Bold`): what After Effects scripts and
/// `.aep`-derived data call a font.
pub postscript: Option<String>,
/// A variable font: it has an `fvar` table with at least one axis (the font menu's
/// Variable Fonts filter).
pub variable: bool,
}
fn be16(b: &[u8], o: usize) -> Option<u16> {
@@ -66,12 +69,14 @@ fn read_face<R: Read + Seek>(r: &mut R, off: u64, index: u32) -> Option<FaceName
let dir = read_at(r, off + 12, n * 16)?;
let mut name = None;
let mut os2 = None;
let mut fvar = None;
for i in 0..n {
let rec = &dir[i * 16..i * 16 + 16];
let (o, l) = (be32(rec, 8)? as u64, be32(rec, 12)? as usize);
match &rec[0..4] {
b"name" => name = Some((o, l)),
b"OS/2" => os2 = Some((o, l)),
b"fvar" => fvar = Some((o, l)),
_ => {}
}
}
@@ -92,7 +97,9 @@ fn read_face<R: Read + Seek>(r: &mut R, off: u64, index: u32) -> Option<FaceName
}
let sl = style.to_ascii_lowercase();
italic |= sl.contains("italic") || sl.contains("oblique");
Some(FaceNames { index, family, style, full_name, weight, italic, native_family, postscript })
// fvar header: version (4), axesArrayOffset (2), reserved (2), axisCount (2).
let variable = fvar.filter(|(_, l)| *l >= 10).and_then(|(fo, _)| read_at(r, fo, 10)).and_then(|fb| be16(&fb, 8)).is_some_and(|axes| axes > 0);
Some(FaceNames { index, family, style, full_name, weight, italic, native_family, postscript, variable })
}
/// A Windows Unicode name-table string by id in a language other than US English.
@@ -165,5 +172,57 @@ mod tests {
let n = read_faces_bytes(crate::fonts::NOTO_SERIF_REGULAR);
assert_eq!(n[0].family, "Noto Serif");
assert!(read_faces_bytes(b"not a font").is_empty());
assert!(!f[0].variable && !n[0].variable, "the bundled fonts are static");
}
/// A font file of just `name` (family, Regular) and, when `fvar` is given, an `fvar` table
/// with that many axes (or a truncated one with `Some(None)`).
fn names_font(family: &str, fvar: Option<Option<u16>>) -> Vec<u8> {
let u16s = |v: &[u16]| -> Vec<u8> { v.iter().flat_map(|x| x.to_be_bytes()).collect() };
let strings: Vec<Vec<u8>> = [family, "Regular"].iter().map(|s| s.encode_utf16().flat_map(|u| u.to_be_bytes()).collect()).collect();
let mut name = u16s(&[0, 2, 30]);
let mut off = 0;
for (id, st) in strings.iter().enumerate() {
name.extend(u16s(&[3, 1, 0x409, id as u16 + 1, st.len() as u16, off]));
off += st.len() as u16;
}
strings.iter().for_each(|st| name.extend(st));
let mut tables: Vec<(&[u8; 4], Vec<u8>)> = vec![];
match fvar {
Some(Some(n)) => {
// Header, then `n` axis records: `wght` pinned at 400 (min = default = max).
let mut t = u16s(&[1, 0, 16, 2, n, 20, 0, 4 + 4 * n]);
(0..n).for_each(|_| t.extend(b"wght".iter().copied().chain(u16s(&[0x190, 0, 0x190, 0, 0x190, 0, 0, 256]))));
tables.push((b"fvar", t));
}
Some(None) => tables.push((b"fvar", u16s(&[1, 0, 16]))),
None => {}
}
tables.push((b"name", name));
let mut out = u16s(&[1, 0, tables.len() as u16, 0, 0, 0]);
let mut offset = 12 + 16 * tables.len();
let mut body: Vec<u8> = vec![];
for (tag, data) in &tables {
out.extend(*tag);
out.extend([0u8; 4]);
out.extend((offset as u32).to_be_bytes());
out.extend((data.len() as u32).to_be_bytes());
body.extend(data);
offset += data.len();
}
out.extend(body);
out
}
/// The scan marks a face with an `fvar` table that declares axes as variable, reading only
/// the table directory and the fvar header; a static font, an fvar without axes and a
/// truncated fvar are not.
#[test]
fn detects_variable_faces_from_fvar() {
let v = read_faces_bytes(&names_font("Var Test", Some(Some(1))));
assert_eq!((v.len(), v[0].family.as_str(), v[0].variable), (1, "Var Test", true), "{v:?}");
assert!(!read_faces_bytes(&names_font("Static Test", None))[0].variable);
assert!(!read_faces_bytes(&names_font("No Axes", Some(Some(0))))[0].variable);
assert!(!read_faces_bytes(&names_font("Cut", Some(None)))[0].variable);
}
}
+35 -8
View File
@@ -197,26 +197,53 @@ mod tests {
eprintln!("no variable font installed: skipped");
return;
};
// An axis that changes widths (wdth or wght, else the first that moves).
let a = axes.iter().find(|a| a.tag == "wdth" && a.max > a.min).or_else(|| axes.iter().find(|a| a.tag == "wght" && a.max > a.min)).unwrap_or(&axes[0]);
let info = crate::fonts::face(face).info.clone();
let st = |v: f32| crate::TextStyle {
// An axis that moves the advance: `wdth` and `wght` usually do, but a monospaced family
// keeps its advances fixed along `wght` (Google Sans Code does) and moves them along its
// own axis instead, so try the axes in order and take the first one that moves. A face
// with no axis carrying a range is the only case that skips: a variation path that broke
// completely moves neither the advances nor the outlines, and that has to fail below.
let st = |a: &FontAxis, v: f32| crate::TextStyle {
family: info.family.clone(),
style: info.style.clone(),
size: 100.0,
variations: vec![(a.tag.clone(), v)],
..Default::default()
};
let (lo, hi) = (crate::layout::measure("Hamburgefonts", &st(a.min)), crate::layout::measure("Hamburgefonts", &st(a.max)));
let ranged: Vec<&FontAxis> = axes.iter().filter(|a| a.max > a.min).collect();
if ranged.is_empty() {
eprintln!("no variable axis installed here carries a range: skipped");
return;
}
let mut moving = None;
for a in &ranged {
let (lo, hi) = (crate::layout::measure("Hamburgefonts", &st(a, a.min)), crate::layout::measure("Hamburgefonts", &st(a, a.max)));
if (lo - hi).abs() > 1.0 {
moving = Some(((*a).clone(), lo, hi));
break;
}
}
if crate::resolve(&info.family, &info.style).face != face {
eprintln!("variable face not reachable by family/style: skipped");
return;
}
assert!((lo - hi).abs() > 1.0, "{} {}..{}: advances {lo} vs {hi}", a.tag, a.min, a.max);
let ink = |v: f32| {
let l = crate::layout::layout("H", &st(v), &crate::ParagraphStyle::default());
let ink = |a: &FontAxis, v: f32| {
let l = crate::layout::layout("H", &st(a, v), &crate::ParagraphStyle::default());
l.glyphs.iter().map(|g| crate::glyph_outline(g).area().abs()).sum::<f64>()
};
assert!((ink(a.min) - ink(a.max)).abs() > 1.0);
if let Some((a, lo, hi)) = moving {
// The axis was picked because it moves the advance; the outline has to follow it too.
assert!((lo - hi).abs() > 1.0, "{} {}..{}: advances {lo} vs {hi}", a.tag, a.min, a.max);
let (i_lo, i_hi) = (ink(&a, a.min), ink(&a, a.max));
assert!((i_lo - i_hi).abs() > 1.0, "{} {}..{}: outlines do not move", a.tag, a.min, a.max);
} else {
// Nothing here moves an advance. Holding the advances is what a monospaced family does
// on purpose, but it is also what a broken variation path does, so this case still has
// to fail rather than skip: the outline must move along `wght`, or, on a face without
// one, along the first axis that carries a range.
let a = axes.iter().find(|a| a.tag == "wght" && a.max > a.min).unwrap_or(ranged[0]);
let (i_lo, i_hi) = (ink(a, a.min), ink(a, a.max));
assert!((i_lo - i_hi).abs() > 1.0, "{} {}..{}: neither the advances nor the outlines move, so variation support looks broken", a.tag, a.min, a.max);
}
}
}
+21 -1
View File
@@ -41,7 +41,8 @@ pub fn color_popup(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, c: &mut [f32; 3])
let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos + vec2(0.0, 4.0)).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| out = color_picker(ui, id, c, original));
});
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
// Escape is the picker's: consume it, so nothing later this pass takes it too.
if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
out.close = Some(false);
}
if pressed_outside(ui.ctx(), &area.response) {
@@ -55,11 +56,30 @@ pub fn color_popup(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, c: &mut [f32; 3])
ui.data_mut(|d| {
d.insert_temp(id.with("open"), false);
d.remove::<[f32; 3]>(orig_id);
// Closed: the next Escape is the dialog's again.
d.remove::<u64>(open_pass_id());
});
} else {
// Still open after this pass: dialogs leave Escape to the picker (see `open_recently`).
let pass = ui.ctx().cumulative_pass_nr();
ui.data_mut(|d| d.insert_temp(open_pass_id(), pass));
}
out.changed
}
fn open_pass_id() -> egui::Id {
egui::Id::new("color-picker-open-pass")
}
/// A colour picker is open: it was drawn, and stayed open, in this pass or the last one (so one
/// that stops being drawn without closing, its dialog gone, doesn't count for long). Escape then
/// belongs to the picker (it goes back to the original colour and closes), not to the dialog it
/// was opened from. Closing clears this at once, so the next Escape reaches the dialog.
pub fn open_recently(ctx: &egui::Context) -> bool {
let pass: Option<u64> = ctx.data(|d| d.get_temp(open_pass_id()));
pass.is_some_and(|p| p.saturating_add(1) >= ctx.cumulative_pass_nr())
}
/// What the picker did this frame.
#[derive(Default)]
struct Outcome {
+34 -11
View File
@@ -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.
+7
View File
@@ -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"),
+38
View File
@@ -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")),
+98
View File
@@ -94,6 +94,8 @@ pub enum Dialog {
UnsavedChanges,
/// Delete Project items that compositions use? (`panels::delete_items`).
DeleteItems,
/// A gradient property's Gradient Editor (`panels::gradient_editor`, `prop.editGradient`).
GradientEditor,
}
/// The desktop's light or dark appearance where egui cannot see it (Wayland compositors that send
@@ -544,6 +546,17 @@ impl EffectcraftApp {
self.ui.saved_workspaces.keys().filter(|k| !dock::WORKSPACES.contains(&k.as_str())).cloned().collect()
}
/// The open Gradient Editor's draft (`panels::gradient_editor`): what OK would set.
pub fn gradient_draft(&self) -> &panels::gradient_editor::Draft {
&self.dialog_state.gradient
}
/// The open Gradient Editor's draft, to edit (tests and automation; the editor's own fields
/// do the same).
pub fn gradient_draft_mut(&mut self) -> &mut panels::gradient_editor::Draft {
&mut self.dialog_state.gradient
}
/// Show panel `p` (opening it in its usual place if it is closed), bring it to the front and
/// give it the focus: Window ▸ <panel>.
pub fn show_panel(&mut self, p: PanelKind) {
@@ -1542,6 +1555,11 @@ impl EffectcraftApp {
}
}
effectcraft_engine::Event::PurgeCaches => self.frames.clear(),
effectcraft_engine::Event::CopyImage { width, height, rgba } => {
if let Err(e) = copy_image(ctx, width, height, &rgba) {
self.ui.status = e;
}
}
}
}
}
@@ -1585,6 +1603,7 @@ impl EffectcraftApp {
self.session.poll_camera(true);
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
self.run_script_tasks(&ctx);
self.apply_prefs(&ctx);
self.handle_events(&ctx);
panels::unsaved::on_close_requested(self, &ctx);
@@ -1696,6 +1715,24 @@ impl EffectcraftApp {
}
impl EffectcraftApp {
/// Scripts' `app.scheduleTask` tasks that are due (repeating ones keep a ScriptUI panel
/// fresh): errors as a toast, the last line printed in the status bar, and a repaint when
/// the next one is due.
fn run_script_tasks(&mut self, ctx: &egui::Context) {
if self.session.script_ui.task_hosts.is_empty() {
return;
}
let Some(r) = effectcraft_engine::scriptui::ScriptUi::run_tasks(&mut self.session) else { return };
if let Some(msg) = r["errors"].as_array().and_then(|e| e.first()).and_then(|e| e["message"].as_str()) {
self.toast = Some((format!("Scheduled script task: {msg}"), ctx.input(|i| i.time)));
} else if let Some(line) = r["output"].as_array().and_then(|o| o.last()).and_then(|l| l.as_str()) {
self.ui.status = line.to_string();
}
if let Some(next) = r["next"].as_u64() {
ctx.request_repaint_after(std::time::Duration::from_millis(next));
}
}
/// Auto-save a dirty project when the interval has passed (Settings ▸ Project ▸ Auto-Save).
fn tick_autosave(&mut self, ctx: &egui::Context) {
let now = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0);
@@ -1862,6 +1899,18 @@ pub(crate) fn tick_playback(app: &mut EffectcraftApp, ctx: &egui::Context, scale
app.advance_playback(ctx, scale);
}
/// Put `width` × `height` straight-alpha RGBA8 pixels on the system clipboard (Composition ▸ Copy
/// Frame to Clipboard). The pixel count is checked first: egui's image constructor panics on a
/// size that doesn't match its buffer.
fn copy_image(ctx: &egui::Context, width: u32, height: u32, rgba: &[u8]) -> Result<(), String> {
let (w, h) = (width as usize, height as usize);
if w == 0 || h == 0 || w.checked_mul(h).and_then(|n| n.checked_mul(4)) != Some(rgba.len()) {
return Err(format!("cannot copy a malformed {width}×{height} image to the clipboard"));
}
ctx.copy_image(egui::ColorImage::from_rgba_unmultiplied([w, h], rgba));
Ok(())
}
/// Maximize a first window that doesn't fit the screen, which fits it to the space beside the
/// taskbar or dock. The 1680 × 1020 window on a smaller screen is only shrunk to the monitor's
/// size, so with its title bar it ran under the taskbar and cut menus off at the bottom of the
@@ -1895,6 +1944,55 @@ mod fit_window_tests {
}
}
#[cfg(test)]
mod copy_frame_tests {
use super::*;
fn copied(output: &egui::FullOutput) -> Vec<&egui::ColorImage> {
output
.platform_output
.commands
.iter()
.filter_map(|c| match c {
egui::OutputCommand::CopyImage(image) => Some(image),
_ => None,
})
.collect()
}
/// Composition ▸ Copy Frame to Clipboard puts the rendered frame on the system clipboard.
#[test]
fn copy_frame_puts_the_frame_on_the_clipboard() {
let mut session = Session::default();
session.execute("comp.new", json!({"name": "Copy", "width": 8, "height": 6, "duration": 1})).unwrap();
session.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap();
let mut app = EffectcraftApp::new(session);
let ctx = egui::Context::default();
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
crate::menus::invoke(&mut app, ui.ctx(), "comp.copyFrame", json!({})).unwrap();
app.handle_events(ui.ctx());
});
output.textures_delta.clear();
let images = copied(&output);
assert_eq!(images.len(), 1);
assert_eq!(images[0].size, [8, 6]);
assert!(images[0].pixels.iter().all(|p| *p == egui::Color32::from_rgb(255, 0, 0)));
}
/// A malformed image never reaches egui's panicking constructor.
#[test]
fn a_malformed_image_is_an_error_not_a_crash() {
let ctx = egui::Context::default();
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
assert!(copy_image(ui.ctx(), 4, 4, &[0; 10]).is_err());
assert!(copy_image(ui.ctx(), 0, 4, &[]).is_err());
assert!(copy_image(ui.ctx(), u32::MAX, u32::MAX, &[0; 4]).is_err());
});
output.textures_delta.clear();
assert!(copied(&output).is_empty());
}
}
#[cfg(test)]
mod gpu_failure_tests {
use super::*;
+30 -4
View File
@@ -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(&params) && app.session.state.text_edit.is_none() {
let text = ctx.data_mut(|d| d.remove_temp::<String>(system_paste_id())).or_else(|| app.hooks.clipboard_text.as_ref().and_then(|f| f()));
if let Some(svg) = text.filter(|t| effectcraft_engine::vector::is_svg_text(t)) {
return run_engine(app, ctx, "edit.pasteSvg", json!({"svg": svg}));
}
}
// Clear follows panel focus, including the Edit menu and keyboard shortcut.
if id == "edit.clear" && params.as_object().is_some_and(|p| p.is_empty()) && app.ui.focused == PanelKind::Project {
// Through `invoke`, so deleting items that compositions use still asks first (M3.16).
@@ -758,6 +767,8 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
"view.newViewer" => json!({"viewer": crate::panels::viewers::new_viewer(app)}),
// Run by id (control channel, MCP): the Project panel's selection, whatever has focus.
"project.deleteWithoutConfirmation" => crate::panels::delete_items::delete_confirmed(app, ctx, json!({}))?,
// A gradient property's "Edit Gradient…" (the Timeline's value): the Gradient Editor.
"prop.editGradient" => crate::panels::gradient_editor::open(app, &p)?,
"window.scriptPanel" => {
let id = p.get("window").and_then(Value::as_u64).ok_or("no ScriptUI panel window")? as u32;
app.show_panel(PanelKind::ScriptPanel(id));
@@ -773,12 +784,12 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
Value::Null
}
"app.gpuInfo" => {
let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
crate::panels::dialogs::info(
app,
"GPU Information",
&format!(
"Compositing: CPU, {threads} threads (pure Rust, rayon).\nDisplay: egui on wgpu.\nLayer cache: {} MB • Preview cache: {} MB.",
"{}Layer cache: {} MB • Preview cache: {} MB.",
effectcraft_engine::sysinfo::gpu_summary(&app.session),
app.session.layer_cache.budget() >> 20,
app.frames.budget() >> 20
),
@@ -1226,7 +1237,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Res
}
const MEDIA: &[&str] = &[
"mp4", "mov", "m4v", "mkv", "webm", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "wav", "aif", "aiff", "mp3", "flac", "ogg",
"opus", "svg", "pdf", "ai", "eps", "psd", "psb", "gltf", "glb", "obj", "json", "csv", "tsv",
"opus", "svg", "pdf", "ai", "eps", "psd", "psb", "gltf", "glb", "obj", "stl", "json", "csv", "tsv",
];
// A host whose modal dialogs would run a nested event loop inside this frame (macOS: a
// drag over the window then re-entered winit's event handler and aborted, #511) shows the
@@ -1561,6 +1572,11 @@ fn mods_match(want: egui::Modifiers, got: egui::Modifiers) -> bool {
/// Set (egui temp data) from a plain Spacebar press until its release while a tap would still
/// run the Spacebar shortcut.
/// The system clipboard's text of this frame's paste event, for the Edit ▸ Paste it runs.
fn system_paste_id() -> egui::Id {
egui::Id::new("menus-system-paste")
}
fn space_tap_id() -> egui::Id {
egui::Id::new("spacebar-tap")
}
@@ -1606,7 +1622,8 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only
let start = modifiers_at_start(ctx);
// Dialog cancellation owns Escape even when a text field has keyboard focus.
if !tab_only && app.dialog.is_some() && ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
if crate::panels::shortcut_editor::recording(app) || egui::Popup::is_any_open(ctx) {
// A popup or colour picker takes Escape first (the picker goes back to its original colour).
if crate::panels::shortcut_editor::recording(app) || egui::Popup::is_any_open(ctx) || crate::color_picker::open_recently(ctx) {
return;
}
ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape));
@@ -1641,6 +1658,9 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only
// Text editing in the viewer takes the clipboard events itself.
let clipboard = app.session.state.text_edit.is_none();
let project = app.ui.focused == PanelKind::Project;
// The paste event's text: Edit ▸ Paste pastes SVG markup as a shape layer.
let pasted =
if clipboard { ctx.input(|i| i.events.iter().rev().find_map(|e| if let egui::Event::Paste(t) = e { Some(t.clone()) } else { None })) } else { None };
let mut events: Vec<(egui::Key, egui::Modifiers, bool)> = ctx.input(|i| {
// The windowing layer turns Ctrl+C / Ctrl+X / Ctrl+V into clipboard events instead of
// key presses: map them back to the keys (with the modifiers held) so Edit ▸ Copy, Cut,
@@ -1756,9 +1776,15 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only
}
if let Some((_, _, id, params)) = binds.iter().find(|(m, k, ..)| *k == key && mods_match(*m, mods)) {
let (id, params) = (id.clone(), if params.is_null() { json!({}) } else { params.clone() });
if id == "edit.paste"
&& let Some(t) = &pasted
{
ctx.data_mut(|d| d.insert_temp(system_paste_id(), t.clone()));
}
if let Err(e) = invoke(app, ctx, &id, params) {
app.ui.status = e;
}
ctx.data_mut(|d| d.remove_temp::<String>(system_paste_id()));
}
}
}
+21 -3
View File
@@ -60,6 +60,8 @@ pub struct DialogState {
pub unsaved: super::unsaved::Pending,
/// Deleting Project items in use: the deletion waiting on the prompt.
pub delete_items: super::delete_items::Pending,
/// The Gradient Editor's draft and the property it edits.
pub gradient: super::gradient_editor::Draft,
}
pub fn open_new_comp(app: &mut EffectcraftApp) {
@@ -149,9 +151,23 @@ pub fn route_layer_settings(app: &mut EffectcraftApp, id: &str, params: &Value)
}
}
/// The room a modal dialog may take: the window less a margin.
fn modal_space(ctx: &egui::Context) -> egui::Vec2 {
(ctx.content_rect().size() - vec2(48.0, 48.0)).max(vec2(120.0, 80.0))
}
pub(crate) fn modal(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, body: impl FnOnce(&mut egui::Ui)) {
let available = (ctx.content_rect().size() - vec2(48.0, 48.0)).max(vec2(120.0, 80.0));
let size = size.min(available);
let max_height = (modal_space(ctx).y - 84.0).max(40.0);
modal_fit(ctx, title, size, t, |ui, _| {
egui::ScrollArea::both().max_height(max_height).auto_shrink([false, true]).show(ui, body);
});
}
/// [`modal`] without its scroll area, for a dialog that keeps its buttons in view and scrolls
/// only its content: `body` gets the height left under the title, with `size` kept within the
/// window.
pub(crate) fn modal_fit(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, body: impl FnOnce(&mut egui::Ui, f32)) {
let size = size.min(modal_space(ctx));
egui::Modal::new(egui::Id::new(("modal", title)))
.backdrop_color(Color32::from_black_alpha(120))
.frame(egui::Frame::window(&ctx.style_of(ctx.theme())).fill(t.panel_bg).inner_margin(egui::Margin::same(18)))
@@ -161,7 +177,8 @@ pub(crate) fn modal(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Toke
// The id stays keyed on the untranslated source, so switching language cannot move it.
ui.label(egui::RichText::new(tr(title)).font(Tokens::semibold(15.0)).color(t.tab_text_active));
ui.add_space(10.0);
egui::ScrollArea::both().max_height((available.y - 84.0).max(40.0)).auto_shrink([false, true]).show(ui, body);
let title_h = ui.cursor().top() - ui.min_rect().top();
body(ui, (size.y - 36.0 - title_h).max(40.0));
});
}
@@ -193,6 +210,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context) {
Dialog::RenderTemplates => super::rq_templates::show(app, ctx, &t),
Dialog::UnsavedChanges => super::unsaved::show(app, ctx, &t),
Dialog::DeleteItems => super::delete_items::show(app, ctx, &t),
Dialog::GradientEditor => super::gradient_editor::show(app, ctx, &t),
}
}
@@ -1415,6 +1415,9 @@ pub fn viewer_hook(
if !picking && app.ui.viewer.show_layer_controls && matches!(&g.kind, GroupKind::Effect { effect } if effect == super::fx_editors::RESHAPE) {
super::fx_editors::reshape_overlay(app, ui, painter, map, ectx, layer, g, &m, &mut actions);
}
if !picking && app.ui.viewer.show_layer_controls && matches!(&g.kind, GroupKind::Effect { effect } if effect == super::fx_editors::MESH_WARP) {
super::fx_editors::mesh_warp_overlay(app, ui, painter, map, ectx, layer, g, &fm, &mut actions);
}
let scope = FxScope { effect: &g.match_id, root: g, path: String::new() };
let mut pts = Vec::new();
visible_points(g, &scope, layer, ectx, 0, &mut pts);
+24 -4
View File
@@ -1,7 +1,7 @@
//! Effects & Presets: search (matches highlighted), the contents menu (colour-depth / GPU
//! filters, animation presets on/off, categories or alphabetical, refresh), Favorites (star an
//! effect), Recently Used, "* Animation Presets" (EffectCraft's own text animation presets and
//! the user's `.ecpreset` files) and the effect categories. Double-click applies to the
//! effect), Recently Used, "* Animation Presets" (EffectCraft's own text animation and transition
//! presets and the user's `.ecpreset` files) and the effect categories. Double-click applies to the
//! selected layers; dragging an effect onto a layer (timeline, viewer, Effect Controls) applies
//! it there.
@@ -108,6 +108,8 @@ fn highlighted(p: &egui::Painter, pos: egui::Pos2, name: &str, query: &str, t: &
enum Apply {
Effect(String),
TextPreset(String),
/// A built-in animation preset (Transitions…) by id.
Builtin(String),
PresetFile(String),
}
@@ -340,18 +342,35 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
// * Animation Presets: our text presets and the user's .ecpreset files.
// * Animation Presets: our own presets (Text, Transitions…) and the user's .ecpreset files.
if view.presets {
let text: Vec<(String, String)> =
effectcraft_engine::text_presets().into_iter().filter(|(_, n)| query.is_empty() || n.to_lowercase().contains(&query)).collect();
let builtin: Vec<(String, String, String)> =
effectcraft_engine::animation_presets().into_iter().filter(|(_, n, _)| query.is_empty() || n.to_lowercase().contains(&query)).collect();
let mut categories: Vec<&str> = Vec::new();
for (_, _, c) in &builtin {
if !categories.contains(&c.as_str()) {
categories.push(c);
}
}
let files: Vec<PresetFile> =
user_presets(&ctx, force_scan).into_iter().filter(|f| query.is_empty() || f.name.to_lowercase().contains(&query)).collect();
if (!text.is_empty() || !files.is_empty()) && folder_row(app, ui, &lp, list, &mut y, "presets", "* Animation Presets", 0.0, searching, None) {
let any = !text.is_empty() || !builtin.is_empty() || !files.is_empty();
if any && folder_row(app, ui, &lp, list, &mut y, "presets", "* Animation Presets", 0.0, searching, None) {
if !text.is_empty() && folder_row(app, ui, &lp, list, &mut y, "presets/Text", "Text", 14.0, searching, None) {
for (id, name) in text {
preset_row(app, ui, &lp, list, &mut y, &name, &format!("effects.preset.text.{id}"), &query, Apply::TextPreset(id.clone()), &mut apply);
}
}
for cat in &categories {
if !folder_row(app, ui, &lp, list, &mut y, &format!("presets/{cat}"), cat, 14.0, searching, None) {
continue;
}
for (id, name, _) in builtin.iter().filter(|(_, _, c)| c == cat) {
preset_row(app, ui, &lp, list, &mut y, name, &format!("effects.preset.builtin.{id}"), &query, Apply::Builtin(id.clone()), &mut apply);
}
}
if !files.is_empty() && folder_row(app, ui, &lp, list, &mut y, "presets/User", "User Presets", 14.0, searching, None) {
for f in files {
preset_row(
@@ -418,6 +437,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let (cmd, params) = match &apply {
Apply::Effect(id) => ("effect.apply", json!({"effect": id})),
Apply::TextPreset(id) => ("layer.applyTextPreset", json!({"preset": id})),
Apply::Builtin(id) => ("anim.applyBuiltinPreset", json!({"preset": id})),
Apply::PresetFile(path) => ("anim.applyPreset", json!({"path": path})),
};
match app.session.execute(cmd, params) {
+9 -10
View File
@@ -215,16 +215,15 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
let lt = layer.map(|l| l.layer_time(t)).unwrap_or(t);
let base = p.clone();
let (title, fields): (String, Vec<Field>) = match id {
// Photoshop files: Import Kind (Footage / Composition / – Retain Layer Sizes).
"file.import" if !has(p, &["importAs"]) && is_layered(p) => (
"Import Photoshop File".into(),
vec![Field::choice(
"importAs",
"Import Kind",
&[("Footage", json!("footage")), ("Composition", json!("composition")), ("Composition - Retain Layer Sizes", json!("compositionLayerSizes"))],
1,
)],
),
// Photoshop files: Import Kind (Footage / Composition / – Retain Layer Sizes). Dropped
// files start at Settings ▸ Import ▸ Default Drag Import As.
"file.import" if !has(p, &["importAs"]) && is_layered(p) => {
let kinds =
[("Footage", json!("footage")), ("Composition", json!("composition")), ("Composition - Retain Layer Sizes", json!("compositionLayerSizes"))];
let drag = p.get("drag").and_then(Value::as_bool).unwrap_or(false);
let sel = if drag { kinds.iter().position(|(_, v)| v.as_str() == Some(s.prefs.drag_import_as())).unwrap_or(0) } else { 1 };
("Import Photoshop File".into(), vec![Field::choice("importAs", "Import Kind", &kinds, sel)])
}
// PDF / Illustrator files: Import Kind and the page.
"file.import" if !has(p, &["importAs", "page"]) && is_pdf(p) => (
"Import PDF / Illustrator File".into(),
+103 -1
View File
@@ -11,7 +11,9 @@
//! - Reshape: correspondence point list buttons here, and on the Composition viewer the source
//! and destination mask outlines with draggable correspondence handles ([`reshape_overlay`]);
//! - EXtractoR: a Layer popup of the footage's OpenEXR layers, and Red / Green / Blue / Alpha
//! popups of its channels ([`channel_popup`]).
//! popups of its channels ([`channel_popup`]);
//! - Mesh Warp: on the Composition viewer, the distortion mesh's grid over the layer with a
//! draggable handle on every vertex ([`mesh_warp_overlay`], #303).
//!
//! Every edit is a `prop.set` engine command (undoable; one drag = one undo step) and every
//! handle registers an automation id.
@@ -37,6 +39,7 @@ pub const COLORAMA: &str = "ec.color.colorama";
pub const GLOW: &str = "ec.stylize.glow";
pub const RESHAPE: &str = "ec.distort.reshape";
pub const EXTRACTOR: &str = "ec.3d.extractor";
pub const MESH_WARP: &str = "ec.distort.meshwarp";
fn str_value(layer: &Layer, ectx: &EvalCtx, g: &PropGroup, id: &str) -> String {
match g.get(id).map(|p| ectx.value(layer, p)) {
@@ -1018,10 +1021,109 @@ pub fn reshape_overlay(
}
}
/// Mesh Warp's grid: (rows, columns, vertex offsets in layer pixels, row-major). A stored mesh
/// that doesn't match the grid (Rows or Columns changed since) reads as undistorted, as it
/// renders.
fn mesh_grid(layer: &Layer, ectx: &EvalCtx, g: &PropGroup) -> (usize, usize, Vec<(f64, f64)>) {
let n = |id: &str| {
let v = g.get(id).map(|p| ectx.value(layer, p).as_f64()).unwrap_or(7.0);
if v.is_finite() { v.round().clamp(1.0, 31.0) as usize } else { 7 }
};
let (rows, cols) = (n("rows"), n("columns"));
let count = (rows + 1) * (cols + 1);
let mut offs = effectcraft_engine::effects::parse_mesh(&str_value(layer, ectx, g, "mesh"));
if offs.len() != count {
offs = vec![(0.0, 0.0); count];
}
(rows, cols, offs)
}
/// The `mesh` parameter's text for these offsets (the inverse of `parse_mesh`).
fn format_mesh(offs: &[(f64, f64)]) -> String {
let r = |v: f64| if v.is_finite() { (v * 1000.0).round() / 1000.0 + 0.0 } else { 0.0 };
offs.iter().map(|o| format!("{},{}", r(o.0), r(o.1))).collect::<Vec<_>>().join(" ")
}
/// Mesh Warp on the Composition viewer (#303): the distortion mesh's grid over the layer, with
/// a handle on every vertex. Dragging a vertex moves it (its offset in the hidden `mesh`
/// parameter; the first drag of an undistorted mesh writes the whole grid). `fm` maps effect
/// space to comp.
#[allow(clippy::too_many_arguments)]
pub fn mesh_warp_overlay(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
painter: &egui::Painter,
map: &super::viewer::ViewerMap,
ectx: &EvalCtx,
layer: &Layer,
g: &PropGroup,
fm: &Mat3,
actions: &mut Actions,
) {
let (rows, cols, offs) = mesh_grid(layer, ectx, g);
let [lw, lh] = ectx.effect_bounds(layer).0;
let rest = |k: usize| [(k % (cols + 1)) as f64 / cols as f64 * lw, (k / (cols + 1)) as f64 / rows as f64 * lh];
let screen: Vec<Pos2> = offs
.iter()
.enumerate()
.map(|(k, o)| {
let r = rest(k);
map.to_screen(fw::layer_to_comp(fm, [r[0] + o.0, r[1] + o.1]))
})
.collect();
let line = Stroke::new(1.0, Color32::from_white_alpha(150));
for (k, s) in screen.iter().enumerate() {
if k % (cols + 1) < cols
&& let Some(e) = screen.get(k + 1)
{
painter.line_segment([*s, *e], line);
}
if let Some(e) = screen.get(k + cols + 1) {
painter.line_segment([*s, *e], line);
}
}
let ctx = ui.ctx().clone();
let accent = app.tokens.accent;
for (k, s) in screen.iter().enumerate() {
let hr = Rect::from_center_size(*s, vec2(10.0, 10.0));
let id = egui::Id::new(("mesh-warp-vertex", g.uid, k));
let resp = ui.interact(hr, id, Sense::drag());
let hot = resp.hovered() || resp.dragged();
let dot = Rect::from_center_size(*s, vec2(5.0, 5.0));
painter.rect_filled(dot, 0.0, if hot { accent } else { Color32::WHITE });
painter.rect_stroke(dot, 0.0, Stroke::new(1.0, Color32::BLACK), StrokeKind::Outside);
let (row, col) = (k / (cols + 1), k % (cols + 1));
app.auto.add(&format!("viewer.meshWarp.{}.{row}.{col}", g.uid), hr, &format!("Mesh vertex (row {row}, column {col})"));
if hot {
ctx.set_cursor_icon(egui::CursorIcon::Move);
}
if resp.dragged()
&& let Some(pos) = resp.interact_pointer_pos()
&& let Some(lp) = fw::comp_to_layer(fm, map.to_comp(pos))
{
let mut v = offs.clone();
let r = rest(k);
if let Some(o) = v.get_mut(k) {
*o = (lp[0] - r[0], lp[1] - r[1]);
}
let key = gesture_key(ui, id, resp.drag_started());
set_str(actions, layer, g, "mesh", format_mesh(&v), Some(key));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mesh_text_round_trips() {
let offs = vec![(0.0, 0.0), (12.5, -3.25), (1.0 / 3.0, f64::NAN)];
let s = format_mesh(&offs);
assert_eq!(s, "0,0 12.5,-3.25 0.333,0");
assert_eq!(effectcraft_engine::effects::parse_mesh(&s), vec![(0.0, 0.0), (12.5, -3.25), (0.333, 0.0)]);
}
#[test]
fn offset_points_and_palettes_round_trip() {
let p = OffsetPoints::parse("0.5,0.75 0.1,0.5");
@@ -0,0 +1,519 @@
//! The Gradient Editor (#612), opened by a gradient property's "Edit Gradient…" in the Timeline
//! (Gradient Fill / Gradient Stroke ▸ Colors, Gradient Overlay ▸ Gradient): `prop.editGradient`.
//! Opacity stops sit above the preview bar and colour stops below it. Click above or below the
//! bar to add a stop (it takes that track's value there), drag a stop to move it, edit the
//! selected stop's location, opacity or colour, and Delete removes it (never the last stop of a
//! track). The editor works on a draft: OK sets the property once with `prop.set` at the time it
//! opened (one undo step, nothing at all when nothing changed); Cancel and Escape leave it as it
//! was.
//!
//! Automation ids: `dialog.gradient.bar`, `dialog.gradient.addOpacity`, `dialog.gradient.addColor`,
//! `dialog.gradient.opacityStop.<i>`, `dialog.gradient.colorStop.<i>` (in order along the bar),
//! `dialog.gradient.location`, `dialog.gradient.opacity`, `dialog.gradient.color`,
//! `dialog.gradient.delete`, `dialog.gradient.ok`, `dialog.gradient.cancel`.
use effectcraft_engine::keyframe::{Gradient, Value};
use effectcraft_engine::project::{ItemId, LayerId};
use egui::{Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use serde_json::{Value as Json, json};
use crate::i18n::tr;
use crate::theme::Tokens;
use crate::{Dialog, EffectcraftApp};
/// Which row of stops: opacity (above the bar) or colour (below it).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Track {
Opacity,
Color,
}
/// A stop in the draft. `id` stays with the stop when it moves past another one (the tracks are
/// kept in order along the bar, so its index changes).
#[derive(Clone, Debug, PartialEq)]
pub struct Stop<T> {
pub id: u64,
pub pos: f64,
pub value: T,
}
/// The property being edited, as it was when the editor opened.
#[derive(Clone, Debug, PartialEq)]
pub struct Target {
pub comp: u64,
pub layer: u64,
pub prop: u64,
/// Composition seconds, snapped to the nearest frame: the value is read and set there.
pub time: f64,
}
/// The editor's draft gradient and selection.
#[derive(Clone, Debug, Default)]
pub struct Draft {
pub target: Option<Target>,
pub colors: Vec<Stop<[f32; 4]>>,
pub opacities: Vec<Stop<f32>>,
pub selected: Option<(Track, u64)>,
/// The gradient as it opened, stops in order: OK with an equal draft changes nothing.
pub baseline: Gradient,
/// Stop counts (colour, opacity) of the keyframes just before and after the edit time.
pub neighbors: Vec<(usize, usize)>,
next_id: u64,
}
fn by_pos<T>(v: &mut [Stop<T>]) {
// Stable: stops at the same location keep their order.
v.sort_by(|a, b| a.pos.total_cmp(&b.pos));
}
fn clamp_pos(p: f64) -> f64 {
if p.is_finite() { p.clamp(0.0, 1.0) } else { 0.0 }
}
impl Draft {
/// A draft of `g` (stops put in order, locations clamped to 0..1).
pub fn new(g: &Gradient, target: Option<Target>, neighbors: Vec<(usize, usize)>) -> Draft {
let mut d = Draft { target, neighbors, ..Default::default() };
for &(pos, value) in &g.colors {
let id = d.fresh();
d.colors.push(Stop { id, pos: clamp_pos(pos), value });
}
for &(pos, value) in &g.opacities {
let id = d.fresh();
d.opacities.push(Stop { id, pos: clamp_pos(pos), value });
}
by_pos(&mut d.colors);
by_pos(&mut d.opacities);
d.baseline = d.gradient();
d
}
fn fresh(&mut self) -> u64 {
self.next_id = self.next_id.saturating_add(1);
self.next_id
}
/// The draft as a gradient (the tracks are always in order).
pub fn gradient(&self) -> Gradient {
Gradient { colors: self.colors.iter().map(|s| (s.pos, s.value)).collect(), opacities: self.opacities.iter().map(|s| (s.pos, s.value)).collect() }
}
pub fn len(&self, track: Track) -> usize {
match track {
Track::Color => self.colors.len(),
Track::Opacity => self.opacities.len(),
}
}
/// The id of the stop `i`-th along the bar.
pub fn id_at(&self, track: Track, i: usize) -> Option<u64> {
match track {
Track::Color => self.colors.get(i).map(|s| s.id),
Track::Opacity => self.opacities.get(i).map(|s| s.id),
}
}
/// Where stop `id` is along the bar (its index), if it exists.
pub fn index_of(&self, track: Track, id: u64) -> Option<usize> {
match track {
Track::Color => self.colors.iter().position(|s| s.id == id),
Track::Opacity => self.opacities.iter().position(|s| s.id == id),
}
}
/// Add a stop at `pos` with that track's current value there, select it, return its id.
pub fn add(&mut self, track: Track, pos: f64) -> u64 {
let pos = clamp_pos(pos);
let g = self.gradient();
let id = self.fresh();
match track {
Track::Color => self.colors.push(Stop { id, pos, value: g.color_at(pos) }),
Track::Opacity => self.opacities.push(Stop { id, pos, value: g.opacity_at(pos) }),
}
self.sort();
self.selected = Some((track, id));
id
}
/// Move stop `id` to `pos` (clamped) and keep the track in order.
pub fn move_to(&mut self, track: Track, id: u64, pos: f64) {
let pos = clamp_pos(pos);
match track {
Track::Color => {
if let Some(s) = self.colors.iter_mut().find(|s| s.id == id) {
s.pos = pos;
}
}
Track::Opacity => {
if let Some(s) = self.opacities.iter_mut().find(|s| s.id == id) {
s.pos = pos;
}
}
}
self.sort();
}
/// Remove stop `id` unless it is the last of its track. Returns whether it went.
pub fn remove(&mut self, track: Track, id: u64) -> bool {
if self.len(track) <= 1 {
return false;
}
let before = self.len(track);
match track {
Track::Color => self.colors.retain(|s| s.id != id),
Track::Opacity => self.opacities.retain(|s| s.id != id),
}
if self.selected == Some((track, id)) {
self.selected = None;
}
self.len(track) < before
}
/// Set colour stop `id`'s RGB, keeping its alpha.
pub fn set_rgb(&mut self, id: u64, rgb: [f32; 3]) {
if let Some(s) = self.colors.iter_mut().find(|s| s.id == id) {
s.value = [rgb[0], rgb[1], rgb[2], s.value[3]];
}
}
/// Set opacity stop `id` (0..1).
pub fn set_opacity(&mut self, id: u64, a: f32) {
if let Some(s) = self.opacities.iter_mut().find(|s| s.id == id) {
s.value = if a.is_finite() { a.clamp(0.0, 1.0) } else { s.value };
}
}
fn sort(&mut self) {
by_pos(&mut self.colors);
by_pos(&mut self.opacities);
}
/// The draft's stop counts differ from a neighbouring keyframe's, so the two can't
/// interpolate (gradient keyframes blend only with the same number of stops).
pub fn mismatched(&self) -> bool {
self.neighbors.iter().any(|&(c, o)| c != self.colors.len() || o != self.opacities.len())
}
}
/// `prop.editGradient {layer, prop, comp?}`: open the editor on that gradient property at the
/// current time (snapped to the nearest frame, as `prop.set` snaps it).
pub fn open(app: &mut EffectcraftApp, p: &Json) -> Result<Json, String> {
let cid = match p.get("comp").and_then(Json::as_u64) {
Some(c) => ItemId(c),
None => app.session.active_comp_id().ok_or("no composition is open")?,
};
let comp = app.session.project.comp(cid).ok_or_else(|| format!("no composition {}", cid.0))?;
let lid = p.get("layer").and_then(Json::as_u64).ok_or("missing `layer`")?;
let layer = comp.layer(LayerId(lid)).ok_or_else(|| format!("no layer {lid}"))?;
let uid = p.get("prop").and_then(Json::as_u64).ok_or("missing `prop`")?;
let prop = layer.props.find(uid).ok_or_else(|| format!("no property @{uid}"))?;
let ct = comp.frame_rate.snap_nearest(app.session.time_of(cid));
let lt = layer.layer_time(ct);
let Value::Gradient(g) = prop.value_at(lt) else {
return Err(format!("{} is not a gradient", prop.name));
};
// The keyframes either side of the edit time (one exactly there is the one being edited).
let counts = |v: &Value| match v {
Value::Gradient(g) => Some((g.colors.len(), g.opacities.len())),
_ => None,
};
let before = prop.keys.iter().rev().find(|k| k.time < lt).and_then(|k| counts(&k.value));
let after = prop.keys.iter().find(|k| k.time > lt).and_then(|k| counts(&k.value));
let target = Target { comp: cid.0, layer: lid, prop: uid, time: ct.seconds() };
app.dialog_state.gradient = Draft::new(&g, Some(target), before.into_iter().chain(after).collect());
app.dialog = Some(Dialog::GradientEditor);
Ok(json!({"dialog": "gradientEditor"}))
}
/// Set the property to the draft: one `prop.set` at the time the editor opened, in composition
/// time, or nothing when the draft is unchanged (so OK adds no keyframe).
fn commit(app: &mut EffectcraftApp, ctx: &egui::Context) {
app.dialog = None;
let d = std::mem::take(&mut app.dialog_state.gradient);
let g = d.gradient();
let Some(tg) = d.target else { return };
if g == d.baseline {
return;
}
let params = json!({"comp": tg.comp, "layer": tg.layer, "prop": tg.prop, "time": tg.time, "timeBase": "comp", "value": Value::Gradient(g).to_json()});
if let Err(e) = crate::menus::invoke(app, ctx, "prop.set", params) {
app.ui.status = e;
}
}
fn to_color32(c: [f32; 4]) -> Color32 {
let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
Color32::from_rgba_unmultiplied(b(c[0]), b(c[1]), b(c[2]), b(c[3]))
}
pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
if app.dialog_state.gradient.target.is_none() {
app.dialog = None;
return;
}
let mut answer: Option<bool> = None;
super::dialogs::modal(ctx, tr("Gradient Editor"), vec2(520.0, 270.0), t, |ui| {
let w = ui.available_width().max(200.0);
let (area, _) = ui.allocate_exact_size(vec2(w, 72.0), Sense::hover());
let bar = Rect::from_min_max(pos2(area.min.x + 8.0, area.min.y + 22.0), pos2(area.max.x - 8.0, area.min.y + 48.0));
let opacity_row = Rect::from_min_max(pos2(bar.min.x, area.min.y), pos2(bar.max.x, bar.min.y - 2.0));
let color_row = Rect::from_min_max(pos2(bar.min.x, bar.max.y + 2.0), pos2(bar.max.x, area.max.y));
let pos_of = |x: f32| ((x - bar.min.x) / bar.width().max(1.0)) as f64;
let x_of = |p: f64| bar.min.x + bar.width() * p as f32;
let painter = ui.painter().clone();
// The bar: a checkerboard under the gradient, so opacity shows.
let cell = 6.0;
let mut y = bar.min.y;
let mut row = 0;
while y < bar.max.y {
let mut x = bar.min.x;
let mut col = row;
while x < bar.max.x {
let c = if col % 2 == 0 { Color32::from_gray(200) } else { Color32::from_gray(150) };
painter.rect_filled(Rect::from_min_max(pos2(x, y), pos2((x + cell).min(bar.max.x), (y + cell).min(bar.max.y))), 0.0, c);
x += cell;
col += 1;
}
y += cell;
row += 1;
}
let g = app.dialog_state.gradient.gradient();
let n = 96;
for k in 0..n {
let x0 = bar.min.x + bar.width() * k as f32 / n as f32;
let x1 = bar.min.x + bar.width() * (k + 1) as f32 / n as f32;
let c = g.sample((k as f64 + 0.5) / n as f64);
painter.rect_filled(Rect::from_min_max(pos2(x0, bar.min.y), pos2(x1, bar.max.y)), 0.0, to_color32(c));
}
painter.rect_stroke(bar, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside);
app.auto.add("dialog.gradient.bar", bar, "Gradient");
// Clicking a row's empty space adds a stop there.
for (track, rowr, id) in [(Track::Opacity, opacity_row, "dialog.gradient.addOpacity"), (Track::Color, color_row, "dialog.gradient.addColor")] {
let r = ui.interact(rowr, egui::Id::new(("gradient-add", id)), Sense::click());
app.auto.add(id, rowr, id);
if r.clicked()
&& let Some(p) = r.interact_pointer_pos()
{
app.dialog_state.gradient.add(track, pos_of(p.x));
}
}
// The stops (drawn and hit-tested over the rows, so they take the clicks).
for track in [Track::Opacity, Track::Color] {
for i in 0..app.dialog_state.gradient.len(track) {
let d = &app.dialog_state.gradient;
let Some(id) = d.id_at(track, i) else { continue };
let (pos, fill) = match track {
Track::Color => d.colors.get(i).map(|s| (s.pos, to_color32([s.value[0], s.value[1], s.value[2], 1.0]))),
Track::Opacity => d.opacities.get(i).map(|s| (s.pos, Color32::from_gray((s.value.clamp(0.0, 1.0) * 255.0) as u8))),
}
.unwrap_or((0.0, Color32::WHITE));
let selected = d.selected == Some((track, id));
let x = x_of(pos);
let r = match track {
Track::Opacity => Rect::from_min_max(pos2(x - 5.0, opacity_row.max.y - 15.0), pos2(x + 5.0, opacity_row.max.y)),
Track::Color => Rect::from_min_max(pos2(x - 5.0, color_row.min.y), pos2(x + 5.0, color_row.min.y + 15.0)),
};
painter.rect_filled(r, 1.0, fill);
painter.rect_stroke(
r,
1.0,
Stroke::new(if selected { 2.0 } else { 1.0 }, if selected { t.accent } else { t.field_border }),
StrokeKind::Outside,
);
let resp = ui.interact(r.expand(2.0), egui::Id::new(("gradient-stop", track == Track::Color, id)), Sense::click_and_drag());
let name = match track {
Track::Opacity => format!("dialog.gradient.opacityStop.{i}"),
Track::Color => format!("dialog.gradient.colorStop.{i}"),
};
app.auto.add(&name, r, &name);
if resp.clicked() || resp.drag_started() {
app.dialog_state.gradient.selected = Some((track, id));
}
if resp.dragged()
&& let Some(p) = resp.interact_pointer_pos()
{
app.dialog_state.gradient.move_to(track, id, pos_of(p.x));
}
}
}
ui.add_space(8.0);
// The selected stop's fields.
let selected = app.dialog_state.gradient.selected;
let selected = selected.and_then(|(track, id)| app.dialog_state.gradient.index_of(track, id).map(|i| (track, id, i)));
ui.horizontal(|ui| {
let Some((track, id, i)) = selected else {
ui.label(egui::RichText::new(tr("Click above the bar to add an opacity stop, below it to add a color stop.")).color(t.text_dim));
return;
};
ui.label(tr("Location:"));
let d = &app.dialog_state.gradient;
let pos = match track {
Track::Color => d.colors.get(i).map(|s| s.pos),
Track::Opacity => d.opacities.get(i).map(|s| s.pos),
}
.unwrap_or(0.0);
let mut pct = pos * 100.0;
let r = ui.add(egui::DragValue::new(&mut pct).range(0.0..=100.0).speed(0.5).max_decimals(1).suffix("%"));
app.auto.add("dialog.gradient.location", r.rect, "Location");
if r.changed() {
app.dialog_state.gradient.move_to(track, id, pct / 100.0);
}
// Moving can reorder the track: find the stop again by its id, so the fields below
// show this stop and not the one now at its old index.
let Some(i) = app.dialog_state.gradient.index_of(track, id) else { return };
match track {
Track::Opacity => {
ui.label(tr("Opacity:"));
let mut a = app.dialog_state.gradient.opacities.get(i).map_or(1.0, |s| s.value) as f64 * 100.0;
let r = ui.add(egui::DragValue::new(&mut a).range(0.0..=100.0).speed(0.5).max_decimals(1).suffix("%"));
app.auto.add("dialog.gradient.opacity", r.rect, "Opacity");
if r.changed() {
app.dialog_state.gradient.set_opacity(id, (a / 100.0) as f32);
}
}
Track::Color => {
ui.label(tr("Color:"));
let c = app.dialog_state.gradient.colors.get(i).map_or([1.0; 4], |s| s.value);
// The shared colour picker (sRGB, hex field, OK / Cancel); the stop keeps its alpha.
let mut rgb = [c[0], c[1], c[2]];
let swatch = crate::widgets::srgb_color_button(ui, &mut rgb);
app.auto.add("dialog.gradient.color", swatch.rect, "Color");
if swatch.changed() {
app.dialog_state.gradient.set_rgb(id, rgb);
}
}
}
let can_delete = app.dialog_state.gradient.len(track) > 1;
let r = ui.add_enabled(can_delete, egui::Button::new(tr("Delete")));
app.auto.add("dialog.gradient.delete", r.rect, "Delete");
if r.clicked() {
app.dialog_state.gradient.remove(track, id);
}
});
if app.dialog_state.gradient.mismatched() {
ui.add_space(4.0);
ui.label(egui::RichText::new(tr("These adjacent keyframes cannot interpolate because their stop counts differ.")).color(t.danger).size(11.0));
}
ui.add_space(14.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let r = ui.button(tr("Cancel"));
app.auto.add("dialog.gradient.cancel", r.rect, "Cancel");
if r.clicked() {
answer = Some(false);
}
let r = ui.button(tr("OK"));
app.auto.add("dialog.gradient.ok", r.rect, "OK");
if r.clicked() {
answer = Some(true);
}
});
});
// Delete / Backspace remove the selected stop, but not while typing in a field.
if answer.is_none()
&& !ctx.egui_wants_keyboard_input()
&& ctx.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace))
&& let Some((track, id)) = app.dialog_state.gradient.selected
{
app.dialog_state.gradient.remove(track, id);
}
match answer {
Some(true) => commit(app, ctx),
Some(false) => {
app.dialog = None;
app.dialog_state.gradient = Draft::default();
}
None => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn draft() -> Draft {
Draft::new(&Gradient { colors: vec![(0.0, [1.0, 0.0, 0.0, 1.0]), (1.0, [0.0, 0.0, 1.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 0.5)] }, None, vec![])
}
/// Dragging the first stop past the second keeps the track in order and the same stop
/// selected; a stop added afterwards samples the reordered track.
#[test]
fn stops_stay_in_order_and_selected_while_moving() {
for track in [Track::Color, Track::Opacity] {
let g = Gradient { colors: vec![(0.0, [1.0, 0.0, 0.0, 1.0]), (0.6, [0.0, 0.0, 1.0, 1.0])], opacities: vec![(0.0, 1.0), (0.6, 0.5)] };
let mut d = Draft::new(&g, None, vec![]);
let first = d.id_at(track, 0).unwrap();
d.selected = Some((track, first));
d.move_to(track, first, 0.3);
d.move_to(track, first, 0.8);
assert_eq!(d.index_of(track, first), Some(1), "{track:?}: dragged past the stop at 0.6, it is now second");
assert_eq!(d.selected, Some((track, first)), "{track:?}: the selection followed it");
let g = d.gradient();
let positions: Vec<f64> = match track {
Track::Color => g.colors.iter().map(|c| c.0).collect(),
Track::Opacity => g.opacities.iter().map(|c| c.0).collect(),
};
assert!(positions.windows(2).all(|w| w[0] <= w[1]), "{track:?}: {positions:?}");
let added = d.add(track, 0.5);
assert_eq!(d.index_of(track, added), Some(0), "{track:?}: the new stop sorts before the stops at 0.6 and 0.8");
assert_eq!(d.selected, Some((track, added)));
}
// After the swap the colour track runs blue (at 0) to red (at 1): a stop added at 0.5 is
// their mix in the swapped order, not the original red-to-blue.
let mut d = draft();
let red = d.id_at(Track::Color, 0).unwrap();
let blue = d.id_at(Track::Color, 1).unwrap();
d.move_to(Track::Color, red, 1.0);
d.move_to(Track::Color, blue, 0.0);
let id = d.add(Track::Color, 0.25);
let i = d.index_of(Track::Color, id).unwrap();
let c = d.colors[i].value;
assert!(c[2] > c[0], "blue dominates near 0 after the swap: {c:?}");
}
#[test]
fn last_stop_stays_and_new_stops_keep_their_tracks_values() {
let mut d = draft();
let a = d.id_at(Track::Opacity, 0).unwrap();
let b = d.id_at(Track::Opacity, 1).unwrap();
assert!(d.remove(Track::Opacity, a));
assert!(!d.remove(Track::Opacity, b), "the last opacity stop stays");
let mut d = draft();
let id = d.add(Track::Opacity, 0.5);
let i = d.index_of(Track::Opacity, id).unwrap();
assert!((d.opacities[i].value - 0.75).abs() < 1e-6, "the opacity track's own value");
let id = d.add(Track::Color, 0.5);
let i = d.index_of(Track::Color, id).unwrap();
assert!((d.colors[i].value[3] - 1.0).abs() < 1e-6, "colour stops keep their own alpha, not times opacity");
d.set_rgb(id, [0.0, 1.0, 0.0]);
assert_eq!(d.colors[i].value, [0.0, 1.0, 0.0, 1.0]);
d.set_opacity(d.id_at(Track::Opacity, 0).unwrap(), f32::NAN);
assert!(d.opacities.iter().all(|s| s.value.is_finite()));
d.move_to(Track::Color, id, f64::INFINITY);
assert!(d.colors.iter().all(|s| (0.0..=1.0).contains(&s.pos)));
}
#[test]
fn unchanged_drafts_equal_their_baseline() {
// Out-of-order and out-of-range stops come in sorted and clamped: still "unchanged".
let g = Gradient { colors: vec![(1.2, [0.0, 0.0, 1.0, 1.0]), (0.0, [1.0, 0.0, 0.0, 1.0])], opacities: vec![(0.5, 1.0)] };
let d = Draft::new(&g, None, vec![]);
assert_eq!(d.gradient(), d.baseline);
assert_eq!(d.gradient().colors.iter().map(|c| c.0).collect::<Vec<_>>(), vec![0.0, 1.0]);
let mut e = d.clone();
e.add(Track::Opacity, 0.2);
assert_ne!(e.gradient(), e.baseline);
}
#[test]
fn the_warning_follows_the_neighbours_stop_counts() {
let mut d = Draft::new(&Gradient::default(), None, vec![(2, 2), (2, 2)]);
assert!(!d.mismatched(), "same counts as both neighbours");
d.add(Track::Color, 0.5);
assert!(d.mismatched(), "three colour stops next to keyframes with two");
assert!(!Draft::new(&Gradient::default(), None, vec![]).mismatched(), "not animated: no neighbours");
}
}
+23 -2
View File
@@ -419,7 +419,10 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
&& let Some((press, grabs)) = ctx.data(|dd| dd.get_temp::<HandleDrag>(handle_drag_id()))
{
ctx.data_mut(|dd| dd.insert_temp(drag_id, true));
let (dt, dv) = (tm.t(pt.x) - tm.t(press.x), vmap(pt.y) - vmap(press.y));
// Shift keeps the handle on the time or the value axis, whichever moved more (as for keys).
let (time_ok, value_ok) = shift_axes(ui.input(|i| i.modifiers.shift), pt.x - press.x, pt.y - press.y);
let dt = if time_ok { tm.t(pt.x) - tm.t(press.x) } else { 0.0 };
let dv = if value_ok { vmap(pt.y) - vmap(press.y) } else { 0.0 };
let merge = format!("ghandle-{uid}-{}", k.idx);
for g in &grabs {
actions.push(("keys.setEase".into(), g.ease(g.end.0 + dt, g.end.1 + dv, speed, &merge)));
@@ -470,7 +473,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
{
ctx.data_mut(|dd| dd.insert_temp(drag_id, true));
let (dx, dy) = (pt.x - g.press.x, pt.y - g.press.y);
let (time_ok, value_ok) = if mods.shift { (dx.abs() >= dy.abs(), dy.abs() > dx.abs()) } else { (true, true) };
let (time_ok, value_ok) = shift_axes(mods.shift, dx, dy);
// Graph Editor ▸ Snap: to the current time and other keys.
let others: Vec<f64> = all_key_times.iter().filter(|(u, i, _)| !(*u == uid && *i == k.idx)).map(|x| x.2).collect();
let t = if time_ok { super::graph_tools::snap_time(app, tm, (g.t + (tm.t(pt.x) - tm.t(g.press.x))).max(0.0), &others) } else { g.t };
@@ -703,3 +706,21 @@ type HandleDrag = (Pos2, Vec<HandleGrab>);
fn handle_drag_id() -> egui::Id {
egui::Id::new("graph-handle-drag")
}
/// Which axes a drag may move on (time, value): both, or with Shift only the one the pointer moved more along.
fn shift_axes(shift: bool, dx: f32, dy: f32) -> (bool, bool) {
if shift { (dx.abs() >= dy.abs(), dy.abs() > dx.abs()) } else { (true, true) }
}
#[cfg(test)]
mod tests {
use super::shift_axes;
#[test]
fn shift_locks_drag_to_dominant_axis() {
assert_eq!(shift_axes(false, 10.0, 3.0), (true, true));
assert_eq!(shift_axes(true, 10.0, -3.0), (true, false));
assert_eq!(shift_axes(true, 2.0, -9.0), (false, true));
assert_eq!(shift_axes(true, 5.0, 5.0), (true, false));
}
}
+1
View File
@@ -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;
+197 -13
View File
@@ -3,8 +3,9 @@
//! Media Duration, Frame Rate, File Path, Comment — shown or hidden from the header's context
//! menu), folders (drag items into and out of them), renaming (Enter, or double-click the name),
//! the label colour picker, and the bottom bar (interpret, new folder, new comp: click, or drop
//! items on it for a comp from them, bit depth, delete). Edits are engine commands, so they are
//! undoable.
//! items on it for a comp from them, bit depth, delete), and the keyboard (the arrow keys select,
//! extend the selection and open / close folders; rows and buttons carry AccessKit names and
//! states for screen readers). Edits are engine commands, so they are undoable.
use std::sync::{Arc, Mutex};
@@ -191,6 +192,130 @@ pub fn begin_rename(app: &EffectcraftApp, ctx: &egui::Context) {
}
}
/// Where a Shift-extended selection starts (the last item clicked or moved to without Shift).
fn anchor_id() -> egui::Id {
egui::Id::new("project-selection-anchor")
}
/// The item the arrow keys move from: the last item clicked or moved to.
fn cursor_id() -> egui::Id {
egui::Id::new("project-selection-cursor")
}
/// An item row's widget: its click / drag area, keyboard focus and accessibility node.
fn row_id(item: ItemId) -> egui::Id {
egui::Id::new(("pitem", item.0))
}
/// The last row drawn with keyboard focus (egui drops the focus of a row scrolled out of view).
fn focused_row_id() -> egui::Id {
egui::Id::new("project-focused-row")
}
/// The focused widget is one of the panel's rows, or nothing has keyboard focus: the panel's keys
/// apply. Any other focused widget (a text field, a button reached with Tab) keeps them.
fn keys_reach_rows(app: &EffectcraftApp, ctx: &egui::Context) -> bool {
match ctx.memory(|m| m.focused()) {
None => app.ui.focused == crate::dock::PanelKind::Project,
Some(f) => ctx.data(|d| d.get_temp::<ItemId>(focused_row_id())).is_some_and(|row| row_id(row) == f),
}
}
/// The Project panel's arrow keys, as in After Effects: Up / Down Arrow select the previous /
/// next item (the first or last when nothing is selected), Shift+Up / Down extend the selection
/// from its anchor, Right Arrow opens the selected folder and Left Arrow closes it (on an item
/// inside a folder, Left selects the folder). They apply while the panel has focus and nothing
/// else takes them: a dialog, an open menu, an inline rename. A row focused with Tab keeps them
/// too, and keyboard focus then follows the selection, so a screen reader reads the new item.
/// Returns the item the selection moved to.
fn keyboard_nav(app: &mut EffectcraftApp, ctx: &egui::Context) -> Option<ItemId> {
use egui::Key;
let keys: Vec<(Key, egui::Modifiers)> = ctx.input(|i| {
i.events
.iter()
.filter_map(|e| match e {
egui::Event::Key { key: k @ (Key::ArrowUp | Key::ArrowDown | Key::ArrowLeft | Key::ArrowRight), pressed: true, modifiers, .. } => {
Some((*k, *modifiers))
}
_ => None,
})
.collect()
});
if keys.is_empty()
|| app.dialog.is_some()
|| widgets::any_menu_open(ctx)
|| ctx.data(|d| d.get_temp::<Editing>(edit_id())).is_some()
|| !keys_reach_rows(app, ctx)
{
return None;
}
let row_focused = ctx.memory(|m| m.focused()).is_some();
let mut moved = None;
for (key, m) in keys {
let (plain, shift) = (!m.any(), m.shift_only());
// Folders opened or closed by an earlier key in this frame change the rows.
let rows = visible_rows(app);
let pos = |id: ItemId| rows.iter().position(|(r, _)| *r == id);
let sel = &app.session.state.project_selection;
let cursor = ctx.data(|d| d.get_temp::<ItemId>(cursor_id())).filter(|c| sel.contains(c)).or_else(|| sel.last().copied());
let cur = cursor.and_then(pos);
let to = match key {
Key::ArrowUp | Key::ArrowDown if plain || shift => {
let Some(last) = rows.len().checked_sub(1) else { continue };
let next = match (cur, key == Key::ArrowDown) {
(Some(c), true) => c.saturating_add(1).min(last),
(Some(c), false) => c.saturating_sub(1),
(None, true) => 0,
(None, false) => last,
};
let Some((to, _)) = rows.get(next).copied() else { continue };
if shift {
let anchor = ctx.data(|d| d.get_temp::<ItemId>(anchor_id())).and_then(pos).or(cur).unwrap_or(next);
app.session.state.project_selection = rows.get(anchor.min(next)..=anchor.max(next)).unwrap_or_default().iter().map(|(i, _)| *i).collect();
to
} else {
app.session.state.project_selection = vec![to];
ctx.data_mut(|d| d.insert_temp(anchor_id(), to));
to
}
}
Key::ArrowRight | Key::ArrowLeft if plain => {
let Some((id, folder, parent)) = cursor.and_then(|c| app.session.project.item(c)).map(|it| (it.id, it.is_folder(), it.parent)) else {
continue;
};
let open = app.ui.project_open_folders.contains(&id.0);
if key == Key::ArrowRight {
if folder {
app.ui.project_open_folders.insert(id.0);
}
continue;
}
if folder && open {
app.ui.project_open_folders.remove(&id.0);
continue;
}
let Some(parent) = parent.filter(|p| pos(*p).is_some()) else { continue };
app.session.state.project_selection = vec![parent];
ctx.data_mut(|d| d.insert_temp(anchor_id(), parent));
parent
}
_ => continue,
};
ctx.data_mut(|d| d.insert_temp(cursor_id(), to));
moved = Some(to);
}
// The arrows are the list's, not egui's spatial focus moves.
if row_focused {
ctx.memory_mut(|m| {
m.move_focus(egui::FocusDirection::None);
if let Some(to) = moved {
m.request_focus(row_id(to));
}
});
}
moved
}
pub(crate) fn visible_rows(app: &EffectcraftApp) -> Vec<(ItemId, usize)> {
fn walk(app: &EffectcraftApp, folder: Option<ItemId>, depth: usize, q: &str, out: &mut Vec<(ItemId, usize)>) {
let mut kids = app.session.project.children(folder);
@@ -307,6 +432,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
));
}
app.auto.add(&format!("project.sort.{key}"), hr, if label.is_empty() { "Label" } else { label });
let column = crate::i18n::tr(if label.is_empty() { "Label" } else { label });
let order = (app.ui.project_sort == *key).then(|| crate::i18n::tr(if app.ui.project_sort_desc { "descending" } else { "ascending" }));
resp.widget_info(|| {
let name = match order {
Some(o) => crate::i18n::tr_args("Sort by {}, {}", &[&column, &o]),
None => crate::i18n::tr_args("Sort by {}", &[&column]),
};
egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name)
});
if resp.clicked() {
if app.ui.project_sort == *key {
app.ui.project_sort_desc = !app.ui.project_sort_desc;
@@ -317,7 +451,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
column_menu(app, &resp);
}
let hresp = ui.interact(Rect::from_min_max(pos2(x, hdr.min.y), hdr.max), egui::Id::new("proj-hdr-rest"), Sense::click());
let hresp = ui.interact(Rect::from_min_max(pos2(x, hdr.min.y), hdr.max), egui::Id::new("proj-hdr-rest"), Sense::CLICK);
column_menu(app, &hresp);
app.auto.add("project.columns", hdr, "Columns (right-click to show or hide)");
// Rows.
@@ -327,7 +461,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// The list's empty area (under the rows and the scroll bar, which take their own clicks):
// a click deselects, a drag draws a selection box, a double-click imports, as in After
// Effects (File ▸ Import ▸ File...).
let empty = ui.interact(list, egui::Id::new("proj-empty"), Sense::click_and_drag());
// Not a Tab stop: the keyboard reaches the rows, and nothing else here takes a key.
let empty = ui.interact(list, egui::Id::new("proj-empty"), Sense::CLICK | Sense::DRAG);
app.auto.add("project.empty", list, "Double-click to import files; drag to select items; right-click for project actions");
if overflow > 0.0 && ui.rect_contains_pointer(list) {
let (dx, dy, shift) = ui.input(|i| (i.smooth_scroll_delta.x, i.smooth_scroll_delta.y, i.modifiers.shift));
@@ -340,11 +475,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
};
app.ui.project_hscroll = (app.ui.project_hscroll - d).clamp(0.0, overflow);
}
let moved = keyboard_nav(app, &ctx);
let rows = visible_rows(app);
// Enter renames the selected item (Project panel focused, not typing).
if app.ui.focused == crate::dock::PanelKind::Project
&& app.dialog.is_none()
&& !ctx.egui_wants_keyboard_input()
&& keys_reach_rows(app, &ctx)
&& ctx.data(|d| d.get_temp::<Editing>(edit_id())).is_none()
&& ctx.input(|i| i.key_pressed(egui::Key::Enter))
{
@@ -382,6 +518,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
}
// The arrow keys' item scrolls into view (Shift+Up extends upward, past the selection's end).
if let Some(row) = moved.and_then(|m| rows.iter().position(|(id, _)| *id == m)) {
let top = row as f32 * ROW_H;
// Not `clamp`: a panel shorter than a row would make its bounds cross. The row's top wins.
app.ui.project_scroll = app.ui.project_scroll.max(top + ROW_H - list.height()).min(top);
}
app.ui.project_scroll = app.ui.project_scroll.clamp(0.0, max_scroll);
let scroll = app.ui.project_scroll;
let first = ((scroll / ROW_H).floor() as usize).min(rows.len());
@@ -406,14 +548,37 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
);
// The row's click / drag area goes first, so the twirl, proxy, label and comment widgets
// on top of it take their own clicks.
let mut resp = ui.interact(r.intersect(list), egui::Id::new(("pitem", id.0)), Sense::click_and_drag());
let mut resp = ui.interact(r.intersect(list), row_id(*id), Sense::click_and_drag());
app.auto.add(&format!("project.item.{}", id.0), r, &it.name);
// For screen readers: a tree item named by the item and its type, with its selection,
// depth and (a folder) open state. One Tab stop per row; the panel's keys then apply.
let open = it.is_folder().then(|| app.ui.project_open_folders.contains(&id.0) || !app.ui.project_search.is_empty());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, format!("{}, {}", it.name, crate::i18n::tr(it.type_name()))));
ctx.accesskit_node_builder(resp.id, |n| {
n.set_role(egui::accesskit::Role::TreeItem);
n.set_selected(selected);
n.set_level(depth.saturating_add(1));
if let Some(open) = open {
n.set_expanded(open);
}
});
if resp.has_focus() {
ctx.data_mut(|d| d.insert_temp(focused_row_id(), *id));
}
if resp.gained_focus() {
app.ui.focused = crate::dock::PanelKind::Project;
}
let _ = i;
let x0 = r.min.x + 8.0 + 14.0 * *depth as f32;
if it.is_folder() {
let open = app.ui.project_open_folders.contains(&id.0);
let tw = Rect::from_center_size(pos2(x0 + 4.0, r.center().y), vec2(12.0, 12.0));
if widgets::twirl(ui, tw, open, egui::Id::new(("pf", id.0)), &t).clicked() {
let tresp = widgets::twirl(ui, tw, open, egui::Id::new(("pf", id.0)), &t);
tresp.widget_info(|| {
let name = crate::i18n::tr_args(if open { "Close folder {}" } else { "Open folder {}" }, &[&it.name]);
egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name)
});
if tresp.clicked() {
if open {
app.ui.project_open_folders.remove(&id.0);
} else {
@@ -437,6 +602,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if px.enabled { "in use" } else { "off" }
));
app.auto.add(&format!("project.item.{}.proxy", id.0), pr, if px.enabled { "Proxy in use" } else { "Proxy off" });
presp.widget_info(|| {
let name = crate::i18n::tr_args(if px.enabled { "Proxy of {}: in use" } else { "Proxy of {}: off" }, &[&it.name]);
egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name)
});
if presp.clicked() {
actions.push(("file.useProxy".into(), json!({"item": id.0})));
}
@@ -485,6 +654,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
lp.rect_filled(sw, 2.0, t.label(it.label));
let sresp = ui.interact(sw.expand(3.0).intersect(list), egui::Id::new(("plabel", id.0)), Sense::click());
app.auto.add(&format!("project.item.{}.label", id.0), sw, it.label.name());
sresp.widget_info(|| {
let name = crate::i18n::tr_args("Label of {}: {}", &[&it.name, &app.session.prefs.label_name(it.label)]);
egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name)
});
let pop = egui::Id::new(("plabel-pop", id.0));
if sresp.clicked() || sresp.secondary_clicked() {
widgets::open_popup(ui, pop);
@@ -510,8 +683,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if cell_edit(app, ui, &mut actions, "comment", cr) {
continue;
}
let cresp = ui.interact(cell, egui::Id::new(("pcomment", id.0)), Sense::click());
let cresp = ui.interact(cell, egui::Id::new(("pcomment", id.0)), Sense::CLICK);
app.auto.add(&format!("project.item.{}.comment", id.0), cell, &it.comment);
cresp.widget_info(|| {
egui::WidgetInfo::labeled(egui::WidgetType::Other, true, crate::i18n::tr_args("Comment of {}: {}", &[&it.name, &it.comment]))
});
if cresp.double_clicked() {
ctx.data_mut(|d| d.insert_temp::<Editing>(edit_id(), (id.0, "comment".into(), it.comment.clone())));
} else {
@@ -529,12 +705,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if dragging.is_some_and(|d| d != id.0) && drop_row == Some(*id) && it.is_folder() {
lp.rect_stroke(r.shrink(1.0), 2.0, Stroke::new(1.5, t.accent), egui::StrokeKind::Inside);
}
let nresp = ui.interact(name_clip, egui::Id::new(("pname", id.0)), Sense::click_and_drag());
// The row with keyboard focus (Tab, or the arrow keys after it) shows a focus ring.
if ctx.memory(|m| m.has_focus(row_id(*id))) {
lp.rect_stroke(r.shrink(0.5), 0.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
}
// Part of the row: not a second Tab stop.
let nresp = ui.interact(name_clip, egui::Id::new(("pname", id.0)), Sense::CLICK | Sense::DRAG);
app.auto.add(&format!("project.item.{}.name", id.0), name_clip, &it.name);
let resp = resp.union(nresp);
if resp.clicked() {
let m = ui.input(|i| i.modifiers);
let anchor_key = egui::Id::new("project-selection-anchor");
let anchor_key = anchor_id();
ctx.data_mut(|d| d.insert_temp(cursor_id(), *id));
let anchor = ctx.data(|d| d.get_temp::<ItemId>(anchor_key)).or_else(|| app.session.state.project_selection.first().copied());
if m.shift
&& let Some(a) = anchor.and_then(|a| rows.iter().position(|(i, _)| *i == a))
@@ -692,6 +874,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let r = Rect::from_min_size(pos2(x, foot.min.y + 3.0), vec2(22.0, 22.0));
let resp = widgets::icon_button(ui, r, icon, false, &t, egui::Id::new(("pfoot", id))).on_hover_text(tip);
app.auto.add(&format!("project.{id}"), r, tip);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr(tip)));
if resp.clicked() {
let footage =
app.session.state.project_selection.first().and_then(|i| app.session.project.item(*i)).is_some_and(|i| matches!(i.kind, ItemKind::Footage(_)));
@@ -724,6 +907,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let dresp = ui.interact(dr, egui::Id::new("bpc"), Sense::click());
p.text(dr.center(), Align2::CENTER_CENTER, depth, Tokens::ui(11.5), if dresp.hovered() { t.text } else { t.text_dim });
app.auto.add("project.bitDepth", dr, depth);
dresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr_args("Project color depth: {}", &[&depth])));
// Click cycles 8/16/32 bpc; Alt-click opens Project Settings (as in After Effects).
if dresp.clicked() {
if ui.input(|i| i.modifiers.alt) {
@@ -734,9 +918,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
dresp.on_hover_text(crate::i18n::tr("Project color depth: click to cycle 8/16/32 bpc, Alt-click for Project Settings"));
let tr = Rect::from_min_size(pos2(foot.max.x - 30.0, foot.min.y + 3.0), vec2(22.0, 22.0));
if widgets::icon_button(ui, tr, Icon::Trash, false, &t, egui::Id::new("ptrash")).on_hover_text(crate::i18n::tr("Delete selected project items")).clicked()
&& !app.session.state.project_selection.is_empty()
{
let trash = widgets::icon_button(ui, tr, Icon::Trash, false, &t, egui::Id::new("ptrash")).on_hover_text(crate::i18n::tr("Delete selected project items"));
trash.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr("Delete selected project items")));
if trash.clicked() && !app.session.state.project_selection.is_empty() {
actions.push(("project.delete".into(), json!({})));
}
app.auto.add("project.delete", tr, "Delete");
+41 -31
View File
@@ -10,7 +10,7 @@
use crate::i18n::{tr, tr_args};
use effectcraft_engine::prefs::{APPEARANCE_MODES, DARK_THEMES, Item, Kind, LIGHT_THEMES, Page, pages};
use effectcraft_engine::segment::Task;
use egui::{Color32, RichText, vec2};
use egui::{Color32, Rect, RichText, vec2};
use serde_json::{Value, json};
use crate::theme::{ThemeKind, Tokens};
@@ -118,7 +118,10 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value
let r = match kind {
Kind::Int(lo, hi, unit) => {
let mut x = v.as_i64().unwrap_or(0);
let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut x).range(lo..=hi).speed(0.25)));
// A typed number applies once entered: typing 85 into UI Scale
// doesn't resize the interface to 75 % at its first digit.
let drag = egui::DragValue::new(&mut x).range(lo..=hi).speed(0.25).update_while_editing(false);
let r = ui.add(crate::widgets::drag_value(drag));
if !unit.is_empty() {
ui.label(RichText::new(unit).color(t.text_dim));
}
@@ -152,7 +155,6 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value
r
}
Kind::Choice(opts) => {
// (A number with choices, UI Scale, shows as its text.)
let curv = v.as_str().map_or_else(|| v.to_string(), str::to_string);
let shown = opts.iter().find(|o| o.1 == curv).map(|o| o.0).unwrap_or(curv.as_str()).to_string();
egui::ComboBox::from_id_salt(("settings", key))
@@ -602,10 +604,40 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let cur = serde_json::to_value(&app.session.prefs).unwrap_or_default();
let mut acts: Vec<Act> = vec![];
let (mut ok, mut cancel) = (false, false);
super::dialogs::modal(ctx, "Settings", vec2(820.0, 600.0), t, |ui| {
let body_h = (ctx.content_rect().height() - 170.0).clamp(120.0, 470.0);
ui.allocate_ui_with_layout(vec2(ui.available_width(), body_h), egui::Layout::left_to_right(egui::Align::Min), |ui| {
ui.set_height(body_h);
super::dialogs::modal_fit(ctx, "Settings", vec2(820.0, 600.0), t, |ui, height| {
// The buttons go first, from the bottom up, and the pages get the height they leave: at
// a high UI Scale the pages scroll and the buttons stay in view.
let area = ui.allocate_space(vec2(ui.available_width(), height)).1;
let mut foot = ui.new_child(egui::UiBuilder::new().max_rect(area).layout(egui::Layout::bottom_up(egui::Align::Min)));
foot.horizontal(|ui| {
let r = ui.add_enabled(idx > 0, egui::Button::new(tr("Previous")));
app.auto.add("settings.previous", r.rect, "Previous");
if r.clicked() {
page = all[idx - 1].id.to_string();
}
let r = ui.add_enabled(idx + 1 < all.len(), egui::Button::new(tr("Next")));
app.auto.add("settings.next", r.rect, "Next");
if r.clicked() {
page = all[idx + 1].id.to_string();
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let r = ui.add(egui::Button::new(RichText::new(tr(" OK ")).color(Color32::WHITE)).fill(t.accent));
app.auto.add("settings.ok", r.rect, "OK");
ok |= r.clicked();
let r = ui.button(tr("Cancel"));
app.auto.add("settings.cancel", r.rect, "Cancel");
cancel |= r.clicked();
});
});
foot.separator();
foot.add_space(8.0);
let body_h = (foot.min_rect().top() - area.top()).max(0.0);
// (A child, clipped to its share, so a wide page can't widen the dialog under the buttons.)
let pages = Rect::from_min_size(area.min, vec2(area.width(), body_h));
let mut body = ui.new_child(egui::UiBuilder::new().max_rect(pages).layout(egui::Layout::left_to_right(egui::Align::Min)));
body.set_clip_rect(pages.intersect(body.clip_rect()));
{
let ui = &mut body;
// Page list (left, like After Effects).
ui.vertical(|ui| {
ui.set_width(180.0);
@@ -640,33 +672,11 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let p = &all[idx];
ui.label(RichText::new(tr(p.title)).font(Tokens::semibold(14.0)).color(t.tab_text_active));
ui.add_space(6.0);
egui::ScrollArea::vertical().max_height(body_h - 30.0).auto_shrink([false, false]).show(ui, |ui| {
egui::ScrollArea::vertical().max_height((body_h - 30.0).max(0.0)).auto_shrink([false, false]).show(ui, |ui| {
page_ui(app, ui, p, &cur, t, &mut acts);
});
});
});
ui.add_space(8.0);
ui.separator();
ui.horizontal(|ui| {
let r = ui.add_enabled(idx > 0, egui::Button::new(tr("Previous")));
app.auto.add("settings.previous", r.rect, "Previous");
if r.clicked() {
page = all[idx - 1].id.to_string();
}
let r = ui.add_enabled(idx + 1 < all.len(), egui::Button::new(tr("Next")));
app.auto.add("settings.next", r.rect, "Next");
if r.clicked() {
page = all[idx + 1].id.to_string();
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let r = ui.add(egui::Button::new(RichText::new(tr(" OK ")).color(Color32::WHITE)).fill(t.accent));
app.auto.add("settings.ok", r.rect, "OK");
ok |= r.clicked();
let r = ui.button(tr("Cancel"));
app.auto.add("settings.cancel", r.rect, "Cancel");
cancel |= r.clicked();
});
});
}
});
app.dialog_state.settings_page = page;
for a in acts {
+26 -4
View File
@@ -579,6 +579,10 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui:
});
let preview = app.session.prefs.type_.font_preview;
let t = app.tokens;
// The Variable Fonts filter (After Effects 26.3) stays as set while the app runs, like the
// menu's other settings; the search query resets on every open.
let variable_id = id.with("variable-only");
let mut only_variable = ui.data(|d| d.get_temp::<bool>(variable_id)).unwrap_or(false);
// The font field itself becomes editable. The popup contains only results.
let arrow = Rect::from_min_max(pos2(rect.max.x - 22.0, rect.min.y), rect.max);
let edit_rect = Rect::from_min_max(rect.min, pos2(arrow.min.x, rect.max.y));
@@ -608,12 +612,28 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui:
let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).pivot(pivot).fixed_pos(anchor).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_width(rect.width().max(180.0));
// Filter row: a checkbox that keeps only the variable fonts.
let filter_h = 22.0;
let row = ui.add_sized([ui.available_width(), filter_h], egui::Button::selectable(false, ""));
let box_rect = Rect::from_min_size(row.rect.min, vec2(18.0, filter_h));
let label_rect = Rect::from_min_max(pos2(box_rect.max.x + 2.0, row.rect.min.y), row.rect.max);
let boxed = widgets::checkbox(ui, box_rect, only_variable, &t, id.with("variable-check"));
ui.painter().text(label_rect.left_center(), Align2::LEFT_CENTER, tr("Variable fonts only"), Tokens::ui(11.5), t.text);
app.auto.add(&format!("{auto}.variable"), row.rect, "Variable fonts only");
let toggled = row.clicked() || boxed.clicked();
if toggled {
only_variable = !only_variable;
ui.data_mut(|d| d.insert_temp(variable_id, only_variable));
// Typing goes on into the search field.
ui.memory_mut(|m| m.request_focus(search_id));
}
let query = state.query.trim().to_lowercase();
let mut seen = std::collections::HashSet::new();
let filtered: Vec<_> = rows
.iter()
.filter(|r| {
!r.family.is_empty()
&& (!only_variable || r.variable)
&& (query.is_empty()
|| ((r.family.to_lowercase().contains(&query) || r.display.to_lowercase().contains(&query)) && seen.insert(&r.family)))
})
@@ -621,7 +641,7 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui:
if fresh {
state.selected = filtered.iter().position(|r| r.family == current).unwrap_or(0);
}
if search.changed() {
if search.changed() || toggled {
state.selected = 0;
}
let last = filtered.len().saturating_sub(1);
@@ -637,10 +657,12 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui:
}
// Give the result list its actual height even when the previous query had
// only one row. A cached tiny Area must not collapse a reopened long list.
let height = (filtered.len() as f32 * (24.0 + ui.spacing().item_spacing.y) + 16.0).clamp(28.0, max_height);
ui.set_height(height);
let filter_room = filter_h + ui.spacing().item_spacing.y;
let height = (filtered.len() as f32 * (24.0 + ui.spacing().item_spacing.y) + 16.0).clamp(28.0, (max_height - filter_room).max(28.0));
// A minimum, from below the filter row (a maximum would move the cursor back up).
ui.set_min_height(height);
let mut scroll = egui::ScrollArea::vertical().id_salt(id.with("results")).max_height(height).min_scrolled_height(height);
if search.changed() {
if search.changed() || toggled {
scroll = scroll.vertical_scroll_offset(0.0);
}
scroll.show(ui, |ui| {
+43 -6
View File
@@ -488,6 +488,21 @@ impl TMap {
}
}
/// A Timeline ruler tick label for comp frame `fno`: seconds:frames (`00:15f`, AE) or, when
/// the project's Time Display Style is Frames, the frame number the current-time display shows
/// (`00015`, counted from Start Numbering Frames At) (#710).
fn ruler_label(fno: i64, fps_i: i64, settings: &effectcraft_engine::project::ProjectSettings) -> String {
if settings.time_display == effectcraft_engine::project::TimeDisplayStyle::Frames {
return format!("{:05}", fno.saturating_add(settings.frame_start));
}
let fps_i = fps_i.max(1);
if fno >= 60 * fps_i * 60 {
format!("{}:{:02}:{:02}f", fno / (fps_i * 60), (fno / fps_i) % 60, fno % fps_i)
} else {
format!("{:02}:{:02}f", fno / fps_i, fno % fps_i)
}
}
fn tl_map_id() -> egui::Id {
egui::Id::new("timeline-map")
}
@@ -1513,11 +1528,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let x = tm.x(s);
pr.line_segment([pos2(x, ruler.max.y - 10.0), pos2(x, ruler.max.y)], Stroke::new(1.0, t.tl_ruler_tick));
let fno = (s / fd).round() as i64;
let label = if fno >= 60 * fps_i * 60 {
format!("{}:{:02}:{:02}f", fno / (fps_i * 60), (fno / fps_i) % 60, fno % fps_i)
} else {
format!("{:02}:{:02}f", fno / fps_i, fno % fps_i)
};
let label = ruler_label(fno, fps_i, &app.session.project.settings);
pr.text(pos2(x + 3.0, ruler.max.y - 16.0), Align2::LEFT_CENTER, label, Tokens::ui(10.0), t.tl_ruler_text);
for k in 1..5 {
let xx = tm.x(s + secs_per_label * k as f64 / 5.0);
@@ -3671,7 +3682,16 @@ fn value_editor(
);
}
Value::Gradient(_) => {
p.text(pos2(x, at.y), Align2::LEFT_CENTER, crate::i18n::tr("Edit Gradient…"), Tokens::ui(12.0), t.hot_text);
// Opens the Gradient Editor at the current time (#612).
let r = p.text(pos2(x, at.y), Align2::LEFT_CENTER, crate::i18n::tr("Edit Gradient…"), Tokens::ui(12.0), t.hot_text);
let resp = ui.interact(r.expand(2.0), egui::Id::new(("gradient-edit", uid)), Sense::click());
if resp.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
if resp.clicked() {
actions.push(("prop.editGradient".into(), json!({"layer": layer.id.0, "prop": uid})));
}
}
Value::Str(s) => {
p.text(pos2(x, at.y), Align2::LEFT_CENTER, s.chars().take(30).collect::<String>(), Tokens::ui(12.0), t.text_dim);
@@ -3684,6 +3704,23 @@ fn value_editor(
mod tests {
use super::*;
/// #710: with Time Display set to Frames the ruler labels are frame numbers (as under the
/// CTI, from Start Numbering Frames At); otherwise seconds:frames.
#[test]
fn ruler_labels_follow_the_time_display_style() {
use effectcraft_engine::project::{ProjectSettings, TimeDisplayStyle};
let mut st = ProjectSettings { time_display: TimeDisplayStyle::Timecode, frame_start: 0, ..ProjectSettings::default() };
assert_eq!(ruler_label(15, 30, &st), "00:15f");
assert_eq!(ruler_label(45, 30, &st), "01:15f");
assert_eq!(ruler_label(30 * 3600 + 31, 30, &st), "60:01:01f");
st.time_display = TimeDisplayStyle::Frames;
assert_eq!(ruler_label(0, 30, &st), "00000");
assert_eq!(ruler_label(45, 30, &st), "00045");
st.frame_start = 1;
assert_eq!(ruler_label(45, 30, &st), "00046");
assert_eq!(ruler_label(i64::MAX, 30, &st), format!("{:05}", i64::MAX));
}
/// #468: playback pages the view to the CTI; a scrub past either end scrolls that way.
#[test]
fn the_view_pages_to_the_cti() {
+37 -1
View File
@@ -1113,6 +1113,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let context_layers: Vec<u64> =
app.session.state.selected_layers.iter().filter(|id| comp.layer(**id).is_some_and(|l| !l.switches.locked)).map(|id| id.0).collect();
let mut context_actions = Vec::new();
let copy_frame = crate::i18n::label(app, "comp.copyFrame", "Copy Frame to Clipboard");
resp.context_menu(|ui| {
let hits = ui.data(|d| d.get_temp::<Vec<(u64, String)>>(menu_hits_id)).unwrap_or_default();
if !hits.is_empty() {
@@ -1133,6 +1134,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
} else {
super::timeline::layer_menu(ui, &context_layers, &mut context_actions);
}
ui.separator();
if ui.button(copy_frame).clicked() {
context_actions.push(("comp.copyFrame".to_owned(), serde_json::json!({"comp": cid.0})));
ui.close();
}
});
for (id, params) in context_actions {
if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) {
@@ -2282,7 +2288,7 @@ fn create_shape(app: &mut EffectcraftApp, tool: Tool, a: [f64; 2], b: [f64; 2],
fn empty_state(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let p = ui.painter();
let p = ui.painter().clone();
p.rect_filled(rect, 0.0, t.pasteboard);
let c = rect.center();
p.text(c - vec2(0.0, 40.0), Align2::CENTER_CENTER, crate::i18n::tr("Composition"), Tokens::semibold(16.0), t.text);
@@ -2296,6 +2302,36 @@ fn empty_state(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let _ = crate::menus::invoke(app, &ui.ctx().clone(), "file.import", json!({}));
}
app.auto.add("viewer.empty.import", b2, "New Composition From Footage");
// A Project item dropped on the empty viewer creates the first composition,
// just like dropping it onto an empty Timeline (#684). Multiple selected
// items use the existing New Comp from Selection dialog.
let ctx = ui.ctx().clone();
if let Some(crate::panels::DragPayload::Item(dragged)) = egui::DragAndDrop::payload::<crate::panels::DragPayload>(&ctx).as_deref()
&& ui.rect_contains_pointer(rect)
{
let items: Vec<effectcraft_engine::project::ItemId> = crate::panels::project_drop_items(app, *dragged)
.into_iter()
.map(effectcraft_engine::project::ItemId)
.filter(|id| {
app.session.project.item(*id).is_some_and(|item| match &item.kind {
effectcraft_engine::project::ItemKind::Footage(f) => f.kind != effectcraft_engine::project::FootageKind::Data,
effectcraft_engine::project::ItemKind::Solid(_) | effectcraft_engine::project::ItemKind::Comp(_) => true,
_ => false,
})
})
.collect();
if !items.is_empty() {
p.rect_stroke(rect.shrink(2.0), 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside);
app.auto.add("viewer.newCompDrop", rect, "Drop footage to create a composition");
if ctx.input(|i| i.pointer.any_released()) {
app.session.state.project_selection = items;
if let Err(e) = crate::menus::invoke(app, &ctx, "file.newCompFromSelection", json!({})) {
app.ui.status = e;
}
egui::DragAndDrop::clear_payload(&ctx);
}
}
}
}
/// `#rrggbb` → sRGB bytes.
+6 -1
View File
@@ -91,14 +91,19 @@ pub fn dropped_files(app: &mut EffectcraftApp, ui: &egui::Ui, bounds: Rect) {
// Dropped files import as stills and folders' numbered stills as sequences; Alt-drag
// imports a folder's files one by one (After Effects).
let alt = ctx.input(|i| i.modifiers.alt);
// A dropped Photoshop file asks how to import it (the Import Photoshop File dialog, its
// Import Kind starting at Default Drag Import As), as File ▸ Import ▸ File… does (#678).
let layered = rest.iter().any(|p| p.rsplit('.').next().is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "psd" | "psb")));
let mut params = json!({
"paths": rest,
"drag": true,
"importAs": app.session.prefs.drag_import_as(),
"addToComp": at.is_some(),
"position": at,
"background": true
});
if !layered {
params["importAs"] = json!(app.session.prefs.drag_import_as());
}
if alt {
params["sequence"] = json!(false);
}

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