Merge remote-tracking branch 'origin/main' into HEAD

This commit is contained in:
bflatastic
2026-10-30 07:10:36 +07:00
111 changed files with 4366 additions and 317 deletions
+23 -11
View File
@@ -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 |
+2
View File
@@ -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).
+5
View File
@@ -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) {
+4 -4
View File
@@ -30,10 +30,9 @@ pub struct Confinement {
write: Vec<PathBuf>,
}
/// 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<Option<Lookup>> {
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"
+12
View File
@@ -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);
+3
View File
@@ -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);
+69
View File
@@ -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());
}
}
+6
View File
@@ -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);
+15
View File
@@ -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);
+1 -1
View File
@@ -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;
+85 -10
View File
@@ -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);
+16 -16
View File
@@ -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),
+3 -2
View File
@@ -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 {
+4 -3
View File
@@ -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<Result<Value>> {
@@ -78,7 +79,7 @@ fn batch(s: &mut Session, p: &Value) -> Result<Value> {
.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)?;
+56 -17
View File
@@ -125,7 +125,8 @@ fn revert(s: &mut Session, _: &Value) -> Result<Value> {
}
/// 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<Value> {
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<ItemId>, 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<ItemId>, 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<ItemId>, p: &Value) -> Vec<String> {
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::<Vec<u64>>();
let comps = ids("comps");
let in_comps: std::collections::HashSet<u64> = 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<u64> = 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<ItemId>, 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<u64> {
let ids = |k: &str| r.get(k).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64).collect::<Vec<u64>>();
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<String> {
let items: Vec<ItemId> = 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<Value> {
let pending = prepare_import(s, p)?;
@@ -746,7 +785,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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
),
+163 -5
View File
@@ -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<Value> {
}
_ => 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<Value> {
pub(crate) fn report_js(s: &mut Session, name: &str, out: Value) -> Result<Value> {
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<Va
report_js(s, name, out)
}
/// Run the code of the script file at `path` (its `#include`s and `$.fileName` use that path),
/// reported under its file name `name` (Startup and Shutdown scripts).
pub(crate) fn run_js_file(s: &mut Session, code: &str, path: &str, name: &str) -> Result<Value> {
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<Value> {
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<bool>,
/// Start Timecode.
start: Option<StartTimecode>,
/// 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<bool>,
units: Option<CineonUnits>,
/// The units the Converted Black and White Points are given in (`pointUnits`, else `cineonUnits`,
/// else the footage's own Units).
point_units: Option<CineonUnits>,
converted_black: Option<f64>,
converted_white: Option<f64>,
/// 10 Bit Black and White Points.
black: Option<f64>,
white: Option<f64>,
gamma: Option<f64>,
rolloff: Option<f64>,
}
impl CineonEdit {
fn parse(p: &Value, cmd: &str) -> Result<CineonEdit> {
let units = |key: &str| -> Result<Option<CineonUnits>> {
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<Option<f64>> {
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<Option<CineonSettings>> {
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<f32> {
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<Item = &'a Footage>, 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<Value> {
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<Option<(AlphaMode, [f32; 3])>> = 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<Value> {
"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<Value> {
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<CommandSpec> {
"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
),
+6
View File
@@ -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<std::sync::Arc<effectcraft_raster::Image>> {
s.footage.frame(item, f, t).map(|img| effectcraft_render::cineon_frame(img, f))
}
fn source_time(s: &Session, p: &Value, item: ItemId) -> Option<Tick> {
let (_, fr) = source_timing(s, item);
if let Some(t) = f_p(p, "time") {
+1 -1
View File
@@ -183,7 +183,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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
),
+1 -1
View File
@@ -360,7 +360,7 @@ fn free_transform(s: &mut Session, p: &Value) -> Result<Value> {
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).
+10
View File
@@ -1189,6 +1189,15 @@ fn toggle_hold(s: &mut Session, _: &Value) -> Result<Value> {
})
}
fn set_muted(s: &mut Session, p: &Value) -> Result<Value> {
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<Vec<f64>> {
match p.get(k)? {
@@ -1381,6 +1390,7 @@ pub fn specs() -> Vec<CommandSpec> {
&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...",
+2 -1
View File
@@ -117,6 +117,7 @@ fn interpret_proxy(s: &mut Session, p: &Value) -> Result<Value> {
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<CommandSpec> {
"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
),
+174
View File
@@ -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<Value> {
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<Value> {
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<Value> {
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<String> = 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<Value>| 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<u32> = 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<u32> = 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<Value> {
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<Value> {
s.replace_project(effectcraft_project::Project::default(), None);
Ok(run_folder(s, SHUTDOWN_DIR, true))
}
/// Window ▸ `<ScriptUI panel>`: run the panel script (or bring its panel forward).
fn open_panel(s: &mut Session, p: &Value) -> Result<Value> {
let c = "window.scriptPanel";
@@ -414,6 +510,24 @@ pub fn specs() -> Vec<CommandSpec> {
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);
}
}
+14 -4
View File
@@ -73,13 +73,21 @@ fn prefs_pages(_: &mut Session, _: &Value) -> Result<Value> {
// ---------------------------------------------------------------- shortcuts
/// The bindable named by `command` (+ `params`), or by a binding key (`app.settings {...}`).
fn bindable(s: &Session, p: &Value, cmd: &str) -> Result<crate::shortcuts::Bindable> {
/// 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<crate::shortcuts::Bindable> {
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, &params);
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<Vec<String>> {
@@ -93,6 +101,8 @@ fn keys_param(p: &Value, cmd: &str) -> Result<Vec<String>> {
}
fn sc_list(s: &mut Session, p: &Value) -> Result<Value> {
// 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);
+2
View File
@@ -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
+5 -1
View File
@@ -402,12 +402,16 @@ pub struct ScriptUi {
#[serde(skip)]
pub tasks: Option<ScriptTaskRunner>,
/// Bumped whenever the project is closed or replaced (New / Open / Close Project): repeating
/// tasks of scripts without an open window stop then.
/// 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<u32>,
/// 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<u32>,
}
impl ScriptUi {
+1 -1
View File
@@ -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));
+12 -2
View File
@@ -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<String> = 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.
+33 -2
View File
@@ -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<Bindable> {
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<Bindable> {
names
.iter()
.map(|n| {
let params = json!({"name": n});
Bindable {
key: binding_key("file.runScript", &params),
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<String> {
@@ -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<Bindable> = 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 }
}
+62
View File
@@ -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::<Vec<_>>(), ["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();
+138 -5
View File
@@ -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<Footage, String> {
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]);
}
+40
View File
@@ -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<Footage, String> {
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::<Vec<_>>();
// 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() {
+31
View File
@@ -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");
+83 -14
View File
@@ -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 });
});
})();
+95
View File
@@ -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;
+3
View File
@@ -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);
}
+36 -5
View File
@@ -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<GBuf> {
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<GBuf> {
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)
+3
View File
@@ -180,6 +180,9 @@ fn turbulent(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
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);
+1 -1
View File
@@ -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<f32>(0.6, 0.7, 0.8);
+15 -11
View File
@@ -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<fp::EffectInstance> {
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,
+53
View File
@@ -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();
+51 -20
View File
@@ -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<K: Borrow<Keyframe>>(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<K: Borrow<Keyframe>>(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<K: Borrow<Keyframe>>(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<Value> {
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<K: Borrow<Keyframe>>(keys: &[K], t: Tick, spatial: bool) -> Option<Value> {
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<K: Borrow<Keyframe>>(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<Keyframe>, 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
}
+56
View File
@@ -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));
}
+18 -7
View File
@@ -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<Keyframe>;
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<Json> {
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<Json> {
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;
+18
View File
@@ -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);
}
+48
View File
@@ -23,6 +23,7 @@ pub fn probe(path: impl AsRef<Path>) -> Result<Footage> {
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<Footage> {
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<Footage> {
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<Tick>) {
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<Tick> {
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<Tick> {
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<FrameRate>) {
@@ -322,6 +352,24 @@ fn averaged_rate(timescale: u32, durations: impl Iterator<Item = u32>) -> 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<u32> {
let at = |k: i64| (2 * k * timescale * rate.den + rate.num) / (2 * rate.num);
+16
View File
@@ -596,6 +596,22 @@ pub fn shape_simple_op(ids: &mut Ids, kind: &str) -> Option<PropGroup> {
.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",
+151
View File
@@ -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<CineonUnits> {
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
+1 -1
View File
@@ -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::{
+43
View File
@@ -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::<Footage>(&saved).unwrap().cineon, on);
assert_eq!(serde_json::from_value::<Footage>(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() {
+8
View File
@@ -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).
+1 -1
View File
@@ -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).
+19 -1
View File
@@ -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<Image>, f: &Footage) -> Arc<Image> {
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 {
+2 -2
View File
@@ -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;
+33 -15
View File
@@ -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<Arc<Image>> {
@@ -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.
+80 -3
View File
@@ -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<usize> {
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<usize, usize>, 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<usize, Mat3> = HashMap::new();
let mut done: HashSet<usize> = 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(&copy_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<BezPath>) -> (Vec<Draw>, Vec<usize>) {
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<usize, usize>,
next: usize,
}
fn collect_in(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<BezPath>, copy_ids: &mut Copies) -> (Vec<Draw>, Vec<usize>) {
let mut draws: Vec<Draw> = Vec::new();
let mut live: Vec<usize> = 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<B
let t = layer.layer_time(ctx.time).seconds();
apply(arena, &live, |p| ops::wiggle(p, &w, t));
}
"wiggler" => wiggle_transform(ctx, layer, g, arena, &live, &copy_ids.of, &mut draws),
"merge" => {
let mode = ops::MergeMode::from_index(ctx.e(layer, g, "mode"));
let input: Vec<BezPath> = live.iter().map(|&i| arena[i].clone()).collect();
@@ -342,7 +411,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
}
"group" => {
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<B
// Each copy gets its own transformed slots, so operators below the repeater act on
// the repeated geometry (copy order: original first).
let mut copy_maps: Vec<std::collections::HashMap<usize, usize>> = 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);
+196
View File
@@ -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<i64> = (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<i64> = (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| {
+8
View File
@@ -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,
})
+2 -1
View File
@@ -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 });
};
+1 -1
View File
@@ -126,7 +126,7 @@ pub(crate) fn js_str(s: &str) -> JsValue {
fn native_exec(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
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"));
+31
View File
@@ -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<String> =
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);
}
+290
View File
@@ -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<MemoryConfig>) -> 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<MemoryConfig>) -> 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<MemoryConfig>) -> 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);
}
+10 -5
View File
@@ -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);
+56 -1
View File
@@ -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<Option<std::thread::JoinHandle<usize>>> = 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<usize> {
#[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.
+2
View File
@@ -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");
+3
View File
@@ -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;
}
+4 -4
View File
@@ -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 {
+5 -1
View File
@@ -799,7 +799,11 @@ impl DockContent<PanelKind> 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
+104 -1
View File
@@ -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::<Vec<_>>();
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...");
}
+7
View File
@@ -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`).
///
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -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,
+27 -3
View File
@@ -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() {
+1 -1
View File
@@ -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());
}
}
});
+4 -4
View File
@@ -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(),
@@ -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(&params);
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");
+4 -4
View File
@@ -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;
+2
View File
@@ -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);
});
@@ -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");
@@ -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,
);
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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])));
+130 -4
View File
@@ -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<Field>)> {
fn interpret_footage_form(s: &effectcraft_engine::Session, proxy: bool, base: &mut Value) -> Option<(String, Vec<Field>)> {
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<Field>) = 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("<xmeml"));
}
#[test]
fn interpretation_menu_applies_explicit_cineon_parameters_without_a_dialog() {
use effectcraft_engine::project::{Footage, FootageKind, ItemKind, Proxy};
for command in ["file.interpretFootage", "file.interpretProxy"] {
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
let footage = Footage { path: "/m/scan.dpx".into(), kind: FootageKind::Still, width: 4, height: 4, has_video: true, ..Default::default() };
let project = std::sync::Arc::make_mut(&mut app.session.project);
let item = project.add_item("scan.dpx", Default::default(), None, ItemKind::Footage(footage.clone()));
project.item_mut(item).unwrap().proxy = Some(Box::new(Proxy { footage, enabled: true }));
app.session.state.project_selection = vec![item];
let ctx = egui::Context::default();
for params in [
json!({"logConversion": true}),
json!({"cineonUnits": "10bit"}),
json!({"pointUnits": "10bit"}),
json!({"convertedBlack": 10.0}),
json!({"convertedWhite": 900.0}),
json!({"tenBitBlack": 90.0}),
json!({"tenBitWhite": 690.0}),
json!({"currentGamma": 2.2}),
json!({"highlightRolloff": 30.0}),
] {
crate::menus::invoke(&mut app, &ctx, command, params.clone()).unwrap();
assert!(app.dialog.is_none(), "{command}: {params}");
}
let item = app.session.project.item(item).unwrap();
let footage = if command == "file.interpretProxy" {
&item.proxy.as_ref().unwrap().footage
} else {
let ItemKind::Footage(footage) = &item.kind else { panic!("not footage") };
footage
};
assert!(footage.cineon.log_conversion);
assert_eq!(footage.cineon.gamma, 2.2);
assert_eq!(footage.cineon.converted_white, 900.0);
}
}
/// Interpret Footage shows Cineon Settings for DPX footage only. The converted points show in
/// the footage's Units, and choosing other Units reads the shown values in the old ones.
#[test]
fn interpret_footage_shows_cineon_settings_for_dpx() {
use effectcraft_engine::project::{CineonUnits, Footage, FootageKind, ItemKind};
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
let still =
|path: &str| ItemKind::Footage(Footage { path: path.into(), kind: FootageKind::Still, width: 4, height: 4, has_video: true, ..Default::default() });
let project = std::sync::Arc::make_mut(&mut app.session.project);
let dpx = project.add_item("scan.dpx", Default::default(), None, still("/m/scan.dpx"));
let png = project.add_item("still.png", Default::default(), None, still("/m/still.png"));
let keys = |app: &EffectcraftApp| app.dialog_state.form.fields.iter().map(|f| f.key.clone()).collect::<Vec<_>>();
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:?}");
}
}
+4 -4
View File
@@ -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");
+22 -8
View File
@@ -48,9 +48,21 @@ struct KeyPt {
t: f64,
v: f64,
sel: bool,
muted: bool,
handles: Vec<Handle>,
}
/// 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);
+2 -2
View File
@@ -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);
+6 -2
View File
@@ -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);
+1 -1
View File
@@ -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() {
+46 -4
View File
@@ -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<f64>) {
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<Action>) {
@@ -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");
}
}
+1 -1
View File
@@ -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,
);
@@ -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);
}
+2 -2
View File
@@ -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 {
+20
View File
@@ -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<String> {
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;
+8 -4
View File
@@ -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::<crate::panels::DragPayload>(&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"),
];
+1 -1
View File
@@ -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| {
+27 -1
View File
@@ -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();
+3 -3
View File
@@ -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,
+25 -20
View File
@@ -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 });
+21
View File
@@ -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::<Vec<_>>(), ["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
+21
View File
@@ -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::<Vec<_>>());
}

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