diff --git a/ROADMAP.md b/ROADMAP.md index 1ce9d9bb2..f3e6750ed 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~30 points of "ready for real work" and ~350–620 h away -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (open-issue count, crash list and localization share re-audited against `upstream/main` 470ea7a3) · **Target:** Adobe After Effects 2026 (26.5.0.89) +> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-30 · **Change:** minor (localization: 303 more panel/dialog rows in Japanese and the Chinese catalogs; recounted after #1774: Japanese 27% → 46%, Simplified Chinese 29% → 46%, Traditional Chinese 29% → 38%, overall 18% → 24%) · **Target:** Adobe After Effects 2026 (26.5.0.89) EffectCraft aims to do what After Effects does, with the same panels, menus and behaviour, written from scratch in Rust. This page is the one-page summary; the evidence is in @@ -51,7 +51,7 @@ Linux/Windows reliability (30–50 h) and depth in animation, masks, effects and | UI/UX fidelity | 65% | 60–120 | [ui-parity.md](docs/ui-parity.md) | | File formats | 50% | 130–230 | [file-format-parity.md](docs/file-format-parity.md) | | Hardware | 40% | 60–120 | [hardware-parity.md](docs/hardware-parity.md) | -| Localization | 18% | 60–110 + review | [localization-parity.md](docs/localization-parity.md) | +| Localization | 24% | 60–110 + review | [localization-parity.md](docs/localization-parity.md) | | Performance | 35% | 50–100 | [gaps.md](docs/gaps.md#g5-performance-at-real-world-scale) | | Stability | 45% | 40–80 | [gaps.md](docs/gaps.md#g3-stability-and-a-green-main) | | Platforms | 65% | 30–60 | [gaps.md](docs/gaps.md#g2-real-user-reliability-on-every-platform) | @@ -80,24 +80,24 @@ Weights and evidence per row: [target-app-parity.md](docs/target-app-parity.md#b ## Languages -UI strings translated out of ≈ 3,900 (detail: [localization-parity.md](docs/localization-parity.md)). +UI strings translated out of ≈ 4,200 (detail: [localization-parity.md](docs/localization-parity.md)). | Language | Status | % | |---|---|---:| | English | full | 100% | -| Simplified Chinese | partial (menus, part of the panels) | 29% | -| Spanish | menus only | 16% | +| Simplified Chinese | partial (menus, most of the panels) | 46% | +| Spanish | menus only | 29% | | Hindi | none | 0% | | Arabic | none | 0% | | French | menus only | 16% | -| Portuguese (Brazil) | partial (menus, part of the panels) | 26% | +| Portuguese (Brazil) | partial (menus, part of the panels) | 34% | | Indonesian | none | 0% | -| Japanese | partial (menus, part of the panels) | 27% | +| Japanese | partial (menus, most of the panels) | 46% | | German | menus only | 16% | | Korean | none | 0% | | Vietnamese | none | 0% | -Also shipped: Traditional Chinese (partial, 29%), Ukrainian (partial, 29%), Czech (partial, 29%), Georgian (partial, 38%; native-speaker reviewed) and Russian (menus only, 16%). No other language has had +Also shipped: Traditional Chinese (partial, 38%), Ukrainian (partial, 29%), Czech (partial, 29%), Polish (partial, 34%), Georgian (partial, 39%; native-speaker reviewed) and Russian (menus only, 15%). No other language has had native-speaker review. ## Upcoming @@ -120,10 +120,22 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). - 2026-10-28: A shape layer's Rectangle and Ellipse Size get the Constrain Proportions chain that Scale has, so a circle stays a circle while its size is typed, scrubbed or keyed; it starts off, so existing shapes behave as before (#2489). +- 2026-10-24: Shape layers have the Wiggle Transform path operation (Contents ▸ Add, or + `layer.addShapeItem {kind: "wiggler"}`): it wiggles the anchor point, position, scale and + rotation of the paths above it by random amounts that change over time, up to the Transform + values it holds. The copies of a Repeater above it wiggle independently as far as its + Correlation allows; with the Repeater below it, they wiggle together. +- 2026-10-22: Localization: the panel/dialog catalogs gain 303 rows in Japanese, Simplified + Chinese and Traditional Chinese — panel tabs, form and dialog labels, panel context menus, + tracker/graph/text-panel controls, File-panel footers and credits. Recounted after #1774: + Japanese is 1,923 of ≈ 4,200 interface strings (46%, with 297 effect names); the Chinese + panel catalogs hold 998 (Simplified) and 951 (Traditional) rows, and Simplified Chinese is + 46% with its 307 effect names; the overall share is ≈ 24%. - 2026-10-16: Convert To Bezier Path turns a shape layer's Rectangle, Ellipse or Polystar path into an editable Path 1 with the same geometry, as After Effects does; it used to apply to mask RotoBezier only, so a parametric shape path could not be converted at all and its vertices could not be edited or animated (#932). +- 2026-10-14: Georgian (`ka`): every menu row, every panel-catalog row the Chinese catalogs have plus 195 Georgian-only ones, and the effect names and categories; Match System for `ka` / `ka-GE` / `ka_GE.UTF-8` locales. Reviewed by a native speaker. - 2026-10-12: Roto Brush span edits report strokes outside a trimmed layer without panicking; strokes and undo history are preserved so restoring the timing allows the span edit again. - 2026-10-11: Ukrainian panels caught up with the Chinese catalogs. The 95 rows the panel @@ -138,7 +150,6 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). follow the shared standard: `docs/parity.md` became `docs/target-app-parity.md`; new `docs/roadmap.md`, `localization-parity.md`, `file-format-parity.md`, `hardware-parity.md`, `ui-parity.md`, `effects-parity.md` and `scripting-parity.md`. -- 2026-10-14: Georgian (`ka`): every menu row, every panel-catalog row the Chinese catalogs have plus 195 Georgian-only ones, and the effect names and categories; Match System for `ka` / `ka-GE` / `ka_GE.UTF-8` locales. Reviewed by a native speaker. - 2026-10-10: Spanish (#460) and Brazilian Portuguese (#217) menu translations. - 2026-10-10: Japanese now translates the interface below the menu bar too: `crates/ui-egui/src/i18n/ui.rs` has a Japanese table with the same 396 rows as the Chinese ones (panels, dialogs, buttons and tooltips; the shape test keeps the three catalogs listing the same source strings). Menus were already Japanese. - 2026-10-09: Ukrainian now covers all 396 current panel-catalog strings, including dialogs, buttons and tooltips, alongside its menu translation. The catalog keeps the same source keys and placeholder counts as the Chinese catalogs; missing or engine-generated text still falls back to English. @@ -158,11 +169,12 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Localization: 300 more panel/dialog rows in Japanese and the Chinese catalogs, plus the review's three missing rows (Basic, Advanced, Play/Stop) — panel tabs, form and dialog labels, context menus, tracker/graph/text-panel controls, File-panel footers, credits; language shares recounted after #1774 (Japanese 27% → 46% with 297 effect names, Simplified Chinese 29% → 46% with 307 effect names, Traditional Chinese 29% → 38%, overall 18% → 24%) | | 2026-10-12 | minor | Open-issue count (134 to 216), crash list and localization share (12% to 18%) updated to match docs/gaps.md | | 2026-10-12 | patch | Roto Brush span validation after trimming; command regressions for both layer edges | +| 2026-10-12 | minor | Shared PDF object/xref parsing with bounded app-owned conversion and preserved stream budgets; drawing, EPS and image/font policy remain unchanged | | 2026-10-10 | minor | Hours to ≈ 95% for each audience | | 2026-10-10 | minor | Headline adds mainstream practitioner (≈ 47%) and essentials user (≈ 52%) | -| 2026-10-12 | minor | Shared PDF object/xref parsing with bounded app-owned conversion and preserved stream budgets; drawing, EPS and image/font policy remain unchanged | -| 2026-10-10 | minor | Stage checked against the new core-workflow gate (all six pass; still alpha) | | 2026-10-10 | major | Rewritten to the shared progress-docs standard: stage, two numbers, dimensions, features, languages, upcoming, progress log; milestones moved to docs/roadmap.md | +| 2026-10-10 | minor | Stage checked against the new core-workflow gate (all six pass; still alpha) | | 2026-10-05 | major | Two numbers (breadth vs real use) and workstreams G1–G9 | diff --git a/apps/effectcraft-web/src/app.rs b/apps/effectcraft-web/src/app.rs index 60ff2f8d3..737f59f6a 100644 --- a/apps/effectcraft-web/src/app.rs +++ b/apps/effectcraft-web/src/app.rs @@ -272,6 +272,8 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { } let mut session = session(); session.load_settings(); + // Scripts in Scripts/Startup run before a project opens. + let _ = session.execute("file.runStartupScripts", json!({})); let recovery = session.begin_recovery(); // `?empty`: a blank project; `?demo`: the demo project; otherwise what the config's // `startup` says (default: the last visit's project, the demo on a first visit). diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 452196224..bdbdcda94 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -263,6 +263,9 @@ fn main() -> eframe::Result { // Fit the window to its monitor, or put it back where it was (still hidden). let saved_window = session.config.as_deref().and_then(window::load); let opened = cc.winit_window().map(|w| window::restore(w, saved_window)); + // Scripts in Scripts/Startup run before a project opens (a failing one is reported in + // a toast). + let _ = session.execute("file.runStartupScripts", json!({})); let recovery = if control_port.is_none() { session.begin_recovery() } else { None }; let project = files.iter().find(|f| is_project_file(f)).cloned(); if let Some(p) = project { @@ -495,6 +498,8 @@ impl eframe::App for Desktop { self.launch.exited(); self.window.save(self.app.session.config.as_deref()); self.app.on_exit(); + // Scripts in Scripts/Shutdown run with the project closed. + let _ = self.app.session.execute("file.runShutdownScripts", json!({})); } fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { diff --git a/crates/automation/src/confine.rs b/crates/automation/src/confine.rs index 67e5aef98..2b0795c93 100644 --- a/crates/automation/src/confine.rs +++ b/crates/automation/src/confine.rs @@ -30,10 +30,9 @@ pub struct Confinement { write: Vec, } -/// Commands that run code, act outside the project or open other applications. +/// Commands that run code, act outside the project or open other applications, besides the +/// commands that run scripts ([`effectcraft_engine::commands::scripts::RUNS_SCRIPTS`]). const REFUSED: &[&str] = &[ - "script.run", - "file.runScript", "file.installScript", "file.installScriptUIPanel", "file.uninstallScript", @@ -167,7 +166,7 @@ impl Confinement { /// Check engine command `id` with `params` before it runs. Returns what must still be looked /// up and passed to [`Confinement::check_lookup`] for commands that write implicitly. pub fn check_command(&self, id: &str, params: &Value) -> Result> { - if REFUSED.contains(&id) { + if REFUSED.contains(&id) || effectcraft_engine::commands::scripts::RUNS_SCRIPTS.contains(&id) { return Err(Error::BadArgs(format!("`{id}` is not available while file access is confined (--automation-read-root/--automation-write-root)"))); } // Scripts can't grant themselves the access the user withheld @@ -397,6 +396,7 @@ mod tests { let classified = keys_of(READS, id).is_some() || keys_of(WRITES, id).is_some() || REFUSED.contains(&id) + || effectcraft_engine::commands::scripts::RUNS_SCRIPTS.contains(&id) || IMPLICIT_WRITES.contains(&id) || id.starts_with("renderQueue.") || id == "render.addOutputModule" diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 560a527c7..519c7c433 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -1412,6 +1412,18 @@ fn automation_roots_confine_file_access() { // Scripts can read and write anything: refused. denied(&mut s, "run_script", json!({"code": "1 + 1"})); denied(&mut s, "execute_command", json!({"command": "file.runScript", "params": {"path": p(&read, "x.jsx")}})); + for (id, params) in [ + ("file.runStartupScripts", json!({})), + ("file.runShutdownScripts", json!({})), + // ScriptUI panels run their script when they open. + ("window.scriptPanel", json!({"name": "Layer Tools.jsx"})), + ("window.restoreScriptPanels", json!({})), + ] { + let msg = denied(&mut s, "execute_command", json!({"command": id, "params": params})); + assert!(msg.contains("not available while file access is confined"), "{id}: {msg}"); + let msg = denied(&mut s, "batch", json!({"steps": [{"command": id, "params": params}]})); + assert!(msg.contains("not available while file access is confined"), "{id}: {msg}"); + } // Writes to the files the agent named: none outside. assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 1, "only the unconfined save"); let _ = std::fs::remove_dir_all(&base); diff --git a/crates/effects/src/distort2.rs b/crates/effects/src/distort2.rs index db413544b..7add5fc58 100644 --- a/crates/effects/src/distort2.rs +++ b/crates/effects/src/distort2.rs @@ -95,6 +95,9 @@ fn turbulent_displace(ctx: &EffectCtx, mut b: Buf) -> Buf { let size = (ctx.params.f("size") * b.scale).max(1.0); if ctx.params.b("resizeLayer") && !ctx.adjustment { b.pad(amount.abs().ceil() as u32 + 1); + } else { + // Room for pixels displaced past the content, up to the layer's edge (#1205). + b.pad(crate::cover_pad(ctx, [b.img.width, b.img.height], b.offset, b.scale, amount.abs().ceil() as u32 + 1)); } let off = b.to_px(ctx.params.v2("offset")); let oct = ctx.params.f("complexity").clamp(1.0, 10.0); diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 117506807..44c8a987b 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -312,6 +312,19 @@ impl Buf { self.offset[0] += l as f64; self.offset[1] += t as f64; } + /// Grow by `left` and `top` transparent pixels on those sides only. + pub fn grow_left_top(&mut self, left: u32, top: u32) { + self.pad_sides([left, top, 0, 0]); + } + /// Give the buffer the pixel-grid phase (modulo [`GRID_ALIGN`]) that [`Buf::cover`] of + /// `size` would, by growing left/top only ([`align_plan`]). A downscaled layer is resampled + /// through a pyramid whose 2x2 blocks follow the buffer's grid, so a buffer that starts at + /// its content's corner instead of the cover's resamples to different pixels (#1205). + pub fn align(&mut self, size: [f64; 2]) { + if let Some((l, t)) = align_plan(self.offset, self.scale, size, self.img.width, self.img.height) { + self.grow_left_top(l, t); + } + } /// Grow by `pad` transparent pixels on every side, within [`pad_within_budget`] and as /// clamped by [`Image::effective_pad`]. pub fn pad(&mut self, pad: u32) { @@ -377,6 +390,33 @@ pub fn adjustment_crop(offset: [f64; 2], canvas_offset: [f64; 2], size: (u32, u3 Some((dx as i64, dy as i64)) } +/// Effects that grow their own buffer by what they need (blur and glow margins, Repeat Edge +/// Pixels through [`Buf::cover`], displacement through [`cover_pad`]): a stack of only these +/// skips the renderer's cover of the whole layer rectangle (#1205). +pub const SKIPS_COVER: &[&str] = &["ec.blur.gaussian", "ec.blur.fastbox", "ec.stylize.glow", "ec.distort.turbulentdisplace"]; + +/// The grid phase [`Buf::align`] keeps and the step [`cover_pad`] grows in: covers the levels +/// of the resampling pyramid used for downscaled layers. +pub const GRID_ALIGN: u32 = 64; + +/// Left/top growth giving a buffer [`cover_plan`]'s grid phase modulo [`GRID_ALIGN`]; `None` +/// when there is nothing to add. +pub fn align_plan(offset: [f64; 2], scale: f64, size: [f64; 2], width: u32, height: u32) -> Option<(u32, u32)> { + let (dx, dy, _, _) = cover_plan(offset, scale, size, width, height)?; + let (l, t) = (dx % GRID_ALIGN, dy % GRID_ALIGN); + (l != 0 || t != 0).then_some((l, t)) +} + +/// Padding for an effect that moves pixels up to `want` past the content (not an adjustment +/// layer): `want`, capped at what reaches the layer rectangle, in whole [`GRID_ALIGN`] steps so +/// the grid phase stays. 0 once the buffer covers the layer. +pub fn cover_pad(ctx: &EffectCtx, size: [u32; 2], offset: [f64; 2], scale: f64, want: u32) -> u32 { + if ctx.adjustment { + return 0; + } + want.min(util::layer_cover_pad(ctx, size, offset, scale)).div_ceil(GRID_ALIGN) * GRID_ALIGN +} + /// Where [`Buf::cover`] would take a buffer: shift its pixels by `(dx, dy)` and resize it to /// `width` × `height` so it spans the `size`-layer-pixel region that starts at its offset — the /// whole layer rectangle, which a sized source's buffer already is. `None` when the buffer @@ -555,6 +595,15 @@ pub struct EffectSpec { pub float: bool, } +impl EffectSpec { + /// True when this effect grows its own buffer (blur/glow margins, Repeat Edge + /// [`Buf::cover`], displacement [`cover_pad`]) so a stack of only these can skip the + /// renderer's full-layer cover and keep the pyramid grid phase (#1205). + pub fn skips_cover(&self) -> bool { + SKIPS_COVER.contains(&self.id) + } +} + pub(crate) fn p(id: &'static str, name: &'static str, default: Value, ui: ParamUi) -> ParamSpec { ParamSpec { id, name, default, ui } } @@ -1583,4 +1632,24 @@ mod tests { assert_eq!(g.get("noiseSourceLayerSource").map(|p| p.value.clone()), Some(Value::Enum(0))); assert_eq!(g.props().filter(|p| p.match_id == "noiseSourceLayerSource").count(), 1); } + + #[test] + fn align_plan_is_cover_shift_mod_grid() { + // Content inset from the layer origin: pixel 0 maps to layer (50, 30). + let offset = [-50.0, -30.0]; + let scale = 1.0; + let size = [1920.0, 1080.0]; + let (dx, dy, _, _) = cover_plan(offset, scale, size, 80, 40).unwrap(); + assert_eq!((dx, dy), (50, 30)); + assert_eq!(align_plan(offset, scale, size, 80, 40), Some((50, 30))); + assert_eq!(align_plan([-64.0, -128.0], 1.0, size, 80, 40), None); + } + + #[test] + fn known_margin_effects_skip_cover_and_twirl_does_not() { + for id in SKIPS_COVER { + assert!(find(id).expect(id).skips_cover(), "{id}"); + } + assert!(!find("ec.distort.twirl").unwrap().skips_cover()); + } } diff --git a/crates/effects/src/misc.rs b/crates/effects/src/misc.rs index e1f41954c..15d5d0aac 100644 --- a/crates/effects/src/misc.rs +++ b/crates/effects/src/misc.rs @@ -34,6 +34,9 @@ fn gaussian(ctx: &EffectCtx, mut b: Buf) -> Buf { let repeat = ctx.params.b("repeatEdge"); if !repeat && !ctx.adjustment { b.pad((s * 3.0).ceil() as u32); + } else if repeat && !ctx.adjustment { + // The edge it repeats is the layer's, not the painted content's (#1205). + b.cover(ctx.layer_size); } let (sx, sy) = (s * kx, s * ky); // Small sigmas: integer-radius box passes would step (and do nothing below ~0.6 px), so sample the kernel (#1939). @@ -60,6 +63,9 @@ fn box_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { if !repeat && !ctx.adjustment { let reach = if frac > 0.0 { r + 1 } else { r }; b.pad((reach * it) as u32 + 1); + } else if repeat && !ctx.adjustment { + // The edge it repeats is the layer's, not the painted content's (#1205). + b.cover(ctx.layer_size); } let edge = repeat || ctx.adjustment; let lo = effectcraft_raster::box_blur(&b.img, r * kx as usize, r * ky as usize, it, edge); diff --git a/crates/effects/src/sim/tests.rs b/crates/effects/src/sim/tests.rs index e368c7bbe..d5de4cb74 100644 --- a/crates/effects/src/sim/tests.rs +++ b/crates/effects/src/sim/tests.rs @@ -97,6 +97,21 @@ fn bubbles_rise() { assert!(y0 >= 0.0 && y1 >= 0.0 && (y1 < y0 || y0 < 8.0), "{y0} -> {y1}"); } +/// #2024: bubbles near the layer's edges show its edge pixels, not the pale colour that stands +/// in for transparent ones. +#[test] +fn bubbles_near_edges_show_the_layer() { + let blue = Image::filled(40, 40, [0.0, 0.0, 1.0, 1.0]); + let pale = [0, 1].map(|reflection| { + let vals = [("bubbleAmount", num(200.0)), ("bubbleSize", num(3.0)), ("reflectionType", Value::Enum(reflection))]; + let a = run("ec.sim.ccbubbles", &vals, blue.clone(), 0.3); + assert!(a.data.iter().any(|p| p[3] > 0.5), "bubbles drawn"); + // Blue plus the white highlight keeps blue − red = alpha; the pale colour leaves 0.2 · alpha. + a.data.iter().filter(|p| (p[2] - p[0] - p[3]).abs() > 1e-4).count() + }); + assert_eq!(pale, [0, 0], "pale bubble pixels (liquid, metal)"); +} + #[test] fn drizzle_ripples() { let a = run("ec.sim.ccdrizzle", &[], ramp(48, 48), 0.6); diff --git a/crates/effects/src/sim/weather.rs b/crates/effects/src/sim/weather.rs index b93c0e48b..b58b39b5e 100644 --- a/crates/effects/src/sim/weather.rs +++ b/crates/effects/src/sim/weather.rs @@ -182,7 +182,7 @@ pub(super) fn bubbles(ctx: &EffectCtx, mut b: Buf) -> Buf { // Liquid refracts (inverted, magnified), metal reflects (wide-angle mirror). let k = if metal { 1.6 } else { -0.7 }; let nz = (1.0 - d2).max(0.0).sqrt(); - let p = src.sample_bilinear((q.sx + dx * q.r * k) as f64, (q.sy + dy * q.r * k) as f64); + let p = src.sample_bilinear_clamped((q.sx + dx * q.r * k) as f64, (q.sy + dy * q.r * k) as f64); let (c, sa) = unpremul(p); let mut c = if sa <= 0.0 { [0.6, 0.7, 0.8] } else { c }; let mut a = cov; diff --git a/crates/effects/src/utility.rs b/crates/effects/src/utility.rs index 1d3b3ee4a..e41b42e99 100644 --- a/crates/effects/src/utility.rs +++ b/crates/effects/src/utility.rs @@ -100,17 +100,11 @@ impl Cineon { fn knee(&self) -> f32 { 1.0 - (self.rolloff / 100.0).clamp(0.0, 0.99) } - /// Normalised code value (code / 1023) → linear. + /// Normalized code value (code / 1023) → linear, raised to `1.7 / gamma` + /// ([`effectcraft_project::cineon_log_to_lin`], which Interpret Footage ▸ Cineon Settings + /// also uses with `1 / gamma`). pub fn log_to_lin(&self, v: f32) -> f32 { - let off = self.offset(); - let code = v * 1023.0; - let mut lin = (10f32.powf((code - self.white) * 0.002 / 0.6) - off) / (1.0 - off).max(1e-6); - lin = lin.max(0.0).powf(1.7 / self.gamma.max(0.01)); - let k = self.knee(); - if k < 1.0 && lin > k { - let w = 1.0 - k; - lin = k + w * (1.0 - (-(lin - k) / w).exp()); - } + let lin = effectcraft_project::cineon_log_to_lin(v, self.black, self.white, 1.7, self.gamma, self.rolloff); self.ib + (self.iw - self.ib) * lin } /// Linear → normalised code value. @@ -495,6 +489,87 @@ mod tests { assert!(c.log_to_lin(95.0 / 1023.0).abs() < 1e-4); } + /// Interpret Footage ▸ Cineon Settings and the Cineon Converter's Log to Linear give the same + /// values for the same points and Highlight Rolloff when the converter's Gamma is 1.7 times + /// Cineon Settings' Gamma: the converter's default 1.7 and Cineon Settings' Gamma 1 both give + /// linear light (code 400 is 0.10251). + #[test] + fn cineon_converter_matches_interpret_footage() { + let codes = [0.0f32, 95.0, 250.0, 400.0, 560.0, 685.0, 800.0, 1023.0]; + for (gamma, rolloff) in [(1.0, 20.0), (1.0, 0.0), (1.7, 50.0), (2.4, 50.0)] { + let mut img = Image::new(codes.len() as u32, 1); + for (x, code) in codes.iter().enumerate() { + let v = code / 1023.0; + img.set(x as u32, 0, [v, v, v, 1.0]); + } + let vals = [("conversionType", Value::Enum(1)), ("gamma", num(gamma * 1.7)), ("highlightRolloff", num(rolloff))]; + let out = run("ec.utility.cineon", &vals, img); + let settings = effectcraft_project::CineonSettings { log_conversion: true, gamma: gamma as f32, rolloff: rolloff as f32, ..Default::default() }; + for (x, code) in codes.iter().enumerate() { + let footage = settings.convert(code / 1023.0); + let effect = out.img.data[x][0]; + assert!((footage - effect).abs() < 1e-6, "gamma {gamma}, rolloff {rolloff}, code {code}: {footage} vs {effect}"); + } + } + let linear = effectcraft_project::CineonSettings { gamma: 1.0, rolloff: 0.0, ..Default::default() }; + assert!((linear.convert(400.0 / 1023.0) - 0.10251).abs() < 1e-4); + } + + /// The Cineon Converter gives the same values as before Cineon Settings shared its + /// log-to-linear function, at the default Gamma (1.7) and at Gamma 2.4, for all three + /// conversion types. + #[test] + fn cineon_converter_output_is_unchanged() { + fn log_to_lin(v: f32, gamma: f32, rolloff: f32, black: f32, white: f32) -> f32 { + let off = 10f32.powf((black - white) * 0.002 / 0.6); + let mut lin = (10f32.powf((v * 1023.0 - white) * 0.002 / 0.6) - off) / (1.0 - off).max(1e-6); + lin = lin.max(0.0).powf(1.7 / gamma.max(0.01)); + let k = 1.0 - (rolloff / 100.0).clamp(0.0, 0.99); + if k < 1.0 && lin > k { + let w = 1.0 - k; + lin = k + w * (1.0 - (-(lin - k) / w).exp()); + } + lin + } + fn lin_to_log(v: f32, gamma: f32, rolloff: f32, black: f32, white: f32) -> f32 { + let off = 10f32.powf((black - white) * 0.002 / 0.6); + let mut lin = v; + let k = 1.0 - (rolloff / 100.0).clamp(0.0, 0.99); + if k < 1.0 && lin > k { + let w = 1.0 - k; + lin = k - w * (1.0 - (lin - k) / w).max(1e-6).ln(); + } + lin = lin.max(0.0).powf(gamma.max(0.01) / 1.7); + (white + (lin * (1.0 - off) + off).max(1e-9).log10() * 0.6 / 0.002) / 1023.0 + } + let codes = [0.0f32, 95.0, 250.0, 400.0, 560.0, 685.0, 800.0, 1023.0]; + for gamma in [None, Some(2.4)] { + for kind in 0..3 { + let mut img = Image::new(codes.len() as u32, 1); + for (x, code) in codes.iter().enumerate() { + let v = code / 1023.0; + img.set(x as u32, 0, [v, v, v, 1.0]); + } + let mut vals = vec![("conversionType", Value::Enum(kind))]; + if let Some(g) = gamma { + vals.push(("gamma", num(g))); + } + let out = run("ec.utility.cineon", &vals, img); + let g = gamma.unwrap_or(1.7) as f32; + for (x, code) in codes.iter().enumerate() { + let v = code / 1023.0; + let want = match kind { + 0 => lin_to_log(v, g, 20.0, 95.0, 685.0), + 1 => log_to_lin(v, g, 20.0, 95.0, 685.0), + _ => lin_to_log(log_to_lin(v, g, 20.0, 95.0, 685.0), 1.7, 20.0, 95.0, 685.0), + }; + let got = out.img.data[x][0]; + assert!((want - got).abs() < 1e-6, "gamma {g}, type {kind}, code {code}: {want} vs {got}"); + } + } + } + } + #[test] fn hdr_compression_behaviour() { let mut img = Image::new(2, 1); diff --git a/crates/effects/tests/it/sim_golden.rs b/crates/effects/tests/it/sim_golden.rs index 2a3a7c724..3932418ad 100644 --- a/crates/effects/tests/it/sim_golden.rs +++ b/crates/effects/tests/it/sim_golden.rs @@ -433,26 +433,26 @@ const GOLDEN: &[(&str, u64)] = &[ ("ec.sim.ccstarburst/1/32bpc/t1.5", 0x5c39f8b1bd0e9e43), ("ec.sim.ccstarburst/1/32bpc/t0.8", 0xdf20aa9dcf4b3e22), ("ec.sim.ccstarburst/1/32bpc/t2.2", 0x4fa441737bfcb64d), - ("ec.sim.ccbubbles/0/8bpc/t0.5", 0xdcd187e75eeb2fae), - ("ec.sim.ccbubbles/0/8bpc/t1.5", 0x835ca088747736ed), - ("ec.sim.ccbubbles/0/8bpc/t0.8", 0x5cbb1a6fbb77b41d), - ("ec.sim.ccbubbles/0/8bpc/t2.2", 0x3b168068f5bfa533), - ("ec.sim.ccbubbles/0/32bpc/t0.5", 0x524b84d14edd77e2), - ("ec.sim.ccbubbles/0/32bpc/t1.5", 0xfd7a17f0d40f2a27), - ("ec.sim.ccbubbles/0/32bpc/t0.8", 0x70dd2b2b4ff0f7ce), - ("ec.sim.ccbubbles/0/32bpc/t2.2", 0xae21dd5cc2c2c07c), + ("ec.sim.ccbubbles/0/8bpc/t0.5", 0x6cb63df0c062fdf4), + ("ec.sim.ccbubbles/0/8bpc/t1.5", 0x5bb1350114234ac3), + ("ec.sim.ccbubbles/0/8bpc/t0.8", 0xaa824d49f12c4156), + ("ec.sim.ccbubbles/0/8bpc/t2.2", 0x3528577358c4fc90), + ("ec.sim.ccbubbles/0/32bpc/t0.5", 0xdcd8e71222c13a9a), + ("ec.sim.ccbubbles/0/32bpc/t1.5", 0xd09565e124d8f5db), + ("ec.sim.ccbubbles/0/32bpc/t0.8", 0x91242f8e1fd0ad84), + ("ec.sim.ccbubbles/0/32bpc/t2.2", 0x1f9782131e62149d), ("ec.sim.ccbubbles/0/8bpc/half/t0.5", 0x931b4ca707604220), ("ec.sim.ccbubbles/0/8bpc/half/t1.5", 0x21c25d4555abebe9), ("ec.sim.ccbubbles/0/8bpc/half/t0.8", 0x7e05972683359632), ("ec.sim.ccbubbles/0/8bpc/half/t2.2", 0xadc4f7219dab4921), - ("ec.sim.ccbubbles/1/8bpc/t0.5", 0x89d27a2fe2011067), - ("ec.sim.ccbubbles/1/8bpc/t1.5", 0x2bbfee897bf5bf6e), - ("ec.sim.ccbubbles/1/8bpc/t0.8", 0x26cc704e7b06fffc), - ("ec.sim.ccbubbles/1/8bpc/t2.2", 0x959d331545d96479), - ("ec.sim.ccbubbles/1/32bpc/t0.5", 0xaf486cebfcbf4641), - ("ec.sim.ccbubbles/1/32bpc/t1.5", 0xe150e9fdb558036f), - ("ec.sim.ccbubbles/1/32bpc/t0.8", 0x6395f3967cb24284), - ("ec.sim.ccbubbles/1/32bpc/t2.2", 0x68a55894d86ffb9e), + ("ec.sim.ccbubbles/1/8bpc/t0.5", 0x2f5415c679ff0980), + ("ec.sim.ccbubbles/1/8bpc/t1.5", 0xfcfc354dcd22e6d0), + ("ec.sim.ccbubbles/1/8bpc/t0.8", 0xdf90fd57c217019c), + ("ec.sim.ccbubbles/1/8bpc/t2.2", 0xd365c88c12219802), + ("ec.sim.ccbubbles/1/32bpc/t0.5", 0x50f20497f40bb46d), + ("ec.sim.ccbubbles/1/32bpc/t1.5", 0x74760599ffba220a), + ("ec.sim.ccbubbles/1/32bpc/t0.8", 0x028de52900ed8618), + ("ec.sim.ccbubbles/1/32bpc/t2.2", 0xaf0e21d9a7f625fe), ("ec.sim.ccdrizzle/0/8bpc/t0.5", 0x2bdfbc17d92b65b5), ("ec.sim.ccdrizzle/0/8bpc/t1.5", 0x8c354c46f99e35d3), ("ec.sim.ccdrizzle/0/8bpc/t0.8", 0x891f331ba47ee557), diff --git a/crates/engine/src/analysis/tracking.rs b/crates/engine/src/analysis/tracking.rs index fe04f882b..221b28602 100644 --- a/crates/engine/src/analysis/tracking.rs +++ b/crates/engine/src/analysis/tracking.rs @@ -197,7 +197,8 @@ pub(crate) struct Work { } /// The layer's source frame at comp time `t` at 100 % (layer pixels + offset = image pixels). -/// Footage at its native size is used straight from the footage source (no copy). +/// Footage at its native size is used straight from the footage source (no copy unless its +/// Cineon Settings convert it). pub(crate) fn source_frame( r: &Renderer, project: &Project, @@ -215,7 +216,7 @@ pub(crate) fn source_frame( && img.width == f.width && img.height == f.height { - return Some((img, [0.0; 2])); + return Some((effectcraft_render::cineon_frame(img, f), [0.0; 2])); } let buf = r.layer_source(&ctx, layer)?; if (buf.scale - 1.0).abs() > 1e-9 && buf.scale > 0.0 { diff --git a/crates/engine/src/cmd/batch.rs b/crates/engine/src/cmd/batch.rs index f6e6901cc..5931fe09d 100644 --- a/crates/engine/src/cmd/batch.rs +++ b/crates/engine/src/cmd/batch.rs @@ -9,8 +9,9 @@ use serde_json::{Value, json}; use super::{CommandSpec, always, bad, str_p}; use crate::{EngineError, Result, Session}; -/// Commands that can't run inside a batch (they replace the project or nest batches). -const NOT_IN_BATCH: &[&str] = &["engine.batch", "file.runScript", "script.run"]; +/// Commands that can't run inside a batch (they nest batches), besides the commands that run +/// scripts ([`super::scripts::RUNS_SCRIPTS`]). +const NOT_IN_BATCH: &[&str] = &["engine.batch"]; /// Resolve a `$N` / `$N.key.0.key` reference against the results so far. fn reference(s: &str, results: &[Value]) -> Option> { @@ -78,7 +79,7 @@ fn batch(s: &mut Session, p: &Value) -> Result { .or_else(|| step.get("id")) .and_then(Value::as_str) .ok_or_else(|| bad("engine.batch", "each step needs `command` (a command id)"))?; - if NOT_IN_BATCH.contains(&id) { + if NOT_IN_BATCH.contains(&id) || super::scripts::RUNS_SCRIPTS.contains(&id) { return Err(bad("engine.batch", format!("`{id}` can't run inside a batch"))); } let params = substitute(step.get("params").unwrap_or(&json!({})), results)?; diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index beb161a53..e1f58a1cc 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -125,7 +125,8 @@ fn revert(s: &mut Session, _: &Value) -> Result { } /// File ▸ Import; with `addToComp` (files dropped on the Timeline or the Composition viewer) they -/// also become layers of the active comp ([`add_to_comp`]). +/// also become layers of the active comp ([`add_to_comp`]); with `newComp` (the empty viewer's +/// New Composition From Footage) they become one new composition instead ([`new_comp`]). /// /// With `background` (files dropped on the window or picked in the Import dialog) the files are /// probed on a background thread, so decoding a slow video doesn't freeze the app: the job shows @@ -175,10 +176,10 @@ fn import_cmd(s: &mut Session, p: &Value) -> Result { Ok(r) } -/// After an import (its result `r`): with `addToComp`, the new items become layers of `target`; -/// what couldn't be added joins the result's errors. +/// After an import (its result `r`): with `newComp`, the new items become one new composition; +/// otherwise with `addToComp`, layers of `target`. What couldn't be added joins the result's errors. fn finish_import(s: &mut Session, r: &mut Value, target: Option, p: &Value) { - let errors = add_to_comp(s, r, target, p); + let errors = if b_p(p, "newComp").unwrap_or(false) { new_comp(s, r) } else { add_to_comp(s, r, target, p) }; if !errors.is_empty() && let Some(e) = r.get_mut("errors").and_then(Value::as_array_mut) { @@ -192,18 +193,7 @@ fn finish_import(s: &mut Session, r: &mut Value, target: Option, p: &Val /// Project panel) and the other footage. `target` stays open. Returns what couldn't be added. pub(crate) fn add_to_comp(s: &mut Session, r: &Value, target: Option, p: &Value) -> Vec { let Some(cid) = target.filter(|_| p.get("addToComp").and_then(Value::as_bool).unwrap_or(false)) else { return vec![] }; - let ids = |k: &str| r.get(k).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64).collect::>(); - let comps = ids("comps"); - let in_comps: std::collections::HashSet = comps - .iter() - .filter_map(|c| s.project.comp(ItemId(*c))) - .flat_map(|c| c.layers.iter()) - .filter_map(|l| match l.source { - LayerSource::Footage { item } | LayerSource::Comp { item } | LayerSource::Solid { item } => Some(item.0), - _ => None, - }) - .collect(); - let add: Vec = comps.iter().copied().chain(ids("items").into_iter().filter(|i| !in_comps.contains(i))).collect(); + let add = imported(s, r); let mut errors = vec![]; for (k, item) in add.into_iter().enumerate() { let mut params = json!({"comp": cid.0, "item": item, "time": p.get("time"), "position": p.get("position")}); @@ -220,6 +210,55 @@ pub(crate) fn add_to_comp(s: &mut Session, r: &Value, target: Option, p: errors } +/// What an import (its result `r`) made that belongs in a comp: the comps of layered files +/// (their layers' footage, in their precomps too, stays in the Project panel), then the other items, +/// without those comps again or the files' folders. +fn imported(s: &Session, r: &Value) -> Vec { + let ids = |k: &str| r.get(k).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64).collect::>(); + let comps = ids("comps"); + let mut in_comps = std::collections::HashSet::new(); + let mut todo = comps.clone(); + while let Some(c) = todo.pop() { + for l in s.project.comp(ItemId(c)).into_iter().flat_map(|c| c.layers.iter()) { + match l.source { + LayerSource::Comp { item } if in_comps.insert(item.0) => todo.push(item.0), + LayerSource::Footage { item } | LayerSource::Solid { item } => { + in_comps.insert(item.0); + } + _ => {} + } + } + } + let rest = ids("items").into_iter().filter(|i| !in_comps.contains(i) && !comps.contains(i) && !s.project.item(ItemId(*i)).is_some_and(|it| it.is_folder())); + comps.iter().copied().chain(rest).collect() +} + +/// With `newComp`, make one composition of the footage and solids an import (its result `r`) +/// made, as New Comp from Selection does with `single` (first item on top, its settings). A comp +/// the import made (a layered file) already is the composition, so it stays out, as does data +/// footage; none left, no new comp. Returns what went wrong. +fn new_comp(s: &mut Session, r: &Value) -> Vec { + let items: Vec = imported(s, r) + .into_iter() + .map(ItemId) + .filter(|i| { + s.project.item(*i).is_some_and(|it| match &it.kind { + ItemKind::Footage(f) => f.kind != FootageKind::Data, + ItemKind::Solid(_) => true, + ItemKind::Comp(_) | ItemKind::Folder => false, + }) + }) + .collect(); + if items.is_empty() { + return vec![]; + } + s.state.project_selection = items; + match s.execute("file.newCompFromSelection", json!({"single": true})) { + Ok(_) => vec![], + Err(e) => vec![e.to_string()], + } +} + /// File ▸ Import, all in one go (scripts and commands built on it); see [`import_cmd`]. pub(crate) fn import(s: &mut Session, p: &Value) -> Result { let pending = prepare_import(s, p)?; @@ -746,7 +785,7 @@ pub fn specs() -> Vec { "File...", ["File", "Import"], Some("Cmd+I"), - "{paths: [string] (files or folders), sequence?: bool (the Sequence checkbox: true imports numbered stills of one run as one image sequence, one picked file bringing its whole run and several picked files that range; default false: picked or dropped files import as stills, as in After Effects; folders' runs import as sequences; false imports stills and a folder's files individually), alphabetical?: bool (Force Alphabetical Order: every image of that type in the folder, by name), frameRate?: fps (image sequences; default Settings ▸ Import ▸ Sequence Footage), importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As; files import as stills unless sequence), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}", + "{paths: [string] (files or folders), sequence?: bool (the Sequence checkbox: true imports numbered stills of one run as one image sequence, one picked file bringing its whole run and several picked files that range; default false: picked or dropped files import as stills, as in After Effects; folders' runs import as sequences; false imports stills and a folder's files individually), alphabetical?: bool (Force Alphabetical Order: every image of that type in the folder, by name), frameRate?: fps (image sequences; default Settings ▸ Import ▸ Sequence Footage), importAs?: footage|composition|compositionLayerSizes (Photoshop, PDF, Illustrator and EPS files), layer?: name|index (footage of one Photoshop layer), page?: number from 1 (PDF / Illustrator page), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As; files import as stills unless sequence), addToComp?: bool (also add them to the active comp, at time?, index?, position? as in layer.addItem), newComp?: bool (make one new composition of the footage and solids imported, as file.newCompFromSelection with single; a layered file imported as a composition is that composition and gets no other; takes precedence over addToComp), background?: bool (probe the files in a background job, jobs.list / jobs.wait; returns {job})}", always, import_cmd ), diff --git a/crates/engine/src/cmd/file_more.rs b/crates/engine/src/cmd/file_more.rs index 59a824208..c5018ae7c 100644 --- a/crates/engine/src/cmd/file_more.rs +++ b/crates/engine/src/cmd/file_more.rs @@ -5,7 +5,7 @@ use std::collections::{BTreeMap, BTreeSet}; use effectcraft_color::Label; -use effectcraft_project::{AlphaMode, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid, limits}; +use effectcraft_project::{AlphaMode, CineonSettings, CineonUnits, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid, limits}; use effectcraft_time::{FrameRate, Tick}; use serde_json::{Value, json}; @@ -501,7 +501,7 @@ fn run_script(s: &mut Session, p: &Value) -> Result { } _ => return Err(bad("file.runScript", format!("step {}: needs `command`", i + 1))), }; - if id == "file.runScript" || id == "script.run" { + if super::scripts::RUNS_SCRIPTS.contains(&id.as_str()) { return Err(bad("file.runScript", "scripts can't run scripts")); } let r = if from_file { s.execute_for_script(&id, params) } else { s.execute(&id, params) }; @@ -536,7 +536,7 @@ fn is_jsxbin(name: &str) -> bool { } /// A script error becomes a toast and a command error. -fn report_js(s: &mut Session, name: &str, out: Value) -> Result { +pub(crate) fn report_js(s: &mut Session, name: &str, out: Value) -> Result { if let Some(e) = out.get("error").filter(|e| !e.is_null()) { let line = e.get("line").and_then(Value::as_u64).map(|l| format!(" (line {l})")).unwrap_or_default(); let msg = e.get("message").and_then(Value::as_str).unwrap_or("script error"); @@ -552,6 +552,13 @@ pub(crate) fn run_js_named(s: &mut Session, code: &str, name: &str) -> Result Result { + let out = run_js(s, code, path, false)?; + report_js(s, name, out) +} + /// Run JavaScript through the session's scripting engine. fn run_js(s: &mut Session, code: &str, name: &str, console: bool) -> Result { let runner = s.script.ok_or_else(|| EngineError::Other("JavaScript scripting is not available in this build".into()))?; @@ -594,6 +601,131 @@ pub(crate) struct Interpretation { preserve_rgb: Option, /// Start Timecode. start: Option, + /// Cineon Settings (DPX and Cineon footage). + cineon: CineonEdit, +} + +/// Interpret Footage ▸ Cineon Settings parsed from command parameters (every field optional). +#[derive(Clone, Debug, Default)] +struct CineonEdit { + /// Logarithmic Conversion. + log: Option, + units: Option, + /// The units the Converted Black and White Points are given in (`pointUnits`, else `cineonUnits`, + /// else the footage's own Units). + point_units: Option, + converted_black: Option, + converted_white: Option, + /// 10 Bit Black and White Points. + black: Option, + white: Option, + gamma: Option, + rolloff: Option, +} + +impl CineonEdit { + fn parse(p: &Value, cmd: &str) -> Result { + let units = |key: &str| -> Result> { + match str_p(p, key) { + None => Ok(None), + Some(u) => CineonUnits::parse(u).map(Some).ok_or_else(|| bad(cmd, format!("{key}: 10bit|8bit|float, not `{u}`"))), + } + }; + let num = |key: &str, lo: f64, hi: f64| -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_f64() + .filter(|x| x.is_finite() && (lo..=hi).contains(x)) + .map(Some) + .ok_or_else(|| bad(cmd, format!("{key}: a number from {lo} to {hi}"))), + } + }; + let units_now = units("cineonUnits")?; + Ok(CineonEdit { + log: b_p(p, "logConversion"), + units: units_now, + point_units: units("pointUnits")?.or(units_now), + // Range-checked once their units are known (`settings_for`). + converted_black: num("convertedBlack", f64::MIN, f64::MAX)?, + converted_white: num("convertedWhite", f64::MIN, f64::MAX)?, + black: num("tenBitBlack", 0.0, 1023.0)?, + white: num("tenBitWhite", 0.0, 1023.0)?, + gamma: num("currentGamma", 0.1, 10.0)?, + rolloff: num("highlightRolloff", 0.0, 100.0)?, + }) + } + + fn is_empty(&self) -> bool { + self.log.is_none() + && self.units.is_none() + && self.converted_black.is_none() + && self.converted_white.is_none() + && self.black.is_none() + && self.white.is_none() + && self.gamma.is_none() + && self.rolloff.is_none() + } + + /// Footage `f`'s Cineon Settings after this edit; `None` when nothing changes them (no Cineon + /// parameters, or `f` is not a DPX or Cineon file). + fn settings_for(&self, f: &Footage, cmd: &str) -> Result> { + if self.is_empty() || !f.is_cineon_file() { + return Ok(None); + } + let mut c = f.cineon; + let units = self.point_units.unwrap_or(c.units); + let point = |v: f64| -> Result { + let ten = units.to_ten_bit(v); + let max = units.from_ten_bit(f64::from(CineonSettings::MAX)); + if (0.0..=f64::from(CineonSettings::MAX)).contains(&ten) { + Ok(ten as f32) + } else { + Err(bad(cmd, format!("converted points: 0 to {max} in {} units", units.label()))) + } + }; + if let Some(v) = self.converted_black { + c.converted_black = point(v)?; + } + if let Some(v) = self.converted_white { + c.converted_white = point(v)?; + } + if let Some(v) = self.log { + c.log_conversion = v; + } + if let Some(u) = self.units { + c.units = u; + } + if let Some(v) = self.black { + c.black = v as f32; + } + if let Some(v) = self.white { + c.white = v as f32; + } + if let Some(v) = self.gamma { + c.gamma = v as f32; + } + if let Some(v) = self.rolloff { + c.rolloff = v as f32; + } + if c.black >= c.white { + return Err(bad(cmd, "the 10 Bit Black Point must be below the 10 Bit White Point")); + } + Ok(Some(c)) + } +} + +/// Footage `f`'s Cineon Settings as commands report them (`null` for files they don't apply to). +fn cineon_json(f: &Footage) -> Value { + if !f.is_cineon_file() { + return Value::Null; + } + let c = &f.cineon; + json!({ + "logConversion": c.log_conversion, "units": c.units.id(), + "convertedBlack": c.units.from_ten_bit(f64::from(c.converted_black)), "convertedWhite": c.units.from_ten_bit(f64::from(c.converted_white)), + "tenBitBlack": c.black, "tenBitWhite": c.white, "currentGamma": c.gamma, "highlightRolloff": c.rolloff, + }) } /// Interpret Footage ▸ Start Timecode. @@ -687,9 +819,23 @@ impl Interpretation { v.as_f64().filter(|x| x.is_finite() && (0.0..=StartTimecode::MAX as f64).contains(x)).map(|x| x.round() as i64).ok_or_else(start_err)?, )), }; + it.cineon = CineonEdit::parse(p, cmd)?; Ok(it) } + /// Check the Cineon Settings this interpretation gives each of `footage`: an error names a + /// value out of range, or Cineon parameters when none of the footage is DPX or Cineon. + pub(crate) fn check<'a>(&self, footage: impl IntoIterator, cmd: &str) -> Result<()> { + if self.cineon.is_empty() { + return Ok(()); + } + let mut any = false; + for f in footage { + any |= self.cineon.settings_for(f, cmd)?.is_some(); + } + if any { Ok(()) } else { Err(bad(cmd, "Cineon Settings apply to DPX and Cineon footage only")) } + } + pub(crate) fn apply(&self, f: &mut Footage, guessed: Option<(AlphaMode, [f32; 3])>) { let before = f.frame_rate; self.apply_settings(f, guessed); @@ -749,6 +895,10 @@ impl Interpretation { if let Some(v) = self.preserve_rgb { f.preserve_rgb = v; } + // Checked before the edit (`Interpretation::check`). + if let Ok(Some(c)) = self.cineon.settings_for(f, "file.interpretFootage") { + f.cineon = c; + } // After the frame rate: timecode is read at the footage's rate. if let Some(start) = self.start.as_ref().and_then(|t| t.frame(f.frame_rate)) { f.start_timecode = start; @@ -795,6 +945,11 @@ fn interpret(s: &mut Session, p: &Value) -> Result { return Err(bad("file.interpretFootage", "select footage")); } let it = Interpretation::parse(p, "file.interpretFootage")?; + let footage = items.iter().filter_map(|i| match s.project.item(*i).map(|x| &x.kind) { + Some(ItemKind::Footage(f)) => Some(f), + _ => None, + }); + it.check(footage, "file.interpretFootage")?; let guesses: Vec> = items .iter() .map(|i| match (it.guess, s.project.item(*i).map(|x| &x.kind)) { @@ -830,7 +985,7 @@ fn interpret(s: &mut Session, p: &Value) -> Result { "item": i.0, "alpha": format!("{:?}", f.alpha), "matteColor": f.premul_color, "invertAlpha": f.invert_alpha, "fields": f.fields.label(), "pixelAspect": f.pixel_aspect, "loop": f.loop_count, "frameRate": f.frame_rate.as_f64(), "linearLight": f.linear_light, "preserveRgb": f.preserve_rgb, "startTimecode": f.timecode(0), "overrideStart": f.start_timecode.is_some(), - "frames": f.sequence_frames(), + "frames": f.sequence_frames(), "cineon": cineon_json(f), })), _ => None, }) @@ -861,6 +1016,9 @@ fn apply_interpretation(s: &mut Session, p: &Value) -> Result { f.linear_light = src.linear_light; f.preserve_rgb = src.preserve_rgb; f.color_profile = src.color_profile; + if f.is_cineon_file() && src.is_cineon_file() { + f.cineon = src.cineon; + } if src.native_rate.is_some() { if f.native_rate.is_none() { f.native_rate = Some(f.frame_rate); @@ -1090,7 +1248,7 @@ pub fn specs() -> Vec { "Main...", ["File", "Interpret Footage"], Some("Cmd+Alt+G"), - "{items?|item?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?|premulColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?, preserveRgb? (the file's values unconverted), overrideStart?: bool (false: Use Source File Timecode), startTimecode?: timecode|frame (Override Start)}", + "{items?|item?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?|premulColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?, preserveRgb? (the file's values unconverted), overrideStart?: bool (false: Use Source File Timecode), startTimecode?: timecode|frame (Override Start), logConversion? (Cineon Settings of DPX and Cineon files), cineonUnits?: 10bit|8bit|float, convertedBlack?, convertedWhite?, pointUnits?: 10bit|8bit|float (the units of convertedBlack and convertedWhite; default cineonUnits, else the footage's Units), tenBitBlack?, tenBitWhite? (0..1023), currentGamma? (0.1..10), highlightRolloff? (0..100)}", has_footage_selection, interpret ), diff --git a/crates/engine/src/cmd/footage_panel.rs b/crates/engine/src/cmd/footage_panel.rs index 1bd0d2236..26cc8e5c9 100644 --- a/crates/engine/src/cmd/footage_panel.rs +++ b/crates/engine/src/cmd/footage_panel.rs @@ -56,6 +56,12 @@ pub fn timecode(item: Option<&effectcraft_project::Item>, fr: effectcraft_time:: } } +/// The frame the Footage panel shows: footage `f` at source time `t`, with its Cineon Settings +/// applied. +pub fn frame(s: &Session, item: ItemId, f: &effectcraft_project::Footage, t: Tick) -> Option> { + s.footage.frame(item, f, t).map(|img| effectcraft_render::cineon_frame(img, f)) +} + fn source_time(s: &Session, p: &Value, item: ItemId) -> Option { let (_, fr) = source_timing(s, item); if let Some(t) = f_p(p, "time") { diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 26c4b4c8c..eb9641547 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -183,7 +183,7 @@ pub fn specs() -> Vec { "Add (Shape)", [], None, - "{layer?, kind: group|rect|ellipse|star|polygon|path|fill|stroke|gfill|gstroke|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|merge|frontColor|bevelColor|sideColor|backColor, group?: uid|path} → {uid, path} (frontColor…backColor: the group's Front / Bevel / Side / Back item, which colours that surface of every extruded paint in the group in Advanced 3D)", + "{layer?, kind: group|rect|ellipse|star|polygon|path|fill|stroke|gfill|gstroke|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|wiggler|merge|frontColor|bevelColor|sideColor|backColor, group?: uid|path} → {uid, path} (frontColor…backColor: the group's Front / Bevel / Side / Back item, which colours that surface of every extruded paint in the group in Advanced 3D)", has_layers, add_shape_item ), diff --git a/crates/engine/src/cmd/paths.rs b/crates/engine/src/cmd/paths.rs index 7803e8fac..0bb2eaae2 100644 --- a/crates/engine/src/cmd/paths.rs +++ b/crates/engine/src/cmd/paths.rs @@ -360,7 +360,7 @@ fn free_transform(s: &mut Session, p: &Value) -> Result { const SHAPE_ITEMS: &[&str] = &[ "group", "rect", "ellipse", "star", "path", "fill", "stroke", "gfill", "gstroke", "trim", "repeater", "round", "offset", "pucker", "twist", "zigzag", - "wiggle", "merge", "front", "bevel", "side", "back", + "wiggle", "wiggler", "merge", "front", "bevel", "side", "back", ]; /// Selected shape items (contents entries) of shape layers: (layer, uid). diff --git a/crates/engine/src/cmd/prop.rs b/crates/engine/src/cmd/prop.rs index ab2f0a1e2..663429fb2 100644 --- a/crates/engine/src/cmd/prop.rs +++ b/crates/engine/src/cmd/prop.rs @@ -1189,6 +1189,15 @@ fn toggle_hold(s: &mut Session, _: &Value) -> Result { }) } +fn set_muted(s: &mut Session, p: &Value) -> Result { + let muted = b_p(p, "muted").unwrap_or(true); + let label = if muted { "Mute Keyframes" } else { "Unmute Keyframes" }; + edit_keys(s, label, None, move |keys, i, _| { + keys[i].muted = muted; + false + }) +} + /// A number or a per-dimension array of numbers. fn nums_p(p: &Value, k: &str, scale: f64) -> Option> { match p.get(k)? { @@ -1381,6 +1390,7 @@ pub fn specs() -> Vec { &json!({"which": "out"}) )), cmd!("keys.toggleHold", "Toggle Hold Keyframe", ["Animation"], Some("Cmd+Alt+H"), "{}", has_keys, toggle_hold), + cmd!("keys.setMuted", "Mute Keyframes", ["Animation"], None, "{muted?: bool (default true)}", has_keys, set_muted), cmd!( "keys.interpolation", "Keyframe Interpolation...", diff --git a/crates/engine/src/cmd/proxy.rs b/crates/engine/src/cmd/proxy.rs index d15f93730..99650875c 100644 --- a/crates/engine/src/cmd/proxy.rs +++ b/crates/engine/src/cmd/proxy.rs @@ -117,6 +117,7 @@ fn interpret_proxy(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "the selected items have no proxy")); } let it = super::file_more::Interpretation::parse(p, C)?; + it.check(items.iter().filter_map(|i| s.project.item(*i).and_then(|x| x.proxy.as_ref()).map(|px| &px.footage)), C)?; let guesses: Vec<_> = items .iter() .map(|i| { @@ -229,7 +230,7 @@ pub fn specs() -> Vec { "Proxy...", ["File", "Interpret Footage"], None, - "{items?|item?, alpha?, guessAlpha?, matteColor?|premulColor?, invertAlpha?, frameRate?, loop?, pixelAspect?, fields?, colorProfile?, linearLight?, preserveRgb?, overrideStart?, startTimecode? (as file.interpretFootage)}", + "{items?|item?, alpha?, guessAlpha?, matteColor?|premulColor?, invertAlpha?, frameRate?, loop?, pixelAspect?, fields?, colorProfile?, linearLight?, preserveRgb?, overrideStart?, startTimecode? (as file.interpretFootage), logConversion?, cineonUnits?, convertedBlack?, convertedWhite?, pointUnits?, tenBitBlack?, tenBitWhite?, currentGamma?, highlightRolloff? (Cineon Settings of DPX and Cineon proxies)}", has_proxy, interpret_proxy ), diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index f4162043d..251531a07 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -7,6 +7,9 @@ //! panels appear at the bottom of the Window menu and open as dockable panels //! (`window.scriptPanel`), running with `this` = the panel as in After Effects. A subfolder of //! either folder (one level) is a submenu named after it; its scripts are named `Folder/File.jsx`. +//! * **Startup and Shutdown scripts** in `Scripts/Startup/` and `Scripts/Shutdown/` run by +//! themselves, in alphabetical order: at launch (`file.runStartupScripts`) and as EffectCraft +//! quits, after the project is closed (`file.runShutdownScripts`). //! * **ScriptUI windows** (`scriptui.*`): list the open script windows, read their control //! trees, click buttons and set values like a user, close them (see [`crate::scriptui`]). //! * **Script settings** (`script.settings.*`): After Effects' `app.settings`, string values by @@ -23,6 +26,18 @@ use crate::{EngineError, Event, Result, Session, cmd, scriptui}; /// The settings-store folders of installed scripts. pub const SCRIPTS_DIR: &str = "Scripts"; pub const PANELS_DIR: &str = "Scripts/ScriptUI Panels"; +/// The settings-store folders whose scripts run at launch and as EffectCraft quits. +pub const STARTUP_DIR: &str = "Scripts/Startup"; +pub const SHUTDOWN_DIR: &str = "Scripts/Shutdown"; +/// Commands that run script files or script code (a ScriptUI panel runs its script when it +/// opens). Scripts and batches can't run them, and confined automation sessions refuse them. +pub const RUNS_SCRIPTS: &[&str] = + &["script.run", "file.runScript", "file.runStartupScripts", "file.runShutdownScripts", "window.scriptPanel", "window.restoreScriptPanels"]; +/// Largest script file the Startup and Shutdown folders run (bytes), as `#include` and +/// `$.evalFile` allow. +const MAX_SCRIPT_BYTES: usize = 16 << 20; +/// Modal dialogs a Shutdown script may open one after another; each closes as if canceled. +const MAX_SHUTDOWN_DIALOGS: usize = 64; /// [`PANELS_DIR`]'s name in [`SCRIPTS_DIR`]: it holds the panels, so it is not a folder of scripts. const PANELS_FOLDER: &str = "ScriptUI Panels"; /// Subfolders of Scripts that hold scripts the app runs itself (panels; launch and quit, as in @@ -221,6 +236,7 @@ fn install(s: &mut Session, p: &Value, panel: bool) -> Result { let cfg = s.config.clone().ok_or_else(|| EngineError::Other("this session has no settings folder to install scripts into".into()))?; let dir = if panel { PANELS_DIR } else { SCRIPTS_DIR }; cfg.write(&format!("{dir}/{name}"), &code).map_err(|e| EngineError::Other(format!("cannot install {name}: {e}")))?; + s.scripts_changed(); // After Effects asks for a restart; our menus pick it up at once. s.toast(if panel { format!("Installed ScriptUI panel {name}: find it at the bottom of the Window menu") @@ -256,6 +272,7 @@ fn uninstall(s: &mut Session, p: &Value) -> Result { removed = true; } } + s.scripts_changed(); if removed { Ok(json!({"removed": name})) } else { Err(bad(c, format!("`{name}` is not installed"))) } } @@ -337,6 +354,85 @@ fn restore_panels(s: &mut Session, _: &Value) -> Result { Ok(json!({"opened": opened, "failed": failed})) } +/// Run the scripts in `dir` of the settings store one after another, in alphabetical order (case +/// ignored), as After Effects runs its Startup and Shutdown folders. A script that fails is +/// reported in a toast and the log and listed in `failed`; the next one still runs. A script waiting in a +/// modal dialog is listed in `waiting` and goes on when the dialog closes; the next one runs +/// meanwhile. With `quitting`, no window is left to show a dialog in, so each dialog closes as +/// if canceled (`show()` returns 2) and the script runs on. +fn run_folder(s: &mut Session, dir: &str, quitting: bool) -> Value { + let mut names: Vec = s.config.as_ref().map(|c| c.list(dir)).unwrap_or_default().into_iter().filter(|n| is_script(n) && is_script_name(n)).collect(); + names.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()).then_with(|| a.cmp(b))); + let (mut ran, mut waiting, mut failed) = (vec![], vec![], vec![]); + for name in names { + let code = match s.config.as_ref().map(|c| c.read_bounded(&format!("{dir}/{name}"), MAX_SCRIPT_BYTES)) { + Some(Ok(Some(code))) => code, + read => { + let why = match read { + Some(Err(e)) => format!(": {e}"), + _ => String::new(), + }; + let error = format!("{name}: cannot read the script{why}"); + log::warn!("{dir}: {error}"); + s.events.push(Event::Toast { message: error.clone(), error: true }); + failed.push(json!({"name": name, "error": error})); + continue; + } + }; + let is_waiting = |r: &Result| r.as_ref().is_ok_and(|v| v.get("waiting").and_then(Value::as_bool) == Some(true)); + // In a settings folder on disk, the script runs as its file, so `#include`, `$.fileName` + // and `$.evalFile` find the files beside it. + let path = s.config.as_ref().and_then(|c| c.dir()).map(|d| d.join(dir).join(&name).to_string_lossy().into_owned()).unwrap_or_else(|| name.clone()); + // Windows open before this script runs belong to other scripts, perhaps of the same name. + let before: Vec = s.script_ui.windows.iter().map(|w| w.id).collect(); + let hosts_before = s.script_ui.task_hosts.clone(); + let mut r = super::file_more::run_js_file(s, &code, &path, &name); + if !quitting { + // The Startup script's context: the one that scheduled tasks or holds the windows it + // opened (a task scheduled after its dialog closes runs there too). + let tasks = s.script_ui.task_hosts.iter().filter(|h| !hosts_before.contains(h)).copied(); + let windows = s.script_ui.windows.iter().filter(|w| !before.contains(&w.id)).map(|w| w.host); + let hosts: Vec = tasks.chain(windows).collect(); + for h in hosts { + if !s.script_ui.startup_hosts.contains(&h) { + s.script_ui.startup_hosts.push(h); + } + } + } + let mut closed = 0; + while quitting && closed < MAX_SHUTDOWN_DIALOGS && is_waiting(&r) { + let Some(id) = s.script_ui.windows.iter().rev().find(|w| w.modal && w.script == path && !before.contains(&w.id)).map(|w| w.id) else { break }; + // report_js reports an error the script throws after its dialog closes, as it does for an + // error in the script's first run. + r = scriptui::close(s, &json!({"window": id})).and_then(|out| super::file_more::report_js(s, &name, out)); + closed += 1; + } + match r { + Ok(_) if is_waiting(&r) => waiting.push(name), + Ok(_) => ran.push(name), + Err(e) => { + // Also in the log: no frame draws a Shutdown script's toast. + log::warn!("{dir}: {e}"); + failed.push(json!({"name": name, "error": e.to_string()})); + } + } + } + json!({"ran": ran, "waiting": waiting, "failed": failed}) +} + +/// `file.runStartupScripts`: run the scripts in `Scripts/Startup` (frontends call it once at +/// launch, before a project opens). +fn run_startup(s: &mut Session, _: &Value) -> Result { + Ok(run_folder(s, STARTUP_DIR, false)) +} + +/// `file.runShutdownScripts`: close the project, then run the scripts in `Scripts/Shutdown` +/// (frontends call it as the app quits, after the unsaved-changes prompt). +fn run_shutdown(s: &mut Session, _: &Value) -> Result { + s.replace_project(effectcraft_project::Project::default(), None); + Ok(run_folder(s, SHUTDOWN_DIR, true)) +} + /// Window ▸ ``: run the panel script (or bring its panel forward). fn open_panel(s: &mut Session, p: &Value) -> Result { let c = "window.scriptPanel"; @@ -414,6 +510,24 @@ pub fn specs() -> Vec { always, restore_panels ), + cmd!( + "file.runStartupScripts", + "Run Startup Scripts", + [], + None, + "{} → runs the scripts in Scripts/Startup in alphabetical order (frontends call it at launch) → {ran: [name], waiting: [name] (in a modal dialog), failed: [{name, error}]}", + always, + run_startup + ), + cmd!( + "file.runShutdownScripts", + "Run Shutdown Scripts", + [], + None, + "{} → closes the project, then runs the scripts in Scripts/Shutdown in alphabetical order (frontends call it as the app quits; their modal dialogs close as if canceled) → {ran: [name], waiting: [name], failed: [{name, error}]}", + always, + run_shutdown + ), crate::query!("script.settings.get", "Get Script Setting", "{section, key} → {have, value} (app.settings.haveSetting / getSetting)", settings_get), cmd!( "script.settings.save", @@ -948,4 +1062,64 @@ mod tests { assert!(bad_params(&s.execute("file.uninstallScript", json!({"name": "Hello.jsx"}))), "no longer installed"); let _ = std::fs::remove_dir_all(&root); } + + /// Edit ▸ Keyboard Shortcuts lists the scripts File ▸ Scripts lists (not ScriptUI panels), + /// as soon as one is installed and until it is uninstalled; a key given to one is saved with + /// the preset. + #[test] + fn file_scripts_entries_take_keyboard_shortcuts() { + use crate::shortcuts::{Keymaps, SHORTCUTS_FILE, binding_key}; + let (mut s, root) = session_in_tmp("shortcuts"); + let (script, panel) = (root.join("Hello.jsx"), root.join("Hello Panel.jsx")); + std::fs::write(&script, "hello").unwrap(); + std::fs::write(&panel, "panel").unwrap(); + let listed = |s: &Session, name: &str| { + s.shortcuts().find(&binding_key("file.runScript", &json!({"name": name}))).map(|b| (b.label.clone(), b.path.join(" ▸ "), b.defaults.clone())) + }; + assert_eq!(listed(&s, "Rename Layers.jsx"), Some(("Rename Layers.jsx".into(), "File ▸ Scripts".into(), vec![])), "the bundled samples"); + assert_eq!(listed(&s, "Hello.jsx"), None); + s.execute("file.installScript", json!({"path": script.to_string_lossy()})).unwrap(); + s.execute("file.installScriptUIPanel", json!({"path": panel.to_string_lossy()})).unwrap(); + assert_eq!(listed(&s, "Hello.jsx"), Some(("Hello.jsx".into(), "File ▸ Scripts".into(), vec![]))); + assert_eq!(listed(&s, "Hello Panel.jsx"), None, "panels are in the Window menu"); + let r = s.execute("shortcuts.set", json!({"command": "file.runScript", "params": {"name": "Hello.jsx"}, "keys": "Cmd+Alt+Shift+F7"})).unwrap(); + let key = binding_key("file.runScript", &json!({"name": "Hello.jsx"})); + assert_eq!((&r["command"], &r["keys"]), (&json!(key), &json!(["Cmd+Alt+Shift+F7"])), "{r}"); + assert_eq!(s.shortcuts().shortcut_of("file.runScript", &json!({"name": "Hello.jsx"})), Some("Cmd+Alt+Shift+F7")); + let saved = Keymaps::from_json(&std::fs::read_to_string(root.join("settings").join(SHORTCUTS_FILE)).unwrap()); + assert_eq!(saved.active_preset().and_then(|p| p.overrides.get(&key)), Some(&vec!["Cmd+Alt+Shift+F7".to_string()])); + s.execute("file.uninstallScript", json!({"name": "Hello.jsx"})).unwrap(); + assert_eq!(listed(&s, "Hello.jsx"), None); + let _ = std::fs::remove_dir_all(&root); + } + + /// `shortcuts.list` and `shortcuts.set` see a script copied into the Scripts folder by hand + /// after the shortcut table was built; a script the folder doesn't hold (never there, or + /// removed by hand) can't take a key, and Run Script File… keeps its own keys. + #[test] + fn shortcut_commands_find_scripts_copied_into_the_folder() { + let (mut s, root) = session_in_tmp("copied"); + assert!(s.shortcuts().find("file.runScript").is_some(), "the table is built"); + std::fs::create_dir_all(root.join("settings").join(SCRIPTS_DIR)).unwrap(); + std::fs::write(root.join("settings").join(SCRIPTS_DIR).join("Copied.jsx"), "copied").unwrap(); + let list = s.execute("shortcuts.list", json!({"query": "copied.jsx"})).unwrap(); + assert_eq!(list["count"], 1, "{list}"); + let r = s.execute("shortcuts.set", json!({"command": "file.runScript", "params": {"name": "Copied.jsx"}, "keys": "Cmd+Alt+Shift+F7"})).unwrap(); + assert_eq!(r["command"], r#"file.runScript {"name":"Copied.jsx"}"#, "{r}"); + let r = s.execute("shortcuts.set", json!({"command": "file.runScript", "params": {"name": "Missing.jsx"}, "keys": "Cmd+Alt+Shift+F8"})); + assert!(bad_params(&r), "{r:?}"); + // The binding-key form finds a script copied in since the last lookup, too. + std::fs::write(root.join("settings").join(SCRIPTS_DIR).join("Second.jsx"), "second").unwrap(); + let r = s.execute("shortcuts.set", json!({"command": r#"file.runScript {"name":"Second.jsx"}"#, "keys": "Cmd+Alt+Shift+F10"})).unwrap(); + assert_eq!(r["command"], r#"file.runScript {"name":"Second.jsx"}"#, "{r}"); + // Removed by hand: no longer in the folder. + std::fs::remove_file(root.join("settings").join(SCRIPTS_DIR).join("Copied.jsx")).unwrap(); + std::fs::remove_file(root.join("settings").join(SCRIPTS_DIR).join("Second.jsx")).unwrap(); + let r = s.execute("shortcuts.set", json!({"command": r#"file.runScript {"name":"Second.jsx"}"#, "keys": "Cmd+Alt+Shift+F11"})); + assert!(bad_params(&r), "{r:?}"); + let r = s.execute("shortcuts.set", json!({"command": "file.runScript", "params": {"name": "Copied.jsx"}, "keys": "Cmd+Alt+Shift+F9"})); + assert!(bad_params(&r), "{r:?}"); + assert_eq!(s.shortcuts().shortcut_of("file.runScript", &Value::Null), None, "Run Script File… has no key"); + let _ = std::fs::remove_dir_all(&root); + } } diff --git a/crates/engine/src/cmd/settings.rs b/crates/engine/src/cmd/settings.rs index a6621c3f4..272903c69 100644 --- a/crates/engine/src/cmd/settings.rs +++ b/crates/engine/src/cmd/settings.rs @@ -73,13 +73,21 @@ fn prefs_pages(_: &mut Session, _: &Value) -> Result { // ---------------------------------------------------------------- shortcuts -/// The bindable named by `command` (+ `params`), or by a binding key (`app.settings {...}`). -fn bindable(s: &Session, p: &Value, cmd: &str) -> Result { +/// The bindable named by `command` (+ `params`), or by a binding key (`app.settings {...}`). A +/// File ▸ Scripts entry (`file.runScript {"name": …}`) is looked up in File ▸ Scripts' list as it +/// is now (the Scripts folder and the bundled scripts), and a script it doesn't list is an error, +/// not Run Script File…. +fn bindable(s: &mut Session, p: &Value, cmd: &str) -> Result { let c = str_p(p, "command").ok_or_else(|| bad(cmd, "missing `command`"))?; let params = p.get("params").cloned().unwrap_or(Value::Null); - let t = s.shortcuts(); let key = binding_key(c, ¶ms); - t.find(&key).or_else(|| t.find(c)).cloned().ok_or_else(|| bad(cmd, format!("`{key}` can't have a shortcut (see shortcuts.list)"))) + let script = (c == "file.runScript" && params.get("name").is_some()) || c.starts_with("file.runScript {"); + if script { + s.scripts_changed(); + } + let t = s.shortcuts(); + let found = if script { t.find(&key) } else { t.find(&key).or_else(|| t.find(c)) }; + found.cloned().ok_or_else(|| bad(cmd, format!("`{key}` can't have a shortcut (see shortcuts.list)"))) } fn keys_param(p: &Value, cmd: &str) -> Result> { @@ -93,6 +101,8 @@ fn keys_param(p: &Value, cmd: &str) -> Result> { } fn sc_list(s: &mut Session, p: &Value) -> Result { + // Scripts copied into the Scripts folder since the table was built are listed too. + s.scripts_changed(); let q = str_p(p, "query").unwrap_or("").to_lowercase(); let assigned = p.get("assigned").and_then(Value::as_bool).unwrap_or(false); let keys_q = str_p(p, "keys").and_then(normalize); diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index 9296e1322..a7418ae58 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -1036,6 +1036,8 @@ Animation --- Add Keyframe | anim.addKeyframe Toggle Hold Keyframe | keys.toggleHold + Mute Keyframes | keys.setMuted + Unmute Keyframes | keys.setMuted {"muted":false} Keyframe Interpolation... | keys.interpolation Keyframe Velocity... | keys.velocity Keyframe Assistant diff --git a/crates/engine/src/scriptui.rs b/crates/engine/src/scriptui.rs index 6d218a7bb..082f0c861 100644 --- a/crates/engine/src/scriptui.rs +++ b/crates/engine/src/scriptui.rs @@ -402,12 +402,16 @@ pub struct ScriptUi { #[serde(skip)] pub tasks: Option, /// Bumped whenever the project is closed or replaced (New / Open / Close Project): repeating - /// tasks of scripts without an open window stop then. + /// tasks of scripts without an open window stop then, except those of Startup scripts. #[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, + /// The script contexts of Startup scripts: their tasks go on when the project is closed or + /// replaced, until EffectCraft quits. + #[serde(skip)] + pub startup_hosts: Vec, } impl ScriptUi { diff --git a/crates/engine/src/session/render.rs b/crates/engine/src/session/render.rs index 6f2d273ad..86127a510 100644 --- a/crates/engine/src/session/render.rs +++ b/crates/engine/src/session/render.rs @@ -96,7 +96,7 @@ impl Session { effectcraft_project::ItemKind::Footage(f) if f.has_video => { let f = f.clone(); Some(Box::new(move || { - let img = footage.frame(item, &f, Tick(0))?; + let img = effectcraft_render::cineon_frame(footage.frame(item, &f, Tick(0))?, &f); // Point-sample down to at most `max_side` on the long side. let step = (img.width.max(img.height) as f32 / max_side.max(1) as f32).ceil().max(1.0) as u32; let (w, h) = (img.width.div_ceil(step), img.height.div_ceil(step)); diff --git a/crates/engine/src/settings/session_settings.rs b/crates/engine/src/settings/session_settings.rs index 609ad5987..4fe47e0ef 100644 --- a/crates/engine/src/settings/session_settings.rs +++ b/crates/engine/src/settings/session_settings.rs @@ -195,9 +195,19 @@ impl Session { self.shortcut_table = std::sync::OnceLock::new(); } - /// The active shortcut preset resolved against every bindable command. + /// The scripts in the Scripts folder changed: list File ▸ Scripts' entries again in the + /// shortcut table. + pub fn scripts_changed(&mut self) { + self.shortcut_table = std::sync::OnceLock::new(); + } + + /// The active shortcut preset resolved against every bindable command, File ▸ Scripts' + /// entries included. pub fn shortcuts(&self) -> &ShortcutTable { - self.shortcut_table.get_or_init(|| ShortcutTable::build(&self.keymaps, &self.ui_commands)) + self.shortcut_table.get_or_init(|| { + let scripts: Vec = crate::commands::scripts::scripts(self).into_iter().filter(|e| !e.panel).map(|e| e.name).collect(); + ShortcutTable::build_with_scripts(&self.keymaps, &self.ui_commands, &scripts) + }) } /// Remember a project in File ▸ Open Recent and in the crash-recovery sentinel. diff --git a/crates/engine/src/settings/shortcuts.rs b/crates/engine/src/settings/shortcuts.rs index 2eb6fa680..1d60971ac 100644 --- a/crates/engine/src/settings/shortcuts.rs +++ b/crates/engine/src/settings/shortcuts.rs @@ -1,6 +1,7 @@ //! Keyboard shortcut presets (Edit ▸ Keyboard Shortcuts). //! -//! Every menu entry, registry command and frontend command is *bindable*: it has a binding key +//! Every menu entry, registry command and frontend command, and every script File ▸ Scripts +//! lists, is *bindable*: it has a binding key //! (the command id, plus its bound parameters for entries such as `app.settings //! {"page":"general"}`), a scope (application-wide or a panel) and default keys. The built-in //! preset "EffectCraft Default" is After Effects' default keyboard layout and is read-only; custom @@ -157,6 +158,27 @@ pub fn bindables(ui: &[UiCommand]) -> Vec { out } +/// The File ▸ Scripts entries of the scripts `names` (file names, as the menu lists them), with +/// no default keys. A key is bound to the script's name, so it stays with that script when +/// scripts are added to or removed from the folder. +pub fn script_bindables(names: &[String]) -> Vec { + names + .iter() + .map(|n| { + let params = json!({"name": n}); + Bindable { + key: binding_key("file.runScript", ¶ms), + command: "file.runScript".into(), + params, + label: n.clone(), + path: vec!["File".into(), "Scripts".into()], + scope: APP_SCOPE.into(), + defaults: vec![], + } + }) + .collect() +} + /// Canonical shortcut text (`cmd+shift+k`, `Shift+Cmd+K`, `⌘⇧K` → `Cmd+Shift+K`); `None` if it /// has no key. pub fn normalize(s: &str) -> Option { @@ -436,7 +458,16 @@ pub struct ShortcutTable { impl ShortcutTable { pub fn build(maps: &Keymaps, ui: &[UiCommand]) -> ShortcutTable { - let bindables = bindables(ui); + Self::build_with_scripts(maps, ui, &[]) + } + + /// [`ShortcutTable::build`] with the File ▸ Scripts entries of the scripts `scripts`, listed + /// after the other File ▸ Scripts entries. + pub fn build_with_scripts(maps: &Keymaps, ui: &[UiCommand], scripts: &[String]) -> ShortcutTable { + let mut bindables = bindables(ui); + let at = bindables.iter().rposition(|b| b.path.starts_with(&["File".to_string(), "Scripts".to_string()])).map_or(bindables.len(), |i| i + 1); + let new: Vec = script_bindables(scripts).into_iter().filter(|s| !bindables.iter().any(|b| b.key == s.key)).collect(); + bindables.splice(at..at, new); let keys = bindables.iter().map(|b| (b.key.clone(), maps.keys_of(b))).collect(); ShortcutTable { bindables, keys } } diff --git a/crates/engine/src/tests/menu_cmds.rs b/crates/engine/src/tests/menu_cmds.rs index 93f81736f..cfbbb4966 100644 --- a/crates/engine/src/tests/menu_cmds.rs +++ b/crates/engine/src/tests/menu_cmds.rs @@ -1483,6 +1483,68 @@ fn key_selection_toggles_and_skips_locked_layers_and_hold_restores_bezier() { assert!(s.state.selected_keys.is_empty()); } +#[test] +fn muting_keys_skips_them_in_the_timeline_value_and_undoes() { + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + for (t, v) in [(0.0, 0.0), (1.0, 100.0), (2.0, 300.0)] { + s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": t, "value": v})).unwrap(); + } + let value_at = |s: &Session, t: f64| layer(s, a).props.prop("transform/opacity").unwrap().value_at(Tick::from_seconds_f64(t)).as_f64(); + let select = |s: &mut Session| s.execute("keys.select", json!({"keys": [{"layer": a, "path": "transform/opacity", "time": 1.0}]})).unwrap(); + select(&mut s); + let before = value_at(&s, 0.5); + s.execute("keys.setMuted", json!({"muted": true})).unwrap(); + assert!(layer(&s, a).props.prop("transform/opacity").unwrap().keys[1].muted); + // The muted key no longer bends the 0 → 300 segment. + assert!((value_at(&s, 0.5) - 75.0).abs() < 1e-6); + assert!((value_at(&s, 1.5) - 225.0).abs() < 1e-6); + s.undo(); + assert!(!layer(&s, a).props.prop("transform/opacity").unwrap().keys[1].muted); + assert!((value_at(&s, 0.5) - before).abs() < 1e-6); + // The same command unmutes. + select(&mut s); + s.execute("keys.setMuted", json!({"muted": true})).unwrap(); + s.execute("keys.setMuted", json!({"muted": false})).unwrap(); + assert!(!layer(&s, a).props.prop("transform/opacity").unwrap().keys[1].muted); +} + +/// Both Animation-menu rows mute and unmute through `Session::execute`, and undo/redo work. +#[test] +fn mute_and_unmute_keyframes_menu_rows_work_and_undo() { + fn rows(nodes: &[crate::menus::MenuNode], out: &mut Vec<(String, Value)>) { + for n in nodes { + match n { + crate::menus::MenuNode::Item(e) if e.command == "keys.setMuted" => out.push((e.label.clone(), e.params.clone())), + crate::menus::MenuNode::Submenu { children, .. } => rows(children, out), + _ => {} + } + } + } + let mut menu = vec![]; + rows(&crate::menus::parse(crate::menus::TREE, false).unwrap(), &mut menu); + assert_eq!(menu.iter().map(|(l, _)| l.as_str()).collect::>(), ["Mute Keyframes", "Unmute Keyframes"], "{menu:?}"); + + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + for (t, v) in [(0.0, 0.0), (1.0, 100.0)] { + s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": t, "value": v})).unwrap(); + } + let muted = |s: &Session| layer(s, a).props.prop("transform/opacity").unwrap().keys[1].muted; + s.execute("keys.select", json!({"keys": [{"layer": a, "path": "transform/opacity", "time": 1.0}]})).unwrap(); + let run = |s: &mut Session, params: &Value| { + s.execute("keys.setMuted", if params.is_null() { json!({}) } else { params.clone() }).unwrap(); + }; + run(&mut s, &menu[0].1); + assert!(muted(&s)); + s.undo(); + assert!(!muted(&s)); + s.redo(); + assert!(muted(&s)); + run(&mut s, &menu[1].1); + assert!(!muted(&s)); +} + #[test] fn transform_drags_apply_their_whole_offset_to_the_keys_they_started_with() { let mut s = comp(); diff --git a/crates/engine/src/tests/proxy.rs b/crates/engine/src/tests/proxy.rs index 941a9a96b..40927b8b8 100644 --- a/crates/engine/src/tests/proxy.rs +++ b/crates/engine/src/tests/proxy.rs @@ -1,6 +1,6 @@ //! Proxies (Set Proxy, Use Proxy, Proxy Use render settings, comp proxies, Create Proxy) and -//! Interpret Footage (fields, pixel aspect, alpha guess / invert, linear light), rendered through -//! a synthetic footage source. +//! Interpret Footage (fields, pixel aspect, alpha guess / invert, linear light, Cineon Settings), +//! rendered through a synthetic footage source. use std::sync::Arc; @@ -36,6 +36,12 @@ fn file_px(path: &str, x: u32, y: u32) -> [f32; 4] { "matte_black.png" => [0.0, 0.5, 0.0, 0.5], "matte_white.png" => [0.5, 1.0, 0.5, 0.5], "half.png" => [1.0, 1.0, 1.0, 0.25], + // A log film scan: code values 95, 685, 400 and 1023 across, as the DPX reader hands + // them out (code / 1023). + "scan.dpx" | "plate.dpx" => { + let v = [95.0, 685.0, 400.0, 1023.0][x.min(3) as usize] / 1023.0; + [v, v, v, 1.0] + } _ => { let _ = x; [1.0, 1.0, 1.0, 1.0] @@ -47,7 +53,7 @@ fn size_of(path: &str) -> (u32, u32) { match std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("") { "red16.png" => (16, 16), "blue8.png" => (8, 8), - "lines.mov" | "matte_black.png" | "matte_white.png" | "half.png" => (4, 4), + "lines.mov" | "matte_black.png" | "matte_white.png" | "half.png" | "scan.dpx" | "plate.dpx" => (4, 4), _ => (10, 10), } } @@ -64,7 +70,12 @@ impl FootageSource for Synthetic { for y in 0..h { for x in 0..w { // Decoders hand out premultiplied pixels (straight file colour × alpha). - let p = file_px(&f.path, x, y); + let mut p = file_px(&f.path, x, y); + // A log scan sequence: code 95 on even frames, 685 on odd ones. + if f.path.ends_with("frames.dpx") { + let v = if f.frame_rate.frame_at(t) % 2 == 0 { 95.0 } else { 685.0 } / 1023.0; + p = [v, v, v, 1.0]; + } img.set(x, y, [p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]]); } } @@ -76,7 +87,7 @@ struct Probe; impl crate::Importer for Probe { fn probe(&self, path: &str) -> Result { let (w, h) = size_of(path); - let video = path.ends_with(".mov"); + let video = path.ends_with(".mov") || path.ends_with("frames.dpx"); Ok(Footage { path: path.into(), kind: if video { FootageKind::Video } else { FootageKind::Still }, @@ -431,3 +442,125 @@ fn guess_alpha_invert_and_linear_light() { s.execute("file.applyInterpretation", json!({"items": [w.0]})).unwrap(); assert!(foot(&s, w).preserve_rgb); } + +/// Color ▸ Cineon Settings: Logarithmic Conversion turns a DPX file's log code values into the +/// Current Gamma between the converted points (off, they show as stored); the converted points +/// are read and reported in the chosen Units; out-of-range values and footage that is not DPX +/// or Cineon are refused; Remember / Apply Interpretation and undo carry the settings. +#[test] +fn cineon_settings_convert_dpx_code_values() { + let mut s = session(); + let d = import(&mut s, "/m/scan.dpx"); + let other = import(&mut s, "/m/plate.dpx"); + let png = import(&mut s, "/m/red16.png"); + let foot = |s: &Session, i: ItemId| match &s.project.item(i).unwrap().kind { + ItemKind::Footage(f) => f.clone(), + _ => panic!("not footage"), + }; + s.execute("comp.new", json!({"name": "C", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap(); + let c = s.active_comp_id().unwrap(); + s.execute("layer.addItem", json!({"item": d.0})).unwrap(); + let at = |s: &Session, x: i64| px(s, c, 0.0, RenderOpts::default(), x, 0)[0]; + let near = |a: f32, b: f32| (a - b).abs() < 1.5 / 255.0; + // Off: the code values as stored. + assert!(near(at(&s, 2), 400.0 / 1023.0), "{}", at(&s, 2)); + // The checked path (control channel, MCP) accepts every Cineon parameter the doc lists. + let out = s.execute_checked("file.interpretFootage", json!({"items": [d.0], "logConversion": true, "highlightRolloff": 0})).unwrap(); + assert_eq!(out["items"][0]["cineon"]["logConversion"], true, "{out}"); + assert!(near(at(&s, 0), 0.0) && near(at(&s, 1), 1.0), "{} {}", at(&s, 0), at(&s, 1)); + assert!(near(at(&s, 2), 0.26190), "code 400 at gamma 1.7: {}", at(&s, 2)); + s.execute("file.interpretFootage", json!({"item": d.0, "currentGamma": 1})).unwrap(); + assert!(near(at(&s, 2), 0.10251), "code 400 in linear light: {}", at(&s, 2)); + // Converted points, in 8-bit units. + let out = s + .execute_checked("file.interpretFootage", json!({"items": [d.0], "cineonUnits": "8bit", "convertedBlack": 16, "convertedWhite": 235, "currentGamma": 1, "tenBitBlack": 95, "tenBitWhite": 685})) + .unwrap(); + assert_eq!(out["items"][0]["cineon"]["units"], "8bit"); + assert!((out["items"][0]["cineon"]["convertedWhite"].as_f64().unwrap() - 235.0).abs() < 1e-3, "{out}"); + assert!(near(at(&s, 0), 16.0 / 255.0) && near(at(&s, 1), 235.0 / 255.0), "{} {}", at(&s, 0), at(&s, 1)); + // The form sends the points in the units it showed them in (`pointUnits`) with a new Units. + s.execute_checked("file.interpretFootage", json!({"items": [d.0], "pointUnits": "8bit", "cineonUnits": "float", "convertedWhite": 255})).unwrap(); + let f = foot(&s, d); + assert_eq!(f.cineon.units, effectcraft_project::CineonUnits::Float); + assert!((f.cineon.converted_white - 1023.0).abs() < 1e-3, "{:?}", f.cineon); + // Refused: a black point at or above the white point, values out of range, and footage + // that is not DPX or Cineon. Nothing changes. + let before = foot(&s, d).cineon; + for bad in [ + json!({"item": d.0, "tenBitBlack": 700}), + json!({"item": d.0, "tenBitWhite": 2000}), + json!({"item": d.0, "currentGamma": 0}), + json!({"item": d.0, "highlightRolloff": -1}), + json!({"item": d.0, "convertedWhite": 1.5}), + json!({"item": d.0, "cineonUnits": "12bit"}), + json!({"item": png.0, "logConversion": true}), + ] { + assert!(s.execute("file.interpretFootage", bad.clone()).is_err(), "{bad}"); + } + assert_eq!(foot(&s, d).cineon, before); + assert!(foot(&s, png).cineon == effectcraft_project::CineonSettings::default()); + // With DPX and other footage selected, only the DPX file takes the settings. + s.execute("file.interpretFootage", json!({"items": [png.0, other.0], "logConversion": true})).unwrap(); + assert!(foot(&s, other).cineon.log_conversion && !foot(&s, png).cineon.log_conversion); + s.execute("edit.undo", json!({})).unwrap(); + assert!(!foot(&s, other).cineon.log_conversion); + // Remember / Apply Interpretation. + s.state.project_selection = vec![d]; + s.execute("file.rememberInterpretation", json!({"item": d.0})).unwrap(); + s.execute("file.applyInterpretation", json!({"items": [other.0, png.0]})).unwrap(); + assert_eq!(foot(&s, other).cineon, before); + assert_eq!(foot(&s, png).cineon, effectcraft_project::CineonSettings::default()); + // Off again: as stored. + s.execute("file.interpretFootage", json!({"item": d.0, "logConversion": false})).unwrap(); + assert!(near(at(&s, 2), 400.0 / 1023.0), "{}", at(&s, 2)); + // Frame Mix blends converted frames: halfway between code 95 (black) and 685 (white) at + // Current Gamma 1 is 0.5, not the conversion of the blended code values. + let seq = import(&mut s, "/m/frames.dpx"); + s.execute("file.interpretFootage", json!({"item": seq.0, "logConversion": true, "currentGamma": 1, "highlightRolloff": 0})).unwrap(); + s.execute("comp.new", json!({"name": "Blend", "width": 10, "height": 10, "frameRate": 20, "duration": 1})).unwrap(); + let bc = s.active_comp_id().unwrap(); + s.execute("layer.addItem", json!({"item": seq.0})).unwrap(); + s.execute("layer.frameBlending", json!({"mode": "frameMix"})).unwrap(); + let mid = px(&s, bc, 0.05, RenderOpts::default(), 5, 5)[0]; + assert!(near(mid, 0.5), "frame-mixed log frames: {mid}"); + // A DPX proxy of a composition: Interpret Proxy's settings convert it where it stands in. + s.execute("comp.new", json!({"name": "Inner", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap(); + let inner = s.active_comp_id().unwrap(); + s.execute("comp.new", json!({"name": "Outer", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap(); + let outer = s.active_comp_id().unwrap(); + s.execute("layer.addItem", json!({"item": inner.0})).unwrap(); + s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/scan.dpx"})).unwrap(); + let proxied = |s: &Session, x: i64| px(s, outer, 0.0, RenderOpts::default(), x, 0)[0]; + assert!(near(proxied(&s, 1), 685.0 / 1023.0), "{}", proxied(&s, 1)); + s.execute_checked("file.interpretProxy", json!({"items": [inner.0], "logConversion": true, "highlightRolloff": 0})).unwrap(); + assert!(near(proxied(&s, 0), 0.0) && near(proxied(&s, 1), 1.0), "{} {}", proxied(&s, 0), proxied(&s, 1)); +} + +/// The Footage panel, the Project panel thumbnail and the tracker's source frames show DPX +/// footage with its Cineon Settings, as a composition renders it. +#[test] +fn cineon_settings_apply_to_footage_panel_thumbnail_and_tracking() { + let mut s = session(); + let d = import(&mut s, "/m/scan.dpx"); + s.execute("comp.new", json!({"name": "C", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap(); + let c = s.active_comp_id().unwrap(); + s.execute("layer.addItem", json!({"item": d.0})).unwrap(); + s.execute("file.interpretFootage", json!({"item": d.0, "logConversion": true, "highlightRolloff": 0})).unwrap(); + let near = |a: f32, b: f32| (a - b).abs() < 1.5 / 255.0; + let rendered = px(&s, c, 0.0, RenderOpts::default(), 2, 0)[0]; + assert!(near(rendered, 0.26190), "code 400 at gamma 1.7: {rendered}"); + let f = match &s.project.item(d).unwrap().kind { + ItemKind::Footage(f) => f.clone(), + _ => panic!("not footage"), + }; + let panel = crate::commands::footage_panel::frame(&s, d, &f, Tick::ZERO).unwrap().get(2, 0)[0]; + assert!(near(panel, rendered), "Footage panel: {panel}"); + let (w, _, rgba) = (s.thumbnail_job(d, Tick::ZERO, 64).unwrap())().unwrap(); + assert_eq!(w, 4); + let thumb = rgba[8] as f32 / 255.0; + assert!(near(thumb, rendered), "Project panel thumbnail: {thumb}"); + let comp = s.project.comp(c).unwrap(); + let r = crate::render::Renderer::new(&s.project, s.footage.as_ref(), RenderOpts::default()); + let (img, _) = crate::tracking::source_frame(&r, &s.project, c, comp, &comp.layers[0], Tick::ZERO, None).unwrap(); + assert!(near(img.get(2, 0)[0], rendered), "tracker source frame: {}", img.get(2, 0)[0]); +} diff --git a/crates/engine/src/tests/session.rs b/crates/engine/src/tests/session.rs index d4a47c1cc..7723ae607 100644 --- a/crates/engine/src/tests/session.rs +++ b/crates/engine/src/tests/session.rs @@ -159,6 +159,46 @@ fn background_imports_report_progress_and_select_what_arrived() { assert_eq!(r["items"].as_array().map(Vec::len), Some(1)); } +/// #2573: New Composition From Footage (`file.import {newComp}`) makes one composition of what +/// the import made, first file on top, at once or when the background job lands; an import that +/// made nothing makes no comp. +#[test] +fn new_comp_from_footage_makes_a_comp_of_what_was_imported() { + use effectcraft_project::{Footage, FootageKind, ItemKind}; + struct Probe; + impl crate::Importer for Probe { + fn probe(&self, path: &str) -> Result { + if path.ends_with(".xyz") { + return Err("unsupported file".into()); + } + Ok(Footage { path: path.into(), kind: FootageKind::Still, width: 64, height: 32, has_video: true, ..Default::default() }) + } + } + let mut s = Session { importer: Some(std::sync::Arc::new(Probe)), ..Default::default() }; + let comps = |s: &Session| s.project.items.values().filter(|i| matches!(i.kind, ItemKind::Comp(_))).count(); + let layers = |s: &Session| s.active_comp().unwrap().layers.iter().map(|l| l.name.clone()).collect::>(); + // At once. + s.execute("file.import", json!({"paths": ["/drop/a.png", "/drop/b.xyz"], "newComp": true})).unwrap(); + assert_eq!(comps(&s), 1); + assert_eq!(layers(&s), ["a.png"]); + assert_eq!((s.active_comp().unwrap().width, s.active_comp().unwrap().height), (64, 32)); + // In the background: the job's own items, whatever is selected or active meanwhile; it takes + // precedence over `addToComp`. + s.execute("file.import", json!({"paths": ["/drop/c.png", "/drop/d.png"], "background": true, "newComp": true, "addToComp": true})).unwrap(); + s.state.project_selection.clear(); + s.execute("jobs.wait", json!({})).unwrap(); + assert_eq!(comps(&s), 2); + assert_eq!(layers(&s), ["c.png", "d.png"]); + // Nothing imported: no comp, and the failure is reported. + s.execute("file.import", json!({"paths": ["/drop/e.xyz"], "background": true, "newComp": true})).unwrap(); + s.execute("jobs.wait", json!({})).unwrap(); + assert_eq!(comps(&s), 2); + assert!(s.job_log.last().unwrap().message.contains("unsupported file")); + let r = s.execute("file.import", json!({"paths": ["/drop/f.xyz"], "newComp": true})).unwrap(); + assert_eq!(comps(&s), 2); + assert_eq!(r["errors"].as_array().map(Vec::len), Some(1)); +} + /// Audio, Lock and Shy don't change pixels: toggling them keeps the cached layers (#103). #[test] fn audio_lock_and_shy_keep_cached_layers() { diff --git a/crates/engine/src/tests/vector_import.rs b/crates/engine/src/tests/vector_import.rs index 9cfb94ecc..10c07c2ad 100644 --- a/crates/engine/src/tests/vector_import.rs +++ b/crates/engine/src/tests/vector_import.rs @@ -160,6 +160,37 @@ fn psd_imports_as_composition() { assert!(s.project.comp(cid).is_none()); } +/// #2573: New Composition From Footage on a Photoshop file imported as a composition opens that +/// composition; it isn't wrapped in another. +#[test] +fn psd_imported_as_composition_with_new_comp_is_the_new_comp() { + let path = tmp("newcomp.psd"); + std::fs::write(&path, sample_psd()).unwrap(); + let mut s = Session::default(); + let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition", "newComp": true})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let cid = effectcraft_project::ItemId(r["comps"][0].as_u64().unwrap()); + assert_eq!(s.state.active_comp, Some(cid)); + let roots = s.project.items.values().filter(|i| matches!(i.kind, ItemKind::Comp(_)) && i.name.starts_with("newcomp")).count(); + assert_eq!(roots, 1, "no wrapper comp"); +} + +/// A Photoshop file imported as a composition and dropped on the Timeline (`addToComp`) adds +/// its composition once, not its folder too. +#[test] +fn psd_imported_as_composition_into_a_comp_adds_one_layer() { + let path = tmp("dropped.psd"); + std::fs::write(&path, sample_psd()).unwrap(); + let mut s = Session::default(); + let main = s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap()["comp"].as_u64().unwrap(); + let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition", "addToComp": true})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let psd = r["comps"][0].as_u64().unwrap(); + let layers = &s.project.comp(effectcraft_project::ItemId(main)).unwrap().layers; + assert_eq!(layers.len(), 1, "{layers:?}"); + assert!(matches!(layers[0].source, LayerSource::Comp { item } if item.0 == psd)); +} + #[test] fn psd_retain_layer_sizes() { let path = tmp("retain.psd"); diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index 8fe812590..3196ce5a3 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -31,6 +31,7 @@ function __begin(t, v, c, l, path, uid, idx, fd, ti, tt, sv) { __seed = 0; __timeless = false; __projectInfo = null; + __layersTrim(); } function __finish(r) { @@ -44,7 +45,7 @@ function __finish(r) { function __result(r) { r = __v(r); - if (r !== null && typeof r === 'object' && r.__isLayer) return r.index; + if (typeof r === 'function' && r.__isLayer) return r.index; if (r !== null && typeof r === 'object' && r.__isTextStyle) return r.__out(); // `[temp, temp]` where temp is a property (pick-whip 1D → 2D): read the values here, while // host requests can still be answered and the script re-run. @@ -488,7 +489,7 @@ function __ancestor(c, l, path, countUp) { if (typeof n !== 'number' || !(n >= 1) || !isFinite(n)) __err('propertyGroup(' + countUp + '): the count must be a number of 1 or more'); n = Math.floor(n); var info = __h(__ANCESTOR, c, l, path, n); - if (info === 'layer') return new Layer(c, l); + if (info === 'layer') return __layer(c, l); if (info === null) __err('propertyGroup(' + n + ') goes above the layer'); return __mkGroup(c, l, info); } @@ -518,10 +519,10 @@ Comp.prototype = { return __err('Composition "' + this.name + '" has no active camera'); }, layer: function (a, rel) { - if (a !== null && typeof a === 'object' && a.__isLayer) return this.layer(a.index + (rel || 0)); + if (typeof a === 'function' && a.__isLayer) return this.layer(a.index + (rel || 0)); var id = __h(__LAYER, this.__c, a); if (id === null) __err(typeof a === 'string' ? 'Layer named "' + a + '" does not exist' : 'Layer index ' + a + ' out of range'); - return new Layer(this.__c, id); + return __layer(this.__c, id); }, toString: function () { return '[object Comp]'; }, }; @@ -596,7 +597,55 @@ var thisProject = { // Layer ----------------------------------------------------------------------------------------- -function Layer(c, l) { this.__c = c; this.__l = l; this.__i = null; } +// A layer is a property group too, as in After Effects: `thisLayer("transform")`, another layer's +// `("effects")("Slider Control")`, `thisLayer(1)` (what the pick whip writes with Compact English +// off). Callable like a group (`__mkGroup`), with the Layer methods. +function Layer(c, l) { + var f = function (k) { return f.__group(k); }; + delete f.name; + delete f.length; + Object.setPrototypeOf(f, Layer.prototype); + // Kept across evaluations (`__layer`), so an expression can't change which layer it is, delete + // a field or leave its own on it. Its info is private to `__info`, which reads it again in an + // evaluation whose `__S` isn't the one it was read in (every evaluation has a new `__S`), so + // freezing the layer can't keep its info either. + var info = null, read = null; + Object.defineProperty(f, '__c', { value: c }); + Object.defineProperty(f, '__l', { value: l }); + Object.defineProperty(f, '__info', { + get: function () { + if (read !== __S) { info = __h(__LINFO, c, l); read = __S; } + return info; + }, + }); + Object.preventExtensions(f); + return f; +} + +// Layer `l` of comp `c`, made once per runtime: a callable Layer is dearer to make than a plain +// object, so it is kept, and its info (`__info`) is read again in each evaluation. +// The kept layers are out of an expression's reach (their maps have no prototype, so a change to +// `Object.prototype` doesn't reach them either), and bounded like the runtime's scripts: once +// `max` are kept, the layers after them aren't, and the next evaluation starts over +// (`__layersTrim`, so a layer stays the same object for a whole evaluation). +var __layer, __layersTrim; +(function () { + var layers = Object.create(null), kept = 0, max = 4096; + __layer = function (c, l) { + // By comp, then layer: a `c + ':' + l` key costs more to build than the lookup saves. + var m = layers[c], f = m && m[l]; + if (f) return f; + f = new Layer(c, l); + if (kept < max) { + (m || (layers[c] = Object.create(null)))[l] = f; + kept++; + } + return f; + }; + __layersTrim = function () { + if (kept >= max) { layers = Object.create(null); kept = 0; } + }; +})(); function __m3(m, p) { var x = p[0] || 0, y = p[1] || 0; @@ -620,7 +669,6 @@ function __m4(m, p, vec) { Layer.prototype = { __isLayer: true, - get __info() { return this.__i || (this.__i = __h(__LINFO, this.__c, this.__l)); }, get name() { return this.__info[0]; }, get index() { return this.__info[1]; }, get inPoint() { return this.__info[2]; }, @@ -632,7 +680,7 @@ Layer.prototype = { get parent() { var p = this.__info[7]; if (p === null) __err('Layer "' + this.name + '" has no parent'); - return new Layer(this.__c, p); + return __layer(this.__c, p); }, get hasVideo() { return this.__info[8]; }, get enabled() { return this.__info[11]; }, @@ -804,7 +852,7 @@ Prop.prototype = { propertyGroup: function (n) { return __ancestor(this.__c, this.__l, this.__p, n); }, __wrap: function (v) { var kind = this.__info[2]; - if (kind === 'layer') return v === null ? null : new Layer(this.__c, v); + if (kind === 'layer') return v === null ? null : __layer(this.__c, v); if (kind === 'path' && v !== null) return new __Path(v[0], v[1], v[2], v[3]); return v; }, @@ -834,7 +882,7 @@ Prop.prototype = { for (var i = 1; i < times.length; i++) if (Math.abs(times[i] - t) < Math.abs(times[best] - t)) best = i; return this.key(best + 1); }, - __seedIndex: function () { return this.__self ? __S.idx : new Layer(this.__c, this.__l).index; }, + __seedIndex: function () { return this.__self ? __S.idx : __layer(this.__c, this.__l).index; }, wiggle: function (freq, amp, octaves, ampMult, t) { if (octaves === undefined) octaves = 1; if (ampMult === undefined) ampMult = 0.5; @@ -903,8 +951,9 @@ var __propHandler = { var v = t.value; if (v === null || v === undefined) return undefined; var x = v[k]; - // Property groups are callable proxies: hand them over as they are, not bound like methods (#809). - if (x?.__isGroup) return x; + // Property groups and layers are callable: hand them over as they are, not bound like methods + // (#809). + if (typeof x === 'function' && (x.__isGroup || x.__isLayer)) return x; return typeof x === 'function' ? x.bind(v) : x; }, }; @@ -1082,18 +1131,18 @@ Markers.prototype = { // --------------------------------------------------------------------------------------------- // Unqualified names: thisLayer / thisProperty attributes and the `this*` objects. They're // accessors on the global object; assigning one (`var width = 10`) shadows it for the current -// evaluation only. +// evaluation only. They can't be deleted or redefined, so an expression can't take one away from +// the evaluations after it. function __global(name, get) { Object.defineProperty(globalThis, name, { - configurable: true, get: function () { return name in __S.vars ? __S.vars[name] : get(); }, set: function (v) { __S.vars[name] = v; }, }); } __global('thisComp', function () { return __S.comp || (__S.comp = new Comp(__S.c)); }); -__global('thisLayer', function () { return __S.layer || (__S.layer = new Layer(__S.c, __S.l)); }); +__global('thisLayer', function () { return __S.layer || (__S.layer = __layer(__S.c, __S.l)); }); __global('thisProperty', function () { return __S.prop || (__S.prop = __mkProp(__S.c, __S.l, __S.path)); }); ['name', 'index', 'inPoint', 'outPoint', 'startTime', 'width', 'height', 'hasParent', 'parent', 'hasVideo', 'enabled', 'active', 'transform', 'position', 'scale', 'rotation', 'opacity', 'anchorPoint', 'text', 'marker'].forEach(function (k) { @@ -1226,3 +1275,23 @@ function nearestKey(t) { return thisProperty.nearestKey(t); } delete globalThis.Proxy; delete globalThis.Reflect; })(); + +// --------------------------------------------------------------------------------------------- +// The runtime runs this file once and keeps it for the thread's later evaluations, so an +// expression can't change the object model or the internals: the prototypes are frozen, and the +// `__` globals, the constructors and `thisProject` can't be reassigned. The `__` state below is +// set again by `__begin` in each evaluation. + +[Comp, Footage, Layer, Prop, Markers, __Path, __TextStyle].forEach(function (f) { + Object.freeze(f.prototype); + Object.freeze(f); +}); +[__GroupProto, __groupHandler, __propHandler, thisProject].forEach(Object.freeze); +(function () { + var perEvaluation = { __S: true, __rc: true, __seed: true, __timeless: true, __projectInfo: true }; + var model = { Comp: true, Footage: true, Layer: true, Prop: true, Markers: true, thisProject: true }; + Object.getOwnPropertyNames(globalThis).forEach(function (k) { + if (!(k.slice(0, 2) === '__' && !perEvaluation[k]) && !model[k]) return; + if ('value' in Object.getOwnPropertyDescriptor(globalThis, k)) Object.defineProperty(globalThis, k, { writable: false, configurable: false }); + }); +})(); diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index 55720a327..a9ec582c5 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -1099,6 +1099,8 @@ fn shadowing_layer_attribute_names_is_local() { let f = fx(); assert_close!(f.num("var width = 3;\nwidth * 2", 0.0), 6.0); assert_close!(f.num("width", 0.0), 20.0, 1e-9); + assert_close!(f.num("function width() { return 4; }\nwidth() * 2", 0.0), 8.0); + assert_close!(f.num("width", 0.0), 20.0, 1e-9); } #[test] @@ -1493,4 +1495,97 @@ fn time_to_frames_preserves_coercion_and_small_durations() { } } +/// A layer is a property group too, as in After Effects: `thisLayer("transform")`, another +/// layer's `("effects")("Slider Control")` or `(1)`, as the pick whip writes them with Compact +/// English off (they failed: "not a callable function"). It is still a layer. +#[test] +fn layers_are_callable_property_groups() { + let f = fx(); + assert_close!(f.num("thisLayer(\"transform\")(\"opacity\")", 0.0), 100.0); + assert_close!(f.num("thisComp.layer(\"A\")(\"effects\")(\"ec.control.slider\")(\"slider\")", 0.0), 42.0); + assert_close!(f.num("thisComp.layer(\"A\")(\"effects\")(\"Slider Control\")(1)", 0.0), 42.0); + assert_close!(f.num("thisComp.layer(1)(\"effects\")(1)(1)", 0.0), 42.0); + // Its own name, index and methods, and a layer as the result still reads as its index. + assert_close!(f.num("thisLayer.name == \"A\" ? thisComp.layer(\"B\").index : 0", 0.0), 2.0); + assert_close!(f.num("thisComp.layer(\"B\")", 0.0), 2.0); + assert_close!(f.num("thisComp.layer(thisComp.layer(\"B\"), -1).index", 0.0), 1.0); + assert!(f.err("thisLayer(\"nope\")").contains("no property"), "{}", f.err("thisLayer(\"nope\")")); +} + +/// A layer is kept from one evaluation to the next (it is dear to make) but reads the project +/// afresh in each, and an expression's own fields on it don't reach the next one. +#[test] +fn a_kept_layer_reads_the_project_again() { + let mut f = fx(); + let first = "thisComp.layer(\"B\").index * 10 + thisComp.layer(2).name.length"; + assert_close!(f.num(first, 0.0), 21.0); + f.layer_mut(f.b).name = "Bee".into(); + f.p.comp_mut(f.cid).unwrap().layers.swap(0, 1); + assert_close!(f.num("thisComp.layer(\"Bee\").index * 10 + thisComp.layer(2).name.length", 0.0), 11.0); + assert_close!(f.num("thisLayer.index", 0.0), 2.0); + let _ = f.ev(f.a, ROT, "thisLayer.mine = 5; 0", 0.0); + assert_close!(f.num("thisLayer.mine === undefined ? 1 : 0", 0.0), 1.0); + let _ = f.ev(f.a, ROT, "var L = thisLayer; L.__c = 'x'; L.__l = 99; L.__i = ['Z', 7]; L.__s = __S; 0", 0.0); + assert_close!(f.num("thisLayer.index * 10 + thisLayer.name.length", 0.0), 21.0); + let _ = f.ev(f.a, ROT, "var L = thisLayer; delete L.__c; delete L.__l; delete L.__i; delete L.__s; 0", 0.0); + assert_close!(f.num("thisLayer.index * 10 + thisLayer.name.length", 0.0), 21.0); + let _ = f.ev(f.a, ROT, "Object.freeze(thisLayer); 0", 0.0); + f.layer_mut(f.a).name = "Ay".into(); + f.p.comp_mut(f.cid).unwrap().layers.swap(0, 1); + assert_close!(f.num("thisLayer.index * 10 + thisLayer.name.length", 0.0), 12.0); +} + +/// The kept layers are bounded: past the bound a layer isn't kept, an evaluation that makes more +/// layers than are kept still sees one object for each layer it had kept, and the evaluations +/// after it, which start over, read the right layers. +#[test] +fn the_kept_layers_are_bounded() { + let f = fx(); + let same = "(thisLayer === thisComp.layer(thisLayer.index) ? 1 : 0)"; + let many = + format!("var c = thisLayer.__c;\nfor (var i = 0; i < 5000; i++) __layer(c, 100000 + i);\n{same} * 10 + (__layer(c, 1e6) === __layer(c, 1e6) ? 0 : 1)"); + assert_close!(f.num(&many, 0.0), 11.0); + assert_close!(f.num(&format!("{same} * 100 + thisLayer.index * 10 + thisLayer.name.length"), 0.0), 111.0); + assert_close!(f.num(&format!("{same} * 100 + thisLayer.index * 10 + thisLayer.name.length"), 0.0), 111.0); +} + +/// A change to `Object.prototype` doesn't reach the kept layers: a frozen object put there under +/// the comp's id, before any of its layers is kept, doesn't stop them being kept. +#[test] +fn the_kept_layers_dont_go_through_object_prototype() { + let f = fx(); + let _ = f.ev(f.a, ROT, "try { Object.prototype[thisComp.__c] = Object.freeze({}); } catch (e) {} 0", 0.0); + assert_close!(f.num("thisLayer === thisComp.layer(thisLayer.index) ? 1 : 0", 0.0), 1.0); +} + +/// The prelude is kept from one evaluation to the next, so an expression can't change its object +/// model or internals for the ones after it. +#[test] +fn an_expression_cant_change_the_prelude_for_the_next() { + let f = fx(); + let read = "[thisComp.layer(1).index, thisLayer.index, thisLayer('transform').property('rotation').value, thisLayer.transform.opacity.value, thisProject.bitsPerChannel, comp(thisComp.name).numLayers, width]"; + let before = f.ev(f.a, ROT, read, 0.5).unwrap(); + let attacks = [ + "Comp.prototype.layer = function () { return 0; }", + "Object.getPrototypeOf(thisLayer).index = 0", + "Object.getPrototypeOf(thisLayer('transform')).property = function () { return 0; }", + "Object.getPrototypeOf(thisLayer.transform.opacity).value = 0", + "Comp = function () {}", + "Prop = function () {}", + "thisProject = null", + "__h = function () { return null; }", + "__layer = function () { return null; }", + "__child = function () {}", + "delete __mkProp", + "delete thisLayer", + "delete thisComp", + "delete width", + "Object.defineProperty(globalThis, 'thisComp', { get: function () { return 0; } })", + ]; + for a in attacks { + let _ = f.ev(f.a, ROT, &format!("try {{ {a} }} catch (e) {{}} 0"), 0.0); + assert_eq!(f.ev(f.a, ROT, read, 0.5).unwrap(), before, "after `{a}`"); + } +} + mod property_group; diff --git a/crates/expr/src/tests/property_group.rs b/crates/expr/src/tests/property_group.rs index debe5e5ac..c96045ec2 100644 --- a/crates/expr/src/tests/property_group.rs +++ b/crates/expr/src/tests/property_group.rs @@ -72,6 +72,9 @@ fn property_group_reaches_the_layer() { assert_close!(f.num("thisProperty.propertyGroup(2).name == 'A' ? 1 : 0", 0.0), 1.0); assert_close!(f.num("thisProperty.propertyGroup(2).index", 0.0), 1.0); assert_close!(f.num("thisProperty.propertyGroup(1).propertyGroup(1).name == 'A' ? 1 : 0", 0.0), 1.0); + // The layer it reaches is the same object as `thisLayer`. + assert_close!(f.num("thisLayer('transform').propertyGroup(1) === thisLayer ? 1 : 0", 0.0), 1.0); + assert_close!(f.num("thisProperty.propertyGroup(2) === thisLayer ? 1 : 0", 0.0), 1.0); let s = staggered(); assert_close!(s.shape_num(SCALE_2, "thisProperty.propertyGroup(4).name == 'Shape' ? 1 : 0"), 1.0); } diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index 645f1cc39..5a2b80f1c 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -22,6 +22,20 @@ pub(crate) struct GBuf { } impl GBuf { + /// [`effectcraft_effects::Buf::cover`] on the device; `None` when it can't hold it. + pub fn cover(&mut self, e: &mut Enc, size: [f64; 2]) -> Option<()> { + let Some((dx, dy, w, h)) = effectcraft_effects::cover_plan(self.offset, self.scale, size, self.img.width, self.img.height) else { return Some(()) }; + if !e.g.fits(w, h) { + return None; + } + let out = e.image(w, h); + e.copy_into(&self.img, &out, dx, dy); + self.img = out; + self.offset[0] += dx as f64; + self.offset[1] += dy as f64; + Some(()) + } + pub fn to_px(&self, p: [f64; 2]) -> (f64, f64) { (p[0] * self.scale + self.offset[0], p[1] * self.scale + self.offset[1]) } @@ -167,12 +181,23 @@ impl effectcraft_render::FxTarget for GpuFx<'_, '_> { /// GBuf::cover, like `pad`: a bigger texture with the old one copied into place, no /// readback. A region the device can't allocate leaves the buffer as it is. fn cover(&mut self, size: [f64; 2]) { + let Some(mut b) = self.b.take() else { return }; + let keep = (b.img.clone(), b.offset); + if b.cover(self.e, size).is_none() { + (b.img, b.offset) = keep; + } + self.b = Some(b); + } + + /// [`effectcraft_effects::Buf::align`] on the device: a texture grown left/top, the old one + /// copied in. A size the device can't allocate leaves the buffer as it is. + fn align(&mut self, size: [f64; 2]) { let Some(b) = self.b.take() else { return }; - let out = match effectcraft_effects::cover_plan(b.offset, b.scale, size, b.img.width, b.img.height) { - Some((dx, dy, w, h)) if self.e.g.fits(w, h) => { - let img = self.e.image(w, h); - self.e.copy_into(&b.img, &img, dx, dy); - GBuf { img, offset: [b.offset[0] + dx as f64, b.offset[1] + dy as f64], scale: b.scale } + let out = match effectcraft_effects::align_plan(b.offset, b.scale, size, b.img.width, b.img.height) { + Some((l, t)) if self.e.g.fits(b.img.width + l, b.img.height + t) => { + let img = self.e.image(b.img.width + l, b.img.height + t); + self.e.copy_into(&b.img, &img, l, t); + GBuf { img, offset: [b.offset[0] + l as f64, b.offset[1] + t as f64], scale: b.scale } } _ => b, }; @@ -292,6 +317,9 @@ fn gaussian(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { let repeat = ctx.params.b("repeatEdge"); if !repeat && !ctx.adjustment { b.pad(e, (s * 3.0).ceil() as u32)?; + } else if repeat && !ctx.adjustment { + // The edge it repeats is the layer's, not the painted content's (#1205). + b.cover(e, ctx.layer_size)?; } b.img = gaussian_blur(e, &b.img, s * kx, s * ky, repeat || ctx.adjustment); Some(b) @@ -312,6 +340,9 @@ fn box_blur(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { let repeat = ctx.params.b("repeatEdge"); if !repeat && !ctx.adjustment { b.pad(e, (r * it) as u32 + 1)?; + } else if repeat && !ctx.adjustment { + // The edge it repeats is the layer's, not the painted content's (#1205). + b.cover(e, ctx.layer_size)?; } b.img = box_passes(e, &b.img, &vec![r * kx as usize; it], &vec![r * ky as usize; it], repeat || ctx.adjustment); Some(b) diff --git a/crates/gpu/src/fx_distort.rs b/crates/gpu/src/fx_distort.rs index ce7133822..510f91e8c 100644 --- a/crates/gpu/src/fx_distort.rs +++ b/crates/gpu/src/fx_distort.rs @@ -180,6 +180,9 @@ fn turbulent(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { let size = (ctx.params.f("size") * b.scale).max(1.0); if ctx.params.b("resizeLayer") && !ctx.adjustment { b.pad(e, amount.abs().ceil() as u32 + 1)?; + } else { + // Room for pixels displaced past the content, up to the layer's edge (#1205). + b.pad(e, effectcraft_effects::cover_pad(ctx, [b.img.width, b.img.height], b.offset, b.scale, amount.abs().ceil() as u32 + 1))?; } let off = b.to_px(ctx.params.v2("offset")); let oct = ctx.params.f("complexity").clamp(1.0, 10.0); diff --git a/crates/gpu/src/shaders/fx_sim.wgsl b/crates/gpu/src/shaders/fx_sim.wgsl index d936861d3..8ef3035fe 100644 --- a/crates/gpu/src/shaders/fx_sim.wgsl +++ b/crates/gpu/src/shaders/fx_sim.wgsl @@ -180,7 +180,7 @@ fn fxm_bubble(at: u32, x: f32, y: f32) -> FxmShade { } let k = select(-0.7, 1.6, P.u[1].y != 0u); let nz = sqrt(max(1.0 - d2, 0.0)); - let s = fxm_unpremul(sample_bilinear(src, data[at + 4u] + dx * qr * k, data[at + 5u] + dy * qr * k)); + let s = fxm_unpremul(sample_bilinear_clamped(src, data[at + 4u] + dx * qr * k, data[at + 5u] + dy * qr * k)); var c = s.xyz; if (s.w <= 0.0) { c = vec3(0.6, 0.7, 0.8); diff --git a/crates/interchange/src/export.rs b/crates/interchange/src/export.rs index 7da18913a..cc44475f3 100644 --- a/crates/interchange/src/export.rs +++ b/crates/interchange/src/export.rs @@ -155,12 +155,7 @@ fn unsupported(project: &Project, comp: &Comp, l: &Layer, format: TimelineFormat return Some(format!("blend mode {}", l.blend_mode.label())); } // Premiere's Basic Motion in the interchange formats has one (uniform) scale. - if let Some(s) = l.transform().and_then(|t| t.get("scale")) - && std::iter::once(&s.value).chain(s.keys.iter().map(|k| &k.value)).any(|v| { - let a = v.as_vec3(); - (a[0] - a[1]).abs() > 1e-9 - }) - { + if l.transform().and_then(|t| t.get("scale")).is_some_and(non_uniform_scale) { return Some("non-uniform scale".into()); } if l.preserve_transparency { @@ -581,10 +576,7 @@ impl Exporter<'_> { } } if let Some(p) = tr.get("scale") { - let uniform = std::iter::once(&p.value).chain(p.keys.iter().map(|k| &k.value)).all(|v| { - let a = v.as_vec3(); - (a[0] - a[1]).abs() < 1e-9 - }); + let uniform = !non_uniform_scale(p); m.params.insert("scale".into(), param_out(p, &to_clip, |v| fp::ParamValue::Float(v.as_vec3()[1]))); if !uniform { m.params.insert("scale_width".into(), param_out(p, &to_clip, |v| fp::ParamValue::Float(v.as_vec3()[0]))); @@ -636,6 +628,16 @@ fn differs(v: &Value, def: [f64; 2]) -> bool { (a[0] - def[0]).abs() > 1e-6 || (a[1] - def[1]).abs() > 1e-6 } +/// Whether a scale property is non-uniform at some point: active key values, or the static +/// value when every key is muted (muted keys are not exported, #1894). +fn non_uniform_scale(p: &Property) -> bool { + let non_uniform = |v: &Value| { + let a = v.as_vec3(); + (a[0] - a[1]).abs() > 1e-9 + }; + if p.keys.iter().any(|k| !k.muted) { p.keys.iter().filter(|k| !k.muted).any(|k| non_uniform(&k.value)) } else { non_uniform(&p.value) } +} + /// The clip's `id` effect, or a new instance of it (None if the registry has no such effect). fn effect_or_new(ti: &fp::TrackItem, id: &str) -> Option { ti.effect(id).cloned().or_else(|| fp::find_effect(id).map(|e| e.instance())) @@ -651,7 +653,9 @@ fn set_effect(ti: &mut fp::TrackItem, e: fp::EffectInstance) { /// A property → a FilmCraft parameter (layer-time keys → clip-time keys). fn param_out(p: &Property, to_clip: &impl Fn(Tick) -> filmcraft_time::Tick, conv: impl Fn(&Value) -> fp::ParamValue) -> fp::Param { let mut out = fp::Param::new(conv(&p.value)); - for k in &p.keys { + // XML timelines have no mute state: leave muted keys out, as the Lottie exporter does, so + // the exported animation matches what the renderer shows (#1894). + for k in p.keys.iter().filter(|k| !k.muted) { let mut fk = fp::Keyframe::new(to_clip(k.time), conv(&k.value)); fk.interp = match k.out_interp { Interp::Linear => fp::Interpolation::Linear, diff --git a/crates/interchange/src/tests.rs b/crates/interchange/src/tests.rs index 1e0c7d417..2fe36d47b 100644 --- a/crates/interchange/src/tests.rs +++ b/crates/interchange/src/tests.rs @@ -480,6 +480,59 @@ fn tr_orig(l: &Layer) -> &effectcraft_project::Property { l.transform().unwrap().get("position").unwrap() } +/// #1894: XML timelines have no mute state, so a muted key must not be written as a keyframe +/// (the export would otherwise animate through it). +#[test] +fn export_skips_muted_keyframes() { + let mut p = Project::default(); + let a = footage_item(&mut p, "/media/a.mov"); + let mut c = Comp::new(W, H, FrameRate::new(25, 1), t(4.0)); + let mut l = effectcraft_project::build::layer(&mut p, &c, "clip", LayerSource::Footage { item: a }, (W, H), None); + { + let pos = l.transform_mut().unwrap().get_mut("position").unwrap(); + pos.keys = vec![ + Keyframe::new(t(0.0), Value::Vec3([100.0, 120.0, 0.0])), + Keyframe::new(t(1.0), Value::Vec3([500.0, 240.0, 0.0])), + Keyframe::new(t(2.0), Value::Vec3([300.0, 60.0, 0.0])), + ]; + pos.keys[1].muted = true; + } + c.layers.push(l); + let cid = comp_item(&mut p, "Main", c); + let out = export(&p, cid, &ExportOptions { format: TimelineFormat::Fcp7Xml, ..Default::default() }, &HashMap::new()).unwrap(); + let (q, r) = import_doc(&out.bytes, "Main.xml", &mut probe); + let main = r.comps.iter().copied().find(|c| q.item(*c).unwrap().name == "Main").expect("Main"); + let pos = layer(q.comp(main).unwrap(), "clip").transform().unwrap().get("position").unwrap(); + assert_eq!(pos.keys.len(), 2, "the muted key was exported"); + assert_eq!(pos.keys[0].value.as_vec3()[0], 100.0); + assert_eq!(pos.keys[1].value.as_vec3()[0], 300.0); +} + +/// #1894: a muted non-uniform scale key must not force a uniform layer to pre-render; only the +/// active keys matter, and the static value when every key is muted. +#[test] +fn muted_non_uniform_scale_does_not_force_prerendering() { + let mut p = Project::default(); + let a = footage_item(&mut p, "/media/a.mov"); + let mut c = Comp::new(W, H, FrameRate::new(25, 1), t(2.0)); + let mut l = effectcraft_project::build::layer(&mut p, &c, "clip", LayerSource::Footage { item: a }, (W, H), None); + { + let scale = l.transform_mut().unwrap().get_mut("scale").unwrap(); + scale.keys = vec![Keyframe::new(t(0.0), Value::Vec3([100.0, 100.0, 100.0])), Keyframe::new(t(1.0), Value::Vec3([50.0, 200.0, 100.0]))]; + scale.keys[1].muted = true; + } + let lid = l.id; + c.layers.push(l); + let cid = comp_item(&mut p, "Main", c); + assert_eq!(prerender_reason(&p, cid, lid, &ExportOptions::default()), None); + // With every key muted the static value drives evaluation, so a non-uniform static scale + // still takes the flattened path. + let scale = p.comp_mut(cid).unwrap().layer_mut(lid).unwrap().transform_mut().unwrap().get_mut("scale").unwrap(); + scale.keys[0].muted = true; + scale.value = Value::Vec3([50.0, 200.0, 100.0]); + assert_eq!(prerender_reason(&p, cid, lid, &ExportOptions::default()).as_deref(), Some("non-uniform scale")); +} + #[test] fn export_without_prerender_leaves_text_out() { let (p, cid) = ae_project(); diff --git a/crates/keyframe/src/lib.rs b/crates/keyframe/src/lib.rs index f0dd4849c..abf929fe9 100644 --- a/crates/keyframe/src/lib.rs +++ b/crates/keyframe/src/lib.rs @@ -15,6 +15,7 @@ pub mod value; use effectcraft_time::{TICKS_PER_SECOND, Tick}; use serde::{Deserialize, Serialize}; +use std::borrow::Borrow; pub use text_doc::{BaselineOption, CharStyle, Composer, Direction, FigureStyle, FigureWidth, Kerning, LineJoin, OpenType, ParaStyle, StyleRun}; pub use value::{FeatherPoint, Gradient, Justify, ShapePath, TextDoc, Value}; @@ -89,12 +90,19 @@ pub struct Keyframe { /// Keyframe color label: index into the label list (0 = None). #[serde(default, skip_serializing_if = "is_zero")] pub label: u8, + /// Muted keys stay in the list (and the UI) but evaluation skips them. + #[serde(default, skip_serializing_if = "is_false")] + pub muted: bool, } fn is_zero(v: &u8) -> bool { *v == 0 } +fn is_false(v: &bool) -> bool { + !*v +} + fn yes() -> bool { true } @@ -116,6 +124,7 @@ impl Keyframe { spatial_continuous: false, roving: false, label: 0, + muted: false, } } pub fn hold(mut self) -> Keyframe { @@ -320,16 +329,16 @@ fn v3(v: &Value) -> [f64; 3] { /// tangent is a sixth of the way from the previous key to the next, the in tangent its mirror, and /// key times play no part. An end key uses its one neighbour, so the path leaves the first key /// and enters the last one toward the neighbouring key instead of with no tangent. -fn auto_spatial(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) { +fn auto_spatial>(keys: &[K], i: usize) -> ([f64; 3], [f64; 3]) { if i >= keys.len() || keys.len() < 2 { return ([0.0; 3], [0.0; 3]); } - let a = v3(&keys[i.saturating_sub(1)].value); - let c = v3(&keys[(i + 1).min(keys.len() - 1)].value); + let a = v3(&keys[i.saturating_sub(1)].borrow().value); + let c = v3(&keys[(i + 1).min(keys.len() - 1)].borrow().value); let tout = [(c[0] - a[0]) / 6.0, (c[1] - a[1]) / 6.0, (c[2] - a[2]) / 6.0]; // A side facing a key at the same position gets no tangent, so the segment between two // identical keys has zero length and the layer holds still (#1675). - let p = v3(&keys[i].value); + let p = v3(&keys[i].borrow().value); let tin = if i > 0 && a == p { [0.0; 3] } else { [-tout[0], -tout[1], -tout[2]] }; let tout = if i + 1 < keys.len() && c == p { [0.0; 3] } else { tout }; (tin, tout) @@ -337,7 +346,12 @@ fn auto_spatial(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) { /// Effective spatial tangents of key `i` (auto or stored). pub fn spatial_tangents(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) { - let Some(key) = keys.get(i) else { return ([0.0; 3], [0.0; 3]) }; + tangents_of(keys, i) +} + +/// [`spatial_tangents`] over stored keys or a filtered list of them (see [`evaluate`]). +fn tangents_of>(keys: &[K], i: usize) -> ([f64; 3], [f64; 3]) { + let Some(key) = keys.get(i).map(Borrow::borrow) else { return ([0.0; 3], [0.0; 3]) }; if key.spatial_auto { auto_spatial(keys, i) } else { (key.spatial_in, key.spatial_out) } } @@ -373,18 +387,18 @@ pub fn spatial_handles(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) { } /// Effective temporal ease of key `i` for dimension `d` (auto-Bezier keys get the neighbour slope). -fn effective_ease(keys: &[Keyframe], i: usize, d: usize, spatial: bool, out: bool) -> Ease { - let k = &keys[i]; +fn effective_ease>(keys: &[K], i: usize, d: usize, spatial: bool, out: bool) -> Ease { + let k = keys[i].borrow(); let list = if out { &k.out_ease } else { &k.in_ease }; if k.auto_bezier && i > 0 && i + 1 < keys.len() { - let dt = secs(keys[i + 1].time) - secs(keys[i - 1].time); + let dt = secs(keys[i + 1].borrow().time) - secs(keys[i - 1].borrow().time); let slope = if spatial { - let a = v3(&keys[i - 1].value); - let c = v3(&keys[i + 1].value); + let a = v3(&keys[i - 1].borrow().value); + let c = v3(&keys[i + 1].borrow().value); ((c[0] - a[0]).powi(2) + (c[1] - a[1]).powi(2) + (c[2] - a[2]).powi(2)).sqrt() / dt.max(1e-12) } else { - let a = keys[i - 1].value.components(); - let c = keys[i + 1].value.components(); + let a = keys[i - 1].borrow().value.components(); + let c = keys[i + 1].borrow().value.components(); (c.get(d).unwrap_or(&0.0) - a.get(d).unwrap_or(&0.0)) / dt.max(1e-12) }; return Ease { speed: slope, influence: 1.0 / 6.0 }; @@ -508,23 +522,39 @@ pub fn time_reverse(keys: &mut [Keyframe]) { } /// Evaluate a keyframed property at `t`. `spatial` selects motion-path semantics for 2D/3D values. +/// Muted keys are skipped, so the active neighbours interpolate as if they were not there; `None` +/// means every key is muted (the property's static value applies). pub fn evaluate(keys: &[Keyframe], t: Tick, spatial: bool) -> Option { - let first = keys.first()?; + if !keys.iter().any(|k| k.muted) { + return evaluate_active(keys, t, spatial); + } + // Muting is rare: skip the muted keys by reference rather than copying every key and value. + let active: Vec<&Keyframe> = keys.iter().filter(|k| !k.muted).collect(); + evaluate_active(&active, t, spatial) +} + +fn evaluate_active>(keys: &[K], t: Tick, spatial: bool) -> Option { + let first = keys.first()?.borrow(); if keys.len() == 1 || t <= first.time { return Some(first.value.clone()); } - let last = keys.last()?; + let last = keys.last()?.borrow(); if t >= last.time { return Some(last.value.clone()); } - let i = keys.partition_point(|k| k.time <= t) - 1; - Some(segment_value(keys, i, t, spatial)) + let i = keys.partition_point(|k| k.borrow().time <= t) - 1; + Some(segment_value_of(keys, i, t, spatial)) } /// Value inside segment `i` → `i+1` at time `t`. pub fn segment_value(keys: &[Keyframe], i: usize, t: Tick, spatial: bool) -> Value { - let a = &keys[i]; - let b = &keys[i + 1]; + segment_value_of(keys, i, t, spatial) +} + +/// [`segment_value`] over stored keys or a filtered list of them (see [`evaluate`]). +fn segment_value_of>(keys: &[K], i: usize, t: Tick, spatial: bool) -> Value { + let a = keys[i].borrow(); + let b = keys[i + 1].borrow(); if a.out_interp == Interp::Hold || !a.value.interpolates() { return a.value.clone(); } @@ -539,8 +569,8 @@ pub fn segment_value(keys: &[Keyframe], i: usize, t: Tick, spatial: bool) -> Val if is_spatial { let p0 = v3(&a.value); let p3 = v3(&b.value); - let (_, out_t) = spatial_tangents(keys, i); - let (in_t, _) = spatial_tangents(keys, i + 1); + let (_, out_t) = tangents_of(keys, i); + let (in_t, _) = tangents_of(keys, i + 1); let seg = SpatialSeg::new([p0, [p0[0] + out_t[0], p0[1] + out_t[1], p0[2] + out_t[2]], [p3[0] + in_t[0], p3[1] + in_t[1], p3[2] + in_t[2]], p3]); let len = seg.length(); let f = if lin_out && lin_in || len <= 1e-12 { @@ -617,6 +647,7 @@ pub fn set_key(keys: &mut Vec, key: Keyframe) -> usize { k.spatial_continuous = old.spatial_continuous; k.roving = old.roving; k.label = old.label; + k.muted = old.muted; keys[i] = k; i } diff --git a/crates/keyframe/src/tests.rs b/crates/keyframe/src/tests.rs index 0a31cfc19..312703239 100644 --- a/crates/keyframe/src/tests.rs +++ b/crates/keyframe/src/tests.rs @@ -395,3 +395,59 @@ proptest::proptest! { proptest::prop_assert!((-1e-9..=10.0 + 1e-9).contains(&v)); } } + +#[test] +fn muted_keys_are_skipped_by_evaluation() { + let mut keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(1.0), Value::Scalar(100.0)), Keyframe::new(s(2.0), Value::Scalar(300.0))]; + let at = |keys: &[Keyframe], t: f64| evaluate(keys, s(t), false).unwrap().as_f64(); + assert!((at(&keys, 0.5) - 50.0).abs() < 1e-9); + assert!((at(&keys, 1.5) - 200.0).abs() < 1e-9); + keys[1].muted = true; + // The 0 → 300 segment now spans 0 → 2 s. + assert!((at(&keys, 0.5) - 75.0).abs() < 1e-9); + assert!((at(&keys, 1.0) - 150.0).abs() < 1e-9); + assert!((at(&keys, 1.5) - 225.0).abs() < 1e-9); + for k in &mut keys { + k.muted = true; + } + assert!(evaluate(&keys, s(0.5), false).is_none()); +} + +#[test] +fn muted_key_automation_uses_the_active_neighbours() { + let mut keys = vec![ + Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0])), + Keyframe::new(s(1.0), Value::Vec2([50.0, 100.0])), + Keyframe::new(s(2.0), Value::Vec2([100.0, 0.0])), + ]; + for k in &mut keys { + k.auto_bezier = true; + k.in_interp = Interp::Bezier; + k.out_interp = Interp::Bezier; + } + let all = keys.clone(); + let mut active = keys.clone(); + active.remove(1); + keys[1].muted = true; + for t in [0.0, 0.25, 0.5, 1.0, 1.5, 1.75, 2.0] { + assert_eq!(evaluate(&keys, s(t), true), evaluate(&active, s(t), true), "{t}"); + } + // The muted auto-Bezier key no longer bulges the motion path. + assert_ne!(evaluate(&keys, s(0.5), true), evaluate(&all, s(0.5), true)); +} + +#[test] +fn mute_round_trips_and_survives_value_replacement() { + let mut k = Keyframe::new(s(0.0), Value::Scalar(1.0)); + let json = serde_json::to_value(&k).unwrap(); + assert!(json.get("muted").is_none(), "false is the default and is skipped"); + k.muted = true; + let json = serde_json::to_value(&k).unwrap(); + assert_eq!(json["muted"], serde_json::json!(true)); + let back: Keyframe = serde_json::from_value(json).unwrap(); + assert!(back.muted); + let mut keys = vec![k]; + set_key(&mut keys, Keyframe::new(s(0.0), Value::Scalar(2.0))); + assert!(keys[0].muted); + assert_eq!(keys[0].value, Value::Scalar(2.0)); +} diff --git a/crates/lottie/src/anim.rs b/crates/lottie/src/anim.rs index e3264e3b7..4925460a9 100644 --- a/crates/lottie/src/anim.rs +++ b/crates/lottie/src/anim.rs @@ -73,9 +73,22 @@ fn is_spatial(p: &Property) -> bool { } /// Export a property as a Lottie animatable property (`{a, k}` plus `x` for expressions). -/// `label` names the property in warnings. +/// `label` names the property in warnings. Muted keys are omitted: Lottie has no mute state, and +/// the remaining keys carry the animation the renderer shows. pub(crate) fn export_prop(ex: &mut Ex, p: &Property, conv: &dyn Fn(&Value) -> Json, label: &str) -> Json { - let mut out = if p.keys.is_empty() { json!({"a": 0, "k": conv(&p.value)}) } else { json!({"a": 1, "k": export_keys(ex, p, conv)}) }; + let muted = p.keys.iter().any(|k| k.muted); + if muted { + ex.warn(format!("{label}: muted keyframes are not exported")); + } + let active: Vec; + let keys: &[Keyframe] = if muted { + active = p.keys.iter().filter(|k| !k.muted).cloned().collect(); + &active + } else { + &p.keys + }; + let mut out = + if keys.is_empty() { json!({"a": 0, "k": conv(&p.value)}) } else { json!({"a": 1, "k": export_keys(ex, keys, is_spatial(p), p.hold_only, conv)}) }; if let Some(e) = p.expr.as_ref().filter(|e| e.enabled && !e.text.trim().is_empty()) { if ex.include_expressions { out["x"] = json!(e.text); @@ -86,20 +99,18 @@ pub(crate) fn export_prop(ex: &mut Ex, p: &Property, conv: &dyn Fn(&Value) -> Js out } -fn export_keys(ex: &Ex, p: &Property, conv: &dyn Fn(&Value) -> Json) -> Vec { - let keys = &p.keys; - let spatial = is_spatial(p); +fn export_keys(ex: &Ex, keys: &[Keyframe], spatial: bool, hold_only: bool, conv: &dyn Fn(&Value) -> Json) -> Vec { let mut out = Vec::with_capacity(keys.len()); for (i, k) in keys.iter().enumerate() { let mut o = json!({"t": r6(ex.tb.frame(k.time)), "s": wrap(conv(&k.value))}); if i + 1 == keys.len() { - if k.out_interp == Interp::Hold || p.hold_only { + if k.out_interp == Interp::Hold || hold_only { o["h"] = json!(1); } out.push(o); continue; } - if k.out_interp == Interp::Hold || p.hold_only || !k.value.interpolates() { + if k.out_interp == Interp::Hold || hold_only || !k.value.interpolates() { o["h"] = json!(1); out.push(o); continue; diff --git a/crates/lottie/src/tests.rs b/crates/lottie/src/tests.rs index 31f3a70bb..203f04c04 100644 --- a/crates/lottie/src/tests.rs +++ b/crates/lottie/src/tests.rs @@ -878,3 +878,21 @@ fn external_images_are_local_or_skipped() { assert_eq!(std::path::Path::new(&paths[0]), base.join("images").join("a.png")); assert_eq!(r.warnings.iter().filter(|w| w.contains("is not imported")).count(), assets.len() - 1, "{:?}", r.warnings); } + +#[test] +fn muted_keys_are_omitted_from_the_export() { + let (mut p, cid) = setup(); + let mut l = solid_layer(&mut p, cid, [0.0, 1.0, 0.0], 20, 20); + let mut b = Keyframe::new(s(1.0), Value::Scalar(100.0)); + b.muted = true; + l.props.prop_mut("transform/opacity").unwrap().keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), b, Keyframe::new(s(2.0), Value::Scalar(300.0))]; + push(&mut p, cid, l); + let (q, nc, warnings, json) = roundtrip(&p, cid, &ExportOptions::default()); + assert!(warnings.iter().any(|w| w.contains("muted keyframes")), "{warnings:?}"); + // Lottie has no mute state: the two active keys carry the 0 → 300 animation. + assert_eq!(json["layers"][0]["ks"]["o"]["k"].as_array().unwrap().len(), 2); + assert_eq!(json["layers"][0]["ks"]["o"]["k"][1]["s"], json!([300.0])); + let opacity_at = |p: &Project, cid: ItemId, t: f64| p.comp(cid).unwrap().layers[0].props.prop("transform/opacity").unwrap().value_at(s(t)).as_f64(); + assert!((opacity_at(&p, cid, 1.0) - 150.0).abs() < 1e-6); + assert!((opacity_at(&q, nc, 1.0) - 150.0).abs() < 1e-6); +} diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index f695ff0d0..41b50b0f4 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -23,6 +23,7 @@ pub fn probe(path: impl AsRef) -> Result { let turn = header.as_deref().map_or(Orientation::NoTransforms, video_turn); turn_footage(&mut f, turn); correct_rate(&mut f, header.as_deref().and_then(video_rate)); + trim_to_edits(&mut f, header.as_deref().and_then(video_shown)); return Ok(f); } let bytes = crate::read_regular_file(path).map_err(|e| MediaError::Io(format!("{}: {e}", path.display())))?; @@ -107,6 +108,7 @@ pub fn probe_bytes(path: &str, bytes: Arc<[u8]>) -> Result { let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()); let turn = video_turn(&bytes); let rate = video_rate(&bytes); + let shown = video_shown(&bytes); // A WAV's cue points and XMP markers become the footage's markers. let markers = crate::wav_markers::read(&bytes); let src = filmcraft_codecs::open_bytes(&name, bytes)?; @@ -114,9 +116,37 @@ pub fn probe_bytes(path: &str, bytes: Arc<[u8]>) -> Result { f.markers = markers; turn_footage(&mut f, turn); correct_rate(&mut f, rate); + trim_to_edits(&mut f, shown); Ok(f) } +/// A movie lasts as long as its video edit list shows (#2592): the reader reports the media +/// duration (`mdhd`), which for H.264 with B-frames runs past the last shown frame by the +/// encoder delay the edit list trims (75 frames at 25 fps read as 3.04 s). Never lengthens. +fn trim_to_edits(f: &mut Footage, shown: Option) { + if f.kind == FootageKind::Video + && let Some(shown) = shown.filter(|s| *s > Tick::ZERO && *s < f.duration) + { + f.duration = shown; + } +} + +/// How long the video track's edit list shows its media (`elst` segments, movie timescale): +/// `None` without edits, or with an empty edit (a delayed start keeps the reader's duration). +fn video_shown(bytes: &[u8]) -> Option { + let file = filmcraft_isobmff::open(bytes).ok()?; + let t = file.tracks.iter().find(|t| t.kind == filmcraft_isobmff::TrackKind::Video && !t.samples.is_empty())?; + edits_shown(&t.edits, file.timescale) +} + +fn edits_shown(edits: &[filmcraft_isobmff::Edit], timescale: u32) -> Option { + if edits.is_empty() || timescale == 0 || edits.iter().any(|e| e.media_time < 0) { + return None; + } + let units = edits.iter().try_fold(0u64, |acc, e| acc.checked_add(e.segment_duration))?; + Some(Tick::from_units(i64::try_from(units).ok()?, i64::from(timescale))) +} + /// A movie's frame rate as its frame durations average to ([`video_rate`]), where the reader's /// own estimate misreads it. fn correct_rate(f: &mut Footage, rate: Option) { @@ -322,6 +352,24 @@ fn averaged_rate(timescale: u32, durations: impl Iterator) -> Option mod tests { use super::*; + /// #2592: an encoder-delayed H.264 track's edit list shows 75 frames at 25 fps (3 s at a + /// 1000 timescale), though its media runs a frame longer; empty edits and no edits leave it. + #[test] + fn the_edit_list_trims_a_movie_to_the_frames_it_shows() { + let edit = |segment_duration, media_time| filmcraft_isobmff::Edit { segment_duration, media_time, media_rate: 0x0001_0000 }; + let shown = edits_shown(&[edit(3000, 1024)], 1000); + assert_eq!(shown, Some(Tick::from_units(3, 1))); + let mut f = Footage { kind: FootageKind::Video, duration: Tick::from_units(76, 25), ..Footage::default() }; + trim_to_edits(&mut f, shown); + assert_eq!(f.duration, Tick::from_units(75, 25)); + // Never longer than the reader's duration, and only for video. + trim_to_edits(&mut f, Some(Tick::from_units(4, 1))); + assert_eq!(f.duration, Tick::from_units(75, 25)); + assert_eq!(edits_shown(&[edit(1000, -1), edit(3000, 0)], 1000), None); + assert_eq!(edits_shown(&[], 1000), None); + assert_eq!(edits_shown(&[edit(3000, 0)], 0), None); + } + /// Durations (in 1/`timescale` s) of `n` frames at `rate` whose times are rounded to the timescale. fn rounded(rate: FrameRate, timescale: i64, n: i64) -> Vec { let at = |k: i64| (2 * k * timescale * rate.den + rate.num) / (2 * rate.num); diff --git a/crates/project/src/build.rs b/crates/project/src/build.rs index c4f8ffa26..3ff61ba7d 100644 --- a/crates/project/src/build.rs +++ b/crates/project/src/build.rs @@ -596,6 +596,22 @@ pub fn shape_simple_op(ids: &mut Ids, kind: &str) -> Option { .with(ids.prop("phase", "Temporal Phase", Value::Scalar(0.0)).with_ui(ParamUi::Angle)) .with(wiggle_spatial_phase(ids)) .with(ids.prop("seed", "Random Seed", Value::Scalar(0.0))), + "wiggler" | "wiggleTransform" => { + // The Transform group holds how far each transformation wiggles. + let tr = ids + .group("transform", "Transform") + .with(ids.prop("anchor", "Anchor Point", Value::Vec2([0.0, 0.0])).with_ui(ParamUi::Point)) + .with(ids.prop("position", "Position", Value::Vec2([0.0, 0.0])).with_ui(ParamUi::Point)) + .with(ids.prop("scale", "Scale", Value::Vec2([0.0, 0.0])).with_ui(ParamUi::Percent)) + .with(ids.prop("rotation", "Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle)); + indexed(ids.group("wiggler", "Wiggle Transform 1")) + .with(ids.prop("speed", "Wiggles/Second", Value::Scalar(2.0))) + .with(ids.prop("correlation", "Correlation", Value::Scalar(50.0)).with_ui(ParamUi::Percent)) + .with(ids.prop("phase", "Temporal Phase", Value::Scalar(0.0)).with_ui(ParamUi::Angle)) + .with(ids.prop("spatialPhase", "Spatial Phase", Value::Scalar(0.0)).with_ui(ParamUi::Angle)) + .with(ids.prop("seed", "Random Seed", Value::Scalar(0.0))) + .with(tr) + } "merge" => indexed(ids.group("merge", "Merge Paths 1")).with(ids.prop("mode", "Mode", Value::Enum(0)).with_ui(popup(&[ "Merge", "Add", diff --git a/crates/project/src/footage.rs b/crates/project/src/footage.rs index 486bc25f7..336272d77 100644 --- a/crates/project/src/footage.rs +++ b/crates/project/src/footage.rs @@ -52,6 +52,142 @@ pub enum AlphaMode { Ignore, } +/// Interpret Footage ▸ Cineon Settings ▸ Units: how the Converted Black and White Points are +/// shown and entered. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum CineonUnits { + /// 10-bit code values (0–1023). + #[default] + TenBit, + /// 8-bit values (0–255). + EightBit, + /// Floating point (0–1). + Float, +} + +impl CineonUnits { + pub const ALL: [CineonUnits; 3] = [CineonUnits::TenBit, CineonUnits::EightBit, CineonUnits::Float]; + + /// The id commands use: `10bit`, `8bit` or `float`. + pub fn id(self) -> &'static str { + match self { + CineonUnits::TenBit => "10bit", + CineonUnits::EightBit => "8bit", + CineonUnits::Float => "float", + } + } + + pub fn label(self) -> &'static str { + match self { + CineonUnits::TenBit => "10-bit", + CineonUnits::EightBit => "8-bit", + CineonUnits::Float => "Float", + } + } + + pub fn parse(s: &str) -> Option { + match s.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() { + "10bit" | "10" => Some(CineonUnits::TenBit), + "8bit" | "8" => Some(CineonUnits::EightBit), + "float" | "32bit" => Some(CineonUnits::Float), + _ => None, + } + } + + /// A 10-bit value (0–1023) in these units. + pub fn from_ten_bit(self, v: f64) -> f64 { + match self { + CineonUnits::TenBit => v, + CineonUnits::EightBit => v * 255.0 / 1023.0, + CineonUnits::Float => v / 1023.0, + } + } + + /// A value in these units as a 10-bit value (0–1023). + pub fn to_ten_bit(self, v: f64) -> f64 { + match self { + CineonUnits::TenBit => v, + CineonUnits::EightBit => v * 1023.0 / 255.0, + CineonUnits::Float => v * 1023.0, + } + } +} + +/// Interpret Footage ▸ Cineon Settings of DPX and Cineon footage: how the file's logarithmic +/// (printing density) code values become the footage's pixels. Code values are counted in 10-bit +/// units whatever the file's bit depth. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct CineonSettings { + /// Logarithmic Conversion: convert the code values from log to the Current Gamma. When it + /// is off, the code values are shown as stored. + pub log_conversion: bool, + /// Converted Black Point: the level the 10 Bit Black Point becomes (10-bit units). + pub converted_black: f32, + /// Converted White Point: the level the 10 Bit White Point becomes (10-bit units). + pub converted_white: f32, + /// 10 Bit Black Point: the code value of minimum density. + pub black: f32, + /// 10 Bit White Point: the code value of maximum density. + pub white: f32, + /// Current Gamma: the gamma the converted values are encoded with (1: linear light). + pub gamma: f32, + /// Highlight Rolloff (0–100): values near and above the white point ramp off gradually + /// instead of clipping; 0 keeps over-range values. + pub rolloff: f32, + /// Units of the Converted Black and White Points. + pub units: CineonUnits, +} + +impl Default for CineonSettings { + fn default() -> Self { + CineonSettings { + log_conversion: false, + converted_black: 0.0, + converted_white: 1023.0, + black: 95.0, + white: 685.0, + gamma: 1.7, + rolloff: 20.0, + units: CineonUnits::TenBit, + } + } +} + +impl CineonSettings { + /// The highest 10-bit value. + pub const MAX: f32 = 1023.0; + + /// A code value (0–1 of the 10-bit range) converted from log to the Current Gamma and the + /// converted black and white levels (0–1; above 1 for over-range highlights). + pub fn convert(&self, v: f32) -> f32 { + let lin = cineon_log_to_lin(v, self.black, self.white, 1.0, self.gamma, self.rolloff); + let out = (self.converted_black + (self.converted_white - self.converted_black) * lin) / Self::MAX; + if out.is_finite() { out } else { 0.0 } + } +} + +/// Cineon log to linear, shared by Interpret Footage ▸ Cineon Settings and the Cineon Converter +/// effect. `v` is a code value as 0–1 of the 10-bit range; `black` and `white` are the 10 Bit +/// Black and White Points. The result is 0 at the black point and 1 at the white point, raised to +/// `scale / gamma`, with values above `1 - rolloff / 100` ramped off toward 1 (Highlight Rolloff +/// 0–100; 0 keeps over-range values). A Gamma below 0.01 is raised to 0.01. Cineon Settings passes +/// a `scale` of 1 (Gamma 1 gives linear light) and the Cineon Converter passes 1.7 (its default +/// Gamma 1.7 gives linear light). +pub fn cineon_log_to_lin(v: f32, black: f32, white: f32, scale: f32, gamma: f32, rolloff: f32) -> f32 { + // Kodak printing density: 0.002 density per code value, negative gamma 0.6. + let exposure = |code: f32| 10f32.powf((code - white) * 0.002 / 0.6); + let offset = exposure(black); + let mut lin = ((exposure(v * CineonSettings::MAX) - offset) / (1.0 - offset).max(1e-6)).max(0.0); + lin = lin.powf(scale / gamma.max(0.01)); + let knee = 1.0 - (rolloff / 100.0).clamp(0.0, 0.99); + if knee < 1.0 && lin > knee { + let w = 1.0 - knee; + lin = knee + w * (1.0 - (-(lin - knee) / w).exp()); + } + lin +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Footage { pub path: String, @@ -98,6 +234,9 @@ pub struct Footage { /// colour profile, Interpret As Linear Light and the working space don't apply. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub preserve_rgb: bool, + /// Interpret Footage ▸ Cineon Settings (DPX and Cineon files only; [`Footage::cineon_applies`]). + #[serde(default, skip_serializing_if = "is_default")] + pub cineon: CineonSettings, /// Data footage ([`FootageKind::Data`]): the file's text (JSON, CSV or TSV), kept in the /// project so expressions read it everywhere (and on the web). #[serde(default, skip_serializing_if = "Option::is_none")] @@ -149,6 +288,7 @@ impl Default for Footage { invert_alpha: false, linear_light: false, preserve_rgb: false, + cineon: CineonSettings::default(), data: None, layer: None, page: 0, @@ -160,6 +300,17 @@ impl Default for Footage { } impl Footage { + /// The file is DPX or Cineon (the formats Interpret Footage ▸ Cineon Settings applies to). + pub fn is_cineon_file(&self) -> bool { + let ext = std::path::Path::new(&self.path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase(); + matches!(ext.as_str(), "dpx" | "cin") + } + + /// Cineon Settings ▸ Logarithmic Conversion applies: it is on and the file is DPX or Cineon. + pub fn cineon_applies(&self) -> bool { + self.cineon.log_conversion && self.is_cineon_file() + } + /// Interpret As Linear Light applies (Preserve RGB passes the file's values through). pub fn linear_light_applies(&self) -> bool { self.linear_light && !self.preserve_rgb diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 8e1409c37..37584eec4 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -36,7 +36,7 @@ mod layer; mod settings; pub use comp::{Comp, Guide, Renderer, Resolution}; -pub use footage::{AlphaMode, FieldOrder, Footage, FootageKind, Proxy, Solid, SourceLayer}; +pub use footage::{AlphaMode, CineonSettings, CineonUnits, FieldOrder, Footage, FootageKind, Proxy, Solid, SourceLayer, cineon_log_to_lin}; pub use item::{Item, ItemId, ItemKind, LayerId}; pub use layer::{AutoOrient, FrameBlend, Layer, LayerSource, LightKind, MatteKind, PrimitiveKind, Quality, Sampling, Switches, TrackMatte}; pub use settings::{ diff --git a/crates/project/src/tests.rs b/crates/project/src/tests.rs index c0bdb012f..afd3d3f03 100644 --- a/crates/project/src/tests.rs +++ b/crates/project/src/tests.rs @@ -291,6 +291,49 @@ fn comp_contains_ends_on_cycles_long_chains_and_shared_precomps() { assert!(!p.comp_contains(c[n - 1], c[0])); } +/// Interpret Footage ▸ Cineon Settings: the 10 Bit Black and White Points become the converted +/// points, Current Gamma 1 gives linear light, Highlight Rolloff 0 keeps over-range values and +/// a higher one ramps them off; the settings apply only to DPX and Cineon files, and projects +/// saved without them read the defaults. +#[test] +fn cineon_settings_convert_log_code_values() { + use crate::{CineonSettings, CineonUnits, Footage}; + let code = |v: f32| v / 1023.0; + let c = CineonSettings { log_conversion: true, rolloff: 0.0, ..Default::default() }; + assert!(c.convert(code(95.0)).abs() < 1e-6, "black point"); + assert!((c.convert(code(685.0)) - 1.0).abs() < 1e-5, "white point"); + // Code 400: (10^(-0.95) - 10^(-1.9667)) / (1 - 10^(-1.9667)) = 0.10251 linear, ^(1/1.7). + assert!((c.convert(code(400.0)) - 0.26190).abs() < 1e-4, "{}", c.convert(code(400.0))); + let linear = CineonSettings { gamma: 1.0, ..c }; + assert!((linear.convert(code(400.0)) - 0.10251).abs() < 1e-4, "{}", linear.convert(code(400.0))); + assert!(c.convert(code(1023.0)) > 1.5, "over range kept: {}", c.convert(code(1023.0))); + // The default rolloff (20) ramps the white point and above off, up to 1. + let rolled = CineonSettings { rolloff: 20.0, ..c }; + let (w, mid, top) = (rolled.convert(code(685.0)), rolled.convert(code(720.0)), rolled.convert(code(1023.0))); + assert!(w > 0.8 && w < mid && mid < top && top <= 1.0, "{w} {mid} {top}"); + let points = CineonSettings { converted_black: 64.0, converted_white: 940.0, ..c }; + assert!((points.convert(code(95.0)) - 64.0 / 1023.0).abs() < 1e-5); + assert!((points.convert(code(685.0)) - 940.0 / 1023.0).abs() < 1e-5); + // Units. + assert!((CineonUnits::EightBit.from_ten_bit(1023.0) - 255.0).abs() < 1e-9); + assert!((CineonUnits::Float.to_ten_bit(0.5) - 511.5).abs() < 1e-9); + assert_eq!(CineonUnits::parse("8-bit"), Some(CineonUnits::EightBit)); + assert!(CineonUnits::ALL.iter().all(|u| CineonUnits::parse(u.id()) == Some(*u))); + // DPX and Cineon files only, and only with Logarithmic Conversion on. + let on = CineonSettings { log_conversion: true, ..Default::default() }; + let dpx = Footage { path: "/m/plate.0001.DPX".into(), cineon: on, ..Default::default() }; + assert!(dpx.cineon_applies()); + assert!(Footage { path: "/m/scan.cin".into(), cineon: on, ..Default::default() }.cineon_applies()); + assert!(!Footage { path: "/m/still.png".into(), cineon: on, ..Default::default() }.cineon_applies()); + assert!(!Footage { path: "/m/plate.dpx".into(), ..Default::default() }.cineon_applies()); + // Saved only when changed; read back, and absent in older projects. + let plain = serde_json::to_value(Footage { path: "/m/a.dpx".into(), ..Default::default() }).unwrap(); + assert!(plain.get("cineon").is_none(), "{plain}"); + let saved = serde_json::to_string(&dpx).unwrap(); + assert_eq!(serde_json::from_str::(&saved).unwrap().cineon, on); + assert_eq!(serde_json::from_value::(plain).unwrap().cineon, CineonSettings::default()); +} + /// A Wiggle Paths 100 000 groups deep gets Spatial Phase on a 2 MiB stack. #[test] fn upgrading_deeply_nested_shape_groups_fits_a_small_stack() { diff --git a/crates/render/src/accel.rs b/crates/render/src/accel.rs index 4f93e41d7..e6bbd2113 100644 --- a/crates/render/src/accel.rs +++ b/crates/render/src/accel.rs @@ -131,6 +131,14 @@ pub trait FxTarget { buf }); } + /// Give the buffer the pixel-grid phase the layer rectangle's cover would + /// ([`effectcraft_effects::Buf::align`]): grows left/top by less than 64 pixels. + fn align(&mut self, size: [f64; 2]) { + self.cpu(&mut |mut buf| { + buf.align(size); + buf + }); + } } /// The CPU effect target (GPU chains go through [`Accelerator::effects`]: upload, readback). diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index 1c7aadc55..b6f2e4076 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -458,7 +458,7 @@ fn time_dependent(layer: &Layer, effects: bool) -> bool { if matches!(layer.source, LayerSource::Shape) && let Some(c) = layer.props.sub("contents") { - return group_has(c, "wiggle"); + return group_has(c, "wiggle") || group_has(c, "wiggler"); } // Wiggly selectors move with time; Expression selectors evaluate per character (their // property value alone doesn't capture the result). diff --git a/crates/render/src/frames.rs b/crates/render/src/frames.rs index 3024dfc52..7d1929ecd 100644 --- a/crates/render/src/frames.rs +++ b/crates/render/src/frames.rs @@ -1,6 +1,7 @@ use super::footage::NoFootage; use super::opts::RenderOpts; -use super::{EvalCtx, Footage, FrameBlend, FrameRate, Image, ItemId, Layer, LayerSource, Project, Renderer, TICKS_PER_SECOND, Tick, shapes, text}; +use super::{Arc, EvalCtx, Footage, FrameBlend, FrameRate, Image, ItemId, Layer, LayerSource, Project, Renderer, TICKS_PER_SECOND, Tick, shapes, text}; +use rayon::prelude::*; /// The layer's frame blending mode, if the comp's Enable Frame Blending switch is on. /// One field of an interlaced frame as a full frame: the field's lines (`parity` 0 = even / @@ -33,6 +34,23 @@ pub fn field_frame(img: &Image, parity: usize) -> Image { out } +/// Interpret Footage ▸ Cineon Settings ▸ Logarithmic Conversion converts the log code values of +/// a decoded (premultiplied) frame of `f` to the Current Gamma. When the conversion does not +/// apply, the frame is returned unchanged. +pub fn cineon_frame(img: Arc, f: &Footage) -> Arc { + if !f.cineon_applies() { + return img; + } + let cs = f.cineon; + let mut out = (*img).clone(); + out.data.par_iter_mut().for_each(|p| { + let a = p[3]; + let c = if a > 1e-6 { [p[0] / a, p[1] / a, p[2] / a] } else { [0.0; 3] }.map(|v| cs.convert(v)); + *p = [c[0] * a, c[1] * a, c[2] * a, a]; + }); + Arc::new(out) +} + /// Interpret Footage ▸ Invert Alpha and Interpret As Linear Light on decoded (premultiplied) /// pixels; linear light is encoded in `space` ([`color::Pipe::linear_footage_space`]). pub fn interpret_pixels(img: &Image, f: &Footage, space: effectcraft_color::ColorSpace) -> Image { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 0c2f11e4c..dd5335623 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -48,8 +48,8 @@ mod pipeline; pub use accel::{Accelerator, Backend, FxStep, FxTarget}; pub use footage::{CancellableFootage, FloatCurve, FootageSource, NoFootage, is_vector_footage}; pub use frames::{ - adaptive_samples, blend_frames, content_bounds, extended_region, field_frame, frame_position, frame_size, interpret_pixels, kurbo_path, render_frame, - roi_inside, + adaptive_samples, blend_frames, cineon_frame, content_bounds, extended_region, field_frame, frame_position, frame_size, interpret_pixels, kurbo_path, + render_frame, roi_inside, }; #[cfg(test)] use fx_host::FxHost; diff --git a/crates/render/src/pipeline.rs b/crates/render/src/pipeline.rs index d51344923..a4927144f 100644 --- a/crates/render/src/pipeline.rs +++ b/crates/render/src/pipeline.rs @@ -1,6 +1,6 @@ use super::accel::{Accelerator, Backend, CpuFx, FxStep, FxTarget}; use super::footage::{FootageSource, is_vector_footage}; -use super::frames::{blend_frames, content_bounds, field_frame, frame_blend_mode, frame_position, frame_size, interpret_pixels, roi_inside}; +use super::frames::{blend_frames, cineon_frame, content_bounds, field_frame, frame_blend_mode, frame_position, frame_size, interpret_pixels, roi_inside}; use super::fx_host::{FxHost, essential_overrides}; use super::opts::{Collapse3d, LayerTiming, MAX_THROUGH, RenderOpts, Through}; use super::{ @@ -566,14 +566,26 @@ impl<'a> Renderer<'a> { let Some(fx) = layer.effects() else { return }; let lt = layer.layer_time(ctx.time); let layer_size = ctx.effect_bounds(layer).0; - // #659: an authored layer's buffer spans only its painted content while the stack works - // on the layer's whole region (`layer_size`): give the buffer the room a sized source - // already has, so tiling or displacement can place pixels outside the content instead - // of losing them at its edge. Only when something will actually run, and not for an - // adjustment layer, whose buffer is the comp below and already spans it. - if !adjustment - && fx.groups().any(|g| g.enabled && matches!(&g.kind, GroupKind::Effect { effect } if effectcraft_effects::find(effect).is_some_and(|s| !effectcraft_effects::audio_fx::is_audio_effect(s.id)))) { - target.cover(layer_size); + // #659 / #1205: an authored layer's buffer spans only its painted content. Effects that + // place pixels outside it grow the buffer themselves, by exactly what they need + // (`layer_cover_pad`: CC Tiler, CC Smear, CC Mosaic sharp) instead of a blanket cover of + // the whole layer rectangle on every stack — which paid a comp-sized texture per layer, + // per frame in flight, for glow and blur that never use the extra room. The buffer still + // takes the cover's pixel-grid phase: a downscaled layer resamples along that grid. + // Effects not known to stay inside their own margins (Twirl, Bulge, Transform, ...) still + // get the cover: they move content past the painted bounds without growing the buffer. + let video = || { + fx.groups().filter(|g| g.enabled).filter_map(|g| match &g.kind { + GroupKind::Effect { effect } => effectcraft_effects::find(effect).filter(|s| !effectcraft_effects::audio_fx::is_audio_effect(s.id)), + _ => None, + }) + }; + if !adjustment && video().next().is_some() { + if video().all(|s| s.skips_cover()) { + target.align(layer_size); + } else { + target.cover(layer_size); + } } let mut mask_shapes = masks::shapes(ctx, layer); if origin != [0.0; 2] { @@ -817,16 +829,17 @@ 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_with(item, f, f.frame_rate.tick_of(i / 2), None, self.pipe.float_curve(f))?; + let img = cineon_frame(self.footage.frame_with(item, f, f.frame_rate.tick_of(i / 2), None, self.pipe.float_curve(f))?, 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 }; return Some(Arc::new(interpret_pixels(&field_frame(&img, parity), f, self.pipe.linear_footage_space(f)))); } // Interpretation is per pixel but not linear: it runs on the frame before resampling. - let size = size.filter(|_| !(f.invert_alpha || f.linear_light_applies())); + let per_pixel = f.invert_alpha || f.linear_light_applies(); + let size = size.filter(|_| !(per_pixel || f.cineon_applies())); let img = self.footage_frame_raw(ctx, layer, item, f, t, size)?; - if f.invert_alpha || f.linear_light_applies() { Some(Arc::new(interpret_pixels(&img, f, self.pipe.linear_footage_space(f)))) } else { Some(img) } + if per_pixel { Some(Arc::new(interpret_pixels(&img, f, self.pipe.linear_footage_space(f)))) } else { Some(img) } } fn footage_frame_raw(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick, size: Option<(u32, u32)>) -> Option> { @@ -844,6 +857,10 @@ impl<'a> Renderer<'a> { if f.alpha == effectcraft_project::AlphaMode::Premultiplied { f.premul_color.map(f32::to_bits).hash(&mut h); } + if f.cineon_applies() { + let c = &f.cineon; + [c.converted_black, c.converted_white, c.black, c.white, c.gamma, c.rolloff].map(f32::to_bits).hash(&mut h); + } h.finish() }); if let (Some(c), Some(k)) = (self.cache, key) @@ -851,8 +868,9 @@ impl<'a> Renderer<'a> { { return Some(Arc::new(b.img.clone())); } - 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); + // Log scans are converted before they blend: the conversion is not linear. + let a = self.footage.frame_with(item, f, f.frame_rate.tick_of(i), None, float).map(|a| cineon_frame(a, f)); + let b = self.footage.frame_with(item, f, f.frame_rate.tick_of(i + 1), None, float).map(|b| cineon_frame(b, f)); 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_to_keep(), key) { @@ -862,7 +880,7 @@ impl<'a> Renderer<'a> { } } } - self.footage.frame_with(item, f, t, size, float) + self.footage.frame_with(item, f, t, size, float).map(|img| cineon_frame(img, f)) } /// Source → masks, clamped/quantised to the project depth. diff --git a/crates/render/src/shapes.rs b/crates/render/src/shapes.rs index 67f224793..bbc4283ff 100644 --- a/crates/render/src/shapes.rs +++ b/crates/render/src/shapes.rs @@ -2,8 +2,8 @@ //! with the After Effects stacking semantics: //! //! - path operators (Trim Paths, Round Corners, Offset Paths, Pucker & Bloat, Twist, Zig Zag, -//! Wiggle Paths, Merge Paths, Repeater) act on all paths above them in the same group, including -//! the paths of nested groups; +//! Wiggle Paths, Wiggle Transform, Merge Paths, Repeater) act on all paths above them in the +//! same group, including the paths of nested groups; //! - paint items (fills, strokes, gradient fills/strokes) paint the paths above them, *as modified //! by every operator in the stack* — a Trim Paths below a Stroke still trims it; //! - items higher in the list draw on top; @@ -17,7 +17,9 @@ use effectcraft_keyframe::{Gradient, Value}; use effectcraft_path::{BezPath, Cap, FillRule, Join, StrokeStyle, ops, varstroke}; use effectcraft_project::{Layer, Node, PropGroup}; use effectcraft_raster::{Image, Mask}; +use effectcraft_text::selectors::{self, Wiggly}; use rayon::prelude::*; +use std::collections::{HashMap, HashSet}; use crate::eval::EvalCtx; @@ -185,9 +187,75 @@ fn used_slots(live: &[usize], draws: &[Draw]) -> Vec { v } +/// Wiggle Transform: moves the live slots by a random offset of the Transform group's Anchor +/// Point, Position, Scale and Rotation (the most each one wiggles), changing over time. Its +/// Wiggles/Second, Correlation, Temporal Phase, Spatial Phase and Random Seed work as the Wiggly +/// Selector's do, with each Repeater copy (`copy_of`) as one unit: the copies of a Repeater above +/// the operation move on their own as far as Correlation allows, and the copies of a Repeater +/// below it move as one. The four transformations of one operation move together. +fn wiggle_transform(ctx: &EvalCtx, layer: &Layer, g: &PropGroup, arena: &mut [BezPath], live: &[usize], copy_of: &HashMap, draws: &mut [Draw]) { + let Some(tr) = g.sub("transform") else { return }; + let w = Wiggly { + max: 1.0, + min: -1.0, + wiggles_per_second: ctx.f(layer, g, "speed", 2.0), + correlation: ctx.f(layer, g, "correlation", 50.0) / 100.0, + temporal_phase: ctx.f(layer, g, "phase", 0.0), + spatial_phase: ctx.f(layer, g, "spatialPhase", 0.0), + lock_dimensions: false, + seed: ctx.f(layer, g, "seed", 0.0).round() as i64 as u64, + }; + let anchor = ctx.v2(layer, tr, "anchor", [0.0; 2]); + let pos = ctx.v2(layer, tr, "position", [0.0; 2]); + let scale = ctx.v2(layer, tr, "scale", [0.0; 2]); + let rot = ctx.f(layer, tr, "rotation", 0.0); + let t = layer.layer_time(ctx.time).seconds(); + let matrix = |k: usize| { + let (x, y) = (selectors::wiggly_value(&w, t, k, 0), selectors::wiggly_value(&w, t, k, 1)); + Mat3::translate(vec2(pos[0] * x, pos[1] * y)) + * Mat3::rotate_deg(rot * x) + * Mat3::scale(vec2(1.0 + scale[0] * x / 100.0, 1.0 + scale[1] * y / 100.0)) + * Mat3::translate(vec2(-anchor[0] * x, -anchor[1] * y)) + }; + let mut by_copy: HashMap = HashMap::new(); + let mut done: HashSet = HashSet::new(); + for &s in live { + if !done.insert(s) { + continue; + } + let k = copy_of.get(&s).copied().unwrap_or(0); + let m = *by_copy.entry(k).or_insert_with(|| matrix(k)); + arena[s] = mat_path(&arena[s], &m); + } + // A paint above the operation whose paths all moved by one matrix moves its gradient and + // stroke scale with them, as a group transform does. A paint over copies that moved by + // different matrices stays one paint (its fill rule and opacity apply to all of them at once) + // and keeps its gradient and stroke scale. + for d in draws { + let mut ms = d.slots.iter().map(|s| done.contains(s).then(|| by_copy.get(©_of.get(s).copied().unwrap_or(0)).copied()).flatten()); + let Some(Some(m)) = ms.next() else { continue }; + if ms.all(|x| x == Some(m)) { + d.xf = m * d.xf; + } + } +} + /// Collect the draws of a contents group and the path slots live at its end (in group space). /// `arena` holds every path slot; operators rewrite slots in place. fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec) -> (Vec, Vec) { + collect_in(ctx, layer, contents, arena, &mut Copies::default()) +} + +/// The Repeater copy each path slot belongs to, for Wiggle Transform: `of` maps a slot a Repeater +/// made to its copy's id (slots no Repeater made are copy 0). Ids are unique across the layer's +/// groups, so copies made in a nested group stay apart in the groups around it. +#[derive(Default)] +struct Copies { + of: HashMap, + next: usize, +} + +fn collect_in(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec, copy_ids: &mut Copies) -> (Vec, Vec) { let mut draws: Vec = Vec::new(); let mut live: Vec = Vec::new(); let opacity = |g: &PropGroup| (ctx.f(layer, g, "opacity", 100.0) / 100.0) as f32; @@ -330,6 +398,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec wiggle_transform(ctx, layer, g, arena, &live, ©_ids.of, &mut draws), "merge" => { let mode = ops::MergeMode::from_index(ctx.e(layer, g, "mode")); let input: Vec = live.iter().map(|&i| arena[i].clone()).collect(); @@ -342,7 +411,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec { let Some(inner) = g.sub("contents") else { continue }; - let (cd, cl) = collect(ctx, layer, inner, arena); + let (cd, cl) = collect_in(ctx, layer, inner, arena, copy_ids); let (m, op) = g.sub("transform").map(|t| group_matrix(ctx, layer, t)).unwrap_or((Mat3::IDENTITY, 1.0)); for i in used_slots(&cl, &cd) { arena[i] = mat_path(&arena[i], &m); @@ -379,12 +448,20 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec> = Vec::with_capacity(n); + // Each copy of each earlier copy (a second Repeater) gets an id of its own. + let mut ids: HashMap<(usize, usize), usize> = HashMap::new(); for i in 0..n { let m = step(i as f64 + offset); let mut map = std::collections::HashMap::new(); for &s in &base_slots { arena.push(mat_path(&arena[s], &m)); map.insert(s, arena.len() - 1); + let base = copy_ids.of.get(&s).copied().unwrap_or(0); + let id = *ids.entry((i, base)).or_insert_with(|| { + copy_ids.next += 1; + copy_ids.next + }); + copy_ids.of.insert(arena.len() - 1, id); } live.extend(base_live.iter().map(|s| map[s])); copy_maps.push(map); diff --git a/crates/render/src/shapes/tests.rs b/crates/render/src/shapes/tests.rs index e6f6cba7c..e0c9086a7 100644 --- a/crates/render/src/shapes/tests.rs +++ b/crates/render/src/shapes/tests.rs @@ -310,6 +310,202 @@ fn wiggle_paths_vary_over_time_deterministically() { assert_ne!(a.data, c.data); } +/// Alpha-weighted centroid of the pixels in columns `x0..x1`. +fn centroid(img: &Image, x0: i64, x1: i64) -> [f32; 2] { + let (mut sx, mut sy, mut sa) = (0.0, 0.0, 0.0); + for x in x0..x1 { + for y in 0..img.height as i64 { + let a = alpha(img, x, y); + sx += a * x as f32; + sy += a * y as f32; + sa += a; + } + } + [sx / sa, sy / sa] +} + +fn wiggle_transform(ids: &mut Ids, params: &[(&str, Value)]) -> PropGroup { + op(ids, "wiggler", params) +} + +#[test] +fn wiggle_transform_moves_paths_over_time() { + let run = |position: [f64; 2], t: f64| { + let (p, cid) = shape_comp(|ids| { + let w = wiggle_transform(ids, &[("transform/position", Value::Vec2(position))]); + vec![build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0), w, build::shape_fill(ids, RED)] + }); + render_frame(&p, cid, Tick::from_seconds_f64(t), 1.0) + }; + // The defaults wiggle nothing: the square stays centered on pixel 99.5, 49.5. + let rest = [99.5, 49.5]; + assert_eq!(centroid(&run([0.0, 0.0], 0.37), 0, 200), rest); + let a = run([15.0, 15.0], 0.37); + assert_eq!(a.data, run([15.0, 15.0], 0.37).data, "the same time gives the same frame"); + let b = run([15.0, 15.0], 1.21); + let (ca, cb) = (centroid(&a, 0, 200), centroid(&b, 0, 200)); + assert!(ca != rest && ca != cb, "{ca:?} {cb:?}"); + for c in [ca, cb] { + assert!((c[0] - rest[0]).abs() <= 15.01 && (c[1] - rest[1]).abs() <= 15.01, "{c:?}"); + } + // A translation keeps the square's area. + assert!((coverage(&a) - 400.0).abs() < 1.0, "{}", coverage(&a)); + // The layer cache keys a layer with a Wiggle Transform by time. + let (p, cid) = shape_comp(|ids| vec![build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0), wiggle_transform(ids, &[]), build::shape_fill(ids, RED)]); + let comp = p.comp(cid).unwrap(); + let key = |t: f64| crate::cache::layer_key(&crate::eval::EvalCtx::new(&p, cid, comp, Tick::from_seconds_f64(t)), &comp.layers[0], 1.0, false, false); + assert_ne!(key(0.37), key(1.21)); +} + +#[test] +fn wiggle_transform_scales_and_rotates() { + let run = |params: &[(&str, Value)]| { + let (p, cid) = shape_comp(|ids| { + let w = wiggle_transform(ids, params); + vec![build::shape_rect(ids, [40.0, 40.0], [0.0, 0.0], 0.0), w, build::shape_fill(ids, RED)] + }); + render_frame(&p, cid, Tick::from_seconds_f64(0.37), 1.0) + }; + let scaled = coverage(&run(&[("transform/scale", Value::Vec2([50.0, 50.0]))])); + assert!((scaled - 1600.0).abs() > 20.0, "{scaled}"); + let rotated = run(&[("transform/rotation", Value::Scalar(45.0))]); + assert!((coverage(&rotated) - 1600.0).abs() < 2.0, "{}", coverage(&rotated)); + assert!(alpha(&rotated, 81, 31) < 0.5 || alpha(&rotated, 119, 31) < 0.5, "a corner turned away"); +} + +/// A gradient above a Wiggle Transform moves with the paths it paints, as under a group transform. +#[test] +fn wiggle_transform_moves_the_gradient_with_the_path() { + let (p, cid) = shape_comp(|ids| { + let g = Gradient { colors: vec![(0.0, [0.0, 0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 1.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] }; + vec![ + build::shape_rect(ids, [40.0, 40.0], [0.0, 0.0], 0.0), + build::shape_gradient_fill(ids, false, [-20.0, 0.0], [20.0, 0.0], g), + wiggle_transform(ids, &[("transform/position", Value::Vec2([60.0, 0.0]))]), + ] + }); + let img = render_frame(&p, cid, Tick::from_seconds_f64(0.37), 1.0); + let cols: Vec = (0..200).filter(|&x| alpha(&img, x, 50) > 0.99).collect(); + let (l, r) = (cols[0], cols[cols.len() - 1]); + assert!((l - 80).abs() > 3, "the square moved: {l}"); + let (cl, cr) = (img.get(l, 50), img.get(r, 50)); + assert!(cl[0] < 0.1 && cr[0] > 0.9, "the ramp runs edge to edge: {cl:?} {cr:?}"); +} + +/// A gradient that paints every copy of a Repeater moves with the copies when they wiggle alike +/// (Correlation 100). +#[test] +fn wiggle_transform_moves_a_gradient_shared_by_repeater_copies() { + let (p, cid) = shape_comp(|ids| { + let g = Gradient { colors: vec![(0.0, [0.0, 0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 1.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] }; + vec![ + build::shape_rect(ids, [20.0, 20.0], [-60.0, 0.0], 0.0), + build::shape_repeater(ids, 3.0, [60.0, 0.0]), + build::shape_gradient_fill(ids, false, [0.0, -10.0], [0.0, 10.0], g), + wiggle_transform(ids, &[("correlation", Value::Scalar(100.0)), ("transform/position", Value::Vec2([0.0, 15.0]))]), + ] + }); + let img = render_frame(&p, cid, Tick::from_seconds_f64(0.9), 1.0); + for cx in [40, 100, 160] { + let rows: Vec = (0..100).filter(|&y| alpha(&img, cx, y) > 0.99).collect(); + let (t, b) = (rows[0], rows[rows.len() - 1]); + assert!((t - 40).abs() > 3, "copy at {cx} moved: {t}"); + let (ct, cb) = (img.get(cx, t), img.get(cx, b)); + assert!(ct[0] < 0.1 && cb[0] > 0.9, "copy at {cx}: {ct:?} {cb:?}"); + } +} + +/// A gradient over copies that wiggle on their own keeps its place: every copy shows the ramp's +/// middle at the group's origin row. +#[test] +fn wiggle_transform_keeps_a_gradient_over_copies_wiggling_apart() { + let (p, cid) = shape_comp(|ids| { + let g = Gradient { colors: vec![(0.0, [0.0, 0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 1.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] }; + vec![ + build::shape_rect(ids, [20.0, 40.0], [-60.0, 0.0], 0.0), + build::shape_repeater(ids, 3.0, [60.0, 0.0]), + build::shape_gradient_fill(ids, false, [0.0, -20.0], [0.0, 20.0], g), + wiggle_transform(ids, &[("correlation", Value::Scalar(0.0)), ("transform/position", Value::Vec2([0.0, 15.0]))]), + ] + }); + let img = render_frame(&p, cid, Tick::from_seconds_f64(0.9), 1.0); + for cx in [40, 100, 160] { + let c = img.get(cx, 50); + assert!(c[3] > 0.99 && (c[0] - 0.5).abs() < 0.05, "copy at {cx}: {c:?}"); + } +} + +/// Copies wiggling on their own under one 50 % fill stay one paint: where they overlap, the fill +/// covers them once. +#[test] +fn wiggle_transform_keeps_one_fill_over_overlapping_copies() { + let (p, cid) = shape_comp(|ids| { + let mut fill = build::shape_fill(ids, RED); + set(&mut fill, "opacity", Value::Scalar(50.0)); + vec![ + build::shape_rect(ids, [40.0, 40.0], [-20.0, 0.0], 0.0), + build::shape_repeater(ids, 3.0, [20.0, 0.0]), + fill, + wiggle_transform(ids, &[("correlation", Value::Scalar(0.0)), ("transform/position", Value::Vec2([0.0, 15.0]))]), + ] + }); + let img = render_frame(&p, cid, Tick::from_seconds_f64(0.9), 1.0); + let top = img.data.iter().map(|px| px[3]).fold(0.0f32, f32::max); + assert!((top - 0.5).abs() < 0.01, "{top}"); +} + +/// A long stack of Repeaters, each cleared by a zero-copy Repeater, still renders the last Repeater's copies. +#[test] +fn long_repeater_stack_with_cleared_copies_renders() { + let (p, cid) = shape_comp(|ids| { + let mut items = vec![]; + for _ in 0..70 { + items.push(build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0)); + items.push(build::shape_repeater(ids, 3.0, [10.0, 0.0])); + items.push(build::shape_repeater(ids, 0.0, [10.0, 0.0])); + } + items.push(build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0)); + items.push(build::shape_repeater(ids, 3.0, [30.0, 0.0])); + items.push(wiggle_transform(ids, &[("correlation", Value::Scalar(0.0)), ("transform/position", Value::Vec2([0.0, 15.0]))])); + items.push(build::shape_fill(ids, RED)); + items + }); + let img = render_frame(&p, cid, Tick::from_seconds_f64(0.37), 1.0); + assert!((coverage(&img) - 1200.0).abs() < 2.0, "{}", coverage(&img)); +} + +/// Copies a Repeater makes above a Wiggle Transform wiggle on their own (Correlation 0) or as one +/// (Correlation 100); with the Wiggle Transform above the Repeater every copy wiggles the same. +#[test] +fn wiggle_transform_and_repeater_order() { + let offsets = |wiggle_first: bool, correlation: f64| { + let (p, cid) = shape_comp(|ids| { + let rect = build::shape_rect(ids, [20.0, 20.0], [-60.0, 0.0], 0.0); + let rep = build::shape_repeater(ids, 3.0, [60.0, 0.0]); + let w = wiggle_transform(ids, &[("correlation", Value::Scalar(correlation)), ("transform/position", Value::Vec2([0.0, 15.0]))]); + let fill = build::shape_fill(ids, RED); + if wiggle_first { vec![rect, w, rep, fill] } else { vec![rect, rep, w, fill] } + }); + let img = render_frame(&p, cid, Tick::from_seconds_f64(0.37), 1.0); + [(0, 70), (70, 130), (130, 200)].map(|(x0, x1)| centroid(&img, x0, x1)[1] - 50.0) + }; + let own = offsets(false, 0.0); + assert!((own[0] - own[1]).abs() > 0.5 || (own[1] - own[2]).abs() > 0.5, "copies wiggle on their own: {own:?}"); + // A Repeater inside a group above the Wiggle Transform: its copies still wiggle on their own. + let (p, cid) = shape_comp(|ids| { + let inner = vec![build::shape_rect(ids, [20.0, 20.0], [-60.0, 0.0], 0.0), build::shape_repeater(ids, 3.0, [60.0, 0.0]), build::shape_fill(ids, RED)]; + let group = build::shape_group(ids, "Repeated", inner); + vec![group, wiggle_transform(ids, &[("correlation", Value::Scalar(0.0)), ("transform/position", Value::Vec2([0.0, 15.0]))])] + }); + let img = render_frame(&p, cid, Tick::from_seconds_f64(0.37), 1.0); + let nested = [(0, 70), (70, 130), (130, 200)].map(|(x0, x1)| centroid(&img, x0, x1)[1] - 50.0); + assert!((nested[0] - nested[1]).abs() > 0.5 || (nested[1] - nested[2]).abs() > 0.5, "nested copies: {nested:?}"); + for same in [offsets(false, 100.0), offsets(true, 0.0)] { + assert!(same[0].abs() > 0.5, "{same:?}"); + assert!((same[0] - same[1]).abs() < 0.05 && (same[1] - same[2]).abs() < 0.05, "copies wiggle as one: {same:?}"); + } +} + #[test] fn zigzag_and_twist_render() { let (p, cid) = shape_comp(|ids| { diff --git a/crates/script/src/matchnames.rs b/crates/script/src/matchnames.rs index 3dd717aa8..5837f77b8 100644 --- a/crates/script/src/matchnames.rs +++ b/crates/script/src/matchnames.rs @@ -185,6 +185,7 @@ pub const SHAPE_ITEMS: &[(&str, &str)] = &[ ("trim", "ADBE Vector Filter - Trim"), ("twist", "ADBE Vector Filter - Twist"), ("wiggle", "ADBE Vector Filter - Roughen"), + ("wiggler", "ADBE Vector Filter - Wiggler"), ("zigzag", "ADBE Vector Filter - Zigzag"), ]; @@ -246,6 +247,12 @@ const SHAPE_PROPS: &[(&str, &str, &str)] = &[ ("pucker", "amount", "ADBE Vector PuckerBloat Amount"), ("twist", "angle", "ADBE Vector Twist Angle"), ("twist", "center", "ADBE Vector Twist Center"), + ("wiggler", "speed", "ADBE Vector Xform Temporal Freq"), + ("wiggler", "correlation", "ADBE Vector Correlation"), + ("wiggler", "phase", "ADBE Vector Temporal Phase"), + ("wiggler", "spatialPhase", "ADBE Vector Spatial Phase"), + ("wiggler", "seed", "ADBE Vector Random Seed"), + ("wiggler", "transform", "ADBE Vector Wiggler Transform"), ("merge", "mode", "ADBE Vector Merge Type"), ("wiggle", "size", "ADBE Vector Roughen Size"), ("wiggle", "detail", "ADBE Vector Roughen Detail"), @@ -439,6 +446,7 @@ pub fn shape_kind(name: &str) -> Option<&'static str> { "twist" => "twist", "zigzag" => "zigzag", "wigglepaths" => "wiggle", + "wiggletransform" => "wiggler", "mergepaths" => "merge", _ => return None, }) diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index a33714314..b9108b2a1 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -1706,7 +1706,8 @@ Object.defineProperty(FootageItem.prototype, "mainSource", { return this.__mainSource; }, }); -FootageItem.prototype.replace = function (f) { __call("file.replaceFootage", { item: this.__id, path: __str(f) }); }; +// replace() swaps in one file, even a numbered still (replaceWithSequence brings a run), as in After Effects. +FootageItem.prototype.replace = function (f) { __call("file.replaceFootage", { item: this.__id, path: __str(f), sequence: false }); }; FootageItem.prototype.replaceWithSequence = function (f, forceAlphabetical) { __call("file.replaceFootage", { item: this.__id, path: __str(f), sequence: true, alphabetical: !!forceAlphabetical }); }; diff --git a/crates/script/src/runtime.rs b/crates/script/src/runtime.rs index 608c83d0e..2c18039e8 100644 --- a/crates/script/src/runtime.rs +++ b/crates/script/src/runtime.rs @@ -126,7 +126,7 @@ pub(crate) fn js_str(s: &str) -> JsValue { fn native_exec(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let id = arg_string(args, 0, ctx)?; let mut params = arg_json(args, 1, ctx)?; - if id == "script.run" || id == "file.runScript" { + if effectcraft_engine::commands::scripts::RUNS_SCRIPTS.contains(&id.as_str()) { return Err(throw("scripts can't run scripts")); } let keys = params.as_object_mut().and_then(|o| o.remove("__keys")); diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 875885c11..0af09841a 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -17,6 +17,7 @@ mod include; mod panels; #[cfg(not(windows))] mod services; +mod startup; mod tasks; mod ui; mod ui_more; @@ -371,6 +372,31 @@ fn match_names_and_lookups() { assert_eq!(sh.props.prop("contents/#1/contents/fill/color").unwrap().value, KV::Color([1.0, 0.0, 0.0, 1.0])); } +/// Scripts add a Wiggle Transform by its match name or display name and reach its properties. +#[test] +fn wiggle_transform_by_match_name() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("W", 200, 200, 1, 2, 30); + var sh = c.layers.addShape(); + var root = sh.property("ADBE Root Vectors Group"); + var w = root.addProperty("ADBE Vector Filter - Wiggler"); + var byName = root.addProperty("Wiggle Transform"); + w.property("ADBE Vector Wiggler Transform").property("Position").setValue([20, 10]); + [ + w.matchName, w.name, byName.matchName, w.property("ADBE Vector Xform Temporal Freq").value, + w.property("ADBE Vector Spatial Phase").name, w.property("ADBE Vector Wiggler Transform").name + ] + "#, + ); + assert_eq!(o.result, json!(["ADBE Vector Filter - Wiggler", "Wiggle Transform 1", "ADBE Vector Filter - Wiggler", 2, "Spatial Phase", "Transform"])); + let (_, c) = comp_named(&s, "W"); + let sh = c.layers.iter().find(|l| l.props.sub("contents").is_some()).unwrap(); + assert_eq!(sh.props.prop("contents/wiggler/transform/position").unwrap().value, KV::Vec2([20.0, 10.0])); +} + #[test] fn keyframes_eases_and_interpolation() { let mut s = session(); @@ -1062,6 +1088,11 @@ fn footage_proxies_sequences_and_conform_rate() { let paths: Vec = s.project.items.values().filter_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.path.replace('\\', "/")) } else { None }).collect(); assert!(paths.iter().any(|f| f.contains("take_")), "replaced with the take sequence: {paths:?}"); + // replace() swaps in the one file, even a numbered still, as in After Effects. + ok(&mut s, &format!("app.project.item(1).replace(new File('{}'))", p("shot_0002.png"))); + let kinds: Vec<_> = + s.project.items.values().filter_map(|i| if let ItemKind::Footage(f) = &i.kind { Some((f.kind, f.path.replace('\\', "/"))) } else { None }).collect(); + assert!(kinds.iter().any(|(k, f)| *k == effectcraft_engine::project::FootageKind::Still && f.ends_with("shot_0002.png")), "{kinds:?}"); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/script/src/tests/startup.rs b/crates/script/src/tests/startup.rs new file mode 100644 index 000000000..67da833c5 --- /dev/null +++ b/crates/script/src/tests/startup.rs @@ -0,0 +1,290 @@ +//! Startup and Shutdown scripts, as in After Effects: the scripts in the user's `Scripts/Startup` +//! and `Scripts/Shutdown` folders (in the settings folder) run in alphabetical order at launch and +//! as the app quits, after the project is closed. + +use std::sync::Arc; + +use effectcraft_engine::Session; +use effectcraft_engine::commands::scripts::{OPEN_PANELS_FILE, PANELS_DIR, SCRIPTS_DIR, SHUTDOWN_DIR, STARTUP_DIR}; +use effectcraft_engine::config::{ConfigStore, MemoryConfig}; +use effectcraft_engine::scriptui::WindowKind; +use serde_json::json; + +fn session(store: &Arc) -> Session { + let mut s = Session::default(); + crate::install(&mut s); + s.config = Some(store.clone()); + s +} + +/// A script that appends `mark` to the `Startup Test` / `order` script setting. +fn mark(mark: &str) -> String { + format!( + r#"var k = app.settings.haveSetting("Startup Test", "order") ? app.settings.getSetting("Startup Test", "order") : ""; +app.settings.saveSetting("Startup Test", "order", k + "{mark}");"# + ) +} + +fn setting(s: &mut Session, key: &str) -> serde_json::Value { + s.execute("script.settings.get", json!({"section": "Startup Test", "key": key})).unwrap()["value"].clone() +} + +#[test] +fn startup_scripts_run_in_alphabetical_order_and_a_failing_one_does_not_stop_the_rest() { + let store = Arc::new(MemoryConfig::default()); + store.write(&format!("{STARTUP_DIR}/b.jsx"), &mark("b")).unwrap(); + store.write(&format!("{STARTUP_DIR}/A.jsx"), &mark("A")).unwrap(); + store.write(&format!("{STARTUP_DIR}/B2.jsx"), &mark("B")).unwrap(); + store.write(&format!("{STARTUP_DIR}/C.js"), "throw new Error('no');").unwrap(); + store.write(&format!("{STARTUP_DIR}/d.jsx"), &mark("d")).unwrap(); + // Not scripts, or not in the Startup folder: not run. + store.write(&format!("{STARTUP_DIR}/notes.txt"), &mark("x")).unwrap(); + store.write(&format!("{SCRIPTS_DIR}/Menu Script.jsx"), &mark("m")).unwrap(); + store.write(&format!("{SHUTDOWN_DIR}/Quit.jsx"), &mark("q")).unwrap(); + let mut s = session(&store); + let r = s.execute("file.runStartupScripts", json!({})).unwrap(); + assert_eq!(r["ran"], json!(["A.jsx", "b.jsx", "B2.jsx", "d.jsx"]), "{r}"); + let failed: Vec<&str> = r["failed"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect(); + assert_eq!(failed, ["C.js"]); + assert_eq!(setting(&mut s, "order"), "AbBd"); + // The failing script is reported in a toast. + let events = s.drain_events(); + assert!(events.iter().any(|e| matches!(e, effectcraft_engine::Event::Toast { message, error: true } if message.starts_with("C.js"))), "{events:?}"); + // The Startup folder's scripts are not offered in File ▸ Scripts. + let list = s.execute("file.scripts.list", json!({})).unwrap(); + assert!(list.as_array().unwrap().iter().all(|e| e["name"] != "b.jsx"), "{list}"); +} + +#[test] +fn shutdown_scripts_run_after_the_project_is_closed() { + let store = Arc::new(MemoryConfig::default()); + let probe = r#"app.settings.saveSetting("Startup Test", "items", String(app.project.numItems));"#; + store.write(&format!("{SHUTDOWN_DIR}/Save State.jsx"), probe).unwrap(); + let mut s = session(&store); + s.execute("script.run", json!({"code": r#"app.project.items.addComp("Main", 64, 64, 1, 1, 24);"#})).unwrap(); + assert!(!s.project.items.is_empty()); + let r = s.execute("file.runShutdownScripts", json!({})).unwrap(); + assert_eq!(r, json!({"ran": ["Save State.jsx"], "waiting": [], "failed": []})); + assert_eq!(setting(&mut s, "items"), "0"); + assert!(s.project.items.is_empty()); +} + +#[test] +fn a_startup_dialog_stays_open_and_its_script_goes_on_when_it_closes() { + let store = Arc::new(MemoryConfig::default()); + store.write(&format!("{STARTUP_DIR}/A Dialog.jsx"), &format!("new Window('dialog', 'Welcome').show();\n{}", mark("A"))).unwrap(); + store.write(&format!("{STARTUP_DIR}/B.jsx"), &mark("B")).unwrap(); + let mut s = session(&store); + let r = s.execute("file.runStartupScripts", json!({})).unwrap(); + assert_eq!(r, json!({"ran": ["B.jsx"], "waiting": ["A Dialog.jsx"], "failed": []})); + assert_eq!(setting(&mut s, "order"), "B"); + assert!(s.script_ui.windows.iter().any(|w| w.modal && w.title == "Welcome")); + s.execute("scriptui.close", json!({})).unwrap(); + assert_eq!(setting(&mut s, "order"), "BA"); +} + +#[test] +fn startup_scripts_tasks_go_on_after_a_project_opens() { + use web_time::{Duration, Instant}; + let store = Arc::new(MemoryConfig::default()); + store.write(&format!("{STARTUP_DIR}/A Poll.jsx"), "app.scheduleTask('writeLn(\"poll\")', 100, true);").unwrap(); + // A task scheduled after the script's dialog closes. + store.write(&format!("{STARTUP_DIR}/B Ask.jsx"), "new Window('dialog', 'Ask').show();\napp.scheduleTask('writeLn(\"ask\")', 100, true);").unwrap(); + let mut s = session(&store); + s.execute("file.runStartupScripts", json!({})).unwrap(); + s.execute("scriptui.close", json!({})).unwrap(); + // The launch opens a project after the Startup scripts ran. + s.execute("file.closeProject", json!({})).unwrap(); + let r = crate::tasks::run_tasks_at(&mut s, Instant::now() + Duration::from_millis(500)); + assert_eq!(r["ran"], json!(2), "{r}"); + // A script run from File ▸ Scripts still stops when the project closes. + s.execute("script.run", json!({"code": "app.scheduleTask('writeLn(\"menu\")', 100, true);", "name": "Menu.jsx"})).unwrap(); + s.execute("file.closeProject", json!({})).unwrap(); + let r = crate::tasks::run_tasks_at(&mut s, Instant::now() + Duration::from_millis(5000)); + assert_eq!(r["ran"], json!(2), "{r}"); +} + +#[test] +fn shutdown_dialogs_close_as_if_canceled() { + let store = Arc::new(MemoryConfig::default()); + let code = r#"var a = new Window("dialog", "One").show(); +var b = new Window("dialog", "Two").show(); +app.settings.saveSetting("Startup Test", "shown", a + "," + b);"#; + store.write(&format!("{SHUTDOWN_DIR}/Ask.jsx"), code).unwrap(); + // An error after the dialog closes is a failure. + store.write(&format!("{SHUTDOWN_DIR}/Broken.jsx"), "new Window('dialog', 'Three').show();\nthrow new Error('late');").unwrap(); + let mut s = session(&store); + let r = s.execute("file.runShutdownScripts", json!({})).unwrap(); + assert_eq!((&r["ran"], &r["waiting"]), (&json!(["Ask.jsx"]), &json!([])), "{r}"); + assert_eq!(r["failed"][0]["name"], "Broken.jsx", "{r}"); + assert!(r["failed"][0]["error"].as_str().is_some_and(|e| e.contains("late")), "{r}"); + assert_eq!(setting(&mut s, "shown"), "2,2"); + assert!(s.script_ui.windows.is_empty()); +} + +#[test] +fn shutdown_closes_only_its_own_scripts_dialogs() { + let store = Arc::new(MemoryConfig::default()); + store.write(&format!("{STARTUP_DIR}/Same.jsx"), &format!("new Window('dialog', 'Startup').show();\n{}", mark("S"))).unwrap(); + store.write(&format!("{SHUTDOWN_DIR}/Same.jsx"), &format!("new Window('dialog', 'Shutdown').show();\n{}", mark("Q"))).unwrap(); + let mut s = session(&store); + assert_eq!(s.execute("file.runStartupScripts", json!({})).unwrap()["waiting"], json!(["Same.jsx"])); + let r = s.execute("file.runShutdownScripts", json!({})).unwrap(); + assert_eq!(r, json!({"ran": ["Same.jsx"], "waiting": [], "failed": []})); + // The Startup script's dialog is still open, and its script has not gone on. + assert_eq!(setting(&mut s, "order"), "Q"); + let titles: Vec<&str> = s.script_ui.windows.iter().map(|w| w.title.as_str()).collect(); + assert_eq!(titles, ["Startup"]); +} + +#[test] +fn oversized_scripts_are_not_run() { + let store = Arc::new(MemoryConfig::default()); + let big = format!("{}\n{}", "//".repeat(8 << 20), mark("x")); + store.write(&format!("{STARTUP_DIR}/Big.jsx"), &big).unwrap(); + let mut s = session(&store); + let r = s.execute("file.runStartupScripts", json!({})).unwrap(); + assert_eq!(r["ran"], json!([])); + assert_eq!(r["failed"][0]["name"], "Big.jsx"); + let events = s.drain_events(); + assert!(events.iter().any(|e| matches!(e, effectcraft_engine::Event::Toast { message, error: true } if message.starts_with("Big.jsx"))), "{events:?}"); + assert_eq!(setting(&mut s, "order"), ""); +} + +#[test] +fn scripts_in_a_settings_folder_on_disk_include_files_beside_them() { + use effectcraft_engine::config::DirConfig; + let root = std::env::temp_dir().join(format!("ec-startup-include-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + for (file, code) in [ + (format!("{STARTUP_DIR}/lib/helper.jsx"), mark("i")), + (format!("{STARTUP_DIR}/Main.jsx"), format!("#include \"lib/helper.jsx\"\n{}", mark("m"))), + (format!("{SHUTDOWN_DIR}/Ask.jsx"), format!("new Window('dialog', 'Bye').show();\n{}", mark("q"))), + ] { + let path = root.join(file); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, code).unwrap(); + } + let mut s = Session::default(); + crate::install(&mut s); + s.config = Some(Arc::new(DirConfig::new(&root))); + let r = s.execute("file.runStartupScripts", json!({})).unwrap(); + assert_eq!(r, json!({"ran": ["Main.jsx"], "waiting": [], "failed": []})); + assert_eq!(setting(&mut s, "order"), "im"); + // A Shutdown script run as its file still has its dialog closed. + let r = s.execute("file.runShutdownScripts", json!({})).unwrap(); + assert_eq!(r, json!({"ran": ["Ask.jsx"], "waiting": [], "failed": []})); + assert_eq!(setting(&mut s, "order"), "imq"); + let _ = std::fs::remove_dir_all(&root); +} + +/// A session with one comp, for checking that a refused command leaves the project open. +fn session_with_comp(store: &Arc) -> Session { + let mut s = session(store); + s.execute("script.run", json!({"code": r#"app.project.items.addComp("Main", 64, 64, 1, 1, 24);"#})).unwrap(); + assert!(!s.project.items.is_empty()); + s +} + +const FOLDER_COMMANDS: [&str; 2] = ["file.runStartupScripts", "file.runShutdownScripts"]; + +#[test] +fn scripts_cannot_run_the_startup_or_shutdown_folders() { + let store = Arc::new(MemoryConfig::default()); + for id in FOLDER_COMMANDS { + let mut s = session_with_comp(&store); + let r = s.execute("script.run", json!({"code": format!("app.run('{id}', {{}})")})).unwrap(); + assert!(r["error"]["message"].as_str().is_some_and(|m| m.contains("scripts can't run scripts")), "{id}: {r}"); + assert!(!s.project.items.is_empty(), "{id}"); + } +} + +#[test] +fn command_scripts_cannot_run_the_startup_or_shutdown_folders() { + let store = Arc::new(MemoryConfig::default()); + for id in FOLDER_COMMANDS { + let mut s = session_with_comp(&store); + let e = s.execute("file.runScript", json!({"steps": [{"command": id}]})).unwrap_err().to_string(); + assert!(e.contains("scripts can't run scripts"), "{id}: {e}"); + assert!(!s.project.items.is_empty(), "{id}"); + } +} + +#[test] +fn batches_cannot_run_the_startup_or_shutdown_folders() { + let store = Arc::new(MemoryConfig::default()); + for id in FOLDER_COMMANDS { + let mut s = session_with_comp(&store); + let e = s.execute("engine.batch", json!({"steps": [{"command": id}]})).unwrap_err().to_string(); + assert!(e.contains("can't run inside a batch"), "{id}: {e}"); + assert!(!s.project.items.is_empty(), "{id}"); + } +} + +/// A ScriptUI panel whose script appends `p` to the `Startup Test` / `order` setting each time it +/// runs, remembered as open at the last quit. +fn session_with_panel(store: &Arc) -> Session { + store.write(&format!("{PANELS_DIR}/Mark Panel.jsx"), &format!("this.add('statictext', undefined, 'Mark');\n{}", mark("p"))).unwrap(); + store.write(OPEN_PANELS_FILE, &json!({"open": ["Mark Panel.jsx"]}).to_string()).unwrap(); + session(store) +} + +fn panel_open(s: &Session) -> bool { + s.script_ui.windows.iter().any(|w| w.kind == WindowKind::Panel && w.script == "Mark Panel.jsx") +} + +/// The commands that open ScriptUI panels, which run the panel's script. +fn panel_commands() -> [(&'static str, serde_json::Value); 2] { + [("window.scriptPanel", json!({"name": "Mark Panel.jsx"})), ("window.restoreScriptPanels", json!({}))] +} + +#[test] +fn scripts_cannot_open_script_panels() { + for (id, params) in panel_commands() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session_with_panel(&store); + // Opened by the user: a script's call would only bring it forward. + s.execute("window.scriptPanel", json!({"name": "Mark Panel.jsx"})).unwrap(); + let r = s.execute("script.run", json!({"code": format!("app.run('{id}', {params})")})).unwrap(); + assert!(r["error"]["message"].as_str().is_some_and(|m| m.contains("scripts can't run scripts")), "{id}: {r}"); + assert_eq!(setting(&mut s, "order"), "p", "{id}"); + assert_eq!(store.read(OPEN_PANELS_FILE).unwrap_or_default(), json!({"open": ["Mark Panel.jsx"]}).to_string(), "{id}"); + } +} + +#[test] +fn command_scripts_and_batches_cannot_open_script_panels() { + for (id, params) in panel_commands() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session_with_panel(&store); + let e = s.execute("file.runScript", json!({"steps": [{"command": id, "params": params}]})).unwrap_err().to_string(); + assert!(e.contains("scripts can't run scripts"), "{id}: {e}"); + let e = s.execute("engine.batch", json!({"steps": [{"command": id, "params": params}]})).unwrap_err().to_string(); + assert!(e.contains("can't run inside a batch"), "{id}: {e}"); + assert!(!panel_open(&s), "{id}"); + assert_eq!(setting(&mut s, "order"), "", "{id}"); + } +} + +#[test] +fn the_app_still_reopens_script_panels_at_launch() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session_with_panel(&store); + // As the desktop and web apps call it at launch. + let r = s.execute("window.restoreScriptPanels", json!({})).unwrap(); + assert_eq!(r, json!({"opened": ["Mark Panel.jsx"], "failed": []})); + assert!(panel_open(&s)); + assert_eq!(setting(&mut s, "order"), "p"); +} + +#[test] +fn empty_or_missing_folders_run_nothing() { + let none = json!({"ran": [], "waiting": [], "failed": []}); + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + assert_eq!(s.execute("file.runStartupScripts", json!({})).unwrap(), none); + // A session without a settings folder. + let mut s = Session::default(); + crate::install(&mut s); + assert_eq!(s.execute("file.runShutdownScripts", json!({})).unwrap(), none); +} diff --git a/crates/script/src/ui.rs b/crates/script/src/ui.rs index 082a16cac..ab6f69846 100644 --- a/crates/script/src/ui.rs +++ b/crates/script/src/ui.rs @@ -404,8 +404,14 @@ fn dispatch_inline(session: &mut Session, host: u32, name: &str, ev: &ScriptUiEv // ---------------------------------------------------------------- scheduled tasks +/// Whether `host`'s tasks stop now that the project it ran with was closed: it has no window +/// open and is not a Startup script's. +fn ends_with_project(session: &Session, host: u32) -> bool { + !session.script_ui.windows.iter().any(|w| w.host == host) && !session.script_ui.startup_hosts.contains(&host) +} + /// Run the due tasks of the script-host threads; hosts without windows whose project was closed -/// end. +/// end, except Startup scripts' hosts. #[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; @@ -413,8 +419,7 @@ pub(crate) fn tick_threaded(session: &mut Session, now: Instant, report: &mut cr 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 { + if end.epoch != epoch && ends_with_project(session, host) { // Dropping its end lets the host thread finish. stale.extend(h.remove(&host)); } else if end.next_due.is_some_and(|d| d <= now) { @@ -441,7 +446,7 @@ pub(crate) fn tick_threaded(session: &mut Session, now: Instant, report: &mut cr } /// Run the due tasks of the inline hosts on this thread (the Script Console, web runs); those -/// without windows whose project was closed stop. +/// without windows whose project was closed stop, except Startup scripts' hosts. 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) { @@ -449,7 +454,7 @@ pub(crate) fn tick_inline(session: &mut Session, now: Instant, report: &mut crat } 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) { + if !alive || (epoch != session.script_ui.project_epoch && ends_with_project(session, host)) { crate::tasks::drop_host(host); if host != CONSOLE_HOST && windowless { drop_inline(host); diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index a31a17124..5388629cf 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -385,7 +385,8 @@ pub fn scan_system() -> usize { } /// Scan the system font folders again, adding fonts installed since the last scan (faces already -/// known are kept, so font ids stay valid). Returns the number of faces added. +/// known are kept, so font ids stay valid). Returns the number of faces added. This walks every +/// fontconfig directory, so the UI calls [`start_rescan`] instead of running it on the frame thread. pub fn rescan_system() -> usize { let _scan = SCAN.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let n = if cfg!(target_arch = "wasm32") { 0 } else { add_source(&DirectorySource::system()) }; @@ -393,6 +394,60 @@ pub fn rescan_system() -> usize { n } +/// A rescan started by [`start_rescan`], if one is in flight or finished and not yet polled. +#[cfg(not(target_arch = "wasm32"))] +static RESCAN: std::sync::Mutex>> = std::sync::Mutex::new(None); + +/// Start [`rescan_system`] on a background thread. Returns false when a rescan is already +/// running, the thread could not be spawned, or this build has no system font folders (wasm). +pub fn start_rescan() -> bool { + #[cfg(target_arch = "wasm32")] + { + false + } + #[cfg(not(target_arch = "wasm32"))] + { + let mut slot = RESCAN.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if slot.as_ref().is_some_and(|h| !h.is_finished()) { + return false; + } + match std::thread::Builder::new().name("font-rescan".into()).spawn(rescan_system) { + Ok(handle) => { + *slot = Some(handle); + true + } + Err(_) => false, + } + } +} + +/// Whether a background rescan is still walking the font folders. +pub fn rescan_busy() -> bool { + #[cfg(target_arch = "wasm32")] + { + false + } + #[cfg(not(target_arch = "wasm32"))] + { + RESCAN.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref().is_some_and(|h| !h.is_finished()) + } +} + +/// Faces added by the latest background rescan, once that thread has finished. `None` while it +/// is still running or when nothing was started. +pub fn poll_rescan() -> Option { + #[cfg(target_arch = "wasm32")] + { + None + } + #[cfg(not(target_arch = "wasm32"))] + { + let mut slot = RESCAN.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let finished = slot.as_ref().is_some_and(std::thread::JoinHandle::is_finished); + if finished { slot.take().and_then(|h| h.join().ok()) } else { None } + } +} + /// Start the system font scan on a background thread, so the font menus are complete by the /// time someone opens them without the first open waiting for the scan. Native only; on wasm /// (no threads, no font folders) this does nothing. diff --git a/crates/text/src/fonts/tests.rs b/crates/text/src/fonts/tests.rs index 9a6ba365a..1f7e0d46f 100644 --- a/crates/text/src/fonts/tests.rs +++ b/crates/text/src/fonts/tests.rs @@ -203,6 +203,8 @@ fn system_scan_finds_fonts() { let n = all_faces().iter().filter(|f| f.info.origin == "system").count(); eprintln!("system fonts: {n} faces in {:?}", t.elapsed()); assert_eq!(scan_system(), 0, "scans once"); + // A minimum face count on Linux belongs with the native-fonts gate in #1591. A container + // that has fontconfig and only a handful of faces must still pass this portable test. if cfg!(target_os = "macos") { assert!(n > 20, "{n}"); let r = resolve("Helvetica", "Bold"); diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index a3263a677..afc2af855 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -144,6 +144,9 @@ impl EffectcraftApp { // commands handled here use the right catalog already. let language = crate::i18n::language(self); crate::i18n::set_current(language); + // #1089: a font-menu rescan runs off the UI thread. Poll it on the logic tick so the + // status and the menu update even when the window is not painting. + crate::panels::text_panels::poll_font_rescan(self, ctx); if self.styled_language != language { self.want_paint = true; } diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index 6704017b9..f2dc3975d 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -246,7 +246,7 @@ pub fn contributors_ui(ui: &mut egui::Ui, auto: &mut Registry) { ui.horizontal_wrapped(|ui| { ui.label(tr("Show")); for m in NameMode::ALL { - let r = ui.selectable_label(v.names == m, m.label()); + let r = ui.selectable_label(v.names == m, tr(m.label())); auto.add(&format!("about.credits.show.{}", m.id()), r.rect, m.label()); if r.clicked() { v.names = m; @@ -254,9 +254,9 @@ pub fn contributors_ui(ui: &mut egui::Ui, auto: &mut Registry) { } ui.separator(); ui.label(tr("Sort")); - let combo = craft_ui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| { + let combo = craft_ui::ComboBox::from_id_salt("credits_sort").selected_text(tr(v.key.label().0)).show_ui(ui, |ui| { for k in SortKey::ALL { - let r = ui.selectable_label(v.key == k, k.label().0); + let r = ui.selectable_label(v.key == k, tr(k.label().0)); auto.add(&format!("about.credits.sort.{}", k.id()), r.rect, k.label().0); if r.clicked() { v.key = k; @@ -309,7 +309,7 @@ fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View, auto: &mut Regi egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| { for k in SortKey::ALL { let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" }; - let r = ui.button(RichText::new(format!("{}{arrow}", k.label().1)).strong()).on_hover_text(k.label().0); + let r = ui.button(RichText::new(format!("{}{arrow}", k.label().1)).strong()).on_hover_text(tr(k.label().0)); auto.add(&format!("about.credits.header.{}", k.id()), r.rect, k.label().0); if r.clicked() { if v.key == k { diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index d17af3b46..483dcf4bf 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -799,7 +799,11 @@ impl DockContent for EffectContent<'_> { } fn logical_label(&self, panel: &PanelKind) -> String { - crate::i18n::command_label("window.panel", panel.title()).to_string() + let title = panel.title(); + // The Window menu names most panels; `Markers` is only in the panel catalog, and the two + // panels in neither (`History`, `ScriptUI Panel`) keep their English names. + let listed = crate::i18n::command_label("window.panel", title); + if listed == title { crate::i18n::tr(title).to_string() } else { listed.to_string() } } /// The tab names what the panel shows ("Composition Intro"), after the label swatch of its diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 254eec49c..89df97436 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -266,6 +266,8 @@ pub(crate) fn entry(app: &EffectcraftApp, e: &MenuEntry, shown: String) -> Strin "es" => Some(("Deshacer", "No se puede deshacer", "Rehacer", "No se puede rehacer")), "pt-br" => Some(("Desfazer", "Não há nada para desfazer", "Refazer", "Não há nada para refazer")), "cs" => Some(("Zpět", "Není co vrátit", "Znovu", "Není co zopakovat")), + "fr" => Some(("Annuler", "Impossible d'annuler", "Rétablir", "Impossible de rétablir")), + "de" => Some(("Rückgängig", "Rückgängig nicht möglich", "Wiederholen", "Wiederholen nicht möglich")), // A colon before the history's (untranslated) action name: "დაბრუნება: Comp1". "ka" => Some(("დაბრუნება:", "დაბრუნება შეუძლებელია", "ხელახლა შესრულება:", "ხელახლა შესრულება შეუძლებელია")), // A colon before the history's (untranslated) action name: "Отменить: Comp1". @@ -836,6 +838,8 @@ const SIMPLIFIED_CHINESE: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "行落下"), ("anim.addKeyframe", "Add Keyframe", "添加关键帧"), ("keys.toggleHold", "Toggle Hold Keyframe", "切换定格关键帧"), + ("keys.setMuted", "Mute Keyframes", "静音关键帧"), + ("keys.setMuted", "Unmute Keyframes", "取消静音关键帧"), ("keys.interpolation", "Keyframe Interpolation...", "关键帧插值..."), ("keys.velocity", "Keyframe Velocity...", "关键帧速度..."), ("", "Keyframe Assistant", "关键帧辅助"), @@ -1472,6 +1476,8 @@ const TRADITIONAL_CHINESE: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "行落下"), ("anim.addKeyframe", "Add Keyframe", "新增關鍵影格"), ("keys.toggleHold", "Toggle Hold Keyframe", "切換定格關鍵影格"), + ("keys.setMuted", "Mute Keyframes", "靜音關鍵影格"), + ("keys.setMuted", "Unmute Keyframes", "取消靜音關鍵影格"), ("keys.interpolation", "Keyframe Interpolation...", "關鍵影格插補..."), ("keys.velocity", "Keyframe Velocity...", "關鍵影格速度..."), ("", "Keyframe Assistant", "關鍵影格輔助"), @@ -2105,6 +2111,8 @@ const JAPANESE: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "行をドロップイン"), ("anim.addKeyframe", "Add Keyframe", "キーフレームを追加"), ("keys.toggleHold", "Toggle Hold Keyframe", "停止したキーフレームの切り替え"), + ("keys.setMuted", "Mute Keyframes", "キーフレームをミュート"), + ("keys.setMuted", "Unmute Keyframes", "キーフレームのミュートを解除"), ("keys.interpolation", "Keyframe Interpolation...", "キーフレーム補間法..."), ("keys.velocity", "Keyframe Velocity...", "キーフレーム速度..."), ("", "Keyframe Assistant", "キーフレーム補助"), @@ -2741,6 +2749,8 @@ const UKRAINIAN: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "Поява рядків згори"), ("anim.addKeyframe", "Add Keyframe", "Додати ключовий кадр"), ("keys.toggleHold", "Toggle Hold Keyframe", "Перемкнути утримання ключового кадру"), + ("keys.setMuted", "Mute Keyframes", "Заглушити ключові кадри"), + ("keys.setMuted", "Unmute Keyframes", "Зняти заглушення ключових кадрів"), ("keys.interpolation", "Keyframe Interpolation...", "Інтерполяція ключових кадрів..."), ("keys.velocity", "Keyframe Velocity...", "Швидкість ключових кадрів..."), ("", "Keyframe Assistant", "Помічник ключових кадрів"), @@ -3378,6 +3388,8 @@ const SPANISH: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "Entrada de líneas cayendo"), ("anim.addKeyframe", "Add Keyframe", "Añadir fotograma clave"), ("keys.toggleHold", "Toggle Hold Keyframe", "Conmutar mantener fotograma clave"), + ("keys.setMuted", "Mute Keyframes", "Silenciar fotogramas clave"), + ("keys.setMuted", "Unmute Keyframes", "Reactivar fotogramas clave"), ("keys.interpolation", "Keyframe Interpolation...", "Interpolación de fotogramas clave..."), ("keys.velocity", "Keyframe Velocity...", "Velocidad de fotogramas clave..."), ("", "Keyframe Assistant", "Asistente de fotogramas clave"), @@ -4013,6 +4025,8 @@ const PORTUGUESE: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "Entrar com queda de linhas"), ("anim.addKeyframe", "Add Keyframe", "Adicionar quadro-chave"), ("keys.toggleHold", "Toggle Hold Keyframe", "Alternar quadro-chave de retenção"), + ("keys.setMuted", "Mute Keyframes", "Silenciar quadros-chave"), + ("keys.setMuted", "Unmute Keyframes", "Reativar quadros-chave"), ("keys.interpolation", "Keyframe Interpolation...", "Interpolação de quadro-chave..."), ("keys.velocity", "Keyframe Velocity...", "Velocidade do quadro-chave..."), ("", "Keyframe Assistant", "Assistente de quadro-chave"), @@ -4655,6 +4669,8 @@ const RUSSIAN: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "Появление строк сверху"), ("anim.addKeyframe", "Add Keyframe", "Добавить ключевой кадр"), ("keys.toggleHold", "Toggle Hold Keyframe", "Переключить фиксацию ключевого кадра"), + ("keys.setMuted", "Mute Keyframes", "Отключить ключевые кадры"), + ("keys.setMuted", "Unmute Keyframes", "Включить ключевые кадры"), ("keys.interpolation", "Keyframe Interpolation...", "Интерполяция ключевых кадров..."), ("keys.velocity", "Keyframe Velocity...", "Скорость ключевых кадров..."), ("", "Keyframe Assistant", "Помощник по ключевым кадрам"), @@ -5290,6 +5306,8 @@ const FRENCH: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "Chute des lignes depuis le haut"), ("anim.addKeyframe", "Add Keyframe", "Ajouter une image clé"), ("keys.toggleHold", "Toggle Hold Keyframe", "Activer/Désactiver l'image clé figée"), + ("keys.setMuted", "Mute Keyframes", "Désactiver les images clés"), + ("keys.setMuted", "Unmute Keyframes", "Réactiver les images clés"), ("keys.interpolation", "Keyframe Interpolation...", "Interpolation des images clés..."), ("keys.velocity", "Keyframe Velocity...", "Vitesse des images clés..."), ("", "Keyframe Assistant", "Assistant d'images clés"), @@ -5925,6 +5943,8 @@ const GERMAN: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "Zeilen fallen ein"), ("anim.addKeyframe", "Add Keyframe", "Keyframe hinzufügen"), ("keys.toggleHold", "Toggle Hold Keyframe", "Halte-Keyframe umschalten"), + ("keys.setMuted", "Mute Keyframes", "Keyframes stummschalten"), + ("keys.setMuted", "Unmute Keyframes", "Keyframes wieder aktivieren"), ("keys.interpolation", "Keyframe Interpolation...", "Keyframe-Interpolation..."), ("keys.velocity", "Keyframe Velocity...", "Keyframe-Geschwindigkeit..."), ("", "Keyframe Assistant", "Keyframe-Assistent"), @@ -6561,6 +6581,8 @@ const CZECH: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "Spustit řádky shora"), ("anim.addKeyframe", "Add Keyframe", "Přidat klíčový snímek"), ("keys.toggleHold", "Toggle Hold Keyframe", "Přepnout podržení klíčového snímku"), + ("keys.setMuted", "Mute Keyframes", "Ztlumit klíčové snímky"), + ("keys.setMuted", "Unmute Keyframes", "Obnovit klíčové snímky"), ("keys.interpolation", "Keyframe Interpolation...", "Interpolace klíčových snímků..."), ("keys.velocity", "Keyframe Velocity...", "Rychlost klíčového snímku..."), ("", "Keyframe Assistant", "Pomocník klíčových snímků"), @@ -7192,6 +7214,8 @@ const GEORGIAN: &[(&str, &str, &str)] = &[ ("layer.applyTextPreset", "Drop In Lines", "სტრიქონების ჩამოვარდნა"), ("anim.addKeyframe", "Add Keyframe", "საკვანძო კადრის დამატება"), ("keys.toggleHold", "Toggle Hold Keyframe", "ფიქსირებული საკვანძო კადრის გადართვა"), + ("keys.setMuted", "Mute Keyframes", "საკვანძო კადრების დადუმება"), + ("keys.setMuted", "Unmute Keyframes", "საკვანძო კადრების დადუმების მოხსნა"), ("keys.interpolation", "Keyframe Interpolation...", "საკვანძო კადრის ინტერპოლაცია..."), ("keys.velocity", "Keyframe Velocity...", "საკვანძო კადრის სიჩქარე..."), ("", "Keyframe Assistant", "საკვანძო კადრების ასისტენტი"), @@ -7512,6 +7536,19 @@ mod tests { println!("total: {total}"); } + /// Prints the translated-row counts per language, the numbers `docs/localization-parity.md` + /// quotes. `cargo xtask i18n-report` runs it with `--nocapture`. + #[test] + fn catalog_row_counts_report() { + println!("Localization catalog rows:"); + for code in CATALOG_CODES { + let menus = menu_catalog(code).map_or(0, |catalog| catalog.len()); + let panels = ui::translated_rows(code); + let effect_names = effects::translated_rows(code); + println!("{code}: menus {menus}, panels {panels}, effect names {effect_names}, total {}", menus + panels + effect_names); + } + } + #[test] fn menu_catalogs_have_no_stale_or_duplicate_rows() { let mut actual = BTreeSet::new(); @@ -7550,6 +7587,43 @@ mod tests { app.session.execute("prefs.set", json!({"key":"general.language", "value":"en"})).unwrap(); assert_eq!(label(&app, "", "Composition"), "Composition"); } + /// Match System in the browser also picks the Russian, French and German catalogs, in the + /// order of the browser's preferences (#1700). + #[test] + fn browser_preferences_choose_russian_french_and_german() { + let tags = |t: &[&str]| t.iter().map(|s| s.to_string()).collect::>(); + for (locales, expected) in + [(vec!["de-DE", "es-ES"], "de"), (vec!["xx-XX", "fr-CA"], "fr"), (vec!["ru-RU", "cs"], "ru"), (vec!["en-US", "de-DE"], "en"), (vec!["xx-XX"], "en")] + { + assert_eq!(super::first_supported(None, &tags(&locales), super::web_draws), expected, "{locales:?}"); + } + } + + /// The Edit menu's Undo and Redo name the history's action in the interface language, also in + /// the languages added with #1700. + #[test] + fn undo_and_redo_are_localized_for_russian_french_and_german() { + for (language, undo, no_undo, redo, no_redo) in [ + ("ru", "Отменить:", "Нечего отменять", "Повторить:", "Нечего повторять"), + ("fr", "Annuler", "Impossible d'annuler", "Rétablir", "Impossible de rétablir"), + ("de", "Rückgängig", "Rückgängig nicht möglich", "Wiederholen", "Wiederholen nicht möglich"), + ] { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.session.execute("prefs.set", json!({"key": "general.language", "value": language})).unwrap(); + let entry_of = |app: &EffectcraftApp, command: &str| { + let (_, e) = effectcraft_engine::menus::entries().into_iter().find(|(_, e)| e.command == command).unwrap(); + entry(app, e, e.label.clone()) + }; + assert_eq!(entry_of(&app, "edit.undo"), no_undo, "{language}"); + assert_eq!(entry_of(&app, "edit.redo"), no_redo, "{language}"); + app.session.execute("comp.new", json!({"name": "A", "duration": 1})).unwrap(); + let action = app.session.history.undo.last().map(|u| u.0.clone()).unwrap(); + assert_eq!(entry_of(&app, "edit.undo"), format!("{undo} {action}"), "{language}"); + app.session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(entry_of(&app, "edit.redo"), format!("{redo} {action}"), "{language}"); + } + } + #[test] fn match_system_uses_the_system_language_where_there_is_a_catalog() { for (locale, language) in [ @@ -7643,6 +7717,35 @@ mod tests { } } + /// The dock's tab titles are `PanelKind::title()`; the panels the Window menu lists are drawn in + /// the interface language through their `(window.panel, title)` rows. `History` and `ScriptUI + /// Panel` are in no menu and keep their English names, which is what this pins. + #[test] + fn the_dock_draws_every_panel_it_can_in_the_interface_language() { + use crate::dock::PanelKind; + const WITHOUT_A_ROW: &[&str] = &["History", "ScriptUI Panel"]; + for (name, catalog, code) in [("ja", JAPANESE, "ja"), ("zh-hans", SIMPLIFIED_CHINESE, "zh-hans"), ("zh-hant", TRADITIONAL_CHINESE, "zh-hant")] { + // The dock asks both catalogs, so a title may be named by either. + ui::set_current(code); + for p in PanelKind::ALL { + let title = p.title(); + let in_menu = catalog.iter().any(|(c, en, _)| *c == "window.panel" && *en == title); + let in_panel = ui::tr(title) != title; + assert_eq!( + in_menu || in_panel, + !WITHOUT_A_ROW.contains(&title), + "{name}: {title} is {}named by a catalog and {}in the exception list", + if in_menu || in_panel { "" } else { "not " }, + if WITHOUT_A_ROW.contains(&title) { "" } else { "not " } + ); + } + } + ui::set_current("en"); + // The dock asks the catalog rather than drawing the English name. + let dock_ui = include_str!("dock_ui.rs"); + assert!(dock_ui.contains(r#"crate::i18n::command_label("window.panel", title)"#), "dock_ui.rs no longer asks the catalog for the panel titles"); + } + #[test] fn ukrainian_catalog_matches_the_settings_registry() { let codes: Vec<_> = effectcraft_engine::prefs::LANGUAGES.iter().map(|(_, code)| *code).filter(|code| !matches!(*code, "system" | "en")).collect(); @@ -7676,7 +7779,7 @@ mod tests { app.session.execute("prefs.set", json!({"key":"general.language", "value":"ru"})).unwrap(); assert_eq!(entry(&app, &undo, "Can't Undo".into()), "Нечего отменять"); app.session.execute("prefs.set", json!({"key":"general.language", "value":"de"})).unwrap(); - assert_eq!(entry(&app, &undo, "Can't Undo".into()), "Can't Undo", "no German undo wording yet: the shown label"); + assert_eq!(entry(&app, &undo, "Can't Undo".into()), "Rückgängig nicht möglich"); app.session.execute("prefs.set", json!({"key":"general.language", "value":"en"})).unwrap(); assert_eq!(entry(&app, &new_comp, new_comp.label.clone()), "New Composition..."); } diff --git a/crates/ui-egui/src/i18n/effects.rs b/crates/ui-egui/src/i18n/effects.rs index 0e767f5ac..bb7e3715a 100644 --- a/crates/ui-egui/src/i18n/effects.rs +++ b/crates/ui-egui/src/i18n/effects.rs @@ -87,6 +87,13 @@ pub(crate) fn name(id: &str, english: &str) -> String { catalog(current()).and_then(|c| c.row(id)).map_or_else(|| english.to_string(), |(_, translated)| translated.to_string()) } +/// The number of effect-name rows `language` translates, for the row-count report; 0 when it has +/// no catalog or when it is English. +#[cfg(test)] +pub(super) fn translated_rows(language: &str) -> usize { + catalog(language).map_or(0, |c| c.rows.len()) +} + /// The name to draw for an effect group the property tree stores as `stored`: the catalog name, /// plus the instance index the engine appends to a repeat (`Gaussian Blur 2` → `高斯模糊 2`). /// diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 061090871..af68b3431 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -92,6 +92,13 @@ pub(super) fn missing(language: &str) -> Vec { rows.chain(literals).filter(|en| !table.is_some_and(|c| c.contains_key(en.as_str())) && seen.insert(en.clone())).collect() } +/// The number of panel/dialog rows `language` translates, for the row-count report; 0 when it has +/// no catalog or when it is English. +#[cfg(test)] +pub(super) fn translated_rows(language: &str) -> usize { + catalog(language).map_or(0, Index::len) +} + /// The string literals `src` passes to `tr(` and `tr_args(`. #[cfg(test)] fn tr_literals(src: &str) -> Vec { @@ -196,6 +203,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Add:", "追加:"), ("Adjust Exposure (stops)", "露出を調整(段単位)"), ("Adjust composition duration to the time span of the selected layers", "コンポジションのデュレーションを、選択したレイヤーの時間範囲に合わせる"), + ("Advanced", "詳細"), ("Advanced 3D", "アドバンスド3D"), ("Align Layers to:", "レイヤーの整列基準:"), ("All six face cameras turn together; the scene is not changed.", "6つの面のカメラがすべて一緒に回転します。シーンは変更されません。"), @@ -434,6 +442,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("No layer selected", "レイヤーが選択されていません"), ("No matching effects or presets", "一致するエフェクトまたはプリセットはありません"), ("No matching fonts", "一致するフォントはありません"), + ("Rescan Fonts", "フォントを再スキャン"), ("Variable fonts only", "可変フォントのみ"), ("No model credits were built into this copy.", "このビルドにはモデルのクレジットが含まれていません。"), ("No nested compositions", "ネストされたコンポジションはありません"), @@ -469,6 +478,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Parent pick whip: drag onto a layer", "親ピックウィップ:レイヤー上にドラッグ"), ("Track Matte pick whip: drag onto a layer", "トラックマットピックウィップ:レイヤー上にドラッグ"), ("Pixel Aspect Ratio:", "ピクセル縦横比:"), + ("Play/Stop", "再生/停止"), ("Playing on Video Preview", "ビデオプレビューで再生中"), ("Post-Render Action: {}", "レンダリング後の処理:{}"), ("Preserve frame rate when nested or in render queue", "ネスト時またはレンダーキュー内でフレームレートを保持"), @@ -902,6 +912,310 @@ const JAPANESE: &[(&str, &str)] = &[ ("Total Offset", "合計オフセット"), ("Per Layer", "レイヤーごと"), ("Track", "トラック"), + ("Force alphabetical order", "アルファベット順を強制"), + ("Frame rate (fps)", "フレームレート (fps)"), + ("Import Kind", "読み込みの種類"), + ("Delay (s)", "遅延 (秒)"), + ("Amount (%)", "量 (%)"), + ("Cascade (farther pins trail longer)", "カスケード (離れたピンほど長く遅れる)"), + ("Speed (%)", "速度 (%)"), + ("Use Draft Deformation", "下書き変形を使用"), + ("Show Mesh", "メッシュを表示"), + ("Scale X (%)", "X 方向の拡大率 (%)"), + ("Scale Y (%)", "Y 方向の拡大率 (%)"), + ("Rotation (degrees)", "回転 (度)"), + ("Move X", "X 移動"), + ("Move Y", "Y 移動"), + ("Position (pixels)", "位置 (ピクセル)"), + ("Position (percent of the composition)", "位置 (コンポジションに対する割合)"), + ("Use Dimensions From", "使用するサイズの基準"), + ("Still Duration (seconds)", "静止画のデュレーション (秒)"), + ("Add to Render Queue", "レンダーキューに追加"), + ("Sequence Layers", "レイヤーをシーケンス化"), + ("Duration (seconds)", "デュレーション (秒)"), + ("Matte color (premultiplied)", "マットの色 (プリマルチプライ)"), + ("Invert Alpha", "アルファを反転"), + ("Assume this frame rate", "このフレームレートとみなす"), + ("Separate Fields", "フィールドを分離"), + ("Pixel Aspect Ratio", "ピクセル縦横比"), + ("Loop (times)", "ループ (回)"), + ("Assign Profile", "プロファイルを割り当て"), + ("Preserve RGB", "RGB を保持"), + ("Interpret As Linear Light", "リニア光として解釈"), + ("Start Timecode", "開始タイムコード"), + ("Override Start", "開始値を上書き"), + ("Time display style", "タイムコード表示"), + ("Color engine", "カラーエンジン"), + ("Working space", "作業用スペース"), + ("Working gamma", "作業用ガンマ"), + ("Linearize working space", "作業用スペースをリニア化"), + ("Blend colors using 1.0 gamma", "1.0 ガンマで色を混合"), + ("Output color space", "出力カラースペース"), + ("Video rendering and effects", "ビデオのレンダリングとエフェクト"), + ("Time Span", "時間範囲"), + ("Blur (pixels before auto-trace)", "ブラー (自動トレース前のピクセル)"), + ("Tolerance (pixels)", "許容値 (ピクセル)"), + ("Threshold (%)", "しきい値 (%)"), + ("Minimum Area (pixels)", "最小領域 (ピクセル)"), + ("Corner Roundness (%)", "角の丸み (%)"), + ("Apply To New Layer", "新しいレイヤーに適用"), + ("Sensitivity threshold (0–1, lower finds more)", "感度のしきい値 (0~1、低いほど多く検出)"), + ("Time field (empty: automatic)", "時間フィールド (空欄: 自動)"), + ("Video start (ISO date-time, hh:mm:ss, timecode; empty: file date)", "ビデオ開始 (ISO 日時、hh:mm:ss、タイムコード、空欄: ファイルの日付)"), + ("First sample at (comp seconds)", "最初のサンプル位置 (コンポジション秒)"), + ("Frame rate", "フレームレート"), + ("Color (#rrggbb)", "カラー (#rrggbb)"), + ("Embed footage files", "フッテージを埋め込む"), + ("Embed up to (MB)", "埋め込み上限 (MB)"), + ("Orient Along Path", "パスに沿って向きを設定"), + ("Orient Towards Camera", "カメラの方向に向ける"), + ("Orient Towards Point of Interest", "対象点の方向に向ける"), + ("Single Composition", "単一コンポジション"), + ("Multiple Compositions", "複数のコンポジション"), + ("Dissolve Front Layer", "前面のレイヤーをディゾルブ"), + ("Cross Dissolve Front and Back Layers", "前後のレイヤーをクロスディゾルブ"), + ("Interpret Straight - Unmatted", "ストレート (マットなし) として解釈"), + ("Interpret Premultiplied - Matted With Color", "プリマルチプライ (色でマット) として解釈"), + ("Upper Field First", "偶数フィールド優先"), + ("Lower Field First", "奇数フィールド優先"), + ("Rec. 2020", "Rec. 2020"), + ("Use Source File Timecode", "ソースファイルのタイムコードを使用"), + ("Feet + Frames (35mm)", "フィート + フレーム (35mm)"), + ("Feet + Frames (16mm)", "フィート + フレーム (16mm)"), + ("Current Frame", "現在のフレーム"), + ("Work Area", "作業領域"), + ("Create Markers", "マーカーを作成"), + ("Split Layers", "レイヤーを分割"), + ("Split and Precompose", "分割してプリコンポーズ"), + ("All layers", "すべてのレイヤー"), + ("None (leave them out)", "なし (除外)"), + ("Nested sequences", "ネストしたシーケンス"), + ("Solid…", "平面…"), + ("Light…", "ライト…"), + ("Camera…", "カメラ…"), + ("Null Object", "ヌルオブジェクト"), + ("Shape Layer", "シェイプレイヤー"), + ("Adjustment Layer", "調整レイヤー"), + ("Layer Settings…", "レイヤー設定…"), + ("Create Shapes from Vector Layer", "ベクターレイヤーからシェイプを作成"), + ("Create Shapes from Text", "テキストからシェイプを作成"), + ("Create Masks from Text", "テキストからマスクを作成"), + ("New Mask", "新しいマスク"), + ("Center In View", "ビュー内で中央揃え"), + ("Fit to Comp", "コンポジションに合わせる"), + ("Flip Horizontal", "水平方向に反転"), + ("Flip Vertical", "垂直方向に反転"), + ("Enable Time Remapping", "時間リマップを有効化"), + ("Time-Reverse Layer", "レイヤーを時間反転"), + ("Freeze Frame", "フレームを固定"), + ("Freeze On Last Frame", "最後のフレームで固定"), + ("Add Marker", "マーカーを追加"), + ("Bring Layer to Front", "レイヤーを最前面へ"), + ("Bring Layer Forward", "レイヤーを前面へ"), + ("Send Layer Backward", "レイヤーを背面へ"), + ("Send Layer to Back", "レイヤーを最背面へ"), + ("Pre-compose…", "プリコンポーズ…"), + ("Split Layer", "レイヤーを分割"), + ("Layer Style Options...", "レイヤースタイルのオプション..."), + ("Convert to Editable Styles", "編集可能なスタイルに変換"), + ("Show All", "すべて表示"), + ("Remove All", "すべて削除"), + ("Separate Dimensions", "次元を分離"), + ("Add Property to Essential Graphics", "エッセンシャルグラフィックスにプロパティを追加"), + ("Continuous Bezier", "連続ベジェ"), + ("Auto Bezier", "自動ベジェ"), + ("Lock To Time", "時間に固定"), + ("Rove Across Time", "時間をまたいで移動"), + ("Anchor Point", "アンカーポイント"), + ("All Transform Properties", "すべてのトランスフォームプロパティ"), + ("Line Anchor", "行のアンカー"), + ("Line Spacing", "行間"), + ("Character Offset", "文字オフセット"), + ("Character Value", "文字値"), + ("Fill Color: RGB", "塗りの色: RGB"), + ("Fill Color: Hue", "塗りの色: 色相"), + ("Fill Color: Saturation", "塗りの色: 彩度"), + ("Fill Color: Brightness", "塗りの色: 明るさ"), + ("Fill Color: Opacity", "塗りの色: 不透明度"), + ("Stroke Color: RGB", "線の色: RGB"), + ("Stroke Color: Hue", "線の色: 色相"), + ("Stroke Color: Saturation", "線の色: 彩度"), + ("Stroke Color: Brightness", "線の色: 明るさ"), + ("Stroke Color: Opacity", "線の色: 不透明度"), + ("Stroke Width", "線幅"), + ("Front: Color", "前面: 色"), + ("Bevel: Color", "ベベル: 色"), + ("Side: Color", "側面: 色"), + ("Back: Color", "背面: 色"), + ("Effect Controls", "エフェクトコントロール"), + ("Effects & Presets", "エフェクト&プリセット"), + ("Motion Sketch", "モーションスケッチ"), + ("Render Queue", "レンダーキュー"), + ("Mask Interpolation", "マスク補間"), + ("Script Console", "スクリプトコンソール"), + ("Essential Graphics", "エッセンシャルグラフィックス"), + ("ScriptUI Panel", "ScriptUI パネル"), + ("Lumetri Scopes", "Lumetri スコープ"), + ("Media Browser", "メディアブラウザー"), + ("Content-Aware Fill", "コンテンツに応じた塗り"), + ("Create Nulls From Paths", "パスからヌルを作成"), + ("VR Comp Editor", "VR コンポジションエディター"), + ("Media Duration", "メディアの長さ"), + ("Frame Rate", "フレームレート"), + ("File Path", "ファイルパス"), + ("Interpret Footage", "フッテージの解釈"), + ("Create a new Folder", "新しいフォルダーを作成"), + ("Create a new Composition", "新しいコンポジションを作成"), + ("First Frame", "最初のフレーム"), + ("Previous Frame", "前のフレーム"), + ("Next Frame", "次のフレーム"), + ("Last Frame", "最後のフレーム"), + ("Name (A–Z)", "名前 (A–Z)"), + ("Merged PRs", "マージ済み PR"), + ("Lines added", "追加行数"), + ("Lines deleted", "削除行数"), + ("Line delta", "行数の差分"), + ("Binary assets added", "追加バイナリ"), + ("Binary assets removed", "削除バイナリ"), + ("First commit", "最初のコミット"), + ("Last commit", "最後のコミット"), + ("Display name", "表示名"), + ("Real name", "本名"), + ("ArtCraft website", "ArtCraft のウェブサイト"), + ("Source code on GitHub", "GitHub のソースコード"), + ("3D Renderer", "3D レンダラー"), + ("Preset Name", "プリセット名"), + ("Save Preset…", "プリセットを保存…"), + ("Delete Preset", "プリセットを削除"), + ("Lock Aspect Ratio", "縦横比を固定"), + ("Frame Rate presets", "フレームレートのプリセット"), + ("Background Color", "背景色"), + ("Basic", "基本"), + ("Shutter Angle", "シャッター角度"), + ("Shutter Phase", "シャッター位相"), + ("Samples Per Frame", "フレームあたりのサンプル数"), + ("Adaptive Sample Limit", "適応サンプル数の上限"), + ("X and Y", "X と Y"), + ("X only", "X のみ"), + ("Y only", "Y のみ"), + ("Track Camera", "カメラをトラッキング"), + ("Warp Stabilizer", "ワープスタビライザー"), + ("Track Motion", "モーションをトラッキング"), + ("Stabilize Motion", "モーションを安定化"), + ("Motion Source:", "モーションソース:"), + ("Current Track:", "現在のトラック:"), + ("Track Type:", "トラックの種類:"), + ("Motion Target:", "モーションの適用先:"), + ("Track Mask:", "トラックマスク:"), + ("Analyze forward", "前方を解析"), + ("Analyze backward", "後方を解析"), + ("Continue Tracking", "トラッキングを続行"), + ("Stop Tracking", "トラッキングを停止"), + ("Adapt Feature", "特徴を適応"), + ("Extrapolate Motion", "モーションを外挿"), + ("Attach Point", "アタッチポイント"), + ("Track Name", "トラック名"), + ("Motion Tracker Options", "モーショントラッカーのオプション"), + ("Motion Tracker Apply Options", "モーショントラッカーの適用オプション"), + ("Edit Value Graph", "値グラフを編集"), + ("Edit Speed Graph", "速度グラフを編集"), + ("Show Reference Graph", "参照グラフを表示"), + ( + "Auto-Select Graph Type (speed graph for spatial properties, else value graph)", + "グラフの種類を自動選択 (空間プロパティは速度グラフ、それ以外は値グラフ)", + ), + ("Auto-zoom graph height", "グラフの高さを自動ズーム"), + ("Fit all graphs to view", "すべてのグラフをビューに合わせる"), + ("Fit selection to view", "選択項目をビューに合わせる"), + ("Snap (key drags snap to the current time and other keys)", "スナップ (キーのドラッグを現在の時間や他のキーに合わせる)"), + ("Show Transform Box when multiple keys are selected", "複数のキーを選択したときにトランスフォームボックスを表示"), + ("Separate Dimensions (Position)", "次元を分離 (位置)"), + ("Convert selected keyframes to Hold", "選択したキーフレームをホールドに変換"), + ("Convert selected keyframes to Linear", "選択したキーフレームをリニアに変換"), + ("Convert selected keyframes to Auto Bezier", "選択したキーフレームを自動ベジェに変換"), + ("Easy Ease (F9)", "イージーイーズ (F9)"), + ("Easy Ease In (Shift+F9)", "イージーイーズイン (Shift+F9)"), + ("Easy Ease Out (Ctrl+Shift+F9)", "イージーイーズアウト (Ctrl+Shift+F9)"), + ("Spatial Path", "空間パス"), + ("Temporal Path", "時間パス"), + ("All the Same", "すべて同じ"), + ("All Independently", "すべて個別"), + ("Noise Type:", "ノイズの種類:"), + ("Apply To:", "適用先:"), + ("Temporal Graph", "時間グラフ"), + ("Keyframe Rate:", "キーフレームレート:"), + ("Capture speed at:", "速度の記録位置:"), + ("Start Capture", "記録を開始"), + ("Cancel Capture", "記録をキャンセル"), + ("Small Caps", "スモールキャップ"), + ("All Small Caps", "すべてスモールキャップ"), + ("Superscript / Superior", "上付き文字"), + ("Subscript / Inferior", "下付き文字"), + ("Standard Ligatures", "標準合字"), + ("Discretionary Ligatures", "任意合字"), + ("Contextual Alternates", "状況依存の代替字形"), + ("Stylistic Alternates", "スタイル代替字形"), + ("Titling Alternates", "タイトル用代替字形"), + ("Default Figure Style", "既定の数字スタイル"), + ("Tabular Lining", "等幅ライニング"), + ("Proportional Oldstyle", "プロポーショナルオールドスタイル"), + ("Proportional Lining", "プロポーショナルライニング"), + ("Tabular Oldstyle", "等幅オールドスタイル"), + ("Fill Over Stroke", "線の上に塗り"), + ("Stroke Over Fill", "塗りの上に線"), + ("Stroke order", "線の順序"), + ("Single-line Composer", "単一行コンポーザー"), + ("Every-line Composer", "全行コンポーザー"), + ("Text direction", "テキストの方向"), + ("Recently Used", "最近使用した項目"), + ("User Presets", "ユーザープリセット"), + ("Remove from Favorites", "お気に入りから削除"), + ("Show Animation Presets", "アニメーションプリセットを表示"), + (" Refresh List", " リストを更新"), + (" Clear Recently Used", " 最近使用した項目を消去"), + ("Show 32 bpc Effects Only", "32 bpc のエフェクトのみ表示"), + ("Show GPU Accelerated Effects Only", "GPU アクセラレーションのエフェクトのみ表示"), + ("Show Effects for All Color Depths", "すべての色深度のエフェクトを表示"), + ("* Animation Presets", "* アニメーションプリセット"), + ("Select a layer to apply to", "適用するレイヤーを選択してください"), + ("Search effects and presets", "エフェクトとプリセットを検索"), + ("Alpha Expansion:", "アルファの拡張:"), + ("Fill Method:", "塗りの方法:"), + ("Edge Blend", "エッジのブレンド"), + ("Entire Duration", "全体のデュレーション"), + ("Reference Frame:", "参照フレーム:"), + ("Use Current Time", "現在の時間を使用"), + ("Generate Fill Layer", "塗りレイヤーを生成"), + ("Lighting Correction:", "ライティングの補正:"), + ("Create Text and Camera", "テキストとカメラを作成"), + ("Create Solid and Camera", "平面とカメラを作成"), + ("Create Null and Camera", "ヌルとカメラを作成"), + ("Create Shadow Catcher, Camera and Light", "シャドウキャッチャー、カメラ、ライトを作成"), + ("Unable to solve camera", "カメラを解析できません"), + ("Waiting to solve", "解析待ち"), + ("Create Camera", "カメラを作成"), + ("track point", "トラックポイント"), + ("Ruler zero point", "ルーラーの原点"), + ("Vertical ruler", "垂直ルーラー"), + ("Horizontal ruler", "水平ルーラー"), + ("Viewer controls", "ビューアーのコントロール"), + ("Resolution/Down Sample Factor", "解像度/ダウンサンプル係数"), + ("Adjust Exposure", "露出を調整"), + ("Snapshot taken", "スナップショットを撮影しました"), + ("Fast Previews", "高速プレビュー"), + ("3D View", "3D ビュー"), + ("Current time", "現在の時間"), + ("Layer Transform", "レイヤーのトランスフォーム"), + ("Reset Transform", "トランスフォームをリセット"), + ("Text Animation", "テキストアニメーション"), + ("Text Animation Presets", "テキストアニメーションのプリセット"), + ("EffectCraft on getartcraft.com", "getartcraft.com の EffectCraft"), + ("Small Screen", "小さい画面"), + ("Tool Creates Shape", "ツールでシェイプを作成"), + ("Tool Creates Mask", "ツールでマスクを作成"), + ("Blend Mode", "描画モード"), + ("Show mesh", "メッシュを表示"), + ("Record Options...", "記録オプション..."), ]; const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ @@ -938,6 +1252,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Add:", "添加:"), ("Adjust Exposure (stops)", "调整曝光(光圈级数)"), ("Adjust composition duration to the time span of the selected layers", "将合成时长调整为所选图层的时间范围"), + ("Advanced", "高级"), ("Advanced 3D", "高级 3D"), ("Align Layers to:", "将图层对齐到:"), ("All six face cameras turn together; the scene is not changed.", "六个面摄像机一起旋转;场景不会改变。"), @@ -1167,6 +1482,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("No layer selected", "未选择图层"), ("No matching effects or presets", "没有匹配的效果或预设"), ("No matching fonts", "没有匹配的字体"), + ("Rescan Fonts", "重新扫描字体"), ("Variable fonts only", "仅可变字体"), ("No model credits were built into this copy.", "此副本未内置模型致谢。"), ("No nested compositions", "没有嵌套合成"), @@ -1202,6 +1518,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Parent pick whip: drag onto a layer", "父级拾取器:拖到图层上"), ("Track Matte pick whip: drag onto a layer", "轨道遮罩拾取器:拖到图层上"), ("Pixel Aspect Ratio:", "像素长宽比:"), + ("Play/Stop", "播放/停止"), ("Playing on Video Preview", "在视频预览中播放"), ("Post-Render Action: {}", "渲染后动作:{}"), ("Preserve frame rate when nested or in render queue", "嵌套或在渲染队列中时保留帧速率"), @@ -1643,6 +1960,307 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Total Offset", "总偏移"), ("Per Layer", "每个图层"), ("Track", "跟踪"), + ("Force alphabetical order", "强制按字母顺序"), + ("Frame rate (fps)", "帧速率 (fps)"), + ("Import Kind", "导入类型"), + ("Delay (s)", "延迟 (秒)"), + ("Amount (%)", "数量 (%)"), + ("Cascade (farther pins trail longer)", "级联 (越远的图钉滞后越长)"), + ("Speed (%)", "速度 (%)"), + ("Use Draft Deformation", "使用草稿变形"), + ("Show Mesh", "显示网格"), + ("Scale X (%)", "X 缩放 (%)"), + ("Scale Y (%)", "Y 缩放 (%)"), + ("Rotation (degrees)", "旋转 (度)"), + ("Move X", "移动 X"), + ("Move Y", "移动 Y"), + ("Position (pixels)", "位置 (像素)"), + ("Position (percent of the composition)", "位置 (合成百分比)"), + ("Use Dimensions From", "尺寸来源"), + ("Still Duration (seconds)", "静止图像持续时间 (秒)"), + ("Add to Render Queue", "添加到渲染队列"), + ("Sequence Layers", "序列图层"), + ("Duration (seconds)", "持续时间 (秒)"), + ("Matte color (premultiplied)", "遮罩颜色 (预乘)"), + ("Invert Alpha", "反转 Alpha"), + ("Assume this frame rate", "按此帧速率处理"), + ("Separate Fields", "分离场"), + ("Pixel Aspect Ratio", "像素长宽比"), + ("Loop (times)", "循环 (次)"), + ("Assign Profile", "指定配置文件"), + ("Preserve RGB", "保留 RGB"), + ("Interpret As Linear Light", "解释为线性光"), + ("Start Timecode", "开始时间码"), + ("Override Start", "覆盖开始"), + ("Time display style", "时间显示样式"), + ("Color engine", "颜色引擎"), + ("Working space", "工作空间"), + ("Working gamma", "工作伽马"), + ("Linearize working space", "线性化工作空间"), + ("Blend colors using 1.0 gamma", "使用 1.0 伽马混合颜色"), + ("Output color space", "输出颜色空间"), + ("Video rendering and effects", "视频渲染与效果"), + ("Time Span", "时间范围"), + ("Blur (pixels before auto-trace)", "模糊 (自动描摹前的像素)"), + ("Tolerance (pixels)", "容差 (像素)"), + ("Threshold (%)", "阈值 (%)"), + ("Minimum Area (pixels)", "最小区域 (像素)"), + ("Corner Roundness (%)", "角圆度 (%)"), + ("Apply To New Layer", "应用到新图层"), + ("Sensitivity threshold (0–1, lower finds more)", "灵敏度阈值 (0–1,越低找到的越多)"), + ("Time field (empty: automatic)", "时间字段 (为空: 自动)"), + ("Video start (ISO date-time, hh:mm:ss, timecode; empty: file date)", "视频开始 (ISO 日期时间、hh:mm:ss、时间码;为空: 文件日期)"), + ("First sample at (comp seconds)", "首个采样位置 (合成秒)"), + ("Frame rate", "帧速率"), + ("Color (#rrggbb)", "颜色 (#rrggbb)"), + ("Embed footage files", "嵌入素材文件"), + ("Embed up to (MB)", "嵌入上限 (MB)"), + ("Orient Along Path", "沿路径定向"), + ("Orient Towards Camera", "朝向摄像机"), + ("Orient Towards Point of Interest", "朝向目标点"), + ("Single Composition", "单个合成"), + ("Multiple Compositions", "多个合成"), + ("Dissolve Front Layer", "溶解前层"), + ("Cross Dissolve Front and Back Layers", "交叉溶解前后层"), + ("Interpret Straight - Unmatted", "解释为直接 (未遮罩)"), + ("Interpret Premultiplied - Matted With Color", "解释为预乘 (以颜色遮罩)"), + ("Upper Field First", "上场优先"), + ("Lower Field First", "下场优先"), + ("Rec. 2020", "Rec. 2020"), + ("Use Source File Timecode", "使用源文件时间码"), + ("Feet + Frames (35mm)", "英尺 + 帧 (35mm)"), + ("Feet + Frames (16mm)", "英尺 + 帧 (16mm)"), + ("Current Frame", "当前帧"), + ("Work Area", "工作区域"), + ("Create Markers", "创建标记"), + ("Split Layers", "拆分图层"), + ("Split and Precompose", "拆分并预合成"), + ("All layers", "所有图层"), + ("None (leave them out)", "无 (不含)"), + ("Nested sequences", "嵌套序列"), + ("Solid…", "纯色…"), + ("Light…", "灯光…"), + ("Camera…", "摄像机…"), + ("Null Object", "空对象"), + ("Shape Layer", "形状图层"), + ("Adjustment Layer", "调整图层"), + ("Layer Settings…", "图层设置…"), + ("Create Shapes from Vector Layer", "从矢量图层创建形状"), + ("Create Shapes from Text", "从文本创建形状"), + ("Create Masks from Text", "从文本创建遮罩"), + ("New Mask", "新建遮罩"), + ("Center In View", "在视图中居中"), + ("Fit to Comp", "适应合成"), + ("Flip Horizontal", "水平翻转"), + ("Flip Vertical", "垂直翻转"), + ("Enable Time Remapping", "启用时间重映射"), + ("Time-Reverse Layer", "时间反向图层"), + ("Freeze Frame", "冻结帧"), + ("Freeze On Last Frame", "在最后一帧冻结"), + ("Add Marker", "添加标记"), + ("Bring Layer to Front", "图层置顶"), + ("Bring Layer Forward", "图层上移一层"), + ("Send Layer Backward", "图层下移一层"), + ("Send Layer to Back", "图层置底"), + ("Pre-compose…", "预合成…"), + ("Split Layer", "拆分图层"), + ("Layer Style Options...", "图层样式选项..."), + ("Convert to Editable Styles", "转换为可编辑样式"), + ("Show All", "全部显示"), + ("Remove All", "全部移除"), + ("Separate Dimensions", "分离维度"), + ("Add Property to Essential Graphics", "将属性添加到基本图形"), + ("Continuous Bezier", "连续贝塞尔"), + ("Auto Bezier", "自动贝塞尔"), + ("Lock To Time", "锁定到时间"), + ("Rove Across Time", "跨时间漫游"), + ("Anchor Point", "锚点"), + ("All Transform Properties", "所有变换属性"), + ("Line Anchor", "行锚点"), + ("Line Spacing", "行距"), + ("Character Offset", "字符偏移"), + ("Character Value", "字符值"), + ("Fill Color: RGB", "填充颜色: RGB"), + ("Fill Color: Hue", "填充颜色: 色相"), + ("Fill Color: Saturation", "填充颜色: 饱和度"), + ("Fill Color: Brightness", "填充颜色: 亮度"), + ("Fill Color: Opacity", "填充颜色: 不透明度"), + ("Stroke Color: RGB", "描边颜色: RGB"), + ("Stroke Color: Hue", "描边颜色: 色相"), + ("Stroke Color: Saturation", "描边颜色: 饱和度"), + ("Stroke Color: Brightness", "描边颜色: 亮度"), + ("Stroke Color: Opacity", "描边颜色: 不透明度"), + ("Stroke Width", "描边宽度"), + ("Front: Color", "前面: 颜色"), + ("Bevel: Color", "斜面: 颜色"), + ("Side: Color", "侧面: 颜色"), + ("Back: Color", "背面: 颜色"), + ("Effect Controls", "效果控件"), + ("Effects & Presets", "效果和预设"), + ("Motion Sketch", "运动草图"), + ("Render Queue", "渲染队列"), + ("Mask Interpolation", "遮罩插值"), + ("Script Console", "脚本控制台"), + ("Essential Graphics", "基本图形"), + ("ScriptUI Panel", "ScriptUI 面板"), + ("Lumetri Scopes", "Lumetri 示波器"), + ("Media Browser", "媒体浏览器"), + ("Content-Aware Fill", "内容识别填充"), + ("Create Nulls From Paths", "从路径创建空对象"), + ("VR Comp Editor", "VR 合成编辑器"), + ("Media Duration", "媒体持续时间"), + ("Frame Rate", "帧速率"), + ("File Path", "文件路径"), + ("Interpret Footage", "解释素材"), + ("Create a new Folder", "新建文件夹"), + ("Create a new Composition", "新建合成"), + ("First Frame", "第一帧"), + ("Previous Frame", "上一帧"), + ("Next Frame", "下一帧"), + ("Last Frame", "最后一帧"), + ("Name (A–Z)", "名称 (A–Z)"), + ("Merged PRs", "已合并 PR"), + ("Lines added", "添加的行数"), + ("Lines deleted", "删除的行数"), + ("Line delta", "行数差异"), + ("Binary assets added", "添加的二进制资源"), + ("Binary assets removed", "删除的二进制资源"), + ("First commit", "首次提交"), + ("Last commit", "最近提交"), + ("Display name", "显示名称"), + ("Real name", "真实姓名"), + ("ArtCraft website", "ArtCraft 网站"), + ("Source code on GitHub", "GitHub 上的源代码"), + ("3D Renderer", "3D 渲染器"), + ("Preset Name", "预设名称"), + ("Save Preset…", "保存预设…"), + ("Delete Preset", "删除预设"), + ("Lock Aspect Ratio", "锁定长宽比"), + ("Frame Rate presets", "帧速率预设"), + ("Background Color", "背景颜色"), + ("Basic", "基本"), + ("Shutter Angle", "快门角度"), + ("Shutter Phase", "快门相位"), + ("Samples Per Frame", "每帧采样数"), + ("Adaptive Sample Limit", "自适应采样上限"), + ("X and Y", "X 和 Y"), + ("X only", "仅 X"), + ("Y only", "仅 Y"), + ("Track Camera", "跟踪摄像机"), + ("Warp Stabilizer", "变形稳定器"), + ("Track Motion", "跟踪运动"), + ("Stabilize Motion", "稳定运动"), + ("Motion Source:", "运动源:"), + ("Current Track:", "当前跟踪:"), + ("Track Type:", "跟踪类型:"), + ("Motion Target:", "运动目标:"), + ("Track Mask:", "跟踪遮罩:"), + ("Analyze forward", "向前分析"), + ("Analyze backward", "向后分析"), + ("Continue Tracking", "继续跟踪"), + ("Stop Tracking", "停止跟踪"), + ("Adapt Feature", "自适应特征"), + ("Extrapolate Motion", "外推运动"), + ("Attach Point", "附加点"), + ("Track Name", "跟踪名称"), + ("Motion Tracker Options", "运动跟踪器选项"), + ("Motion Tracker Apply Options", "运动跟踪器应用选项"), + ("Edit Value Graph", "编辑值图表"), + ("Edit Speed Graph", "编辑速度图表"), + ("Show Reference Graph", "显示参考图表"), + ("Auto-Select Graph Type (speed graph for spatial properties, else value graph)", "自动选择图表类型 (空间属性用速度图表,否则用值图表)"), + ("Auto-zoom graph height", "自动缩放图表高度"), + ("Fit all graphs to view", "使所有图表适合视图"), + ("Fit selection to view", "使所选内容适合视图"), + ("Snap (key drags snap to the current time and other keys)", "吸附 (关键帧拖动时吸附到当前时间和其他关键帧)"), + ("Show Transform Box when multiple keys are selected", "选择多个关键帧时显示变换框"), + ("Separate Dimensions (Position)", "分离维度 (位置)"), + ("Convert selected keyframes to Hold", "将所选关键帧转换为定格"), + ("Convert selected keyframes to Linear", "将所选关键帧转换为线性"), + ("Convert selected keyframes to Auto Bezier", "将所选关键帧转换为自动贝塞尔"), + ("Easy Ease (F9)", "缓动 (F9)"), + ("Easy Ease In (Shift+F9)", "缓入 (Shift+F9)"), + ("Easy Ease Out (Ctrl+Shift+F9)", "缓出 (Ctrl+Shift+F9)"), + ("Spatial Path", "空间路径"), + ("Temporal Path", "时间路径"), + ("All the Same", "全部相同"), + ("All Independently", "全部独立"), + ("Noise Type:", "噪波类型:"), + ("Apply To:", "应用到:"), + ("Temporal Graph", "时间图表"), + ("Keyframe Rate:", "关键帧速率:"), + ("Capture speed at:", "速度捕获位置:"), + ("Start Capture", "开始捕获"), + ("Cancel Capture", "取消捕获"), + ("Small Caps", "小型大写字母"), + ("All Small Caps", "全部小型大写字母"), + ("Superscript / Superior", "上标"), + ("Subscript / Inferior", "下标"), + ("Standard Ligatures", "标准连字"), + ("Discretionary Ligatures", "可选连字"), + ("Contextual Alternates", "上下文替换"), + ("Stylistic Alternates", "样式替换"), + ("Titling Alternates", "标题替换"), + ("Default Figure Style", "默认数字样式"), + ("Tabular Lining", "等宽线条"), + ("Proportional Oldstyle", "比例旧式"), + ("Proportional Lining", "比例线条"), + ("Tabular Oldstyle", "等宽旧式"), + ("Fill Over Stroke", "填充在描边之上"), + ("Stroke Over Fill", "描边在填充之上"), + ("Stroke order", "描边顺序"), + ("Single-line Composer", "单行书写器"), + ("Every-line Composer", "逐行书写器"), + ("Text direction", "文本方向"), + ("Recently Used", "最近使用"), + ("User Presets", "用户预设"), + ("Remove from Favorites", "从收藏夹移除"), + ("Show Animation Presets", "显示动画预设"), + (" Refresh List", " 刷新列表"), + (" Clear Recently Used", " 清除最近使用"), + ("Show 32 bpc Effects Only", "仅显示 32 bpc 效果"), + ("Show GPU Accelerated Effects Only", "仅显示 GPU 加速效果"), + ("Show Effects for All Color Depths", "显示所有颜色深度的效果"), + ("* Animation Presets", "* 动画预设"), + ("Select a layer to apply to", "请选择要应用到的图层"), + ("Search effects and presets", "搜索效果和预设"), + ("Alpha Expansion:", "Alpha 扩展:"), + ("Fill Method:", "填充方式:"), + ("Edge Blend", "边缘混合"), + ("Entire Duration", "整个持续时间"), + ("Reference Frame:", "参考帧:"), + ("Use Current Time", "使用当前时间"), + ("Generate Fill Layer", "生成填充图层"), + ("Lighting Correction:", "光照校正:"), + ("Create Text and Camera", "创建文本和摄像机"), + ("Create Solid and Camera", "创建纯色和摄像机"), + ("Create Null and Camera", "创建空对象和摄像机"), + ("Create Shadow Catcher, Camera and Light", "创建阴影捕捉器、摄像机和灯光"), + ("Unable to solve camera", "无法求解摄像机"), + ("Waiting to solve", "等待求解"), + ("Create Camera", "创建摄像机"), + ("track point", "跟踪点"), + ("Ruler zero point", "标尺零点"), + ("Vertical ruler", "垂直标尺"), + ("Horizontal ruler", "水平标尺"), + ("Viewer controls", "查看器控件"), + ("Resolution/Down Sample Factor", "分辨率/降采样系数"), + ("Adjust Exposure", "调整曝光"), + ("Snapshot taken", "已拍摄快照"), + ("Fast Previews", "快速预览"), + ("3D View", "3D 视图"), + ("Current time", "当前时间"), + ("Layer Transform", "图层变换"), + ("Reset Transform", "重置变换"), + ("Text Animation", "文本动画"), + ("Text Animation Presets", "文本动画预设"), + ("EffectCraft on getartcraft.com", "getartcraft.com 上的 EffectCraft"), + ("Small Screen", "小屏幕"), + ("Tool Creates Shape", "工具创建形状"), + ("Tool Creates Mask", "工具创建遮罩"), + ("Blend Mode", "混合模式"), + ("Show mesh", "显示网格"), + ("Record Options...", "记录选项..."), ("Selection Tool", "选择工具"), ("Hand Tool", "抓手工具"), ("Zoom Tool", "缩放工具"), @@ -1677,7 +2295,6 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Shortcut", "快捷键"), ("Range", "范围"), ("Play From", "从以下位置播放"), - ("Frame Rate", "帧速率"), ("Resolution", "分辨率"), ("Overlays", "叠加"), ("Layer Controls", "图层控制"), @@ -1686,6 +2303,32 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Pre-roll", "预卷"), ("Post-roll", "后卷"), ("If caching, play cached frames", "缓存时播放已缓存帧"), + ("Apply", "应用"), + ("Analyze", "分析"), + ("Create Camera", "创建摄像机"), + ("Add Point", "添加点"), + ("Remove Point", "移除点"), + ("Render", "渲染"), + ("Save Current as Template…", "将当前项存为模板…"), + ("New Composition From Footage", "从素材新建合成"), + ("Search effects and presets", "搜索效果和预设"), + ("Search project", "搜索项目"), + ("Search", "搜索"), + ("(no composition)", "没有合成"), + ("per second", "每秒"), + ("Waveform", "波形"), + ("steps", "步"), + ("Building waveform…", "正在生成波形…"), + ("No effects. Drag one from Effects & Presets.", "没有效果。从效果和预设中拖入一个。"), + ("Click the ring to add a colour; Alt-click a stop (or drag it off) to remove it", "点击色环添加颜色;按住 Alt 点击色标(或将其拖出)可移除"), + ("Start from a ready-made project. Opening a template makes an untitled copy.", "从现成项目开始。打开模板会创建一份未命名副本。"), + ("Square Pixels", "方形像素"), + ("Basic", "基本"), + ("Advanced", "高级"), + ("3D Renderer", "3D 渲染器"), + ("Black", "黑色"), + ("White", "白色"), + ("Composition Settings", "合成设置"), ]; const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ @@ -1722,6 +2365,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Add:", "添加:"), ("Adjust Exposure (stops)", "調整曝光(光圈級數)"), ("Adjust composition duration to the time span of the selected layers", "將合成時長調整為所選圖層的時間範圍"), + ("Advanced", "進階"), ("Advanced 3D", "高級 3D"), ("Align Layers to:", "將圖層對齊到:"), ("All six face cameras turn together; the scene is not changed.", "六個面攝影機一起旋轉;場景不會改變。"), @@ -1951,6 +2595,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("No layer selected", "未選取圖層"), ("No matching effects or presets", "沒有匹配的效果或預設集"), ("No matching fonts", "沒有符合的字體"), + ("Rescan Fonts", "重新掃描字型"), ("Variable fonts only", "僅可變字型"), ("No model credits were built into this copy.", "此副本未內置模型致謝。"), ("No nested compositions", "沒有嵌套合成"), @@ -1986,6 +2631,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Parent pick whip: drag onto a layer", "上層拾取線:拖到圖層上"), ("Track Matte pick whip: drag onto a layer", "軌道遮罩拾取線:拖到圖層上"), ("Pixel Aspect Ratio:", "像素長寬比例:"), + ("Play/Stop", "播放/停止"), ("Playing on Video Preview", "在視訊預覽中播放"), ("Post-Render Action: {}", "算圖後動作:{}"), ("Preserve frame rate when nested or in render queue", "嵌套或在算圖佇列中時保留影格速率"), @@ -2373,6 +3019,306 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Total Offset", "總偏移"), ("Per Layer", "每個圖層"), ("Track", "追蹤"), + ("Force alphabetical order", "強制依字母順序"), + ("Frame rate (fps)", "幀率 (fps)"), + ("Import Kind", "匯入類型"), + ("Delay (s)", "延遲 (秒)"), + ("Amount (%)", "數量 (%)"), + ("Cascade (farther pins trail longer)", "階層 (越遠的圖釘延遲越長)"), + ("Speed (%)", "速度 (%)"), + ("Use Draft Deformation", "使用草稿變形"), + ("Show Mesh", "顯示網格"), + ("Scale X (%)", "X 縮放 (%)"), + ("Scale Y (%)", "Y 縮放 (%)"), + ("Rotation (degrees)", "旋轉 (度)"), + ("Move X", "移動 X"), + ("Move Y", "移動 Y"), + ("Position (pixels)", "位置 (像素)"), + ("Position (percent of the composition)", "位置 (合成百分比)"), + ("Use Dimensions From", "尺寸來源"), + ("Still Duration (seconds)", "靜態影像持續時間 (秒)"), + ("Add to Render Queue", "新增至轉譯佇列"), + ("Sequence Layers", "序列圖層"), + ("Duration (seconds)", "持續時間 (秒)"), + ("Matte color (premultiplied)", "遮罩顏色 (預乘)"), + ("Invert Alpha", "反轉 Alpha"), + ("Assume this frame rate", "以此幀率為準"), + ("Separate Fields", "分離場"), + ("Pixel Aspect Ratio", "像素長寬比"), + ("Loop (times)", "循環 (次)"), + ("Assign Profile", "指定設定檔"), + ("Preserve RGB", "保留 RGB"), + ("Interpret As Linear Light", "解譯為線性光"), + ("Start Timecode", "開始時間碼"), + ("Override Start", "覆寫開始"), + ("Time display style", "時間顯示樣式"), + ("Color engine", "色彩引擎"), + ("Working space", "工作空間"), + ("Working gamma", "工作 Gamma"), + ("Linearize working space", "線性化工作空間"), + ("Blend colors using 1.0 gamma", "使用 1.0 Gamma 混合顏色"), + ("Output color space", "輸出色彩空間"), + ("Video rendering and effects", "視訊轉譯與效果"), + ("Time Span", "時間範圍"), + ("Blur (pixels before auto-trace)", "模糊 (自動描摹前的像素)"), + ("Tolerance (pixels)", "容差 (像素)"), + ("Threshold (%)", "閾值 (%)"), + ("Minimum Area (pixels)", "最小區域 (像素)"), + ("Corner Roundness (%)", "角圓度 (%)"), + ("Apply To New Layer", "套用至新圖層"), + ("Sensitivity threshold (0–1, lower finds more)", "靈敏度閾值 (0–1,越低找到的越多)"), + ("Time field (empty: automatic)", "時間欄位 (空白: 自動)"), + ("Video start (ISO date-time, hh:mm:ss, timecode; empty: file date)", "視訊開始 (ISO 日期時間、hh:mm:ss、時間碼;空白: 檔案日期)"), + ("First sample at (comp seconds)", "首個取樣位置 (合成秒)"), + ("Frame rate", "幀率"), + ("Color (#rrggbb)", "顏色 (#rrggbb)"), + ("Embed footage files", "內嵌素材檔案"), + ("Embed up to (MB)", "內嵌上限 (MB)"), + ("Orient Along Path", "沿路徑定向"), + ("Orient Towards Camera", "朝向攝影機"), + ("Orient Towards Point of Interest", "朝向目標點"), + ("Single Composition", "單一合成"), + ("Multiple Compositions", "多個合成"), + ("Dissolve Front Layer", "溶解前層"), + ("Cross Dissolve Front and Back Layers", "交叉溶解前後層"), + ("Interpret Straight - Unmatted", "解譯為直接 (未遮罩)"), + ("Interpret Premultiplied - Matted With Color", "解譯為預乘 (以顏色遮罩)"), + ("Upper Field First", "上場優先"), + ("Lower Field First", "下場優先"), + ("Rec. 2020", "Rec. 2020"), + ("Use Source File Timecode", "使用來源檔案時間碼"), + ("Feet + Frames (35mm)", "英尺 + 幀 (35mm)"), + ("Feet + Frames (16mm)", "英尺 + 幀 (16mm)"), + ("Current Frame", "目前幀"), + ("Work Area", "工作區域"), + ("Create Markers", "建立標記"), + ("Split Layers", "分割圖層"), + ("Split and Precompose", "分割並預合成"), + ("All layers", "所有圖層"), + ("None (leave them out)", "無 (不含)"), + ("Nested sequences", "巢狀序列"), + ("Solid…", "純色…"), + ("Light…", "燈光…"), + ("Camera…", "攝影機…"), + ("Null Object", "空物件"), + ("Shape Layer", "形狀圖層"), + ("Adjustment Layer", "調整圖層"), + ("Layer Settings…", "圖層設定…"), + ("Create Shapes from Vector Layer", "從向量圖層建立形狀"), + ("Create Shapes from Text", "從文字建立形狀"), + ("Create Masks from Text", "從文字建立遮罩"), + ("New Mask", "新增遮罩"), + ("Center In View", "在視圖中置中"), + ("Fit to Comp", "符合合成"), + ("Flip Horizontal", "水平翻轉"), + ("Flip Vertical", "垂直翻轉"), + ("Enable Time Remapping", "啟用時間重對應"), + ("Time-Reverse Layer", "時間反轉圖層"), + ("Freeze Frame", "凍結幀"), + ("Freeze On Last Frame", "在最後一幀凍結"), + ("Add Marker", "新增標記"), + ("Bring Layer to Front", "圖層移至最上層"), + ("Bring Layer Forward", "圖層上移一層"), + ("Send Layer Backward", "圖層下移一層"), + ("Send Layer to Back", "圖層移至最下層"), + ("Pre-compose…", "預合成…"), + ("Split Layer", "分割圖層"), + ("Layer Style Options...", "圖層樣式選項..."), + ("Convert to Editable Styles", "轉換為可編輯樣式"), + ("Show All", "全部顯示"), + ("Remove All", "全部移除"), + ("Separate Dimensions", "分離維度"), + ("Add Property to Essential Graphics", "將屬性新增至基本圖形"), + ("Continuous Bezier", "連續貝茲"), + ("Auto Bezier", "自動貝茲"), + ("Lock To Time", "鎖定至時間"), + ("Rove Across Time", "跨時間漫遊"), + ("Anchor Point", "錨點"), + ("All Transform Properties", "所有變換屬性"), + ("Line Anchor", "行錨點"), + ("Line Spacing", "行距"), + ("Character Offset", "字元偏移"), + ("Character Value", "字元值"), + ("Fill Color: RGB", "填色: RGB"), + ("Fill Color: Hue", "填色: 色相"), + ("Fill Color: Saturation", "填色: 飽和度"), + ("Fill Color: Brightness", "填色: 亮度"), + ("Fill Color: Opacity", "填色: 不透明度"), + ("Stroke Color: RGB", "筆畫顏色: RGB"), + ("Stroke Color: Hue", "筆畫顏色: 色相"), + ("Stroke Color: Saturation", "筆畫顏色: 飽和度"), + ("Stroke Color: Brightness", "筆畫顏色: 亮度"), + ("Stroke Color: Opacity", "筆畫顏色: 不透明度"), + ("Stroke Width", "筆畫寬度"), + ("Front: Color", "前面: 顏色"), + ("Bevel: Color", "斜角: 顏色"), + ("Side: Color", "側面: 顏色"), + ("Back: Color", "背面: 顏色"), + ("Effect Controls", "效果控制項"), + ("Effects & Presets", "效果與預設集"), + ("Motion Sketch", "運動草圖"), + ("Render Queue", "轉譯佇列"), + ("Mask Interpolation", "遮罩插補"), + ("Script Console", "指令碼主控台"), + ("Essential Graphics", "基本圖形"), + ("ScriptUI Panel", "ScriptUI 面板"), + ("Lumetri Scopes", "Lumetri 示波器"), + ("Media Browser", "媒體瀏覽器"), + ("Content-Aware Fill", "內容感知填色"), + ("Create Nulls From Paths", "從路徑建立空物件"), + ("VR Comp Editor", "VR 合成編輯器"), + ("Media Duration", "媒體持續時間"), + ("File Path", "檔案路徑"), + ("Interpret Footage", "解譯素材"), + ("Create a new Folder", "新增資料夾"), + ("Create a new Composition", "新增合成"), + ("First Frame", "第一幀"), + ("Previous Frame", "上一幀"), + ("Next Frame", "下一幀"), + ("Last Frame", "最後一幀"), + ("Name (A–Z)", "名稱 (A–Z)"), + ("Merged PRs", "已合併 PR"), + ("Lines added", "新增的行數"), + ("Lines deleted", "刪除的行數"), + ("Line delta", "行數差異"), + ("Binary assets added", "新增的二進位資源"), + ("Binary assets removed", "刪除的二進位資源"), + ("First commit", "首次提交"), + ("Last commit", "最近提交"), + ("Display name", "顯示名稱"), + ("Real name", "真實姓名"), + ("ArtCraft website", "ArtCraft 網站"), + ("Source code on GitHub", "GitHub 上的原始碼"), + ("3D Renderer", "3D 轉譯器"), + ("Preset Name", "預設集名稱"), + ("Save Preset…", "儲存預設集…"), + ("Delete Preset", "刪除預設集"), + ("Lock Aspect Ratio", "鎖定長寬比"), + ("Frame Rate presets", "幀率預設集"), + ("Background Color", "背景顏色"), + ("Basic", "基本"), + ("Shutter Angle", "快門角度"), + ("Shutter Phase", "快門相位"), + ("Samples Per Frame", "每幀取樣數"), + ("Adaptive Sample Limit", "自適應取樣上限"), + ("X and Y", "X 和 Y"), + ("X only", "僅 X"), + ("Y only", "僅 Y"), + ("Track Camera", "追蹤攝影機"), + ("Warp Stabilizer", "彎曲穩定器"), + ("Track Motion", "追蹤運動"), + ("Stabilize Motion", "穩定運動"), + ("Motion Source:", "運動來源:"), + ("Current Track:", "目前追蹤:"), + ("Track Type:", "追蹤類型:"), + ("Motion Target:", "運動目標:"), + ("Track Mask:", "追蹤遮罩:"), + ("Analyze forward", "向前分析"), + ("Analyze backward", "向後分析"), + ("Continue Tracking", "繼續追蹤"), + ("Stop Tracking", "停止追蹤"), + ("Adapt Feature", "自適應特徵"), + ("Extrapolate Motion", "外推運動"), + ("Attach Point", "附加點"), + ("Track Name", "追蹤名稱"), + ("Motion Tracker Options", "運動追蹤器選項"), + ("Motion Tracker Apply Options", "運動追蹤器套用選項"), + ("Edit Value Graph", "編輯數值圖表"), + ("Edit Speed Graph", "編輯速度圖表"), + ("Show Reference Graph", "顯示參考圖表"), + ("Auto-Select Graph Type (speed graph for spatial properties, else value graph)", "自動選擇圖表類型 (空間屬性用速度圖表,否則用數值圖表)"), + ("Auto-zoom graph height", "自動縮放圖表高度"), + ("Fit all graphs to view", "使所有圖表符合視圖"), + ("Fit selection to view", "使所選內容符合視圖"), + ("Snap (key drags snap to the current time and other keys)", "貼齊 (關鍵影格拖曳時貼齊目前時間和其他關鍵影格)"), + ("Show Transform Box when multiple keys are selected", "選擇多個關鍵影格時顯示變換框"), + ("Separate Dimensions (Position)", "分離維度 (位置)"), + ("Convert selected keyframes to Hold", "將所選關鍵影格轉換為定格"), + ("Convert selected keyframes to Linear", "將所選關鍵影格轉換為線性"), + ("Convert selected keyframes to Auto Bezier", "將所選關鍵影格轉換為自動貝茲"), + ("Easy Ease (F9)", "緩和 (F9)"), + ("Easy Ease In (Shift+F9)", "緩入 (Shift+F9)"), + ("Easy Ease Out (Ctrl+Shift+F9)", "緩出 (Ctrl+Shift+F9)"), + ("Spatial Path", "空間路徑"), + ("Temporal Path", "時間路徑"), + ("All the Same", "全部相同"), + ("All Independently", "全部獨立"), + ("Noise Type:", "雜訊類型:"), + ("Apply To:", "套用至:"), + ("Temporal Graph", "時間圖表"), + ("Keyframe Rate:", "關鍵影格率:"), + ("Capture speed at:", "速度擷取位置:"), + ("Start Capture", "開始擷取"), + ("Cancel Capture", "取消擷取"), + ("Small Caps", "小型大寫字母"), + ("All Small Caps", "全部小型大寫字母"), + ("Superscript / Superior", "上標"), + ("Subscript / Inferior", "下標"), + ("Standard Ligatures", "標準連字"), + ("Discretionary Ligatures", "選用連字"), + ("Contextual Alternates", "上下文替代"), + ("Stylistic Alternates", "樣式替代"), + ("Titling Alternates", "標題替代"), + ("Default Figure Style", "預設數字樣式"), + ("Tabular Lining", "等寬襯線數字"), + ("Proportional Oldstyle", "比例舊式"), + ("Proportional Lining", "比例襯線數字"), + ("Tabular Oldstyle", "等寬舊式"), + ("Fill Over Stroke", "填色在筆畫之上"), + ("Stroke Over Fill", "筆畫在填色之上"), + ("Stroke order", "筆畫順序"), + ("Single-line Composer", "單行排版器"), + ("Every-line Composer", "逐行排版器"), + ("Text direction", "文字方向"), + ("Recently Used", "最近使用"), + ("User Presets", "使用者預設集"), + ("Remove from Favorites", "從我的最愛移除"), + ("Show Animation Presets", "顯示動畫預設集"), + (" Refresh List", " 重新整理清單"), + (" Clear Recently Used", " 清除最近使用"), + ("Show 32 bpc Effects Only", "僅顯示 32 bpc 效果"), + ("Show GPU Accelerated Effects Only", "僅顯示 GPU 加速效果"), + ("Show Effects for All Color Depths", "顯示所有色彩深度的效果"), + ("* Animation Presets", "* 動畫預設集"), + ("Select a layer to apply to", "請選擇要套用的圖層"), + ("Search effects and presets", "搜尋效果與預設集"), + ("Alpha Expansion:", "Alpha 擴張:"), + ("Fill Method:", "填色方式:"), + ("Edge Blend", "邊緣混合"), + ("Entire Duration", "整個持續時間"), + ("Reference Frame:", "參考幀:"), + ("Use Current Time", "使用目前時間"), + ("Generate Fill Layer", "產生填色圖層"), + ("Lighting Correction:", "光照校正:"), + ("Create Text and Camera", "建立文字和攝影機"), + ("Create Solid and Camera", "建立純色和攝影機"), + ("Create Null and Camera", "建立空物件和攝影機"), + ("Create Shadow Catcher, Camera and Light", "建立陰影擷取器、攝影機和燈光"), + ("Unable to solve camera", "無法求解攝影機"), + ("Waiting to solve", "等待求解"), + ("Create Camera", "建立攝影機"), + ("track point", "追蹤點"), + ("Ruler zero point", "尺規零點"), + ("Vertical ruler", "垂直尺規"), + ("Horizontal ruler", "水平尺規"), + ("Viewer controls", "檢視器控制項"), + ("Resolution/Down Sample Factor", "解析度/降取樣係數"), + ("Adjust Exposure", "調整曝光"), + ("Snapshot taken", "已拍攝快照"), + ("Fast Previews", "快速預覽"), + ("3D View", "3D 視圖"), + ("Current time", "目前時間"), + ("Layer Transform", "圖層變換"), + ("Reset Transform", "重設變換"), + ("Text Animation", "文字動畫"), + ("Text Animation Presets", "文字動畫預設集"), + ("EffectCraft on getartcraft.com", "getartcraft.com 上的 EffectCraft"), + ("Small Screen", "小螢幕"), + ("Tool Creates Shape", "工具建立形狀"), + ("Tool Creates Mask", "工具建立遮罩"), + ("Blend Mode", "混合模式"), + ("Show mesh", "顯示網格"), + ("Record Options...", "記錄選項..."), ("Selection Tool", "選取工具"), ("Hand Tool", "手形工具"), ("Zoom Tool", "縮放工具"), @@ -2416,6 +3362,32 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Pre-roll", "預卷"), ("Post-roll", "後卷"), ("If caching, play cached frames", "快取時播放已快取影格"), + ("Apply", "套用"), + ("Analyze", "分析"), + ("Create Camera", "建立攝影機"), + ("Add Point", "添加點"), + ("Remove Point", "移除點"), + ("Render", "算圖"), + ("Save Current as Template…", "將目前項目存為範本…"), + ("New Composition From Footage", "從素材新增合成"), + ("Search effects and presets", "搜尋效果與預設"), + ("Search project", "搜尋專案"), + ("Search", "搜尋"), + ("(no composition)", "沒有合成"), + ("per second", "每秒"), + ("Waveform", "波形"), + ("steps", "步驟"), + ("Building waveform…", "正在產生波形…"), + ("No effects. Drag one from Effects & Presets.", "沒有效果。從效果與預設集中拖入一個。"), + ("Click the ring to add a colour; Alt-click a stop (or drag it off) to remove it", "點擊色環新增顏色;按住 Alt 點擊色標(或將其拖出)即可移除"), + ("Start from a ready-made project. Opening a template makes an untitled copy.", "從現成專案開始。開啟範本會建立一份未命名副本。"), + ("Square Pixels", "方形像素"), + ("Basic", "基本"), + ("Advanced", "高級"), + ("3D Renderer", "3D 算圖引擎"), + ("Black", "黑色"), + ("White", "白色"), + ("Composition Settings", "合成設置"), ]; /// Ukrainian (uk): original translations of the same interface rows as the Chinese catalogs. @@ -2695,6 +3667,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("No layer selected", "Шар не виділено"), ("No matching effects or presets", "Не знайдено відповідних ефектів або наборів"), ("No matching fonts", "Не знайдено відповідних шрифтів"), + ("Rescan Fonts", "Повторно сканувати шрифти"), ("Variable fonts only", "Лише змінні шрифти"), ("No model credits were built into this copy.", "Ця копія не містить відомостей про моделі."), ("No nested compositions", "Немає вкладених композицій"), @@ -3230,6 +4203,7 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("New", "Novo"), ("New Comp from Selection", "Nova composição a partir da seleção"), ("Replace with Precomposition", "Substituir por pré-composição"), + ("Rescan Fonts", "Reanalisar fontes"), ("used once", "usado uma vez"), ("used {} times", "usado {} vezes"), ("Compositions that use this item", "Composições que usam este item"), @@ -3991,6 +4965,7 @@ const CZECH: &[(&str, &str)] = &[ ("New", "Nový"), ("New Comp from Selection", "Nová kompozice z výběru"), ("Replace with Precomposition", "Nahradit předkompozicí"), + ("Rescan Fonts", "Znovu nalézt písma"), ("used once", "použito jednou"), ("used {} times", "použito {}×"), ("Compositions that use this item", "Kompozice, které tuto položku používají"), @@ -4575,6 +5550,7 @@ const SPANISH: &[(&str, &str)] = &[ ("No layer selected", "No hay ninguna capa seleccionada"), ("No matching effects or presets", "No hay efectos ni ajustes preestablecidos que coincidan"), ("No matching fonts", "No hay fuentes coincidentes"), + ("Rescan Fonts", "Volver a analizar fuentes"), ("Variable fonts only", "Solo fuentes variables"), ("No model credits were built into this copy.", "No se incluyeron créditos de modelos en esta copia."), ("No nested compositions", "No hay composiciones anidadas"), @@ -5898,6 +6874,8 @@ mod tests { ("engine settings choices", include_str!("../../../engine/src/settings/prefs/choices.rs")), ("engine viewer channels", include_str!("../../../engine/src/viewer.rs")), ("engine viewer commands", include_str!("../../../engine/src/cmd/viewer_cmds.rs")), + // The text animator menu's labels come from the engine as data, like the settings pages. + ("engine text animator kinds", include_str!("../../../project/src/build.rs")), ]; fn catalogs() -> Vec<(&'static str, &'static str)> { @@ -6017,10 +6995,321 @@ mod tests { assert_eq!(tr_args("Proxy Use: {}", &[]), "Proxy Use: "); } + /// Japanese reads its own rows: values fill the placeholders in order, and a name that is not a + /// row (a layer or file name) stays verbatim. + #[test] + fn japanese_rows_translate_the_panels() { + set_current("ja"); + assert_eq!(tr("Composition Name:"), "コンポジション名:"); + // The panel/dialog second step: panel tabs, form labels, context menus and messages. + assert_eq!(tr("Effect Controls"), "エフェクトコントロール"); + assert_eq!(tr("Media Duration"), "メディアの長さ"); + assert_eq!(tr("Bring Layer to Front"), "レイヤーを最前面へ"); + assert_eq!(tr("All Transform Properties"), "すべてのトランスフォームプロパティ"); + assert_eq!(tr("Fill Color: RGB"), "塗りの色: RGB"); + assert_eq!(tr("Lines added"), "追加行数"); + // The composition-settings tabs and the Preview transport pass these through `tr` as + // variables, so a missing row would fall back to English unnoticed. + assert_eq!(tr("Basic"), "基本"); + assert_eq!(tr("Advanced"), "詳細"); + assert_eq!(tr("Play/Stop"), "再生/停止"); + assert_eq!(tr("my layer.mp4"), "my layer.mp4"); + assert_eq!(tr_args("Proxy Use: {}", &[&"Best"]), "プロキシの使用:Best"); + assert_eq!(tr_args("Step {} of {}: {}", &[&1, &3, &"Colour"]), "ステップ1/3:Colour"); + set_current("en"); + } + /// Every row is asked for by a converted panel: a leftover row is stale, because the panel's /// English source changed or went away. A string a helper draws for the panel (a toolbar /// tooltip, a popup item, a checkbox label) is looked up inside that helper, so its English /// source only appears as a literal there. + #[test] + fn helper_and_painter_labels_are_translated() { + /// The `widgets.rs` helpers that draw a label, and which argument holds it. + const HELPER_LABELS: &[(&str, usize)] = + &[("text_button", 2), ("search_field", 3), ("text_field", 3), ("dropdown", 2), ("dropdown_tip", 2), ("text_fit", 3), ("check_label", 1)]; + /// Literals that draw as they are: a brand, a value, a separator, a unit, and the kerning + /// sample pair the Character panel shows. + const VERBATIM: &[&str] = &["EffectCraft", "32", "dB", "···", "×", "V/A"]; + + /// The top-level arguments of a call, given the text after its `(`. A call split over + /// several lines is still one argument list, so this walks past newlines until the + /// parenthesis closes. It understands the two string forms a label can be written in - a + /// plain one with escapes and a raw one (`r"…"`, `r#"…"#`) - so a comma or parenthesis + /// inside either is not mistaken for code. + fn args_of(s: &str) -> Vec<&str> { + #[derive(Clone, Copy)] + enum Mode { + Code, + Str(bool), + Raw(usize), + } + let cs: Vec<(usize, char)> = s.char_indices().collect(); + let (mut out, mut depth, mut start, mut end) = (Vec::new(), 0i32, None, s.len()); + let mut mode = Mode::Code; + let mut k = 0; + while k < cs.len() { + let (i, c) = cs[k]; + match mode { + Mode::Raw(hashes) => { + if c == '"' { + let mut n = 0; + while cs.get(k + 1 + n).is_some_and(|(_, h)| *h == '#') { + n += 1; + } + if n >= hashes { + mode = Mode::Code; + k += n; + } + } + } + Mode::Str(escaped) => { + mode = if c == '"' && !escaped { Mode::Code } else { Mode::Str(c == '\\' && !escaped) }; + } + Mode::Code => match c { + '"' => { + mode = Mode::Str(false); + if start.is_none() { + start = Some(i); + } + } + 'r' if start.is_none() && cs.get(k + 1).is_some_and(|(_, n)| *n == '#' || *n == '"') => { + let mut n = 0; + while cs.get(k + 1 + n).is_some_and(|(_, h)| *h == '#') { + n += 1; + } + if cs.get(k + 1 + n).is_some_and(|(_, q)| *q == '"') { + mode = Mode::Raw(n); + start = Some(i); + k += n + 1; + } + } + '(' | '[' | '{' => depth += 1, + ')' | ']' | '}' => { + if depth == 0 { + end = i; + break; + } + depth -= 1; + } + ',' if depth == 0 => { + if let Some(st) = start { + out.push(s[st..i].trim()); + } + start = None; + } + c if !c.is_whitespace() && start.is_none() => start = Some(i), + _ => {} + }, + } + k += 1; + } + if let Some(st) = start { + out.push(s[st..end].trim()); + } + out + } + + /// The text a literal argument draws: a plain string with its escapes undone, or a raw + /// string with none. `None` when the argument is not one literal (a concatenation, a call). + fn literal_text(arg: &str) -> Option { + if let Some(rest) = arg.strip_prefix('r') { + let hashes = rest.chars().take_while(|c| *c == '#').count(); + // An identifier that starts with `r` (`result`) is not a raw string: the quote has + // to follow the `#`s. + if rest.chars().nth(hashes) != Some('"') { + return None; + } + let close = format!("\"{}", "#".repeat(hashes)); + return arg.get(1 + hashes + 1..)?.strip_suffix(&close).map(str::to_string); + } + let body = arg.strip_prefix('"')?.strip_suffix('"')?; + // Decode the escapes that matter for the exemption: a literal written as `"\u{bb}"` is + // one character (`»`), not six. + let mut out = String::new(); + let mut it = body.chars().peekable(); + while let Some(c) = it.next() { + if c != '\\' { + if c == '"' { + return None; // two literals side by side + } + out.push(c); + continue; + } + match it.next()? { + '"' => out.push('"'), + '\\' => out.push('\\'), + 'n' => out.push('\n'), + 't' => out.push('\t'), + 'x' => { + let hex: String = it.by_ref().take(2).collect(); + let byte = u8::from_str_radix(&hex, 16).ok()?; + out.push(char::from(byte)); + } + 'u' => { + if it.next() != Some('{') { + return None; + } + let hex: String = it.by_ref().take_while(|c| *c != '}').collect(); + out.push(char::from_u32(u32::from_str_radix(&hex, 16).ok()?)?); + } + other => { + out.push('\\'); + out.push(other); + } + } + } + Some(out) + } + + /// `source` with comments blanked and the *contents* of every string literal blanked too, + /// with offsets, newlines and the quotes kept. Call tokens are looked for in this text, so a + /// `widgets::…(` or `.text(` that only appears inside a doc string or a sample is not + /// mistaken for a call; the argument list is then read from the original at that offset. + fn code_only(source: &str) -> String { + #[derive(Clone, Copy)] + enum Mode { + Code, + Str(bool), + Raw(usize), + Comment, + } + let cs: Vec<(usize, char)> = source.char_indices().collect(); + // Byte-for-byte: a blanked character becomes as many spaces as its UTF-8 length, so + // every offset found here indexes the original source as well. + let mut out: Vec = Vec::with_capacity(source.len()); + let keep = |out: &mut Vec, c: char| out.extend_from_slice(c.encode_utf8(&mut [0; 4]).as_bytes()); + let blank = |out: &mut Vec, c: char| out.extend(std::iter::repeat_n(b' ', c.len_utf8())); + let mut mode = Mode::Code; + let mut k = 0; + while k < cs.len() { + let (_, c) = cs[k]; + match mode { + Mode::Comment => { + if c == '\n' { + mode = Mode::Code; + keep(&mut out, c); + } else { + blank(&mut out, c); + } + } + Mode::Raw(hashes) => { + if c == '"' { + let mut n = 0; + while cs.get(k + 1 + n).is_some_and(|(_, h)| *h == '#') { + n += 1; + } + if n >= hashes { + keep(&mut out, c); + for j in 1..=n { + keep(&mut out, cs[k + j].1); + } + mode = Mode::Code; + k += n; + } else { + blank(&mut out, c); + } + } else { + blank(&mut out, c); + } + } + Mode::Str(escaped) => { + if c == '"' && !escaped { + mode = Mode::Code; + keep(&mut out, c); + } else { + mode = Mode::Str(c == '\\' && !escaped); + blank(&mut out, c); + } + } + Mode::Code => match c { + '"' => { + mode = Mode::Str(false); + keep(&mut out, c); + } + '/' if out.last() == Some(&b'/') => { + // The first `/` stays; this one becomes a space. Both are one byte, so + // the mask keeps its length and every offset still indexes the source. + blank(&mut out, c); + mode = Mode::Comment; + } + 'r' if matches!(cs.get(k + 1), Some((_, n)) if *n == '#' || *n == '"') => { + let mut n = 0; + while cs.get(k + 1 + n).is_some_and(|(_, h)| *h == '#') { + n += 1; + } + if cs.get(k + 1 + n).is_some_and(|(_, q)| *q == '"') { + keep(&mut out, c); + for j in 1..=n { + keep(&mut out, cs[k + j].1); + } + keep(&mut out, '"'); + mode = Mode::Raw(n); + k += n + 1; + } else { + keep(&mut out, c); + } + } + _ => keep(&mut out, c), + }, + } + k += 1; + } + debug_assert_eq!(out.len(), source.len(), "the mask must keep every byte offset"); + String::from_utf8(out).expect("mask keeps UTF-8 boundaries") + } + + for (path, source) in CONVERTED { + // The test module draws nothing; it holds sample strings. A test-only helper above it + // (`#[cfg(test)] pub(crate) fn press_key` in `dialogs.rs`) is kept in the scanned range, + // because production code follows it. + let cut = source + .match_indices("#[cfg(test)]") + .find(|(at, _)| source[at + "#[cfg(test)]".len()..].lines().take(3).any(|l| l.trim_start().starts_with("mod "))) + .map_or(source.len(), |(at, _)| at); + let source = &source[..cut]; + let code = code_only(source); + let line_of = |at: usize| code[..at].matches('\n').count() + 1; + let check = |at: usize, arg: &str, what: &str| { + // A single character is a symbol or a glyph, not interface text - the same exemption + // the call-shape scan above makes. A call that already asks the catalog is not a + // literal here at all. + if let Some(text) = literal_text(arg) + && text.chars().count() >= 2 + && !VERBATIM.contains(&text.as_str()) + { + panic!("{path}:{}: the interface text {arg} that {what} draws is not in the catalog", line_of(at)); + } + }; + for (call, index) in HELPER_LABELS { + let marker = format!("widgets::{call}("); + for (at, _) in code.match_indices(&marker) { + if let Some(arg) = args_of(&source[at + marker.len()..]).get(*index) { + check(at, arg, &format!("widgets::{call}")); + } + } + } + for (at, _) in code.match_indices(".text(") { + if let Some(arg) = args_of(&source[at + ".text(".len()..]).get(2) { + check(at, arg, "Painter::text"); + } + } + } + } + + #[test] + fn the_composition_settings_labels_are_translated() { + // The Composition Settings dialog (and New Composition) draws these from a `(label, value)` + // table, a tab array and a `match` arm, which the call-shape scan cannot see (#1451). They + // used to have no row in any catalog, so every interface drew them in English. + for key in ["Square Pixels", "Basic", "Advanced", "3D Renderer", "Black", "White", "Composition Settings"] { + for (name, table) in [("Simplified", SIMPLIFIED_CHINESE), ("Traditional", TRADITIONAL_CHINESE)] { + let value = table.iter().find(|(k, _)| *k == key).map(|(_, v)| *v); + assert!(value.is_some_and(|v| !v.is_empty()), "{key} has an empty {name} row"); + } + } + } + #[test] fn every_row_is_used_by_a_converted_panel() { for (en, _) in catalogs() { diff --git a/crates/ui-egui/src/menus/bar.rs b/crates/ui-egui/src/menus/bar.rs index 0c334f74d..ec9ab9170 100644 --- a/crates/ui-egui/src/menus/bar.rs +++ b/crates/ui-egui/src/menus/bar.rs @@ -179,7 +179,12 @@ fn menu_nodes( "recentScripts" => app.session.prefs.recent_scripts.get(i).map(|r| r.text().to_string()), _ => None, }; - let r = ui.add_enabled(entry_enabled(app, e), egui::Button::new((gutter(entry_checked(app, e) == Some(true)), e.label.as_str()))); + let mut b = egui::Button::new((gutter(entry_checked(app, e) == Some(true)), e.label.as_str())); + // File ▸ Scripts entries show the key a script was given. + if let Some(s) = entry_shortcut(app, e) { + b = b.shortcut_text(shortcut_text(&s)); + } + let r = ui.add_enabled(entry_enabled(app, e), b); let r = match &tip { Some(t) => r.on_hover_text(t), None => r, diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs index 85d0fb193..81882c08e 100644 --- a/crates/ui-egui/src/native_menu.rs +++ b/crates/ui-egui/src/native_menu.rs @@ -196,8 +196,9 @@ pub fn build(app: &EffectcraftApp) -> NativeMenu { label: e.label.clone(), command: e.command.clone(), params: e.params.clone(), - shortcut: None, - accelerator: None, + // File ▸ Scripts entries show the key a script was given. + shortcut: crate::menus::entry_shortcut(app, e), + accelerator: crate::menus::entry_shortcut(app, e).as_deref().and_then(accelerator), enabled: crate::menus::entry_enabled(app, e), checked: crate::menus::entry_checked(app, e), })); @@ -360,7 +361,12 @@ pub fn state_key(app: &EffectcraftApp) -> u64 { s.history.undo.iter().for_each(|(l, _)| l.hash(&mut h)); s.state.open_comps.hash(&mut h); s.active_comp_id().and_then(|c| s.state.views3d.get(&c)).map(|v| format!("{:?}", v.current)).hash(&mut h); - s.shortcuts().keys.len().hash(&mut h); + // Each binding's keys: a key changed to another one gives the item a new accelerator. + let shortcuts = s.shortcuts(); + for b in &shortcuts.bindables { + b.key.hash(&mut h); + shortcuts.keys.get(&b.key).hash(&mut h); + } s.keymaps.active.hash(&mut h); let v = &app.ui.viewer; (v.rulers, v.guides, v.snap_guides, v.lock_guides, v.grid, v.snap_grid, v.show_layer_controls, v.pasteboard).hash(&mut h); @@ -722,6 +728,24 @@ mod tests { assert!(spec2.items().iter().any(|i| i.command == "file.openRecent" && i.label == "Recent Test.ecproj")); } + /// A script's key changed to another key changes the key, so the File ▸ Scripts entry gets + /// the new accelerator even though the number of bindings stays the same. + #[test] + fn a_changed_shortcut_changes_the_state_key() { + let mut app = app(); + let script = json!({"name": "Rename Layers.jsx"}); + let b = app.session.shortcuts().find(&effectcraft_engine::shortcuts::binding_key("file.runScript", &script)).cloned().unwrap(); + app.session.keymaps.set(&b, &["Cmd+Alt+Shift+F7".to_string()]).unwrap(); + app.session.shortcuts_changed(); + let k0 = state_key(&app); + app.session.keymaps.set(&b, &["Cmd+Alt+Shift+F8".to_string()]).unwrap(); + app.session.shortcuts_changed(); + assert_ne!(state_key(&app), k0); + let spec = build(&app); + let entry = spec.items().into_iter().find(|i| i.command == "file.runScript" && i.params == script).unwrap(); + assert_eq!(entry.shortcut.as_deref(), Some("Cmd+Alt+Shift+F8")); + } + /// #1630: a checked Window menu entry closes its panel; an unchecked one opens it. #[test] fn window_menu_entry_toggles_its_panel() { diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index d386d9784..d12ee62a1 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -331,7 +331,7 @@ pub fn panel_menu_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui) { } } _ => { - ui.label(egui::RichText::new(panel.title()).weak()); + ui.label(egui::RichText::new(crate::i18n::label(app, "window.panel", panel.title())).weak()); } } }); diff --git a/crates/ui-egui/src/panels/anim_tools.rs b/crates/ui-egui/src/panels/anim_tools.rs index b07da8567..e6b62efb4 100644 --- a/crates/ui-egui/src/panels/anim_tools.rs +++ b/crates/ui-egui/src/panels/anim_tools.rs @@ -127,7 +127,7 @@ pub fn wiggler(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.ui.anim_tools = st.clone(); let b = Rect::from_min_size(pos2(rect.max.x - 92.0, y), vec2(80.0, 24.0)); let enabled = app.session.state.selected_keys.len() >= 2; - if widgets::text_button(ui, b, "Apply", enabled, &t, egui::Id::unique("wg-apply")).clicked() { + if widgets::text_button(ui, b, crate::i18n::tr("Apply"), enabled, &t, egui::Id::unique("wg-apply")).clicked() { let params = json!({ "apply": if st.wiggle_spatial { "spatial" } else { "temporal" }, "noise": if st.wiggle_smooth { "smooth" } else { "jagged" }, @@ -175,7 +175,7 @@ pub fn smoother(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { y += ROW + 8.0; let b = Rect::from_min_size(pos2(rect.max.x - 92.0, y), vec2(80.0, 24.0)); let enabled = app.session.state.selected_keys.len() >= 3; - if widgets::text_button(ui, b, "Apply", enabled, &t, egui::Id::unique("sm-apply")).clicked() { + if widgets::text_button(ui, b, crate::i18n::tr("Apply"), enabled, &t, egui::Id::unique("sm-apply")).clicked() { apply(app, "keys.smooth", json!({"tolerance": tol})); } app.auto.add("smoother.apply", b, "Apply"); @@ -375,7 +375,7 @@ pub fn mask_interpolation(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rec changes.insert("keyframeRate".into(), json!(nr)); } } else { - p.text(pos2(xv + 78.0, y + 9.0), Align2::LEFT_CENTER, "per second", Tokens::ui(12.0), t.text_dim); + p.text(pos2(xv + 78.0, y + 9.0), Align2::LEFT_CENTER, crate::i18n::tr("per second"), Tokens::ui(12.0), t.text_dim); } y += ROW; let mut check = |app: &mut EffectcraftApp, y: f32, key: &str, text: &str, on: bool| { @@ -454,7 +454,7 @@ pub fn mask_interpolation(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rec } let b = Rect::from_min_size(pos2(rect.max.x - 92.0, y), vec2(80.0, 24.0)); let enabled = app.session.state.selected_keys.len() >= 2; - if widgets::text_button(ui, b, "Apply", enabled, &t, egui::Id::unique("mi-apply")).clicked() { + if widgets::text_button(ui, b, crate::i18n::tr("Apply"), enabled, &t, egui::Id::unique("mi-apply")).clicked() { match app.session.execute("mask.interpolate", json!({})) { Ok(r) => app.ui.status = format!("Mask Interpolation: {} keyframes added ({} vertices)", r["keys"], r["vertices"]), Err(e) => app.ui.status = e.to_string(), diff --git a/crates/ui-egui/src/panels/camera_tracker_ui.rs b/crates/ui-egui/src/panels/camera_tracker_ui.rs index bca98ea4b..3dc0be8d2 100644 --- a/crates/ui-egui/src/panels/camera_tracker_ui.rs +++ b/crates/ui-egui/src/panels/camera_tracker_ui.rs @@ -413,12 +413,12 @@ pub fn editor( let b1 = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 4.0), vec2(84.0, 22.0)); let b2 = Rect::from_min_size(pos2(b1.max.x + 8.0, b1.min.y), vec2(84.0, 22.0)); let b3 = Rect::from_min_size(pos2(b2.max.x + 8.0, b1.min.y), vec2(110.0, 22.0)); - if widgets::text_button(ui, b1, "Analyze", false, &t, egui::Id::unique(("ct-analyze", g.uid))).clicked() && !busy { + if widgets::text_button(ui, b1, crate::i18n::tr("Analyze"), false, &t, egui::Id::unique(("ct-analyze", g.uid))).clicked() && !busy { actions.push(("camera.analyze".into(), me.clone())); } app.auto.add(&format!("effectControls.cameraTracker.{}.analyze", g.uid), b1, "Analyze"); if running { - if widgets::text_button(ui, b2, "Cancel", false, &t, egui::Id::unique(("ct-cancel", g.uid))).clicked() { + if widgets::text_button(ui, b2, crate::i18n::tr("Cancel"), false, &t, egui::Id::unique(("ct-cancel", g.uid))).clicked() { actions.push(("camera.cancel".into(), json!({}))); } app.auto.add(&format!("effectControls.cameraTracker.{}.cancel", g.uid), b2, "Cancel"); @@ -426,7 +426,7 @@ pub fn editor( let params = effectcraft_engine::camera_track::static_params(g); let solve = ct::solve(¶ms); if solve.is_some() { - if widgets::text_button(ui, b3, "Create Camera", false, &t, egui::Id::unique(("ct-camera", g.uid))).clicked() { + if widgets::text_button(ui, b3, crate::i18n::tr("Create Camera"), false, &t, egui::Id::unique(("ct-camera", g.uid))).clicked() { actions.push(("camera.create".into(), me.clone())); } app.auto.add(&format!("effectControls.cameraTracker.{}.createCamera", g.uid), b3, "Create Camera"); diff --git a/crates/ui-egui/src/panels/comp_settings.rs b/crates/ui-egui/src/panels/comp_settings.rs index 0d07afd94..402a7fd6d 100644 --- a/crates/ui-egui/src/panels/comp_settings.rs +++ b/crates/ui-egui/src/panels/comp_settings.rs @@ -330,11 +330,11 @@ fn tab_body( let cur = PIXEL_ASPECTS .iter() .find(|(_, v)| (v - d.pixel_aspect).abs() < 1e-3) - .map(|p| p.0.to_string()) + .map(|p| tr(p.0).to_string()) .unwrap_or_else(|| format!("{:.2}", d.pixel_aspect)); let r = craft_ui::ComboBox::from_id_salt("comp-par").width(220.0).selected_text(cur).show_ui(ui, |ui| { for (name, v) in PIXEL_ASPECTS { - if ui.selectable_label((v - d.pixel_aspect).abs() < 1e-3, *name).clicked() { + if ui.selectable_label((v - d.pixel_aspect).abs() < 1e-3, tr(name)).clicked() { d.pixel_aspect = *v; } } @@ -402,7 +402,7 @@ fn tab_body( ui.horizontal(|ui| { let r = crate::widgets::srgb_color_button(ui, &mut d.bg); auto.add("dialog.comp.background", r.rect, "Background Color"); - ui.label(color_name(d.bg)); + ui.label(tr(color_name(d.bg))); }); ui.end_row(); }); @@ -498,7 +498,7 @@ pub fn show( ui.add_space(8.0); ui.horizontal(|ui| { for (tab, label, key) in [(Tab::Basic, "Basic", "basic"), (Tab::Advanced, "Advanced", "advanced"), (Tab::Renderer, "3D Renderer", "renderer")] { - let r = ui.selectable_label(d.tab == tab, label); + let r = ui.selectable_label(d.tab == tab, tr(label)); auto.add(&format!("dialog.comp.tab.{key}"), r.rect, label); if r.clicked() { d.tab = tab; diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 7c89ad32d..8e89c109a 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -403,6 +403,8 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis let mut result = None; let custom = app.session.prefs.comp_presets.clone(); let mut cmds = Vec::new(); + // The title keys the modal id and its drag offset; `modal_fit` translates the heading itself, + // so passing the source keeps the dialog in place when the interface language changes. modal(ctx, "Composition Settings", vec2(600.0, 470.0), t, |ui| { result = super::comp_settings::show(ui, &mut d, t, &mut app.auto, &custom, &mut cmds); }); diff --git a/crates/ui-egui/src/panels/effect_controls/editors.rs b/crates/ui-egui/src/panels/effect_controls/editors.rs index c95c90c07..bd566cb7f 100644 --- a/crates/ui-egui/src/panels/effect_controls/editors.rs +++ b/crates/ui-egui/src/panels/effect_controls/editors.rs @@ -8,12 +8,12 @@ pub(super) fn warp_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui: let busy = app.session.is_warp_analyzing(); let b1 = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 4.0), vec2(84.0, 22.0)); let b2 = Rect::from_min_size(pos2(b1.max.x + 8.0, b1.min.y), vec2(84.0, 22.0)); - if widgets::text_button(ui, b1, "Analyze", false, &t, egui::Id::unique(("warp-analyze", g.uid))).clicked() && !busy { + if widgets::text_button(ui, b1, crate::i18n::tr("Analyze"), false, &t, egui::Id::unique(("warp-analyze", g.uid))).clicked() && !busy { actions.push(("warp.analyze".into(), json!({"layer": layer.id.0, "effect": g.uid}))); } app.auto.add(&format!("effectControls.warp.{}.analyze", g.uid), b1, "Analyze"); if running { - if widgets::text_button(ui, b2, "Cancel", false, &t, egui::Id::unique(("warp-cancel", g.uid))).clicked() { + if widgets::text_button(ui, b2, crate::i18n::tr("Cancel"), false, &t, egui::Id::unique(("warp-cancel", g.uid))).clicked() { actions.push(("warp.cancel".into(), json!({}))); } app.auto.add(&format!("effectControls.warp.{}.cancel", g.uid), b2, "Cancel"); diff --git a/crates/ui-egui/src/panels/effect_controls/panel.rs b/crates/ui-egui/src/panels/effect_controls/panel.rs index 12f3e0057..122a6abf7 100644 --- a/crates/ui-egui/src/panels/effect_controls/panel.rs +++ b/crates/ui-egui/src/panels/effect_controls/panel.rs @@ -192,7 +192,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { bp.text( pos2(body.center().x, body.min.y + 40.0), Align2::CENTER_CENTER, - "No effects. Drag one from Effects & Presets.", + crate::i18n::tr("No effects. Drag one from Effects & Presets."), Tokens::ui(12.0), t.text_faint, ); diff --git a/crates/ui-egui/src/panels/effects_presets.rs b/crates/ui-egui/src/panels/effects_presets.rs index 44c77ec22..e140e49e1 100644 --- a/crates/ui-egui/src/panels/effects_presets.rs +++ b/crates/ui-egui/src/panels/effects_presets.rs @@ -376,7 +376,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } let sr = Rect::from_min_size(rect.min + vec2(8.0, 8.0), vec2(rect.width() - 44.0, 24.0)); let mut q = app.ui.effects_search.clone(); - widgets::search_field(ui, sr, &mut q, "Search effects and presets", &t); + widgets::search_field(ui, sr, &mut q, crate::i18n::tr("Search effects and presets"), &t); app.ui.effects_search = q.clone(); app.auto.add("effects.search", sr, "Search"); let force_scan = contents_menu(app, ui, &ctx, rect, sr, &t); diff --git a/crates/ui-egui/src/panels/flowchart.rs b/crates/ui-egui/src/panels/flowchart.rs index c7019492f..89cc3493e 100644 --- a/crates/ui-egui/src/panels/flowchart.rs +++ b/crates/ui-egui/src/panels/flowchart.rs @@ -250,7 +250,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let p = ui.painter().with_clip_rect(rect); p.rect_filled(rect, 0.0, t.tl_bg); let Some(rid) = root(app) else { - p.text(rect.center(), Align2::CENTER_CENTER, "(no composition)", Tokens::ui(12.0), t.text_faint); + p.text(rect.center(), Align2::CENTER_CENTER, crate::i18n::tr("(no composition)"), Tokens::ui(12.0), t.text_faint); return; }; // ---- toolbar: switches and direction. diff --git a/crates/ui-egui/src/panels/footage_panel.rs b/crates/ui-egui/src/panels/footage_panel.rs index 64c2ced12..ef6458475 100644 --- a/crates/ui-egui/src/panels/footage_panel.rs +++ b/crates/ui-egui/src/panels/footage_panel.rs @@ -68,7 +68,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let cached: Option<((u64, i64, u64), egui::TextureHandle, [u32; 2])> = ctx.data(|d| d.get_temp(tid)); let tex = match cached { Some((k, tex, size)) if k == key => Some((tex, size)), - _ => app.session.footage.frame(view.item, f, view.time).map(|img| { + _ => effectcraft_engine::commands::footage_panel::frame(&app.session, view.item, f, view.time).map(|img| { let ci = egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.to_rgba8()); let tex = crate::frames::load_fitted(&ctx, "footage-panel", ci, egui::TextureOptions::LINEAR); ctx.data_mut(|d| d.insert_temp(tid, (key, tex.clone(), [img.width, img.height]))); diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index e121d5d02..29364275b 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -233,7 +233,7 @@ fn new_comp_from_selection_form(s: &effectcraft_engine::Session) -> (String, Vec } /// The Interpret Footage dialog for the selected footage (or its proxy), if there is one. -fn interpret_footage_form(s: &effectcraft_engine::Session, proxy: bool) -> Option<(String, Vec)> { +fn interpret_footage_form(s: &effectcraft_engine::Session, proxy: bool, base: &mut Value) -> Option<(String, Vec)> { let f = s.state.project_selection.first().and_then(|i| s.project.item(*i)).and_then(|it| match (&it.kind, &it.proxy) { (_, Some(px)) if proxy => Some(px.footage.clone()), (effectcraft_engine::project::ItemKind::Footage(f), _) if !proxy => Some(f.clone()), @@ -286,6 +286,30 @@ fn interpret_footage_form(s: &effectcraft_engine::Session, proxy: bool) -> Optio Field::bool("preserveRgb", "Preserve RGB", f.preserve_rgb), Field::bool("linearLight", "Interpret As Linear Light", f.linear_light), ]; + // Color ▸ Cineon Settings (DPX and Cineon files). The converted points show in the + // footage's Units, and `pointUnits` reads them back in those units when Units changes. + if f.is_cineon_file() { + use effectcraft_engine::project::CineonUnits; + let c = f.cineon; + let units: Vec<(&str, Value)> = CineonUnits::ALL.iter().map(|u| (u.label(), json!(u.id()))).collect(); + fields.extend([ + Field::note("cineonSettings", "Cineon Settings"), + Field::bool("logConversion", "Logarithmic Conversion", c.log_conversion), + Field::num("convertedBlack", "Converted Black Point", c.units.from_ten_bit(f64::from(c.converted_black))), + Field::num("convertedWhite", "Converted White Point", c.units.from_ten_bit(f64::from(c.converted_white))), + Field::num("tenBitBlack", "10 Bit Black Point", f64::from(c.black)), + Field::num("tenBitWhite", "10 Bit White Point", f64::from(c.white)), + Field::num("currentGamma", "Current Gamma", f64::from(c.gamma)), + Field::num("highlightRolloff", "Highlight Rolloff", f64::from(c.rolloff)), + Field::choice("cineonUnits", "Units", &units, CineonUnits::ALL.iter().position(|u| *u == c.units).unwrap_or(0)), + ]); + if !base.is_object() { + *base = json!({}); + } + if let Some(m) = base.as_object_mut() { + m.insert("pointUnits".into(), json!(c.units.id())); + } + } // After Frame Rate: an image sequence's frames (missing ones show colour bars, as in // After Effects), then Start Timecode. let mut timing = Vec::new(); @@ -487,7 +511,7 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { let layer = comp.and_then(|c| s.state.selected_layers.first().and_then(|l| c.layer(*l))); let t = s.time(); let lt = layer.map(|l| l.layer_time(t)).unwrap_or(t); - let base = p.clone(); + let mut base = p.clone(); let (title, fields): (String, Vec) = match id { // Photoshop files: Import Kind (Footage / Composition / – Retain Layer Sizes). Dropped // files start at Settings ▸ Import ▸ Default Drag Import As. @@ -651,10 +675,33 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { ], ), "file.interpretFootage" | "file.interpretProxy" - if !has(p, &["frameRate", "alpha", "loop", "pixelAspect", "colorProfile", "fields", "invertAlpha", "matteColor", "linearLight", "startFrame"]) => + if !has( + p, + &[ + "frameRate", + "alpha", + "loop", + "pixelAspect", + "colorProfile", + "fields", + "invertAlpha", + "matteColor", + "linearLight", + "startFrame", + "logConversion", + "cineonUnits", + "pointUnits", + "convertedBlack", + "convertedWhite", + "tenBitBlack", + "tenBitWhite", + "currentGamma", + "highlightRolloff", + ], + ) => { let proxy = id == "file.interpretProxy"; - let Some(form) = interpret_footage_form(s, proxy) else { return false }; + let Some(form) = interpret_footage_form(s, proxy, &mut base) else { return false }; form } "file.projectSettings" if p.as_object().is_none_or(|m| m.is_empty()) => project_settings_form(s), @@ -1074,4 +1121,83 @@ mod tests { assert_eq!(r["path"], path.as_str()); assert!(std::fs::read_to_string(&path).unwrap().contains(">(); + app.session.state.project_selection = vec![png]; + assert!(open_form(&mut app, "file.interpretFootage", &json!({}))); + assert!(!keys(&app).iter().any(|k| k == "logConversion"), "{:?}", keys(&app)); + app.session.state.project_selection = vec![dpx]; + assert!(open_form(&mut app, "file.interpretFootage", &json!({}))); + for k in ["logConversion", "convertedBlack", "convertedWhite", "tenBitBlack", "tenBitWhite", "currentGamma", "highlightRolloff", "cineonUnits"] { + assert!(keys(&app).iter().any(|x| x == k), "{k}: {:?}", keys(&app)); + } + // Units ▸ 8-bit with Converted White Point still showing 1023 (10-bit). + let form = &mut app.dialog_state.form; + for f in form.fields.iter_mut() { + match (f.key.as_str(), &mut f.kind) { + ("cineonUnits", FieldKind::Choice { sel, .. }) => *sel = 1, + ("logConversion", FieldKind::Bool(b)) => *b = true, + _ => {} + } + } + let params = form.params(); + assert_eq!((params["pointUnits"].as_str(), params["cineonUnits"].as_str()), (Some("10bit"), Some("8bit")), "{params}"); + app.session.execute("file.interpretFootage", params).unwrap(); + let Some(ItemKind::Footage(f)) = app.session.project.item(dpx).map(|i| &i.kind) else { panic!("not footage") }; + assert!(f.cineon.log_conversion && f.cineon.units == CineonUnits::EightBit, "{:?}", f.cineon); + assert!((f.cineon.converted_white - 1023.0).abs() < 1e-3, "{:?}", f.cineon); + // Opened again, the point shows in 8-bit units. + assert!(open_form(&mut app, "file.interpretFootage", &json!({}))); + let white = app.dialog_state.form.fields.iter().find(|f| f.key == "convertedWhite").map(|f| f.kind.clone()); + assert!(matches!(white, Some(FieldKind::Number { value, .. }) if (value - 255.0).abs() < 1e-6), "{white:?}"); + } } diff --git a/crates/ui-egui/src/panels/fx_editors.rs b/crates/ui-egui/src/panels/fx_editors.rs index 4ed96797a..85b6be25f 100644 --- a/crates/ui-egui/src/panels/fx_editors.rs +++ b/crates/ui-egui/src/panels/fx_editors.rs @@ -600,7 +600,7 @@ fn colorama_wheel( p.text( pos2(sr.min.x, sr.max.y + 9.0), Align2::LEFT_CENTER, - "Click the ring to add a colour; Alt-click a stop (or drag it off) to remove it", + crate::i18n::tr("Click the ring to add a colour; Alt-click a stop (or drag it off) to remove it"), Tokens::ui(10.5), t.text_faint, ); @@ -801,19 +801,19 @@ fn reshape_buttons( let b1 = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 4.0), vec2(84.0, 22.0)); let b2 = Rect::from_min_size(pos2(b1.max.x + 8.0, b1.min.y), vec2(96.0, 22.0)); let b3 = Rect::from_min_size(pos2(b2.max.x + 8.0, b1.min.y), vec2(56.0, 22.0)); - if widgets::text_button(ui, b1, "Add Point", false, &t, egui::Id::unique(("reshape-add", euid))).clicked() { + if widgets::text_button(ui, b1, crate::i18n::tr("Add Point"), false, &t, egui::Id::unique(("reshape-add", euid))).clicked() { let mut v = pairs.clone(); v.push(add_pair(&pairs)); set_str(actions, layer, g, "correspondencePoints", format_pairs(&v), None); } app.auto.add(&format!("effectControls.effect.{euid}.reshape.add"), b1, "Add Point"); - if widgets::text_button(ui, b2, "Remove Point", false, &t, egui::Id::unique(("reshape-remove", euid))).clicked() && !pairs.is_empty() { + if widgets::text_button(ui, b2, crate::i18n::tr("Remove Point"), false, &t, egui::Id::unique(("reshape-remove", euid))).clicked() && !pairs.is_empty() { let mut v = pairs.clone(); v.pop(); set_str(actions, layer, g, "correspondencePoints", format_pairs(&v), None); } app.auto.add(&format!("effectControls.effect.{euid}.reshape.remove"), b2, "Remove Point"); - if widgets::text_button(ui, b3, "Clear", false, &t, egui::Id::unique(("reshape-clear", euid))).clicked() && !pairs.is_empty() { + if widgets::text_button(ui, b3, crate::i18n::tr("Clear"), false, &t, egui::Id::unique(("reshape-clear", euid))).clicked() && !pairs.is_empty() { set_str(actions, layer, g, "correspondencePoints", String::new(), None); } app.auto.add(&format!("effectControls.effect.{euid}.reshape.clear"), b3, "Clear"); diff --git a/crates/ui-egui/src/panels/graph.rs b/crates/ui-egui/src/panels/graph.rs index 24df22616..35a745c81 100644 --- a/crates/ui-egui/src/panels/graph.rs +++ b/crates/ui-egui/src/panels/graph.rs @@ -48,9 +48,21 @@ struct KeyPt { t: f64, v: f64, sel: bool, + muted: bool, handles: Vec, } +/// A graph key's fill and edge colours: muted keys stay visible and selectable, dimmed. +fn key_colors(t: &Tokens, edge: Color32, k: &KeyPt) -> (Color32, Color32) { + let fill = match (k.muted, k.sel) { + (true, true) => t.keyframe_selected.gamma_multiply(0.5), + (true, false) => Color32::from_gray(0x70), + (false, true) => t.keyframe_selected, + (false, false) => Color32::from_gray(0xe0), + }; + (fill, if k.muted { edge.gamma_multiply(0.4) } else { edge }) +} + struct Curve<'a> { layer: &'a Layer, prop: &'a Property, @@ -153,7 +165,7 @@ fn curves<'a>(app: &EffectcraftApp, comp: &'a Comp, ectx: &EvalCtx, tm: TMap, pl let dt = e.influence * dur * if out_side { 1.0 } else { -1.0 }; let (hv, show) = if speed { (e.speed.abs(), true) } else { (k.value.components().get(d).copied().unwrap_or(0.0) + e.speed * dt, !spatial) }; let bezier = if out_side { k.out_interp == Interp::Bezier } else { k.in_interp == Interp::Bezier }; - if ks && show && (bezier || speed) { + if ks && !k.muted && show && (bezier || speed) { handles.push(Handle { out: out_side, t: kt + dt, v: hv, speed: e.speed, dur }); } } @@ -162,7 +174,7 @@ fn curves<'a>(app: &EffectcraftApp, comp: &'a Comp, ectx: &EvalCtx, tm: TMap, pl } else { k.value.components().get(d).copied().unwrap_or(0.0) }; - keys.push(KeyPt { idx: i, time: k.time, t: kt, v, sel: ks, handles }); + keys.push(KeyPt { idx: i, time: k.time, t: kt, v, sel: ks, muted: k.muted, handles }); } out.push(Curve { layer: l, prop: pr, dim: d, color, pts, keys, editable: !spatial }); } @@ -221,7 +233,7 @@ impl ButtonBar { let chosen = widgets::popup_list(ui, pid, pos, r, rows, 0, |ui| { let mut chosen = None; for (id, label, on, tip) in &self.hidden { - let item = ui.selectable_label(*on, label.as_str()).on_hover_text(tip.as_str()); + let item = ui.selectable_label(*on, crate::i18n::tr(label)).on_hover_text(crate::i18n::tr(tip)); app.auto.add(&format!("timeline.graph.more.{id}"), item.rect, tip); if item.clicked() { chosen = Some(id.clone()); @@ -251,14 +263,16 @@ struct BarBtn<'a> { fn text_button(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, bar: &mut ButtonBar, y: f32, btn: BarBtn) -> bool { let BarBtn { label, on, id, tip } = btn; let t = app.tokens; - let w = p.layout_no_wrap(label.to_string(), Tokens::ui(11.0), t.text).size().x + 14.0; + let (shown, shown_tip) = (crate::i18n::tr(label), crate::i18n::tr(tip)); + let w = p.layout_no_wrap(shown.to_string(), Tokens::ui(11.0), t.text).size().x + 14.0; // Once one button overflows, the rest follow it into the menu (they keep their order). if !bar.hidden.is_empty() || bar.x + w > bar.limit { + // The menu translates these English labels when it draws them; automation keeps them. bar.hidden.push((id.to_string(), label.to_string(), on, tip.to_string())); return bar.chosen.as_deref() == Some(id); } let r = Rect::from_min_size(pos2(bar.x, y - 9.0), vec2(w, 18.0)); - let resp = ui.interact(r, egui::Id::unique(("graph-btn", id)), Sense::click()).on_hover_text(tip); + let resp = ui.interact(r, egui::Id::unique(("graph-btn", id)), Sense::click()).on_hover_text(shown_tip); p.rect_filled( r, 3.0, @@ -270,7 +284,7 @@ fn text_button(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, b Color32::from_rgb(0x2c, 0x2c, 0x2c) }, ); - p.text(r.center(), Align2::CENTER_CENTER, label, Tokens::ui(11.0), if on { Color32::WHITE } else { t.text }); + p.text(r.center(), Align2::CENTER_CENTER, shown, Tokens::ui(11.0), if on { Color32::WHITE } else { t.text }); app.auto.add(&format!("timeline.graph.{id}"), r, tip); bar.x += w + 4.0; resp.clicked() @@ -522,9 +536,9 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte } } // The key itself. - let col = if k.sel { t.keyframe_selected } else { Color32::from_gray(0xe0) }; + let (col, edge) = key_colors(&t, c.color, k); pc.rect_filled(Rect::from_center_size(kp, vec2(7.0, 7.0)), 0.0, col); - pc.rect_stroke(Rect::from_center_size(kp, vec2(7.0, 7.0)), 0.0, Stroke::new(1.0, c.color), StrokeKind::Outside); + pc.rect_stroke(Rect::from_center_size(kp, vec2(7.0, 7.0)), 0.0, Stroke::new(1.0, edge), StrokeKind::Outside); let kr = Rect::from_center_size(kp, vec2(11.0, 11.0)); let kresp = ui.interact(kr, egui::Id::unique(("gkey", uid, d, k.idx)), Sense::click_and_drag()); app.auto.add(&format!("timeline.graph.key.{uid}.{d}.{}", k.idx), kr, &c.prop.name); diff --git a/crates/ui-egui/src/panels/learn.rs b/crates/ui-egui/src/panels/learn.rs index a3c4a85f6..fcb98cce1 100644 --- a/crates/ui-egui/src/panels/learn.rs +++ b/crates/ui-egui/src/panels/learn.rs @@ -18,7 +18,7 @@ pub fn home_tab(app: &mut EffectcraftApp, ui: &mut egui::Ui, area: Rect) { p.text( pos2(x, y + 26.0), Align2::LEFT_CENTER, - "Hands-on tutorials that run inside EffectCraft. Do each step yourself, or press Show me.", + crate::i18n::tr("Hands-on tutorials that run inside EffectCraft. Do each step yourself, or press Show me."), Tokens::ui(11.5), t.text_faint, ); @@ -38,7 +38,7 @@ pub fn home_tab(app: &mut EffectcraftApp, ui: &mut egui::Ui, area: Rect) { p.circle_filled(badge.center(), 22.0, t.accent.gamma_multiply(0.25)); p.circle_stroke(badge.center(), 22.0, Stroke::new(1.5, t.accent)); p.text(badge.center() - vec2(0.0, 5.0), Align2::CENTER_CENTER, tut.steps.len().to_string(), Tokens::semibold(15.0), Color32::WHITE); - p.text(badge.center() + vec2(0.0, 10.0), Align2::CENTER_CENTER, "steps", Tokens::ui(9.0), t.text_dim); + p.text(badge.center() + vec2(0.0, 10.0), Align2::CENTER_CENTER, crate::i18n::tr("steps"), Tokens::ui(9.0), t.text_dim); let tx = badge.max.x + 16.0; p.text(pos2(tx, r.min.y + 22.0), Align2::LEFT_CENTER, &tut.title, Tokens::semibold(14.0), Color32::WHITE); p.text(pos2(tx, r.min.y + 40.0), Align2::LEFT_CENTER, format!("{} min", tut.minutes), Tokens::ui(11.0), t.text_faint); diff --git a/crates/ui-egui/src/panels/misc.rs b/crates/ui-egui/src/panels/misc.rs index 65e188c90..2fec0e4c2 100644 --- a/crates/ui-egui/src/panels/misc.rs +++ b/crates/ui-egui/src/panels/misc.rs @@ -11,7 +11,8 @@ use crate::{EffectcraftApp, widgets}; pub fn placeholder(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, p: PanelKind) { let t = app.tokens; - ui.painter().text(rect.center(), Align2::CENTER_CENTER, tr_args("{} — coming soon", &[&(p.title())]), Tokens::ui(12.0), t.text_faint); + let name = crate::i18n::label(app, "window.panel", p.title()); + ui.painter().text(rect.center(), Align2::CENTER_CENTER, tr_args("{} — coming soon", &[&name]), Tokens::ui(12.0), t.text_faint); } /// Preview panel: transport controls, the shortcut whose options are shown, and that @@ -42,7 +43,10 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { for (icon, cmd, tip) in buttons { let r = Rect::from_min_size(pos2(x, y), vec2(bw, 28.0)); let icon = if cmd == "playback.toggle" && app.playback.playing { Icon::Pause } else { icon }; - if widgets::icon_button(ui, r, icon, cmd == "playback.toggle" && app.playback.playing, &t, egui::Id::unique(("pv", cmd))).on_hover_text(tip).clicked() { + if widgets::icon_button(ui, r, icon, cmd == "playback.toggle" && app.playback.playing, &t, egui::Id::unique(("pv", cmd))) + .on_hover_text(tr(tip)) + .clicked() + { // The play button plays with the options of the shortcut shown below. let params = if cmd == "playback.toggle" { json!({"shortcut": current.id()}) } else { json!({}) }; let _ = crate::menus::invoke(app, &ctx, cmd, params); diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 1944f333f..6367acb0b 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -812,7 +812,7 @@ fn footer(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, foot: (Icon::NewComp, "newComp", "Create a new Composition"), ] { 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::unique(("pfoot", id))).on_hover_text(tip); + let resp = widgets::icon_button(ui, r, icon, false, &t, egui::Id::unique(("pfoot", id))).on_hover_text(crate::i18n::tr(tip)); app.auto.add(&format!("project.{id}"), r, tip); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, crate::i18n::tr(tip))); if resp.clicked() { diff --git a/crates/ui-egui/src/panels/render_queue.rs b/crates/ui-egui/src/panels/render_queue.rs index 5e39a30c2..321224c64 100644 --- a/crates/ui-egui/src/panels/render_queue.rs +++ b/crates/ui-egui/src/panels/render_queue.rs @@ -451,6 +451,22 @@ fn queue_drop(app: &EffectcraftApp, ui: &egui::Ui, rect: Rect, p: &egui::Painter } } +/// Elapsed and estimated remaining seconds at UI time `now`. The job reports them as frames +/// finish; between reports they count on from the last one, as a clock does, so a slow frame +/// doesn't read as a stalled render (#2593). +fn live_times(ctx: &egui::Context, pr: &effectcraft_engine::render_queue::JobState, now: f64) -> (f64, Option) { + let report = (pr.current, pr.done, pr.item_elapsed.to_bits()); + let since = ctx.data_mut(|d| { + let seen = d.get_temp_mut_or_insert_with(egui::Id::unique("rq-progress-seen"), || (report, now)); + if seen.0 != report { + *seen = (report, now); + } + seen.1 + }); + let dt = if pr.finished { 0.0 } else { (now - since).max(0.0) }; + (pr.item_elapsed + dt, pr.remaining.map(|r| (r - dt).max(0.0))) +} + /// The Current Render strip inside `top`: title, elapsed / remaining time, Render, Stop, Notify /// and the progress bar. fn current_render_strip(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, top: Rect, rq: &RqView, actions: &mut Vec) { @@ -461,11 +477,15 @@ fn current_render_strip(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::P let cur_item = progress.as_ref().and_then(|p| p.current).and_then(|id| queue.iter().find(|i| i.id == id)); let cur_name = cur_item.and_then(|i| app.session.project.item(i.comp)).map(|i| i.name.clone()); if let Some(pr) = &progress { - let rem = pr.remaining.map(hms).unwrap_or_else(|| "–".into()); + let (elapsed, remaining) = live_times(ui.ctx(), pr, ui.input(|i| i.time)); + if rendering { + ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); + } + let rem = remaining.map(hms).unwrap_or_else(|| "–".into()); p.text( pos2(top.max.x - 12.0, top.min.y + 16.0), Align2::RIGHT_CENTER, - format!("Elapsed: {} Est. Remain: {rem}", hms(pr.item_elapsed)), + format!("Elapsed: {} Est. Remain: {rem}", hms(elapsed)), Tokens::ui(11.5), t.text_dim, ); @@ -474,7 +494,7 @@ fn current_render_strip(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::P let render_b = Rect::from_min_size(pos2(top.max.x - 12.0 - bw, top.min.y + 34.0), vec2(bw, 26.0)); let stop_b = Rect::from_min_size(pos2(render_b.min.x - 8.0 - bw, render_b.min.y), vec2(bw, 26.0)); let can_render = !rendering && queue.iter().any(RenderQueueItem::is_queued) && !available.is_empty(); - if widgets::text_button(ui, render_b, "Render", can_render, &t, egui::Id::unique("rq-render")).clicked() && can_render { + if widgets::text_button(ui, render_b, crate::i18n::tr("Render"), can_render, &t, egui::Id::unique("rq-render")).clicked() && can_render { actions.push(("renderQueue.render", json!({"wait": false}))); } app.auto.add("renderQueue.render", render_b, "Render"); @@ -487,7 +507,7 @@ fn current_render_strip(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::P p.text(pos2(nb.max.x + 5.0, nb.center().y), Align2::LEFT_CENTER, tr("Notify"), Tokens::ui(11.5), t.text_dim); app.auto.add("renderQueue.notify", nb, if notify { "Notify: on" } else { "Notify: off" }); if rendering { - if widgets::text_button(ui, stop_b, "Stop", false, &t, egui::Id::unique("rq-stop")).clicked() { + if widgets::text_button(ui, stop_b, crate::i18n::tr("Stop"), false, &t, egui::Id::unique("rq-stop")).clicked() { actions.push(("renderQueue.stop", json!({}))); } app.auto.add("renderQueue.stop", stop_b, "Stop"); @@ -903,4 +923,26 @@ mod tests { assert_eq!(super::utc_stamp(u64::MAX), "584554051223-11-09 07:00:15"); assert_eq!(super::hms(3725.4), "1:02:05"); } + + /// #2593: between progress reports the times count on from the last report, and a new + /// report starts the count again. + #[test] + fn render_times_tick_between_reports() { + let ctx = egui::Context::default(); + let mut pr = effectcraft_engine::render_queue::JobState { + current: Some(1), + done: 2, + total: 10, + item_elapsed: 20.0, + remaining: Some(80.0), + ..Default::default() + }; + assert_eq!(super::live_times(&ctx, &pr, 100.0), (20.0, Some(80.0))); + assert_eq!(super::live_times(&ctx, &pr, 103.0), (23.0, Some(77.0))); + assert_eq!(super::live_times(&ctx, &pr, 300.0).1, Some(0.0), "never below zero"); + (pr.done, pr.item_elapsed, pr.remaining) = (3, 31.0, Some(72.0)); + assert_eq!(super::live_times(&ctx, &pr, 301.0), (31.0, Some(72.0))); + pr.finished = true; + assert_eq!(super::live_times(&ctx, &pr, 400.0), (31.0, Some(72.0)), "a finished job stands still"); + } } diff --git a/crates/ui-egui/src/panels/script_console.rs b/crates/ui-egui/src/panels/script_console.rs index affd46317..7a2cecd30 100644 --- a/crates/ui-egui/src/panels/script_console.rs +++ b/crates/ui-egui/src/panels/script_console.rs @@ -102,7 +102,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { p.text( out_rect.center(), Align2::CENTER_CENTER, - "JavaScript with the After Effects scripting object model — try app.project.numItems", + crate::i18n::tr("JavaScript with the After Effects scripting object model — try app.project.numItems"), Tokens::ui(12.0), t.text_faint, ); diff --git a/crates/ui-egui/src/panels/shortcut_editor.rs b/crates/ui-egui/src/panels/shortcut_editor.rs index 2721c6750..60b908069 100644 --- a/crates/ui-egui/src/panels/shortcut_editor.rs +++ b/crates/ui-egui/src/panels/shortcut_editor.rs @@ -49,6 +49,8 @@ const PANEL_COLOR: Color32 = Color32::from_rgb(0x3f, 0xa8, 0x6c); pub fn open(app: &mut EffectcraftApp) { app.dialog_state.shortcuts = EditorState::default(); + // Scripts copied into the Scripts folder since the table was built are listed too. + app.session.scripts_changed(); app.dialog = Some(Dialog::Shortcuts); } diff --git a/crates/ui-egui/src/panels/templates.rs b/crates/ui-egui/src/panels/templates.rs index ff8778a15..5a1ac35a0 100644 --- a/crates/ui-egui/src/panels/templates.rs +++ b/crates/ui-egui/src/panels/templates.rs @@ -62,14 +62,14 @@ pub fn home_tab(app: &mut EffectcraftApp, ui: &mut egui::Ui, area: Rect) { p.text( pos2(x, y + 26.0), Align2::LEFT_CENTER, - "Start from a ready-made project. Opening a template makes an untitled copy.", + crate::i18n::tr("Start from a ready-made project. Opening a template makes an untitled copy."), Tokens::ui(11.5), t.text_faint, ); // Save the open project as a user template. let can_save = app.session.project.comps().next().is_some(); let sr = Rect::from_min_size(pos2(area.max.x - 210.0, y - 4.0), vec2(210.0, 28.0)); - let save = widgets::text_button(ui, sr, "Save Current as Template…", false, &t, egui::Id::unique("home-templates-save")).clicked(); + let save = widgets::text_button(ui, sr, crate::i18n::tr("Save Current as Template…"), false, &t, egui::Id::unique("home-templates-save")).clicked(); app.auto.add("home.templates.saveCurrent", sr, "Save Current as Template…"); if save { if can_save { diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index 38c93ebd4..2e7e62a08 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -654,6 +654,17 @@ struct FontSearch { } /// Shared font menu for Character and Properties: search, recent fonts and previews. +/// Finish a font rescan started from the font menu and report how many faces it added. +/// While the scan is still walking font folders, ask for another frame so the menu updates. +pub(crate) fn poll_font_rescan(app: &mut EffectcraftApp, ctx: &egui::Context) { + if effectcraft_engine::text::fonts::rescan_busy() { + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } + if let Some(n) = effectcraft_engine::text::fonts::poll_rescan() { + app.ui.status = format!("{} +{n}", tr("Rescan Fonts")); + } +} + pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui::Id, rect: Rect, current: &str, auto: &str, enabled: bool) -> Option { let state_id = id.with("search-state"); app.auto.add(auto, rect, "Font family"); @@ -724,6 +735,15 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui: 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_ref.auto.add(&format!("{auto}.variable"), row.rect, "Variable fonts only"); + // #1089: fonts installed after launch appear without restarting. The button is its own + // row: the popup is only 180 px wide, so a control pinned to the filter row covers + // "Variable fonts only". The scan itself runs off the UI thread (`poll_font_rescan`). + let rescan_h = 22.0; + let (resc, _) = ui.allocate_exact_size(vec2(ui.available_width(), rescan_h), egui::Sense::hover()); + if widgets::text_button(ui, resc, tr("Rescan Fonts"), false, &t, id.with("rescan")).clicked() && effectcraft_engine::text::fonts::start_rescan() { + app_ref.ui.status = format!("{}…", tr("Rescan Fonts")); + } + app_ref.auto.add(&format!("{auto}.rescan"), resc, "Rescan Fonts"); let toggled = row.clicked() || boxed.clicked(); if toggled { only_variable = !only_variable; diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 98bc4d8d2..ad4844c2b 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1772,7 +1772,7 @@ fn layout(app: &mut EffectcraftApp, ui: &egui::Ui, rect: Rect, cid: ItemId, comp fn empty_timeline(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let ctx = ui.ctx().clone(); - ui.painter().text(rect.center(), Align2::CENTER_CENTER, "(no composition)", Tokens::ui(12.0), t.text_faint); + ui.painter().text(rect.center(), Align2::CENTER_CENTER, crate::i18n::tr("(no composition)"), Tokens::ui(12.0), t.text_faint); // Dropping footage into an empty Timeline should create its first composition, // just like dropping onto the Project panel's New Composition button (#649). if let Some(crate::panels::DragPayload::Item(dragged)) = egui::DragAndDrop::payload::(&ctx).as_deref() @@ -2213,7 +2213,7 @@ fn header_search_and_switches(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &T let comp = g.comp.clone(); let sr = Rect::from_min_size(pos2(tc_rect.max.x + 14.0, top + 12.0), vec2((left_w - 420.0).clamp(120.0, 220.0), 22.0)); let mut search = app.ui.timeline.search.clone(); - widgets::search_field(ui, sr, &mut search, "Search", &t); + widgets::search_field(ui, sr, &mut search, crate::i18n::tr("Search"), &t); app.ui.timeline.search = search; app.auto.add("timeline.search", sr, "Search"); let mut bx = rect.min.x + left_w - 10.0; @@ -3622,7 +3622,7 @@ fn waveform_row(app: &mut EffectcraftApp, g: &Rw, rc: &RowCtx<'_>) { let RowKind::Waveform { item, .. } = &rc.row.kind else { return }; lp.rect_filled(left, 0.0, if ri.is_multiple_of(2) { t.row } else { t.row_alt }); let indent = cw.name + 6.0 + 14.0 * row.depth as f32; - lp.text(pos2(indent + 10.0, cy), Align2::LEFT_CENTER, "Waveform", Tokens::ui(12.0), t.text); + lp.text(pos2(indent + 10.0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Waveform"), Tokens::ui(12.0), t.text); let wr = Rect::from_min_max(pos2(graph_x0, r.min.y), r.max); gp.rect_filled(wr, 0.0, t.tl_bg); app.auto.add(&format!("timeline.layer.{}.waveform", layer.id.0), wr, "Waveform"); @@ -3632,7 +3632,7 @@ fn waveform_row(app: &mut EffectcraftApp, g: &Rw, rc: &RowCtx<'_>) { gp.text( pos2(tm.x(layer.in_point.seconds()).max(wr.min.x) + 6.0, wr.center().y), Align2::LEFT_CENTER, - "Building waveform…", + crate::i18n::tr("Building waveform…"), Tokens::ui(11.0), t.text_faint, ); @@ -4111,6 +4111,7 @@ fn key_context_menu(app: &mut EffectcraftApp, g: &mut Rw, rc: &RowCtx<'_>, prop: ("Keyframe Interpolation…", "keys.interpolation", json!({})), ("Keyframe Velocity…", "keys.velocity", json!({})), ("Toggle Hold Keyframe", "keys.toggleHold", json!({})), + (if k.muted { "Unmute Keyframes" } else { "Mute Keyframes" }, "keys.setMuted", json!({"muted": !k.muted})), ("Rove Across Time", "keys.interpolation", json!({"roving": !k.roving})), ("-", "", json!({})), ("Easy Ease", "keys.easyEase", json!({})), @@ -4203,6 +4204,8 @@ fn key_paint(g: &Rw, rc: &RowCtx<'_>, k: &effectcraft_engine::keyframe::Keyframe None if ks => t.keyframe_selected, None => t.keyframe, }; + // Muted keys stay visible and editable but no longer drive the value. + let kcol = if k.muted { kcol.gamma_multiply(0.35) } else { kcol }; if k.roving { // Roving keys: a small dot (their time follows their neighbours). gp.circle_filled(c, 2.5, kcol); @@ -5128,6 +5131,7 @@ const SHAPE_ADD_ITEMS: &[(&str, &str)] = &[ ("trim", "Trim Paths"), ("twist", "Twist"), ("wiggle", "Wiggle Paths"), + ("wiggler", "Wiggle Transform"), ("zigzag", "Zig Zag"), ]; diff --git a/crates/ui-egui/src/panels/tracker.rs b/crates/ui-egui/src/panels/tracker.rs index 3edde09df..dac9f7d2b 100644 --- a/crates/ui-egui/src/panels/tracker.rs +++ b/crates/ui-egui/src/panels/tracker.rs @@ -626,7 +626,7 @@ pub fn apply_dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { super::dialogs::modal(ctx, "Motion Tracker Apply Options", vec2(380.0, 170.0), t, |ui| { ui.horizontal(|ui| { ui.label(crate::i18n::tr("Apply Dimensions:")); - let labels = ["X and Y", "X only", "Y only"]; + let labels = [crate::i18n::tr("X and Y"), crate::i18n::tr("X only"), crate::i18n::tr("Y only")]; let r = craft_ui::ComboBox::from_id_salt("track-apply-dims") .selected_text(labels[d.dims.min(2)]) .show_ui(ui, |ui| { diff --git a/crates/ui-egui/src/panels/unsaved.rs b/crates/ui-egui/src/panels/unsaved.rs index b5fc582b2..7befef4e2 100644 --- a/crates/ui-egui/src/panels/unsaved.rs +++ b/crates/ui-egui/src/panels/unsaved.rs @@ -16,7 +16,16 @@ use crate::theme::Tokens; use crate::{Dialog, EffectcraftApp}; /// Commands that replace or close the open project (Quit is asked when the window closes). -const CLOSING: &[&str] = &["file.newProject", "file.open", "file.openRecent", "file.closeProject", "file.revert", "file.openDemoProject", "templates.create"]; +const CLOSING: &[&str] = &[ + "file.newProject", + "file.open", + "file.openRecent", + "file.closeProject", + "file.revert", + "file.openDemoProject", + "templates.create", + "file.runShutdownScripts", +]; /// The command waiting on the prompt, and whether the user already answered it. #[derive(Clone, Debug, Default)] @@ -239,6 +248,23 @@ mod tests { assert!(app.session.is_dirty()); } + #[test] + fn shutdown_scripts_prompt_before_discarding_unsaved_work() { + let (mut app, ctx) = app(); + modified(&mut app); + let result = crate::menus::invoke(&mut app, &ctx, "file.runShutdownScripts", json!({})).unwrap(); + assert_eq!(result, json!({"dialog": "unsavedChanges"})); + assert_eq!(app.session.project.comps().count(), 1); + assert!(app.session.is_dirty()); + answer_with(&mut app, &ctx, Answer::Cancel); + assert_eq!(app.session.project.comps().count(), 1); + assert!(app.session.is_dirty()); + crate::menus::invoke(&mut app, &ctx, "file.runShutdownScripts", json!({})).unwrap(); + answer_with(&mut app, &ctx, Answer::Discard); + assert_eq!(app.session.project.comps().count(), 0); + assert!(!app.session.is_dirty() && app.dialog.is_none()); + } + #[test] fn closing_a_modified_project_asks_and_cancel_keeps_it() { let (mut app, ctx) = app(); diff --git a/crates/ui-egui/src/panels/viewer/frame.rs b/crates/ui-egui/src/panels/viewer/frame.rs index ac467ecd5..65095b399 100644 --- a/crates/ui-egui/src/panels/viewer/frame.rs +++ b/crates/ui-egui/src/panels/viewer/frame.rs @@ -9,13 +9,13 @@ pub(super) fn empty_state(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rec 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); let b1 = Rect::from_center_size(c + vec2(0.0, 0.0), vec2(220.0, 30.0)); - if widgets::text_button(ui, b1, "New Composition", true, &t, egui::Id::unique("empty-newcomp")).clicked() { + if widgets::text_button(ui, b1, crate::i18n::tr("New Composition"), true, &t, egui::Id::unique("empty-newcomp")).clicked() { crate::panels::dialogs::open_new_comp(app); } app.auto.add("viewer.empty.newComp", b1, "New Composition"); let b2 = Rect::from_center_size(c + vec2(0.0, 40.0), vec2(220.0, 30.0)); - if widgets::text_button(ui, b2, "New Composition From Footage", false, &t, egui::Id::unique("empty-fromfootage")).clicked() { - let _ = crate::menus::invoke(app, &ui.ctx().clone(), "file.import", json!({})); + if widgets::text_button(ui, b2, crate::i18n::tr("New Composition From Footage"), false, &t, egui::Id::unique("empty-fromfootage")).clicked() { + let _ = crate::menus::invoke(app, &ui.ctx().clone(), "file.import", json!({"newComp": true})); } app.auto.add("viewer.empty.import", b2, "New Composition From Footage"); // A Project item dropped on the empty viewer creates the first composition, diff --git a/crates/ui-egui/src/panels/viewer_overlays.rs b/crates/ui-egui/src/panels/viewer_overlays.rs index ef4549db6..05884abce 100644 --- a/crates/ui-egui/src/panels/viewer_overlays.rs +++ b/crates/ui-egui/src/panels/viewer_overlays.rs @@ -360,31 +360,36 @@ pub(crate) fn motion_path( } let v = k.value.as_vec3(); let s = to_scr(v); - // Auto-Bezier keys show handles on a side the path leaves straight too (a two-key path's - // ends), to drag it into a curve; Linear keys (no tangents) show none. - let (tin, tout) = if k.spatial_auto { - effectcraft_engine::keyframe::spatial_handles(&pos.keys, i) - } else { - effectcraft_engine::keyframe::spatial_tangents(&pos.keys, i) - }; - for (out, tg) in [(false, tin), (true, tout)] { - let has = (out && i + 1 < pos.keys.len()) || (!out && i > 0); - if !has || (tg[0] == 0.0 && tg[1] == 0.0) { - continue; + let selected = sel.contains(&KeyRef { layer: l.id, prop: pos.uid, time: k.time }); + // A muted position key is off the motion path: no tangents, and a dimmed point. + let kc = if k.muted { col.gamma_multiply(if selected { 0.55 } else { 0.35 }) } else { col }; + if !k.muted { + // Auto-Bezier keys show handles on a side the path leaves straight too (a two-key path's + // ends), to drag it into a curve; Linear keys (no tangents) show none. + let (tin, tout) = if k.spatial_auto { + effectcraft_engine::keyframe::spatial_handles(&pos.keys, i) + } else { + effectcraft_engine::keyframe::spatial_tangents(&pos.keys, i) + }; + for (out, tg) in [(false, tin), (true, tout)] { + let has = (out && i + 1 < pos.keys.len()) || (!out && i > 0); + if !has || (tg[0] == 0.0 && tg[1] == 0.0) { + continue; + } + let h = to_scr([v[0] + tg[0], v[1] + tg[1], v[2] + tg[2]]); + painter.line_segment([s, h], Stroke::new(1.0, kc)); + painter.circle_filled(h, 3.0, kc); + let side = if out { "out" } else { "in" }; + app.auto.add(&format!("viewer.motionPath.{}.{i}.{side}", l.id.0), Rect::from_center_size(h, vec2(8.0, 8.0)), "Spatial tangent"); + hits.push(KeyHit { layer: l.id, prop: pos.uid, time: k.time, pos: h, tangent: Some(out), value: v, p2c }); } - let h = to_scr([v[0] + tg[0], v[1] + tg[1], v[2] + tg[2]]); - painter.line_segment([s, h], Stroke::new(1.0, col)); - painter.circle_filled(h, 3.0, col); - let side = if out { "out" } else { "in" }; - app.auto.add(&format!("viewer.motionPath.{}.{i}.{side}", l.id.0), Rect::from_center_size(h, vec2(8.0, 8.0)), "Spatial tangent"); - hits.push(KeyHit { layer: l.id, prop: pos.uid, time: k.time, pos: h, tangent: Some(out), value: v, p2c }); } let r = Rect::from_center_size(s, vec2(7.0, 7.0)); - if sel.contains(&KeyRef { layer: l.id, prop: pos.uid, time: k.time }) { - painter.rect_filled(r, 0.0, col); + if selected { + painter.rect_filled(r, 0.0, kc); } else { painter.rect_filled(r, 0.0, Color32::from_black_alpha(140)); - painter.rect_stroke(r, 0.0, Stroke::new(1.0, col), StrokeKind::Inside); + painter.rect_stroke(r, 0.0, Stroke::new(1.0, kc), StrokeKind::Inside); } app.auto.add(&format!("viewer.motionPath.{}.{i}", l.id.0), r.expand(2.0), "Position keyframe"); hits.push(KeyHit { layer: l.id, prop: pos.uid, time: k.time, pos: s, tangent: None, value: v, p2c }); diff --git a/crates/ui-egui/tests/it/ui_drag_drop.rs b/crates/ui-egui/tests/it/ui_drag_drop.rs index ec8db35cc..e0e853d68 100644 --- a/crates/ui-egui/tests/it/ui_drag_drop.rs +++ b/crates/ui-egui/tests/it/ui_drag_drop.rs @@ -632,6 +632,27 @@ fn import_dialog_offers_the_image_sequence_option() { let _ = std::fs::remove_dir_all(&dir); } +/// #2573: the empty Composition viewer's New Composition From Footage imports the picked file +/// (through the Import Image Sequence question a numbered still asks) and makes a composition +/// of it, as in After Effects. +#[test] +fn new_composition_from_footage_makes_a_comp_of_the_picked_file() { + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(Session::default())); + h.run_steps(3); + let dir = frames("fromfootage", 2); + let pick = dir.join("frame_0002.png").to_string_lossy().to_string(); + h.state_mut().session.importer = Some(Arc::new(Stills)); + h.state_mut().hooks.pick_files = Some(Box::new(move |_: &[&str]| vec![pick.clone()])); + click_id(&mut h, "viewer.empty.import"); + assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Form)); + click_id(&mut h, "form.ok"); + step_until(&mut h, |h| h.state().session.active_comp().is_some()); + let comp = h.state().session.active_comp().unwrap(); + assert_eq!((comp.width, comp.height), (64, 32)); + assert_eq!(comp.layers.iter().map(|l| l.name.as_str()).collect::>(), ["frame_0002.png"]); + let _ = std::fs::remove_dir_all(&dir); +} + /// #297: a folder of frames dropped on the window arrives as one image sequence, and Interpret /// Footage shows its frames and Start Timecode (missing frames always show colour bars, as in /// After Effects: there is no Missing Frames choice). #431: dropped files import as stills, one diff --git a/crates/ui-egui/tests/it/ui_font_search.rs b/crates/ui-egui/tests/it/ui_font_search.rs index 141d8ec58..77984cf6b 100644 --- a/crates/ui-egui/tests/it/ui_font_search.rs +++ b/crates/ui-egui/tests/it/ui_font_search.rs @@ -256,3 +256,24 @@ fn font_menu_grows_back_after_an_empty_search() { click(&mut h, "character.font.option.JetBrains Mono"); assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "JetBrains Mono", "a click on a row picks the font"); } + +/// #1089: the font menu can re-run the system scan without restarting the app, and the press +/// does not also flip the "Variable fonts only" filter. +#[test] +fn font_menu_rescan_button_adds_fonts_installed_later() { + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 320, "height": 180})).unwrap(); + s.execute("layer.newText", json!({"text": "Keep this text", "font": "Inter"})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(move |_| EffectcraftApp::new(s)); + h.state_mut().show_panel(PanelKind::Character); + h.run_steps(3); + click(&mut h, "character.font"); + h.run_steps(3); + assert!(h.state().auto.find("character.font.rescan").is_some(), "the filter row offers a rescan"); + click(&mut h, "character.font.rescan"); + assert!(h.state().ui.status.contains("Rescan Fonts"), "the status reports the rescan: {}", h.state().ui.status); + // The popup stays open, the variable filter did not flip, and the list still has the bundled families. + assert!(h.state().auto.find("character.font.search").is_some(), "the popup stays open"); + let rows = h.state().auto.query("character.font.option."); + assert!(rows.iter().any(|r| r.label == "Inter"), "{:?}", rows.iter().map(|r| &r.label).collect::>()); +} diff --git a/crates/ui-egui/tests/it/ui_scriptui.rs b/crates/ui-egui/tests/it/ui_scriptui.rs index aa439737b..938d1a827 100644 --- a/crates/ui-egui/tests/it/ui_scriptui.rs +++ b/crates/ui-egui/tests/it/ui_scriptui.rs @@ -113,6 +113,42 @@ fn panels_open_at_quit_dock_again_at_launch() { assert_eq!(store.read(effectcraft_engine::commands::scripts::OPEN_PANELS_FILE).as_deref(), Some(r#"{"open":[]}"#)); } +/// A script copied into the Scripts folder while the app runs is listed in Edit ▸ Keyboard +/// Shortcuts and takes a key there: File ▸ Scripts shows the key next to the script, and +/// pressing it runs the script as its menu entry does. +#[test] +fn a_key_given_to_a_script_runs_it() { + use effectcraft_engine::config::{ConfigStore, MemoryConfig}; + let store = std::sync::Arc::new(MemoryConfig::default()); + let mut s = effectcraft_host::session(); + s.config = Some(store.clone()); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + assert!(h.state().session.shortcuts().find(r#"file.runScript {"name":"Rename Layers.jsx"}"#).is_some(), "the shortcut table is built"); + store.write("Scripts/Add Comp.jsx", "app.project.items.addComp('From Key', 64, 64, 1, 1, 25);").unwrap(); + effectcraft_ui_egui::panels::shortcut_editor::open(h.state_mut()); + let script = json!({"name": "Add Comp.jsx"}); + let r = h.state_mut().session.execute("shortcuts.set", json!({"command": "file.runScript", "params": script, "keys": "Cmd+Alt+Shift+F7"})).unwrap(); + assert_eq!(r["command"], r#"file.runScript {"name":"Add Comp.jsx"}"#, "{r}"); + assert_eq!(r["keys"], json!(["Cmd+Alt+Shift+F7"]), "{r}"); + assert_eq!(r["conflicts"], json!([]), "{r}"); + h.state_mut().dialog = None; + let spec = effectcraft_ui_egui::native_menu::build(h.state()); + let entry = spec.items().into_iter().find(|i| i.command == "file.runScript" && i.params == script).expect("the File ▸ Scripts entry"); + assert_eq!(entry.shortcut.as_deref(), Some("Cmd+Alt+Shift+F7")); + let (m, k, ..) = effectcraft_ui_egui::menus::bindings(&h.state().session) + .into_iter() + .find(|b| b.2 == "file.runScript" && b.3 == script) + .expect("the key is bound to the script"); + let made = |h: &Harness<'_, EffectcraftApp>| h.state().session.project.items.values().filter(|i| i.name == "From Key").count(); + assert_eq!(made(&h), 0); + for pressed in [true, false] { + h.event(Event::Key { key: k, physical_key: Some(k), pressed, repeat: false, modifiers: m }); + h.step(); + } + assert_eq!(made(&h), 1, "the key runs the script"); +} + /// The bundled Ease Presets ScriptUI panel (Window ▸ Ease Presets.jsx) docks, and clicking a /// preset in its list eases the selected keyframes in one undo step. #[test] diff --git a/crates/ui-egui/tests/it/ui_timeline_shapes.rs b/crates/ui-egui/tests/it/ui_timeline_shapes.rs index aadb52ffe..73686ee99 100644 --- a/crates/ui-egui/tests/it/ui_timeline_shapes.rs +++ b/crates/ui-egui/tests/it/ui_timeline_shapes.rs @@ -76,6 +76,22 @@ fn shape_contents_add_menu_adds_path_operations() { assert_eq!(items, ["group", "trim"]); } +#[test] +fn shape_contents_add_menu_adds_wiggle_transform() { + let mut s = session(); + let l = s.execute("layer.newShape", json!({"kind": "ellipse"})).unwrap()["layer"].as_u64().unwrap(); + let contents = s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.sub("contents").unwrap().uid; + let mut h = harness(s); + open_layer(&mut h, l); + click(&mut h, &format!("timeline.group.{contents}.add")); + assert!(h.get_by_label("Zig Zag").rect().max.y <= 1000.0, "the whole menu fits in the window"); + let entry = h.get_by_label("Wiggle Transform").rect(); + click_at(&mut h, entry.center()); + let layer = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().clone(); + let items: Vec<&str> = layer.props.sub("contents").unwrap().groups().map(|g| g.match_id.as_str()).collect(); + assert_eq!(items, ["group", "wiggler"]); +} + /// A shape group's inner Contents is elided in After Effects: opening the group lists its items /// (here the path) directly, with no "Contents" row between them. #[test] diff --git a/docs/agents.md b/docs/agents.md index eeb98c334..033762da4 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -600,7 +600,9 @@ A dialog's `show()` waits for the user: the `script.run` / `file.runScript` repl `"waiting": true`, and the script continues (its final output arrives in the reply of the click that closes the dialog). File ▸ Scripts: `file.scripts.list`, `file.runScript {"name": …}`, `file.installScript` / `file.installScriptUIPanel {"path": …}`, `window.scriptPanel {"name": …}`, -`window.restoreScriptPanels` (the panels open when the app last quit). Scripts' `app.settings`: +`window.restoreScriptPanels` (the panels open when the app last quit), `file.runStartupScripts` and +`file.runShutdownScripts` (the scripts in `Scripts/Startup` and `Scripts/Shutdown`; the second closes +the project first). Scripts' `app.settings`: `script.settings.get {"section": …, "key": …}` and `script.settings.save {…, "value": …}`. Extension points and the bundled extensions: [plugins.md](plugins.md). diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index e10a242fa..50dff8aff 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -49,7 +49,7 @@ trips and renders, checked by tests, not against After Effects), **partial** (na | BMP | R | yes | — | good | | WebP | — | yes (still; animated WebP as footage with its frames, timing and alpha, #762) | — | beyond After Effects | | Targa `.tga` | R/W | yes (still and sequence; alpha interpreted on import) | yes (sequence; uncompressed 24-bit RGB or 32-bit RGB + Alpha, straight or premultiplied) | good: import (#745); output reads back through it unchanged; the alpha is not labelled in the file (no TGA 2.0 extension area), so it is interpreted on import | -| DPX / Cineon `.dpx` `.cin` | R/W | yes (still and sequence; 8/10/12/16-bit luma, RGB, RGBA, both byte orders; log code values shown as stored) | none | partial: import (#756); no Interpret Footage ▸ Cineon Settings (log to linear), no DPX output; common in film VFX | +| DPX / Cineon `.dpx` `.cin` | R/W | yes (still and sequence; 8/10/12/16-bit luma, RGB, RGBA, both byte orders; log code values shown as stored, or converted by Interpret Footage ▸ Cineon Settings ▸ Logarithmic Conversion) | none | partial: import (#756) and Cineon Settings; no DPX output; common in film VFX | | Radiance `.hdr` | R/W | yes (still and sequence, 32-bit linear float like OpenEXR) | none | good for import (#745); no Radiance output | | SGI, PICT, ElectricImage, RLA/RPF images | R | none | none | Legacy; RPF camera data import exists (`keys.rpfCameraImport`) | | Camera Raw (DNG, CR3, NEF…) | R (Camera Raw) | none | none | LightCraft has decoders that could be shared | diff --git a/docs/footage.md b/docs/footage.md index f23bcdcc7..f7f1df21b 100644 --- a/docs/footage.md +++ b/docs/footage.md @@ -94,16 +94,21 @@ used, and layers that ran to the end of the footage keep doing so. | Fields, pixel aspect, loop | As in After Effects. | | **Assign Profile**, **Interpret As Linear Light** | The colour profile the file's values are in (the working space converts from it), and whether they are linear light. | | **Preserve RGB** | The file's values enter the composition unconverted: no colour profile, linear light or working-space conversion, and float files (OpenEXR, float TIFF) keep their linear values instead of being sRGB-encoded. Use it to convert chosen layers yourself with an OCIO effect, or to read data passes (depth, mist, normals) as numbers. | +| **Cineon Settings** (DPX and Cineon files) | With *Logarithmic Conversion* on, the file's log code values are converted to the *Current Gamma* (1.7 by default; 1 gives linear light): the *10 Bit Black Point* (95) becomes the *Converted Black Point* (0), and with *Highlight Rolloff* at 0 the *10 Bit White Point* (685) becomes the *Converted White Point* (1023). *Highlight Rolloff* (20) bends the top 20% of the converted range into a curve, as the Cineon Converter effect does: the white point then converts to a level below the Converted White Point, and brighter code values stay below it. At 0, a 32 bpc project keeps over-range values. *Units* shows the converted points as 10-bit, 8-bit or float values. With Logarithmic Conversion off (the default), the code values are shown as stored. | Settings ▸ Import ▸ **Report Missing Frames** (on by default) lists the missing frame numbers when a sequence imports; Interpret Footage lists them too. **File ▸ Interpret Footage ▸ Remember Interpretation / Apply Interpretation** copy the alpha, -fields, pixel aspect, loop and colour settings (Preserve RGB too) to other footage. +fields, pixel aspect, loop and colour settings (Preserve RGB too, and Cineon Settings between +DPX and Cineon files) to other footage. Agents: `file.interpretFootage {"items": [id], "frameRate": 24, "alpha": "premultiplied", "startTimecode": 1001, "preserveRgb": true}` (timecode or a frame number; `"overrideStart": -false` goes back to Use Source File Timecode). +false` goes back to Use Source File Timecode). Cineon Settings: `{"logConversion": true, +"tenBitBlack": 95, "tenBitWhite": 685, "currentGamma": 1.7, "highlightRolloff": 20, +"cineonUnits": "8bit", "convertedBlack": 0, "convertedWhite": 255}` (converted points in +`cineonUnits`, else the footage's Units). ## Multi-layer OpenEXR diff --git a/docs/gaps.md b/docs/gaps.md index c851fe0ff..566d1edfa 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -37,7 +37,7 @@ because 179 issues were closed with regression tests between 5 and 10 October. | UI/UX fidelity | ≈ 65% | 60–120 | [ui-parity.md](ui-parity.md) | | File formats | ≈ 50% | 130–230 | [file-format-parity.md](file-format-parity.md) | | Hardware | ≈ 40% | 60–120 | [hardware-parity.md](hardware-parity.md) | -| Localization | ≈ 18% | 60–110 + review | [localization-parity.md](localization-parity.md) | +| Localization | ≈ 24% | 60–110 + review | [localization-parity.md](localization-parity.md) | | Stability | ≈ 45% | 40–80 | 23 never-crash PRs landed on 4 October and the "Never crash" rules bind every crate, but crash reports are open (#580 Chromatic Aberrations pan/tilt, #501 crash on startup, #426 timeline drag while playing, #341 quit on Intel Mac, #234 macOS 27 exit, #879 `--gpu` export aborts on a failed allocation; #591, #306 and #511 are closed), and no CI workflow runs `cargo xtask ci` on pull requests | | Real-user experience | ≈ 60%, uneven by platform | (in G2) | 179 issues closed 5–10 October (as counted on 10 October), nearly all with regression tests ([log below](#real-user-report-log-510-october)); 216 open and 328 closed on 12 October, many from Linux and Windows. Most checking still happens on macOS | | Performance | unknown against After Effects, ≈ 35% | 50–100 | Internal numbers only (e.g. Advanced 3D 290 ms/frame at 1080p on the GPU on an M4 Pro). Users report stutter on cached frames (#595, #304; the RAM preview now prefetches nearest the playhead, 271000b2), late audio (#504, #335); slow scrubbing (#329, closed by #784) and #554 are closed. No 4K / hundreds-of-layers project tested | @@ -72,8 +72,8 @@ are grouped by area; the issue tracker has the rest. | 13 | **3D model formats and materials** | After Effects 26 imports FBX, USD/USDZ, STL besides glTF and OBJ (`usd_plugins`), and Substance 3D materials with mesh displacement (26.0–26.2); we read glTF, OBJ, STL (binary and ASCII, with facet colours) and FBX 7.x (binary and ASCII: meshes, hierarchy, Lambert/Phong materials and textures; no FBX animation or skinning yet) | Product shots and 3D scenes come as FBX/USDZ | 25–45 h | — · [file-format-parity.md](file-format-parity.md) | | 14 | **Object Matte** (26.2, disk cache in 26.5) | First slice (#783): the Object Matte effect (Effect ▸ Matte) and `objectMatte.apply` turn a point (and optional box) on a reference frame into a matte and follow the object up to 600 frames each way, or segment every frame on its own; Roto Brush's engine, cache and model (MobileSAM from Settings ▸ Roto Brush, classical graph cut without it). Since then: the Object Matte tool in the viewer (click / drag a box, hover crosshair), the box as the model's box prompt, background tracking with progress and cancel, Freeze, and segmentations kept in the disk cache (26.5). Missing: a comparison with After Effects ([plan](fidelity/object-matte.md)) | The new one-click roto | 10–20 h | G7 · [target-app-parity.md](target-app-parity.md) | | 15 | **Hardware video decode and encode** | No VideoToolbox, NVENC, Quick Sync or VA-API; #498 asks for it (answered: CUDA / NVENC need Nvidia's proprietary SDK through FFI, which the no-`unsafe` rule forbids); open PR #819 adds hardware decoding via `filmcraft-platform`; FilmCraft has VideoToolbox / NVENC / VA-API paths to borrow | 4K playback and export speed | 25–45 h | G5/G6 · [hardware-parity.md](hardware-parity.md) | -| 16 | **Localization depth** | Menus in 11 languages (ja, zh-hans, zh-hant, uk, es, pt-br, ru, fr, de, cs, ka: 628 rows each); panels in 7 (ja, zh-hans, zh-hant, uk, pt-br, cs, ka); effect names translated in Simplified Chinese and Georgian (307, plus the generated Effect menu), their ~1,650 parameter names untranslated; Korean and Italian (After Effects ships them) absent; Hangul probe added (#599 closed) | After Effects is used heavily in Japan, China, Korea, Brazil, Europe | 60–110 h + native review | G9 · [localization-parity.md](localization-parity.md) | -| 17 | **Media Browser lists files import refuses** | `media_browser.rs` lists AVI; `media::is_importable` has none of them (Targa and Radiance HDR import since #745; DPX and Cineon since #756, log shown as stored: no Cineon Settings conversion yet; MXF OP1a / OP-Atom since #767; FilmCraft has no AVI demuxer); #431 and #324 closed | Confusing failures on common VFX formats | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) | +| 16 | **Localization depth** | Menus in 11 languages (ja, zh-hans, zh-hant, uk, es, pt-br, ru, fr, de, cs, ka: 630 rows each, 629 in Georgian); panels in 7 (ja 996 rows, zh-hans 998, zh-hant 951, uk 524, pt-br 695, cs 518, ka 885); effect names translated in Simplified Chinese (307), Japanese (297) and Georgian (307, plus the generated Effect menu), their ~1,650 parameter names untranslated; Korean and Italian (After Effects ships them) absent; Hangul probe added (#599 closed) | After Effects is used heavily in Japan, China, Korea, Brazil, Europe | 60–110 h + native review | G9 · [localization-parity.md](localization-parity.md) | +| 17 | **Media Browser lists files import refuses** | `media_browser.rs` lists AVI; `media::is_importable` has none of them (Targa and Radiance HDR import since #745; DPX and Cineon since #756, log shown as stored unless Interpret Footage ▸ Cineon Settings converts it; MXF OP1a / OP-Atom since #767; FilmCraft has no AVI demuxer); #431 and #324 closed | Confusing failures on common VFX formats | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) | | 18 | **Relative paths and relinking** | Projects store absolute footage paths; no relink when a project moves | Moving or sharing a project breaks it | 6–12 h | G4 · [file-format-parity.md](file-format-parity.md) | | 19 | **Plug-in ecosystem** | No After Effects SDK hosting (#600, #327 OpenFX, #334, #376 plug-in manager); our WebAssembly API has no third-party plug-ins | Many pro projects depend on Trapcode, Element, Saber… | 40–80 h for API growth; SDK hosting needs an owner decision | G8 · [scripting-parity.md](scripting-parity.md) | | 20 | **Multi-monitor and video output** | #486: on Windows, macOS and X11 every floating panel is an OS window of its own (Undock Panel, a drag out of the main window, panel menu ▸ Move Panel to New Window, `window.panelWindow`) and so can go onto another screen (on Wayland, the web and in embedded viewports floating panels stay in the app window, since Wayland reports no window positions); tabs and groups drag between windows, shortcuts work in them, and the current and named workspaces restore their places; still missing: Mercury Transmit to video hardware (we have a second preview window only) | Two-screen setups are standard for professionals | 10–20 h | G2 · [hardware-parity.md](hardware-parity.md) | @@ -310,8 +310,11 @@ Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the vie on the same extension points as After Effects: scripts, ScriptUI panels and effect plug-ins ([plugins.md](plugins.md#extension-points-compared-with-after-effects) compares them). ScriptUI panels in the user's ScriptUI Panels folder are listed in the Window menu, dock like any panel - and open again at the next launch. Missing: the Startup and Shutdown script folders, keyboard - shortcuts for scripts and `.jsxbin`; `#include` / `#target` directives landed (#759, + and open again at the next launch. The scripts in the Startup folder run at launch, and the desktop + app runs the Shutdown folder's scripts as it quits. Each File ▸ Scripts entry can be given a key in + Edit ▸ Keyboard Shortcuts. Missing: a global scope that scripts share for the session (a Startup + script's variables and functions are not available to later scripts) and `.jsxbin`; + `#include` / `#target` directives landed (#759, [scripting-parity.md](scripting-parity.md)). - Done so far: ease presets (#254) cover what ease-curve plug-ins are used for: easing curves kept by name and applied to pairs of keyframes. Modelled on a third-party panel, they are not core: a diff --git a/docs/localization-parity.md b/docs/localization-parity.md index e980fe50f..a5ddd6e8a 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-23 · **Change:** minor (Georgian: menus, panels and effect names; missing strings show in English; `missing_translations_report` lists them) · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** minor (recounted at this tree after #1774: Japanese 1,923 of ≈ 4,200 interface strings (46%, with 297 effect names), Simplified Chinese 1,935 (46%, with 307 effect names), Traditional Chinese 1,581 (38%, its panel catalog 951 rows after #2244); overall ≈ 24%; previously: minor, the panel/dialog catalogs grow by 303 rows in Japanese, Simplified Chinese and Traditional Chinese: panel tabs, form and dialog labels, context menus, tracker/graph/text-panel labels, credits and File-panel footers; previously: minor, catalogs recounted at `upstream/main` 470ea7a3: menus in 10 languages, panels in 6, Hangul probe (#599)) · **Target:** Adobe After Effects 2026 How much of EffectCraft's interface is translated, language by language, compared with After Effects. [gaps.md](gaps.md) gap 16 and workstream G9 hold the work; @@ -17,28 +17,31 @@ interface; Arabic and Hebrew text *in layers* is supported through its Middle Ea ## How EffectCraft translates - **Menus:** `crates/ui-egui/src/i18n.rs`, one catalog per language keyed by - `(command, English label)` (624 catalog rows each at 470ea7a3, counted). A test - (`menu_catalogs_have_no_stale_or_duplicate_rows`) fails on a row whose entry left the menu tree - or on a repeated row. The generated Effect menu is not part of that fixed tree: its entries - and category submenus take their labels from the effect catalog below. + `(command, English label)`. A test (`menu_catalogs_have_no_stale_or_duplicate_rows`) fails on a + row whose entry left the menu tree or on a repeated row, so every catalog covers all fixed + entries (630 catalog rows each at this tree, counted; 629 in Georgian). The generated Effect menu is not part of + that fixed tree: its entries and category submenus take their labels from the effect catalog + below. - **Panels, dialogs, buttons, tooltips:** `crates/ui-egui/src/i18n/ui.rs`, keyed by the English - source string, with English fallback. Catalogs exist for Japanese (693 rows), Simplified and - Traditional Chinese (695 and 648 since #2244 pruned the Traditional one), Ukrainian (524), - Brazilian Portuguese (695) and Czech (518), counted at this tree. Georgian has every row the - Simplified Chinese catalog has, plus 191 for panels converted to the catalog. + source string, with English fallback. Catalogs exist for Japanese (996 rows), Simplified + Chinese (998), Traditional Chinese (951, after #2244 pruned the table), Ukrainian (524), + Brazilian Portuguese (695), Czech (518) and Spanish (513); counted at this tree. Georgian has + the Simplified Chinese catalog's 694 rows, plus 191 for panels converted to the catalog since + (885 in all). - **Effect names:** `crates/ui-egui/src/i18n/effects.rs`, keyed by **effect id**, in Simplified - Chinese and Georgian (307 rows, one per effect in the registry, plus its 21 categories). The registry's + Chinese (307 rows, one per effect in the registry), Japanese (297 of the 307 ids) and Georgian + (307 rows, plus its 21 categories). The registry's English name stays the effect's identity: `effect.list`, `effect.apply {"effect": …}`, scripts and expressions match on it, and the property tree stores it as the effect group's name. Only the drawing — the Effect menu, Effect Controls, the Timeline and Effects & Presets — goes through the catalog, so a project file is identical whichever interface language opens it. A repeat (`Gaussian Blur 2`) keeps the index the engine appends, and a group the user renamed in Effect Controls stays verbatim. Adding a language there is two tables and one arm, and no - panel changes: Traditional Chinese and Japanese have their tables in place and empty, so those - languages draw English until a translator fills them (the Japanese set for #821 is what that - slot is for). Each language decides for itself which names stay English — Adobe's Japanese - interface leaves the `CC`, `VR` and `OCIO` sets in English while its Chinese one translates - them — so a row repeating the English name is a choice, not a missing row. + panel changes: Traditional Chinese has its table in place and empty, and Japanese fills 297 of + the 307 ids, so the rest draw English until a translator completes them. Each language decides + for itself which names stay English — Adobe's Japanese interface leaves the `CC`, `VR` and + `OCIO` sets in English while its Chinese one translates them — so a row repeating the English + name is a choice, not a missing row. - **Missing strings:** a string with no row in the interface language shows in English, and no test, build or release fails on a missing translation. The `missing_translations_report` test (`crates/ui-egui/src/i18n.rs`) prints, per language, the fixed menu entries, panel strings, effect @@ -60,25 +63,27 @@ interface; Arabic and Hebrew text *in layers* is supported through its Middle Ea ## Measurement -Measured 10 October 2026 at `origin/main` 1446e27 and recounted on 12 October at `upstream/main` -470ea7a3 by counting catalog rows (`const` tables in `crates/ui-egui/src/i18n.rs` and -`i18n/ui.rs`; menu rows 624 per language, so the share is computed from 624 rather than 618). The -denominator, **≈ 3,900 user-visible strings**, was estimated on 10 October and is kept: 618 fixed menu entries + ≈ 1,320 capitalised UI -literals in `crates/ui-egui/src` outside tests and catalogs (a regex count, so an upper bound) + -≈ 1,650 distinct effect parameter names + 306 effect names. Engine messages are not counted, so -the true percentages are slightly lower. +Measured 10 October 2026 at `origin/main` 1446e27, recounted on 22 October 2026 at `ad8b6acb`, +and recounted again at this tree after #1774 by counting catalog rows (`const` tables in +`crates/ui-egui/src/i18n.rs`, `i18n/ui.rs` and `i18n/effects.rs`: 630 menu rows, 998 +panel/dialog sources in Simplified Chinese, 951 in Traditional Chinese and 996 in Japanese). The +denominator, **≈ 4,200 user-visible strings**, is a rounded estimate: the 630 fixed menu entries, +the 998 panel/dialog sources, 307 effect names, ≈ 1,650 distinct effect parameter names and +≈ 620 other unwrapped interface strings (Home screen literals, engine-built tooltips, error and +status messages). It counts distinct strings, not catalog rows; engine messages are only partly +counted, so the true percentages are slightly lower. | Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | To `full` | |---|---|---|---|---|---|---|---| -| English | `en` | ≈ 3,900 (100%, source) | All; docs in English | — | yes | full | — | -| Simplified Chinese | `zh-hans` | 1,324 (≈ 34%): menus 629 rows, panels 695 | Panels and dialogs at the zh source list; no help, effect names translated (307) and their parameter names not; Home screen literals unwrapped (#574, closed) | System CJK fallback; Han follows the UI language; CJK text in comps renders wrongly (#453); IME in text layers | no | partial | 6–9 h + review | +| English | `en` | ≈ 4,200 (100%, source) | All; docs in English | — | yes | full | — | +| Simplified Chinese | `zh-hans` | 1,935 (≈ 46%): menus 630 rows, panels 998, effect names 307 | Panels and dialogs partly; no help, effect names translated and their parameter names not; Home screen literals unwrapped (#574, closed) | System CJK fallback; Han follows the UI language; CJK text in comps renders wrongly (#453); IME in text layers | no | partial | 6–9 h + review | | Spanish | `es` | 1,142 (≈ 29%): menus 629 rows, panels 513 | Menus only; the panel catalog from #996 | Latin, fine | no | partial | 9–12 h + review | | Hindi | `hi` | 0 | — | UI: Devanagari fallback font but no shaping; text layers shape Devanagari | no | none (After Effects: none) | 9–13 h + shaping in the UI | | Arabic | `ar` | 0 | — | UI: Arabic fallback font but no shaping and no RTL layout; text layers: bidi + shaping | no | none (After Effects: none) | 9–13 h + 15–25 h RTL UI | | French | `fr` | 624 (≈ 16%) | Menus only | Latin, fine | no | menus only | 7–10 h + review | | Portuguese (Brazil) | `pt-br` | 1,324 (≈ 34%): menus 629 rows, panels 695 | Panels and dialogs at the Chinese catalogs' source list; the six keys main added after that count are translated too; AI-assisted, not yet native-speaker proofread | Latin, fine | no | partial | independent native review | | Indonesian | `id` | 0 | — | Latin, fine | no | none (After Effects: none) | 8–12 h + review | -| Japanese | `ja` | 1,322 (≈ 34%): menus 629 rows, panels 693 | Panels and dialogs partly | Japanese system fallback; vertical text and Tate-Chu-Yoko in text layers; IME | no | partial | 6–9 h + review | +| Japanese | `ja` | 1,923 (≈ 46%): menus 630 rows, panels 996, effect names 297 | Panels and dialogs partly; no help, effect names translated for 297 of the 307 ids and their parameter names not; Home screen literals unwrapped (#574, closed) | Japanese system fallback; vertical text and Tate-Chu-Yoko in text layers; IME | no | partial | 6–9 h + review | | German | `de` | 624 (≈ 16%) | Menus only (open PR #666 adds interface translations) | Latin, fine | no | menus only | 7–10 h + review | | Korean | `ko` | 0 | — | Hangul fallback probe added (#599, closed); not checked on macOS here | no | none | 9–13 h + review | | Vietnamese | `vi` | 0 | — | Latin with stacked diacritics; untested | no | none (After Effects: none) | 8–12 h + review | @@ -87,22 +92,22 @@ Other languages EffectCraft ships (4): | Language | Code | UI strings translated | Status | To `full` | |---|---|---|---|---| -| Traditional Chinese | `zh-hant` | 1,277 (≈ 33%): menus 629 rows, panels 648 | partial | 6–9 h + review | +| Traditional Chinese | `zh-hant` | 1,581 (≈ 38%): menus 630 rows, panels 951 | partial | 6–9 h + review | | Ukrainian | `uk` | 1,153 (≈ 29%): menus 629 rows, panels 524 | partial | 6–9 h + review | | Czech | `cs` | 1,147 (≈ 29%): menus 629 rows, panels 518 | Existing catalog entries covered; unwrapped UI, effects and engine text still in English; AI-assisted, not yet native-speaker proofread | Remaining strings + independent native review | -| Georgian | `ka` | 1,514 (≈ 39%): menus 628 rows, panels 886 (the Chinese catalogs' 695 plus 191 Georgian-only rows: form dialogs, context menus, column headers), effect names 307 + 21 categories; native-speaker reviewed | partial | 5–8 h | +| Georgian | `ka` | 1,514 (≈ 39%): menus 629 rows, panels 885 (the Simplified Chinese catalog's 694 plus 191 Georgian-only rows: form dialogs, context menus, column headers), effect names 307 + 21 categories; native-speaker reviewed | partial | 5–8 h | -Russian (`ru`) has a menu catalog (624 rows, ≈ 16%); After Effects also ships Italian, which EffectCraft does not (8–12 h). +Russian (`ru`) has a menu catalog (630 rows, ≈ 15%); After Effects also ships Italian, which EffectCraft does not (8–12 h). -**Overall: ≈ 21%**, the average coverage of After Effects' 10 non-English languages -(Simplified Chinese, Traditional Chinese, Japanese and Brazilian Portuguese 34%, Spanish 29%, -French, German and Russian 16%, Korean and Italian 0%; 212 / 10, was ≈ 12% on 10 October; -Ukrainian (≈ 29%) and Czech (≈ 29%) are EffectCraft-only and stay out of that average). This is the localization row in -[target-app-parity.md](target-app-parity.md#by-dimension). +**Overall: ≈ 24%**, the average coverage of After Effects' 10 non-English languages +(Simplified Chinese 46%, Traditional Chinese 38%, Japanese 46%, Brazilian Portuguese 34%, +Spanish 29%, French, German and Russian 16%, Korean and Italian 0%; 241 / 10, was ≈ 21% on +22 October; Ukrainian (≈ 29%) and Czech (≈ 29%) are EffectCraft-only and stay out of that +average). This is the localization row in [target-app-parity.md](target-app-parity.md#by-dimension). **Effort.** One-time work first: route parameter names and engine messages through a catalog, wrap the remaining panel literals, and add a Hangul probe (10–20 h). Then each language -needs ≈ 2,900 more strings (≈ 5–9 h per language at the pace of #398 and #401). Parity with After +needs ≈ 2,600 more strings (≈ 5–9 h per language at the pace of #398 and #401). Parity with After Effects' 10 non-English languages: **≈ 60–110 h**; the four extra languages of the standard list (Hindi, Arabic, Indonesian, Vietnamese) add ≈ 50–80 h, most of it right-to-left layout and complex shaping in the interface. Every language needs native-speaker review, which only a human @@ -112,9 +117,11 @@ can give; only Georgian, contributed by a native speaker (#1843), has had it. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Recounted on the merged tree after #1774: Japanese 1,923 (46%, 297 effect names), Simplified Chinese 1,935 (46%, 307 effect names), Traditional Chinese 1,581 (38%, 951 panel rows after #2244's prune); overall ≈ 24% | +| 2026-10-22 | minor | Panel/dialog second step: 300 more catalog rows in Japanese, Simplified Chinese and Traditional Chinese, plus the three rows the review found missing (Basic, Advanced and Play/Stop) — panel tabs, form and dialog labels, panel context menus, the tracker and graph controls, text-panel options, File-panel footers and credits; the panel tables are now 996 Japanese and 998 Simplified Chinese rows, all drawn through `tr`/`tr_args`, with the tests keeping the three catalogs listing the same sources; the 30 October recount above carries the share | | 2026-10-18 | minor | Brazilian Portuguese completes its panel catalog (278 new rows, 417 → 695, the Chinese catalogs' source list); the doc recount closes the drift the batch merges left in every language's panel count; AI-assisted, not yet native-speaker proofread | -| 2026-10-12 | minor | Recounted: Russian, French, German and Czech menus (#873, #987), Brazilian Portuguese and Czech panels (#830, #987), Hangul probe (#599); overall ≈ 12% to ≈ 18%; Korean and Italian still absent | | 2026-10-14 | minor | Georgian (`ka`): menus 628, panels 886 (191 Georgian-only, in `GEORGIAN_PANELS`), effect names and categories in `effects.rs` | -| 2026-10-10 | major | First localization parity document: catalog rows counted for all six languages, the twelve-language table, After Effects' 11 shipped languages from its bundle | -| 2026-10-11 | minor | Ukrainian panel catalog brought level with the Chinese ones (495 of 496 rows, counted at this commit); its share rises to ≈ 29%. The other languages' panel counts are the 10 October measurement | | 2026-10-13 | minor | Effect names and the generated Effect menu leave English: `i18n/effects.rs` adds 306 rows keyed by effect id (Simplified Chinese, from After Effects 2026's interface and Adobe's Chinese documentation for the `CC` series) and the 21 category names. Parameter names are still English; no other language has rows | +| 2026-10-12 | minor | Recounted: Russian, French, German and Czech menus (#873, #987), Brazilian Portuguese and Czech panels (#830, #987), Hangul probe (#599); overall ≈ 12% to ≈ 18%; Korean and Italian still absent | +| 2026-10-11 | minor | Ukrainian panel catalog brought level with the Chinese ones (495 of 496 rows, counted at this commit); its share rises to ≈ 29%. The other languages' panel counts are the 10 October measurement | +| 2026-10-10 | major | First localization parity document: catalog rows counted for all six languages, the twelve-language table, After Effects' 11 shipped languages from its bundle | diff --git a/docs/plugins.md b/docs/plugins.md index 2117152a1..3b9182a60 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -16,9 +16,9 @@ Guide): | File ▸ Scripts ▸ Run Script File… (`.jsx`, `.jsxbin`) | `.jsx` / `.js` | Gap: `.jsxbin` (an undocumented binary encoding) | | File ▸ Scripts ▸ Recent Script Files: the 10 most recently run scripts; Cmd+Option+Shift+D (Ctrl+Alt+Shift+D on Windows) reruns one | File ▸ Scripts ▸ Recent Script Files lists the 10 scripts run last (script files and File ▸ Scripts entries, newest first, kept in the settings); Cmd+Alt+Shift+D runs the most recent one again (`file.runScript {"recent": 0}`, a key that can be changed in Edit ▸ Keyboard Shortcuts) | Same | | ExtendScript preprocessor directives (`#include`, `#target`, `#targetengine`) | `#include` / `//@include` / `#includepath` inline files relative to the script (once each, depth-limited, file gate applies); `#target`, `#targetengine`, `#strict` are ignored | Same | -| `Scripts/Startup` and `Scripts/Shutdown`: run in alphabetical order when the app starts and quits | Not run | Gap | +| `Scripts/Startup` and `Scripts/Shutdown`: run in alphabetical order when the app starts and quits | `Scripts/Startup/` and `Scripts/Shutdown/` in the settings folder, in alphabetical order (case ignored): the Startup scripts run at launch before a project opens, and the Shutdown scripts run as the desktop app quits, after the project is closed. A Startup script that fails is reported in a toast and in the log, a Shutdown script that fails is reported in the log, and the next script still runs | Partial: each script runs in its own global scope, so variables and functions a Startup script defines are not available to later scripts; a Startup script's modal dialog stays open while the next scripts run and the app starts; a Shutdown script's modal dialogs close as if canceled; the web app runs no Shutdown scripts | | `Scripts/ScriptUI Panels`: listed at the bottom of the Window menu, dockable like the app's own panels, `this` is the panel | `Scripts/ScriptUI Panels/` in the settings folder (File ▸ Scripts ▸ Install ScriptUI Panel… copies one there), listed at the bottom of the Window menu (a subfolder, one level, is a submenu named after it), docked like any panel, `this` is the panel; the panels open when the app quits open again at the next launch | Same. Where a panel was docked isn't kept: workspace layouts aren't saved between launches | -| Keyboard shortcuts for scripts (the Keyboard Shortcuts editor lists the scripts of the Scripts folder) | Shortcuts bind to commands and menu entries; the File ▸ Scripts and Window menu entries of scripts aren't offered | Gap | +| Keyboard shortcuts for scripts (the Keyboard Shortcuts editor lists the scripts of the Scripts folder) | Edit ▸ Keyboard Shortcuts lists the scripts of File ▸ Scripts under File ▸ Scripts (`file.runScript {"name": ""}`); a key given to one is saved with the preset, File ▸ Scripts shows it, and pressing it runs the script as its menu entry does | Same. After Effects binds a key to a script's place in the alphabetical list (`ExecuteScriptMenuItem01`…), and EffectCraft binds it to the script's file name. The Window menu entries of ScriptUI panels aren't offered | | Running a script from the command line (`afterfx -r script.jsx`) | `effectcraft-cli script`, the control channel's and MCP's `script.run` | Same purpose, other entry points | | `app.settings` (kept in the preferences between sessions) | Kept in `script_settings.json` in the settings folder (`script.settings.get` / `script.settings.save`) | Same | | Allow Scripts to Write Files and Access Network | Settings ▸ Scripting & Expressions | Same | @@ -39,6 +39,10 @@ journaled and the same as the UI's action. The `effectcraft-script` crate's docs file.jsx`, or `script.run {code, name}` over the control channel and MCP. * Install one: File ▸ Scripts ▸ Install Script File… (or copy it into `Scripts/` in the settings folder). It is listed in File ▸ Scripts at once (After Effects asks for a restart). +* Run one at every launch or quit: copy it into `Scripts/Startup/` or `Scripts/Shutdown/` in the + settings folder. These scripts are not listed in File ▸ Scripts. `file.runStartupScripts` and + `file.runShutdownScripts` run them and return `{ran, waiting, failed}`; the desktop app calls them at + launch and quit, and the web app calls `file.runStartupScripts` at launch. * `file.scripts.list` lists installed and bundled scripts and panels; `file.runScript {name}` runs one; `file.uninstallScript {name}` removes an installed one. * A subfolder of `Scripts/` is a submenu of File ▸ Scripts named after the folder, and its scripts diff --git a/docs/preferences.md b/docs/preferences.md index 2402b9a32..af53f12f6 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -190,7 +190,8 @@ Effect Controls uses compact, fixed 20 pt parameter rows, as After Effects does: Edit ▸ Keyboard Shortcuts (`Cmd+Alt+'`) opens the shortcut editor: an on-screen keyboard showing which keys are assigned for the held modifiers (purple: application-wide, green: panel-specific, both: split), modifier toggles, a searchable list of every command (all engine commands, menu -entries with bound parameters and the frontend's tool / panel commands), and a preset menu. +entries with bound parameters, the scripts File ▸ Scripts lists and the frontend's tool / panel +commands), and a preset menu. - **Presets.** "EffectCraft Default" is After Effects' default layout and is read-only; editing it creates a "Custom" copy. Custom presets store only their differences from the default and diff --git a/docs/scripting-parity.md b/docs/scripting-parity.md index 1d808726a..8d2934518 100644 --- a/docs/scripting-parity.md +++ b/docs/scripting-parity.md @@ -21,12 +21,12 @@ our own scripts; no real-world script library has been run against both apps. | Scripting object model | `app`, `Project`, items, layers, properties, keys, eases, render queue, `TextDocument`, `Shape`, markers, `File`/`Folder`, `Socket`, `app.settings` | Same classes, edits run through undoable engine commands; match names table; layers, groups and properties are callable (`layer("Effects")(1)` is `.property(...)`) | good | `crates/script` tests (≈ 10 test files) | | `$.evalFile`, `BridgeTalk` | Evaluate another script file; identify the host application and message other Adobe applications | `$.evalFile` evaluates a file relative to the calling file (nested at most 32 deep, under the scripts' file gate) and returns its last value; `BridgeTalk` names the host honestly (`appName` `effectcraft`, its version); messaging other applications throws "not supported" | partial | `tests_evalfile.rs` (#760); no inter-application messaging | | ScriptUI | Dialogs, palettes, dockable panels, resource strings, `onDraw` / ScriptUIGraphics, mouse events | Same, dockable panels reopen at launch | good | `ui_scriptui`, `tests_ui*` | -| `app.scheduleTask` / `cancelTask` | One-shot and repeating tasks (polling, panel refresh) until `cancelTask` | Same: repeating tasks run every `delay` ms (at least 50 ms, at most 64 pending per script) until `cancelTask`, until they throw (reported once, then cancelled), or until their owner goes: a script's last window closing, or the project closing for scripts without windows; frontends tick them every frame, agents with `script.runTasks` | good | `tests_tasks.rs` (#773); one-shot tasks of a script file still run right after it, not after their delay | +| `app.scheduleTask` / `cancelTask` | One-shot and repeating tasks (polling, panel refresh) until `cancelTask` | Same: repeating tasks run every `delay` ms (at least 50 ms, at most 64 pending per script) until `cancelTask`, until they throw (reported once, then cancelled), or until their owner goes: a script's last window closing, or the project closing for scripts without windows (a Startup script's tasks go on until EffectCraft quits); frontends tick them every frame, agents with `script.runTasks` | good | `tests_tasks.rs` (#773); one-shot tasks of a script file still run right after it, not after their delay | | Scripts menu, ScriptUI Panels folder | yes | yes; a subfolder (one level) of either folder is a submenu named after it | same | [plugins.md](plugins.md) | -| Startup / Shutdown folders | yes | not run | missing | plugins.md | +| Startup / Shutdown folders | yes | `Scripts/Startup` runs at launch and `Scripts/Shutdown` runs as the desktop app quits, after the project is closed, each in alphabetical order | partial | `crates/script/src/tests/startup.rs`; each script has its own global scope, so a Startup script's variables and functions are not available to later scripts ([plugins.md](plugins.md)) | | `#include`, `#target` directives | yes | `#include` / `//@include` / `#includepath` inline files relative to the including script (once each, nested at most 32 deep, under the scripts' file gate; errors name the included file and line); `#target`, `#targetengine`, `#strict` are ignored | good | `tests_include.rs` (#759); installed scripts and panels have no folder of their own, so they include from the project's folder or absolute paths | | `.jsxbin` | yes | no | out of scope | No public specification | -| Keyboard shortcuts for scripts | yes | no | missing | plugins.md | +| Keyboard shortcuts for scripts | yes: the scripts of the Scripts folder take keys, by their place in the alphabetical list | Edit ▸ Keyboard Shortcuts lists the scripts of File ▸ Scripts; a key stays with the script's file name | same | `file_scripts_entries_take_keyboard_shortcuts`, `a_key_given_to_a_script_runs_it` ([plugins.md](plugins.md)); ScriptUI panels in the Window menu take no key | | Recent Script Files | File ▸ Scripts ▸ Recent Script Files (10 scripts), a key to rerun | Same list, kept in the settings; Cmd+Alt+Shift+D reruns the most recent script | same | `recent_script_files_run_again` ([plugins.md](plugins.md)) | | Command-line rendering `aerender` | yes | `effectcraft-cli render`, `script`, `exec` | same purpose | `apps/effectcraft-cli` | | Essential Graphics scripting hooks | `addToMotionGraphicsTemplate…` | yes (M13.7) | good | — | @@ -35,7 +35,7 @@ our own scripts; no real-world script library has been run against both apps. ## Estimate -**≈ 20–40 h**: `#include` / `#target` (landed, #759) and Startup / Shutdown folders 4–8 h as first estimated (Startup / Shutdown remain), script shortcuts 2–4 h, +**≈ 20–40 h**: `#include` / `#target` (landed, #759) and Startup / Shutdown folders 4–8 h as first estimated (Startup / Shutdown landed; a global scope shared between scripts remains), script shortcuts 2–4 h, a corpus of public scripts and expressions with the fixes it finds 10–20 h, an expression performance benchmark 4–8 h. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 34829d406..770e1390e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -117,7 +117,7 @@ compositing and effects dominate; audio is light). Hours are to full parity for | Project, footage and import | 8% | 92% | 55% | 25–45 | No relative paths or relinking when a project moves; Media Browser lists AVI, which it can't import (TGA/HDR/DPX/MXF import since #745, #756, #767); #431, #324, #552 and #351 are closed | | Compositions, layers and compositing | 13% | 98% | 60% | 30–55 | 32-bpc blend modes differed from After Effects (#494, closed); expand precomps in place (#601); #527, #510, #513, #539 and #540 are closed | | Animation: keyframes, graph editor, motion paths, puppet | 15% | 97% | 55% | 35–60 | Only scalar ease is measured against After Effects (168 samples); puppet lag (#586); #456, #457, #355, #553 and #318 are closed; Proportional Scrubbing (26.2) | -| Expressions and scripting | 7% | 93% | 65% | 20–40 | `.jsxbin`, Startup/Shutdown folders, script shortcuts (`#include` landed, #759); external editor (#364); see [scripting-parity.md](scripting-parity.md) | +| Expressions and scripting | 7% | 93% | 65% | 20–40 | `.jsxbin`, a global scope shared between scripts, script shortcuts (`#include` landed, #759; Startup/Shutdown folders run); external editor (#364); see [scripting-parity.md](scripting-parity.md) | | Shapes, masks and roto | 9% | 93% | 45% | 30–50 | Open mask and shape issues (#1004, #999, #1042, #932); #510, #513, #527, #509 and #303 are closed; paste SVG/AI as shape layers (26.3; SVG markup pastes since #738) | | Text and typography | 7% | 94% | 50% | 20–35 | CJK text in comps renders wrongly (#453); paragraph alignment inverted (#316); soft text at Fit (#417, mostly fixed by #444); toolbar font options (#550); #483 and #583 are closed; variable-font filter landed (#741) | | Effects and animation presets | 14% | 85% | 40% | 70–130 | All 306 effects exist, but rendered output is unmeasured against After Effects; 76 built-in presets (text, Transitions, Backgrounds, Behaviors) against After Effects' 621 ([effects-parity.md](effects-parity.md)); open effect bugs (#580 crash, #463, #369, #473, #474; #514 and #526 are closed); effect colour labels (26.5); see [effects-parity.md](effects-parity.md) | @@ -139,7 +139,7 @@ Feature depth (≈ 50%) feeds the overall *ready* number with the other dimensio | UI/UX fidelity | 65% | 60–120 | After Effects-shaped menus (587 entries), panels, workspaces, shortcuts; many interaction issues still open; multi-monitor only as separate panel windows (#486 open); Effect Controls not yet like 26.5's | [ui-parity.md](ui-parity.md) | | File formats | 50% | 130–230 | `.aep` / `.aepx` / `.mogrt` / `.ffx` can't be read; strong modern codecs (H.264, HEVC, AV1, VP9, ProRes) | [file-format-parity.md](file-format-parity.md) | | Hardware | 40% | 60–120 | wgpu GPU on Metal/Vulkan/Direct3D 12/WebGPU with CPU fallback; no hardware decode/encode; Direct3D 12 compositor fails (#613); no video-out hardware | [hardware-parity.md](hardware-parity.md) | -| Localization | 18% | 60–110 + review | Average coverage of After Effects' 10 non-English languages (measured catalog rows over ≈ 3,900 strings): menus in 10 languages, panels in 6, effects in none; After Effects ships 11 languages fully | [localization-parity.md](localization-parity.md) | +| Localization | 24% | 60–110 + review | Average coverage of After Effects' 10 non-English languages (measured catalog rows over ≈ 4,200 strings): menus in 10 languages, panels in 7, effects in Simplified Chinese and Japanese; After Effects ships 11 languages fully | [localization-parity.md](localization-parity.md) | | Performance | 35% | 50–100 | Internal numbers only; users report stutter and slow scrubbing | [gaps.md](gaps.md#g5-performance-at-real-world-scale) | | Stability | 45% | 40–80 | Open crash reports #580, #501, #426, #341, #234, #879 (#591, #306 and #511 are closed); no CI runs the tests on pull requests | [gaps.md](gaps.md#g3-stability-and-a-green-main) | | Platforms | 65% | 30–60 | Releases for macOS, Windows x64/x86/ARM64, Linux, FreeBSD and the web (more than After Effects), but Linux and Windows users hit basic bugs | [gaps.md](gaps.md#g2-real-user-reliability-on-every-platform) |