From 058da6f3b053e42161d73670300eaffbd7b93723 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Tue, 13 Oct 2026 10:09:51 +0000 Subject: [PATCH 001/279] CI: use a match guard in the STL reader --- crates/model/src/stl.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/model/src/stl.rs b/crates/model/src/stl.rs index 6684858ad..34e9a7f06 100644 --- a/crates/model/src/stl.rs +++ b/crates/model/src/stl.rs @@ -175,10 +175,8 @@ fn ascii(bytes: &[u8]) -> Result { let Some(key) = it.next() else { continue }; let bad = || perr(format!("STL line {}: expected three numbers", ln + 1)); match key.to_ascii_lowercase().as_str() { - "solid" => { - if name.is_none() { - name = Some(line.get(5..).unwrap_or("").trim().to_string()); - } + "solid" if name.is_none() => { + name = Some(line.get(5..).unwrap_or("").trim().to_string()); } "facet" => { // `facet normal nx ny nz` From 037a21d5a6330a47b338daad0109f6a09419249f Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Tue, 13 Oct 2026 10:09:51 +0000 Subject: [PATCH 002/279] #1200: allow TGA proxies and add import regressions --- contributors/people.toml | 2 +- crates/host/tests/tga_import.rs | 48 +++++++++++++ crates/media/tests/tga.rs | 115 ++++++++++++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 19 +++++- 4 files changed, 182 insertions(+), 2 deletions(-) create mode 100644 crates/host/tests/tga_import.rs create mode 100644 crates/media/tests/tga.rs diff --git a/contributors/people.toml b/contributors/people.toml index 705feb257..0de777b11 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -66,7 +66,7 @@ sync_github_name = true display_name = "Jason Brashear" [people.nanox333] -display_name = "nanox333" +display_name = "nnaannoo" [people.Nael-Nathanael] sync_github_name = true diff --git a/crates/host/tests/tga_import.rs b/crates/host/tests/tga_import.rs new file mode 100644 index 000000000..5462f4bec --- /dev/null +++ b/crates/host/tests/tga_import.rs @@ -0,0 +1,48 @@ +//! Targa import through the same host and commands used by the desktop and CLI. + +use effectcraft_engine::render::RenderOpts; +use effectcraft_project::{FootageKind, ItemId, ItemKind}; +use effectcraft_time::{FrameRate, Tick}; +use serde_json::json; + +#[test] +fn tga_stills_and_numbered_sequences_import_and_render() { + let dir = std::env::temp_dir().join(format!("effectcraft-tga-import-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + // A generated 4×4, 24-bit, top-origin TGA. No binary fixtures are retained. + let write = |name: &str, bgr: [u8; 3]| { + let mut bytes = vec![0; 18]; + bytes[2] = 2; + bytes[12] = 4; + bytes[14] = 4; + bytes[16] = 24; + bytes[17] = 0x20; + bytes.extend_from_slice(&bgr.repeat(16)); + let path = dir.join(name); + std::fs::write(&path, bytes).unwrap(); + path.to_string_lossy().to_string() + }; + let still = write("still.TGA", [0, 0, 255]); + let first = write("shot_0001.tga", [0, 0, 255]); + write("shot_0002.tga", [255, 0, 0]); + for (path, sequence) in [(still, false), (first, true)] { + let mut session = effectcraft_host::session(); + let imported = session.execute_checked("file.import", json!({"paths": [path], "sequence": sequence})).unwrap(); + assert_eq!(imported["errors"], json!([]), "{imported}"); + assert_eq!(imported["items"].as_array().unwrap().len(), 1); + let item = ItemId(imported["items"][0].as_u64().unwrap()); + let ItemKind::Footage(footage) = &session.project.item(item).unwrap().kind else { panic!("not footage") }; + assert_eq!(footage.kind, if sequence { FootageKind::Sequence } else { FootageKind::Still }); + assert_eq!(footage.sequence.len(), if sequence { 2 } else { 0 }); + session.execute_checked("comp.new", json!({"name": "TGA", "width": 4, "height": 4, "frameRate": 30, "duration": 1})).unwrap(); + session.execute_checked("layer.addItem", json!({"item": item.0})).unwrap(); + let comp = session.active_comp_id().unwrap(); + let image = session.render(comp, Tick::ZERO, RenderOpts::default()); + assert_eq!(image.get(2, 2), [1.0, 0.0, 0.0, 1.0]); + if sequence { + let image = session.render(comp, FrameRate::FPS_30.tick_of(1), RenderOpts::default()); + assert_eq!(image.get(2, 2), [0.0, 0.0, 1.0, 1.0]); + } + } + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/media/tests/tga.rs b/crates/media/tests/tga.rs new file mode 100644 index 000000000..61aacd957 --- /dev/null +++ b/crates/media/tests/tga.rs @@ -0,0 +1,115 @@ +//! Tiny synthetic Targa files exercise import without external fixtures or codecs. + +use std::path::Path; +use std::sync::Arc; + +use effectcraft_media::{MediaPool, is_importable, probe_bytes}; +use effectcraft_project::{AlphaMode, Footage, FootageKind}; +use effectcraft_time::Tick; + +/// A classic 2×2 TGA without a footer. True-colour pixels are stored BGR(A); RLE +/// packets repeat each row's colour twice. `pixels` is always in top-down RGBA order. +fn tga(pixels: [[u8; 4]; 4], alpha: bool, rle: bool, top: bool) -> Vec { + let mut bytes = vec![0; 18]; + bytes[2] = if rle { 10 } else { 2 }; + bytes[12] = 2; + bytes[14] = 2; + bytes[16] = if alpha { 32 } else { 24 }; + bytes[17] = if top { 0x20 } else { 0 } | if alpha { 8 } else { 0 }; + let order = if top { [0, 1, 2, 3] } else { [2, 3, 0, 1] }; + for row in order.chunks_exact(2) { + if rle { + assert_eq!(pixels[row[0]], pixels[row[1]]); + bytes.push(0x81); // A two-pixel run. + } + for &i in row.iter().take(if rle { 1 } else { 2 }) { + let [r, g, b, a] = pixels[i]; + bytes.extend_from_slice(&[b, g, r]); + if alpha { + bytes.push(a); + } + } + } + bytes +} + +#[test] +fn tga_raw_and_rle_preserve_colours_orientation_and_alpha() { + for alpha in [false, true] { + for rle in [false, true] { + for top in [false, true] { + let pixels = if rle { + [[255, 0, 0, 128], [255, 0, 0, 128], [0, 0, 255, 255], [0, 0, 255, 255]] + } else { + [[255, 0, 0, 128], [0, 255, 0, 255], [0, 0, 255, 64], [255, 255, 255, 0]] + }; + let path = if top { "/frame.TGA" } else { "/frame.tga" }; + assert!(is_importable(Path::new(path))); + let bytes: Arc<[u8]> = tga(pixels, alpha, rle, top).into(); + let footage = probe_bytes(path, bytes.clone()).unwrap(); + assert_eq!(footage.kind, FootageKind::Still); + assert_eq!((footage.width, footage.height), (2, 2)); + assert_eq!(footage.codec, "TGA"); + assert_eq!(footage.alpha, if alpha { AlphaMode::Straight } else { AlphaMode::Ignore }); + assert!(footage.has_video && !footage.has_audio); + let pool = MediaPool::new(); + pool.add_bytes(path, bytes); + let image = pool.frame_at(&footage, Tick::ZERO).unwrap(); + for (actual, [r, g, b, a]) in image.data.iter().zip(pixels) { + let a = if alpha { f32::from(a) / 255.0 } else { 1.0 }; + let expected = [f32::from(r) / 255.0 * a, f32::from(g) / 255.0 * a, f32::from(b) / 255.0 * a, a]; + assert!(actual.iter().zip(expected).all(|(a, b)| (a - b).abs() < 1e-6), "{actual:?} != {expected:?}"); + } + } + } + } +} + +#[test] +fn tga_sequence_decodes_each_frame_from_memory() { + let pool = MediaPool::new(); + pool.set_read_ahead(false); + let paths = ["/shot_0001.tga", "/shot_0002.tga"]; + let colours = [[255, 0, 0, 255], [0, 0, 255, 255]]; + let mut footage = None; + for (path, colour) in paths.iter().zip(colours) { + let bytes: Arc<[u8]> = tga([colour; 4], false, true, true).into(); + footage.get_or_insert_with(|| probe_bytes(path, bytes.clone()).unwrap()); + pool.add_bytes(path, bytes); + } + let mut footage = Footage { kind: FootageKind::Sequence, sequence: paths.iter().map(|s| s.to_string()).collect(), ..footage.unwrap() }; + footage.sync_sequence_duration(); + for (n, expected) in [[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]].into_iter().enumerate() { + let image = pool.frame_at(&footage, footage.frame_rate.tick_of(n as i64)).unwrap(); + assert_eq!(image.get(0, 0), expected); + } +} + +#[test] +fn malformed_tga_returns_contextual_errors() { + for bytes in [vec![], vec![0; 17], vec![0; 18]] { + let error = probe_bytes("/broken.tga", bytes.into()).unwrap_err().to_string(); + assert!(error.contains("/broken.tga"), "{error}"); + } + let mut bytes = tga([[255, 0, 0, 255]; 4], true, false, true); + bytes.truncate(19); // Valid metadata, truncated pixel data. + let bytes: Arc<[u8]> = bytes.into(); + let footage = probe_bytes("/truncated.tga", bytes.clone()).unwrap(); + let pool = MediaPool::new(); + pool.add_bytes(&footage.path, bytes); + let error = pool.frame_at(&footage, Tick::ZERO).unwrap_err().to_string(); + assert!(error.contains("/truncated.tga"), "{error}"); +} + +#[test] +fn a_recognised_signature_takes_precedence_over_the_tga_extension() { + let mut bytes = std::io::Cursor::new(Vec::new()); + image::DynamicImage::new_rgb8(3, 2).write_to(&mut bytes, image::ImageFormat::Png).unwrap(); + let bytes: Arc<[u8]> = bytes.into_inner().into(); + let footage = probe_bytes("/actually-png.tga", bytes.clone()).unwrap(); + assert_eq!(footage.codec, "PNG"); + let pool = MediaPool::new(); + pool.add_bytes(&footage.path, bytes); + let image = pool.frame_at(&footage, Tick::ZERO).unwrap(); + assert_eq!((image.width, image.height), (3, 2)); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 8b5fd1c30..53783859e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1361,7 +1361,9 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option ("path", Ask::Open(&["json", "csv", "txt"])), "essential.exportTemplate" => ("path", Ask::Save("Template.ectemplate")), "essential.importTemplate" => ("path", Ask::Open(&["ectemplate"])), - "file.setProxy" => ("path", Ask::Open(&["mp4", "mov", "m4v", "mkv", "webm", "mxf", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr"])), + "file.setProxy" => { + ("path", Ask::Open(&["mp4", "mov", "m4v", "mkv", "webm", "mxf", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "tga", "exr"])) + } _ => return None, }; if params.get(key).is_some() @@ -2494,6 +2496,21 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn tga_is_available_in_import_replace_and_proxy_dialogs() { + use std::sync::{Arc, Mutex}; + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + let ctx = egui::Context::default(); + let pending = Arc::new(Mutex::new(Vec::new())); + let queue = pending.clone(); + app.hooks.file_dialog_async = Some(Box::new(move |dialog, _done| queue.lock().unwrap().push(dialog))); + for command in ["file.import", "file.importMultiple", "file.replaceFootage", "file.setProxy"] { + assert_eq!(invoke(&mut app, &ctx, command, json!({})).unwrap(), json!({"dialog": command})); + let crate::FileDialog::Open { exts, .. } = pending.lock().unwrap().pop().unwrap() else { panic!("not an open dialog") }; + assert!(exts.iter().any(|ext| ext == "tga"), "{command}: {exts:?}"); + } + } + #[test] fn menu_items_cover_the_tree() { assert_eq!(menus().last(), Some(&"Help")); From 2ddbf20466e79b71e53dc565d1bb0b22c814e926 Mon Sep 17 00:00:00 2001 From: Moein Saharkhiz <142007707+moecr7@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:31:17 -0400 Subject: [PATCH 003/279] #1070: refresh layered composition sources on import --- crates/engine/src/commands/file.rs | 7 +++ crates/host/tests/footage_reload.rs | 77 +++++++++++++++++++++++++++++ docs/footage.md | 3 ++ 3 files changed, 87 insertions(+) diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index 3b43eb633..0b131ec7a 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -415,6 +415,13 @@ fn prepare_import(s: &mut Session, p: &Value) -> Result { } } paths = rest; + // Composition imports already made their sources, so they bypass add_footage's + // probed-footage refresh. Refresh them before any background probe job starts (#1070). + for id in &items { + if let Some(ItemKind::Footage(f)) = s.project.item(ItemId(*id)).map(|it| &it.kind) { + s.forget_decoded(f); + } + } } // Numbered stills as image sequences (the Import dialog's " Sequence" and Force // Alphabetical Order), at `frameRate` or Settings ▸ Import ▸ Sequence Footage. diff --git a/crates/host/tests/footage_reload.rs b/crates/host/tests/footage_reload.rs index 6834fc543..faf2b09e3 100644 --- a/crates/host/tests/footage_reload.rs +++ b/crates/host/tests/footage_reload.rs @@ -213,3 +213,80 @@ fn a_new_project_importing_an_overwritten_file_shows_the_new_pixels() { let cid = s.active_comp_id().unwrap(); assert!(near(pixel(&s, cid, 32, 24), BLUE), "stale pixels from the earlier project: {:?}", pixel(&s, cid, 32, 24)); } + +fn layered_comp(s: &mut Session, path: &str, import_as: &str) -> ItemId { + let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": import_as})).unwrap(); + ItemId(r["comps"][0].as_u64().unwrap()) +} + +/// Composition imports create their sources before probing ordinary footage (#1070). +#[test] +fn fresh_layered_composition_import_refreshes_its_sources() { + for import_as in ["composition", "compositionLayerSizes"] { + let path = tmp(&format!("fresh-{import_as}.psd")); + let (red, blue) = (flat(RED, false), flat(BLUE, false)); + assert_eq!(red.len(), blue.len(), "the overwritten file has the same byte size"); + std::fs::write(&path, red).unwrap(); + let mut s = effectcraft_host::session(); + let old_comp = layered_comp(&mut s, &path, import_as); + assert!(near(pixel(&s, old_comp, 32, 24), RED)); + s.execute_checked("file.newProject", json!({})).unwrap(); + save(&path, &blue); + s.drain_events(); + let comp = layered_comp(&mut s, &path, import_as); + assert_eq!(comp, old_comp, "new projects reuse their item ids"); + assert!(!s.drain_events().contains(&effectcraft_engine::Event::PurgeCaches), "import must retain the RAM preview"); + assert!(near(pixel(&s, comp, 32, 24), BLUE), "layered import showed {:?}", pixel(&s, comp, 32, 24)); + std::fs::remove_file(path).unwrap(); + } +} + +#[test] +fn layered_reimport_refreshes_existing_layers_and_keeps_unrelated_decoded_files() { + let path = tmp("layered-reimport.psd"); + let other = tmp("layered-unchanged.psd"); + std::fs::write(&path, flat(RED, false)).unwrap(); + std::fs::write(&other, flat(GREEN, false)).unwrap(); + let mut s = effectcraft_host::session(); + let comp = layered_comp(&mut s, &path, "compositionLayerSizes"); + let r = s.execute_checked("file.import", json!({"paths": [&other]})).unwrap(); + let other_item = ItemId(r["items"][0].as_u64().unwrap()); + let other_footage = footage(&s, other_item); + let cached = s.footage.frame(other_item, &other_footage, Tick::ZERO).unwrap(); + assert!(near(pixel(&s, comp, 32, 24), RED)); + let before = s.project.clone(); + let undo = s.history.undo.len(); + save(&path, &flat(BLUE, false)); + s.drain_events(); + layered_comp(&mut s, &path, "compositionLayerSizes"); + assert!(!s.drain_events().contains(&effectcraft_engine::Event::PurgeCaches), "import must retain the RAM preview"); + assert!(near(pixel(&s, comp, 32, 24), BLUE), "existing layers see the refreshed source"); + assert!(std::sync::Arc::ptr_eq(&cached, &s.footage.frame(other_item, &other_footage, Tick::ZERO).unwrap())); + assert_eq!(s.history.undo.len(), undo + 1, "composition import stays one undo step"); + s.undo(); + assert!(std::sync::Arc::ptr_eq(&s.project, &before)); + assert!(near(pixel(&s, comp, 32, 24), BLUE), "undo does not undo an external file write"); + std::fs::remove_file(path).unwrap(); + std::fs::remove_file(other).unwrap(); +} + +#[test] +fn background_import_refreshes_layered_sources_before_the_probe_job_finishes() { + let path = tmp("layered-background.psd"); + let other = tmp("layered-background.png"); + std::fs::write(&path, flat(RED, false)).unwrap(); + image::RgbaImage::from_pixel(64, 48, image::Rgba([0, 255, 0, 255])).save(&other).unwrap(); + let mut s = effectcraft_host::session(); + let comp = layered_comp(&mut s, &path, "compositionLayerSizes"); + assert!(near(pixel(&s, comp, 32, 24), RED)); + save(&path, &flat(BLUE, false)); + s.drain_events(); + let r = s.execute_checked("file.import", json!({"paths": [&path, &other], "importAs": "compositionLayerSizes", "background": true})).unwrap(); + assert_eq!(r["started"], true, "ordinary footage is probed in a background job"); + assert!(near(pixel(&s, comp, 32, 24), BLUE), "composition sources are refreshed before the job is applied"); + s.wait_jobs(); + assert!(!s.drain_events().contains(&effectcraft_engine::Event::PurgeCaches), "import must retain the RAM preview"); + assert!(near(pixel(&s, s.active_comp_id().unwrap(), 32, 24), BLUE)); + std::fs::remove_file(path).unwrap(); + std::fs::remove_file(other).unwrap(); +} diff --git a/docs/footage.md b/docs/footage.md index 4a5a498c7..84d4c49e6 100644 --- a/docs/footage.md +++ b/docs/footage.md @@ -107,6 +107,9 @@ vectors, positions and IDs are never the picture. EXtractoR reads every other la ## Replacing and reloading +Importing a layered file as a composition refreshes the decoded sources used by its layers, +including files overwritten at the same path and byte size. + - **File ▸ Replace Footage ▸ File…** (Ctrl+H) points the item at another file. Pick a numbered file to replace it with that file's whole sequence (`file.replaceFootage {"path": …, "sequence": false}` for one frame). From dbc595b0d6cd37db886fc0b34057c8360b6c4f1a Mon Sep 17 00:00:00 2001 From: Jannik Wappler <167129173+RavoxX@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:38:06 +0300 Subject: [PATCH 004/279] #1220: Hide layer bounds during path editing --- crates/ui-egui/src/panels/viewer.rs | 8 +++ crates/ui-egui/tests/ui_viewer.rs | 97 +++++++++++++++++++++++++++++ docs/gaps.md | 6 +- docs/ui-parity.md | 9 ++- 4 files changed, 118 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index f4bb5a881..6ec3be36c 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1028,6 +1028,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } } + // Path editing keeps vertices and tangents visible without the layer's transform + // box competing with them (#1220). The path's own free-transform box is separate. + let editing_path = paths.iter().any(|p| { + p.layer == l.id && (app.ui.tool.is_pen() || (app.ui.tool == Tool::Selection && sel_vertices.iter().any(|v| v.layer == l.id && v.mask == p.uid))) + }); + if editing_path { + continue; + } let Some((m, q, _)) = layer_quad(&ectx, l) else { // A 3D layer reaching past the camera: the part of its box in front of it. let outline = layer_outline(&ectx, l); diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index e60226b1d..d37180a8e 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -677,6 +677,103 @@ fn fill_options_change_the_selected_shape_layer() { } } +/// #1220: path tools hide the layer transform box, while path vertices remain editable. +#[test] +fn path_editing_hides_layer_bounds_and_keeps_corner_vertices_editable() { + for mask in [false, true] { + let mut h = harness(); + let vertices = [[200.0, 100.0], [400.0, 100.0], [400.0, 260.0], [200.0, 260.0]]; + let (layer, uid, kind) = if mask { + let layer = layer_id(&h, "Plate"); + h.state_mut().session.execute("layer.select", json!({"layers": [layer]})).unwrap(); + let r = h + .state_mut() + .session + .execute("mask.new", json!({"layer": layer, "vertices": vertices, "outTangents": [[40, 0], [0, 0], [0, 0], [0, 0]], "closed": true})) + .unwrap(); + (layer, r["mask"].as_u64().unwrap(), "mask") + } else { + let r = h.state_mut().session.execute("shape.newPath", json!({"vertices": vertices, "outTangents": [[40, 0], [0, 0], [0, 0], [0, 0]], "closed": true, "fill": false, "stroke": "#ffffff", "strokeWidth": 4})).unwrap(); + (r["layer"].as_u64().unwrap(), r["path"].as_u64().unwrap(), "shapePath") + }; + h.state_mut().session.state.selected_vertices.clear(); + h.run_steps(2); + let handle = format!("viewer.handle.{layer}.0"); + assert!(h.state().auto.find(&handle).is_some(), "ordinary layer selection has transform handles (mask={mask})"); + for tool in [Tool::Pen, Tool::PenAdd, Tool::PenDelete, Tool::PenConvert, Tool::MaskFeather] { + h.state_mut().ui.tool = tool; + h.run_steps(2); + assert!(h.state().auto.find(&handle).is_none(), "{tool:?}: hide the layer box"); + assert!(h.state().auto.find(&format!("viewer.{kind}.{uid}.vertex.0")).is_some(), "{tool:?}: keep the path point"); + } + h.state_mut().ui.tool = Tool::Selection; + h.run_steps(2); + let vertex = format!("viewer.{kind}.{uid}.vertex.0"); + let from = rect(&h, &vertex).center(); + click(&mut h, from); + h.run_steps(2); // The automation registry also keeps the preceding frame's elements. + assert!(h.state().auto.find(&handle).is_none(), "selecting a vertex starts path editing (mask={mask})"); + let position = h.state().session.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.prop("transform/position").unwrap().value.clone(); + let path = |h: &Harness<'_, EffectcraftApp>| { + h.state() + .session + .active_comp() + .unwrap() + .layer(LayerId(layer)) + .unwrap() + .props + .find_group(uid) + .unwrap() + .get("path") + .unwrap() + .value + .as_path() + .unwrap() + .clone() + }; + let before = path(&h); + drag(&mut h, from, from + vec2(15.0, 12.0)); + assert_ne!(path(&h).vertices[0], before.vertices[0], "the corner vertex can still be moved"); + assert_eq!(path(&h).vertices[1..], before.vertices[1..]); + assert_eq!(h.state().session.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.prop("transform/position").unwrap().value, position); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.run_steps(2); + assert_eq!(path(&h), before, "moving the vertex undoes in one step"); + let z = rect(&h, "viewer.comp").width() / 640.0; + let tangent = rect(&h, &vertex).center() + vec2(40.0 * z, 0.0); + drag(&mut h, tangent, tangent + vec2(0.0, 20.0)); + assert_ne!(path(&h).out_tangents[0], before.out_tangents[0], "the Bézier handle is still editable"); + assert_eq!(path(&h).vertices, before.vertices, "a tangent drag does not move the path points"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.run_steps(2); + assert_eq!(path(&h), before, "the tangent drag undoes in one step"); + h.state_mut().ui.tool = Tool::Rotate; + h.run_steps(2); + assert!(h.state().auto.find(&handle).is_some(), "a layer transform tool restores the box"); + h.state_mut().ui.tool = Tool::Selection; + h.state_mut().session.state.selected_vertices.clear(); + h.run_steps(2); + assert!(h.state().auto.find(&handle).is_some(), "ending vertex selection restores the box"); + } +} + +#[test] +fn path_tools_keep_bounds_on_layers_without_visible_paths() { + let mut h = harness(); + let layer = layer_id(&h, "Box"); + h.state_mut().session.execute("layer.select", json!({"layers": [layer]})).unwrap(); + h.state_mut().ui.tool = Tool::Pen; + h.run_steps(2); + let handle = format!("viewer.handle.{layer}.0"); + assert!(h.state().auto.find(&handle).is_some(), "a layer without a path keeps its box"); + h.state_mut().session.execute("mask.new", json!({"layer": layer, "vertices": [[0, 0], [80, 0], [80, 80]], "closed": true})).unwrap(); + h.run_steps(2); + assert!(h.state().auto.find(&handle).is_none()); + h.state_mut().ui.viewer.show_masks = false; + h.run_steps(2); + assert!(h.state().auto.find(&handle).is_some(), "hidden paths do not suppress the layer controls"); +} + #[test] fn shape_pen_draws_a_closed_shape_layer() { let mut h = harness(); diff --git a/docs/gaps.md b/docs/gaps.md index c22a97fbd..0c5e30949 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where EffectCraft falls short -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (issue, PR and catalog references gap 21: percentage guides and effect colour labels in; earlier change: re-audited against `upstream/main` 470ea7a3; ranked gap list from the 10 October re-measure otherwise unchanged) · **Target:** Adobe After Effects 2026 (26.5.0.89) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (path-editing layer bounds, #1220; issue, PR and catalog references gap 21: percentage guides and effect colour labels in; earlier change: re-audited against `upstream/main` 470ea7a3; ranked gap list from the 10 October re-measure otherwise unchanged) · **Target:** Adobe After Effects 2026 (26.5.0.89) An honest assessment of how far EffectCraft is from being a real replacement for After Effects, and the work that closes the gap. This document is meant for contributors and agents choosing @@ -114,6 +114,9 @@ The most important missing piece: it turns every other estimate here into a meas - Run the headless snapshot and control-channel checks on Linux (X11 and Wayland) and Windows, not only macOS. Interactions that only fail with real input (docking drags, viewer pan, drag-and-drop import, inline rename) need scripted input tests. +- Path editing (#1220): selected shape and mask paths suppress the layer transform box while + their points are edited. `ui_viewer` regressions cover Pen-family tools, Selection vertex + dragging and undo, restoration of layer controls, and layers without visible paths. - Regression evidence for [#66](https://github.com/storytold/effectcraft/issues/66): scripted pointer tests in `ui_viewer` and `ui_text_edit`, checked headlessly on Linux, cover viewer clicks, marquee selection and text picking when layers are hidden or excluded by solo. They also check @@ -322,6 +325,7 @@ Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the vie | Date | Change | Summary | |---|---|---| +| 2026-10-13 | patch | G2: layer bounds no longer compete with editable shape/mask path points (#1220) | | 2026-10-12 | minor | Audited against `upstream/main` 470ea7a3: open-issue count and crash list, closed issues removed from rows 1, 3, 5, 6, 8–12, 15–17, 22–24, G8 and G9 (#759 `#include`), localization catalogs recounted (≈ 12% to ≈ 18%); rows 7, 14 and 21 left to open PRs #1102, #792 and #1098 | | 2026-10-10 | minor | Alpha gate checked: no alpha blockers | | 2026-10-12 | minor | Gap 21: percentage guides and effect colour labels added (#1095) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 22da668da..366d8f770 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (closed issue references re-audited against `upstream/main` 470ea7a3) · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (path-editing layer bounds, #1220; closed issue references re-audited against `upstream/main` 470ea7a3) · **Target:** Adobe After Effects 2026 How closely EffectCraft's interaction matches After Effects: tools, handles, snapping, nudging, modifier keys, shortcuts, drag behaviour, numeric entry, panels, context menus and feel. Evidence @@ -33,6 +33,12 @@ feel and platform differences, and the 26.x interface changes are not in yet. | Accessibility | AccessKit enabled; focus labels tested; Project panel keyboard navigation and accessible names (#776) | 20% | No screen-reader pass or keyboard-navigation audit of the other panels; no contrast check | | UI scale | Settings ▸ Appearance ▸ UI Scale 75–200% | beyond | After Effects follows the system's scaling (#284) | +Path editing (#1220): the Composition viewer suppresses a selected layer’s transform box while +using a Pen-family tool on a visible path, or while selecting its vertices with the Selection +tool. Path points, Bézier handles and the path’s free-transform controls remain available. +Layer transform tools and ordinary layer selection restore the box. Headless `ui_viewer` tests +cover shape paths and masks, corner-vertex dragging and undo, and layers without visible paths. + ## Estimate **≈ 60–120 h**: the open interaction issues above (25–45 h, about 0.2–0.5 h each with a scripted @@ -44,6 +50,7 @@ Effects running; most of the rest parallelises. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | patch | Layer bounds are suppressed during path-point editing (#1220); shape/mask interaction regressions | | 2026-10-12 | minor | Closed issues (#302, #320, #491, #511, #553 and others) marked as closed instead of open; panels in their own windows (#486) | | 2026-10-12 | minor | Percentage guides and effect colour labels (26.5) added; rows updated | | 2026-10-10 | major | First UI parity checklist | From 2720ae5fe29d721622502e34eb0504b05a46d726 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 23:40:32 +1100 Subject: [PATCH 005/279] Wave Warp: cover the Top/Bottom edge pinning paths end to end (#1181) The pinning regression only exercised All Edges (and Left Edge in the CPU unit test), so the reported 'does not work with Top/Bottom Edge' had no coverage to catch or clear it. This holds the pinned first/last row in a CPU test at Direction 90, and adds CPU/GPU parity cases for Top (4), Bottom (6), diagonal directions, and single-edge pinning on shape layers. CPU pinning verified correct on main: the guards in dst_wave_pin/wave_pin agree; the gap was that neither edge path was tested. --- crates/effects/src/distort.rs | 14 +++++++++++++- crates/gpu/src/tests_fx_distort.rs | 8 ++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/effects/src/distort.rs b/crates/effects/src/distort.rs index a68de1094..195ac8985 100644 --- a/crates/effects/src/distort.rs +++ b/crates/effects/src/distort.rs @@ -556,8 +556,20 @@ mod tests { let (a, b) = (o.img.get(pad, y + pad), im.get(0, y)); assert!((0..4).all(|i| (a[i] - b[i]).abs() < 0.01), "row {y}: {a:?} vs {b:?}"); } - let free = run("ec.distort.wavewarp", &[("direction", num(0.0)), ("height", num(5.0))], im); + let free = run("ec.distort.wavewarp", &[("direction", num(0.0)), ("height", num(5.0))], im.clone()); assert!(free.img.data.iter().zip(&o.img.data).any(|(a, b)| differs(*a, *b))); + // #1181: direction 90° → vertical displacement. Top Edge (4) holds the first row, + // Bottom Edge (6) the last, and the rest of the layer still moves. + for (pin, edge_row) in [(Value::Enum(4), 0i64), (Value::Enum(6), 29i64)] { + let set = [("direction", num(90.0)), ("height", num(5.0)), ("pinning", pin)]; + let o = run("ec.distort.wavewarp", &set, im.clone()); + let pad = ((o.img.width - im.width) / 2) as i64; + for x in 0..40 { + let (a, b) = (o.img.get(x + pad, edge_row + pad), im.get(x, edge_row)); + assert!((0..4).all(|i| (a[i] - b[i]).abs() < 0.01), "pin {edge_row} col {x}: {a:?} vs {b:?}"); + } + assert!(o.img.data.iter().zip(im.data.iter()).any(|(a, b)| differs(*a, *b)), "pin {edge_row}: layer must still distort"); + } } #[test] diff --git a/crates/gpu/src/tests_fx_distort.rs b/crates/gpu/src/tests_fx_distort.rs index e0e89fb67..19cdcd605 100644 --- a/crates/gpu/src/tests_fx_distort.rs +++ b/crates/gpu/src/tests_fx_distort.rs @@ -28,6 +28,11 @@ fn twirl_bulge_ripple_wave_warp() { effect_case("ec.distort.wavewarp", &[("waveType", e(2)), ("pinning", e(1)), ("phase", n(400.0)), ("speed", n(-2.0))]); effect_case("ec.distort.wavewarp", &[("waveType", e(8)), ("pinning", e(2)), ("height", n(-5.0)), ("direction", n(200.0))]); effect_case("ec.distort.wavewarp", &[("waveType", e(4)), ("pinning", e(7)), ("width", n(33.0))]); + // #1181: the single-edge pinning paths, with the default vertical displacement direction. + effect_case("ec.distort.wavewarp", &[("direction", n(90.0)), ("height", n(7.0)), ("width", n(25.0)), ("pinning", e(4))]); + effect_case("ec.distort.wavewarp", &[("direction", n(90.0)), ("height", n(7.0)), ("width", n(19.0)), ("pinning", e(6)), ("waveType", e(1))]); + effect_case("ec.distort.wavewarp", &[("direction", n(45.0)), ("height", n(-6.0)), ("pinning", e(4)), ("speed", n(2.0))]); + effect_case("ec.distort.wavewarp", &[("direction", n(45.0)), ("height", n(-6.0)), ("pinning", e(6)), ("speed", n(2.0))]); } #[test] @@ -79,6 +84,9 @@ fn shape_case(id: &str, vals: &[(&str, Value)]) { fn edge_pinning_on_a_shape_layer() { shape_case("ec.distort.turbulentdisplace", &[("size", n(30.0)), ("amount", n(20.0))]); shape_case("ec.distort.wavewarp", &[("pinning", e(1)), ("height", n(6.0)), ("width", n(25.0))]); + // #1181: single-edge pinning over the layer rect, not just All Edges. + shape_case("ec.distort.wavewarp", &[("pinning", e(4)), ("direction", n(90.0)), ("height", n(6.0)), ("width", n(25.0))]); + shape_case("ec.distort.wavewarp", &[("pinning", e(6)), ("direction", n(90.0)), ("height", n(6.0)), ("width", n(25.0))]); shape_case( "ec.distort.bulge", &[("pinAllEdges", on()), ("height", n(2.0)), ("hradius", n(40.0)), ("vradius", n(30.0)), ("center", Value::Vec2([28.5, 20.5]))], From 48738093b571322646247cfc91921aff804fce95 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Wed, 14 Oct 2026 00:02:12 +1100 Subject: [PATCH 006/279] Shapes: Merge Paths keeps paint that belongs to a group above it (#1174) A Merge Paths item appended to a shape layer's Contents cleared every draw collected so far, including fills and strokes inside groups above it, so the layer's shapes vanished from the render. AE's rule is per group: only paint items in the merge's own group above it are dropped. Draws now record which contents level created them; the merge drops only its own group's paint, and the group-bubbled draws keep rendering. Adds a regression test (Merge Shapes and Add modes) that renders nothing without the fix. --- crates/render/src/shapes.rs | 13 +++++++++++-- crates/render/src/shapes/tests.rs | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/crates/render/src/shapes.rs b/crates/render/src/shapes.rs index aa46fa501..d5e1d5b85 100644 --- a/crates/render/src/shapes.rs +++ b/crates/render/src/shapes.rs @@ -57,6 +57,10 @@ struct Draw { blend: BlendMode, /// Front / Bevel / Side / Back colours of the paint's extrusion (Advanced 3D). surfaces: SurfaceColors, + /// Paint item of the contents group this draw was collected in (as opposed to one bubbled up + /// from a nested group). Merge Paths drops only these paints above it (#1174); a group's own + /// fill or stroke keeps rendering inside the group. + local: bool, } /// Straight RGB colours of an extrusion's surfaces, by [`effectcraft_model::extrude::Surface`] @@ -234,6 +238,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec { @@ -247,6 +252,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec { @@ -270,6 +276,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec { @@ -328,8 +335,9 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec { let Some(inner) = g.sub("contents") else { continue }; @@ -341,6 +349,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec 0.99 && c[1] < 0.01 && c[3] > 0.99, "mode {mode}: group rectangle must still render, got {c:?}"); + assert!((coverage(&img) - 40.0 * 40.0).abs() < 20.0, "mode {mode}: {}", coverage(&img)); + } +} + #[test] fn merge_subtract_and_intersect() { let sub = |mode: u32| { From 5270eab095a04347eed080db7a50f0e63bd75027 Mon Sep 17 00:00:00 2001 From: Ian-Chege Date: Tue, 13 Oct 2026 19:15:30 +0300 Subject: [PATCH 007/279] Masks: Mask Expansion past a curve's radius grows a solid mask instead of a ring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expansion was a fill plus a stroke 2·|expansion| wide, blended per pixel. Once the expansion passed the shape's curvature radius the stroke's inner edge inverted: a 20 px circle expanded by 60 left a ring with the layer showing through the middle, +600 cut only the original dot, and negative values left islands. The outline is now offset with Offset Paths' geometry (round joins) and filled. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/lib.rs | 2 + crates/render/src/masks.rs | 20 ++---- crates/render/src/tests_mask_expansion.rs | 82 +++++++++++++++++++++++ 3 files changed, 91 insertions(+), 13 deletions(-) create mode 100644 crates/render/src/tests_mask_expansion.rs diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 41036fe6b..655009bb4 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -2390,6 +2390,8 @@ mod tests_frame_blend; #[cfg(test)] mod tests_mask_blur; #[cfg(test)] +mod tests_mask_expansion; +#[cfg(test)] mod tests_nested; #[cfg(test)] mod tests_paint; diff --git a/crates/render/src/masks.rs b/crates/render/src/masks.rs index 3f8d7542b..bb91ef71d 100644 --- a/crates/render/src/masks.rs +++ b/crates/render/src/masks.rs @@ -3,7 +3,7 @@ use effectcraft_effects::Buf; use effectcraft_geom::{Mat3, vec2}; use effectcraft_keyframe::Value; -use effectcraft_path::{FillRule, StrokeStyle}; +use effectcraft_path::{FillRule, Join}; use effectcraft_project::{FeatherFalloff, GroupKind, Layer, MaskMode, MaskMotionBlur}; use effectcraft_raster::{Mask, box_blur, gaussian_blur}; use rayon::prelude::*; @@ -72,21 +72,15 @@ fn coverage_at(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, let variable = !sp.feather.is_empty(); let mut cov = if variable { effectcraft_path::feather::variable_feather_coverage(&sp, &m, w, h, exp, linear) + } else if exp.abs() > 0.01 { + // Expansion offsets the outline itself (round joins). Offset Paths' geometry stays right + // when the expansion exceeds a curvature radius, where a fill + stroke blend leaves a + // ring around an uncovered middle (growing) or islands (shrinking). + let grown = effectcraft_path::offset::offset_path(&path, exp, Join::Round, 4.0); + effectcraft_path::fill_coverage(std::slice::from_ref(&grown), &m, w, h, FillRule::NonZero) } else { effectcraft_path::fill_coverage(std::slice::from_ref(&path), &m, w, h, FillRule::NonZero) }; - if exp.abs() > 0.01 && !variable { - let ring = effectcraft_path::stroke_coverage( - std::slice::from_ref(&path), - &StrokeStyle { width: exp.abs() * 2.0, join: effectcraft_path::Join::Round, ..Default::default() }, - &m, - w, - h, - ); - cov.data.par_iter_mut().zip(ring.data.par_iter()).for_each(|(c, r)| { - *c = if exp > 0.0 { (*c + r - *c * r).min(1.0) } else { (*c * (1.0 - r)).max(0.0) }; - }); - } let feather = ctx.v2(layer, g, "feather", [0.0; 2]); if feather[0] > 0.0 || feather[1] > 0.0 { let img = cov.to_image(); diff --git a/crates/render/src/tests_mask_expansion.rs b/crates/render/src/tests_mask_expansion.rs new file mode 100644 index 000000000..700c779aa --- /dev/null +++ b/crates/render/src/tests_mask_expansion.rs @@ -0,0 +1,82 @@ +//! Mask Expansion grows or shrinks the mask outline by its distance, however large: past the +//! shape's curvature radius it must still cover everything within the distance of the path, +//! never leave a ring around an uncovered middle. + +use effectcraft_color::Label; +use effectcraft_keyframe::{ShapePath, Value}; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, MaskMode, Node, Project, Solid}; +use effectcraft_time::{FrameRate, Tick}; + +use crate::{NoFootage, RenderOpts, Renderer}; + +const W: u32 = 400; +const H: u32 = 300; + +/// A white 400×300 solid with one mask of `shape` in `mode` expanded by `expansion` pixels. +fn project(shape: ShapePath, mode: MaskMode, expansion: f64) -> (Project, ItemId) { + let mut p = Project::default(); + let comp = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color: [1.0; 3], width: W, height: H, pixel_aspect: 1.0 })); + let mut l = build::layer(&mut p, &comp, "S", LayerSource::Solid { item: sid }, (W, H), None); + let mut next = p.next_id; + let mut m = build::mask(&mut Ids(&mut next), "Mask 1", shape, mode, [255, 0, 0]); + p.next_id = next; + m.get_mut("expansion").unwrap().value = Value::Scalar(expansion); + l.props.sub_mut("masks").unwrap().children.push(Node::Group(m)); + let cid = p.add_item("C", Label::Sandstone, None, ItemKind::Comp(comp.into())); + p.comp_mut(cid).unwrap().layers.push(l); + (p, cid) +} + +/// Alpha at each of `points` for a Subtract mask on a 40 px circle at the centre (the hole). +fn hole(expansion: f64, points: &[(i64, i64)]) -> Vec { + let (p, cid) = project(ShapePath::ellipse([200.0, 150.0], 40.0, 40.0), MaskMode::Subtract, expansion); + let img = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO); + points.iter().map(|&(x, y)| img.get(x, y)[3]).collect() +} + +#[test] +fn no_expansion_cuts_the_circle() { + let a = hole(0.0, &[(200, 150), (215, 150), (225, 150)]); + assert!(a[0] < 0.01 && a[1] < 0.01, "inside the 20 px radius is cut: {a:?}"); + assert!(a[2] > 0.99, "outside it is kept: {a:?}"); +} + +#[test] +fn expansion_past_the_radius_grows_a_solid_hole() { + // +60 on a 20 px radius: a solid hole of radius 80. The fill + stroke blend left a ring + // from 40 to 80 px with the layer showing at 21–39 px. + let a = hole(60.0, &[(200, 150), (230, 150), (200, 120), (275, 150), (285, 150)]); + assert!(a[..4].iter().all(|&v| v < 0.01), "everything within 80 px is cut: {a:?}"); + assert!(a[4] > 0.99, "beyond 80 px is kept: {a:?}"); +} + +#[test] +fn huge_expansion_cuts_the_whole_frame() { + // +600 reaches every pixel; the blend left only the 20 px circle cut. + let a = hole(600.0, &[(200, 150), (230, 150), (0, 0), (399, 299), (399, 0)]); + assert!(a.iter().all(|&v| v < 0.01), "{a:?}"); +} + +#[test] +fn negative_expansion_shrinks_then_removes_the_hole() { + let a = hole(-15.0, &[(200, 150), (210, 150)]); + assert!(a[0] < 0.01, "a 5 px hole remains: {a:?}"); + assert!(a[1] > 0.99, "shrunk away from 10 px out: {a:?}"); + let a = hole(-25.0, &[(200, 150), (190, 150)]); + assert!(a.iter().all(|&v| v > 0.99), "shrunk past its radius, nothing is cut: {a:?}"); +} + +#[test] +fn expanded_rect_add_mask_has_round_corners() { + // A 100 px square mask (Add) grown by 30: the sides move out 30 px, the corners round off. + let (p, cid) = project(ShapePath::rect([200.0, 150.0], 100.0, 100.0), MaskMode::Add, 30.0); + let img = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO); + assert!(img.get(275, 150)[3] > 0.99, "25 px past the right side is inside"); + assert!(img.get(285, 150)[3] < 0.01, "35 px past it is outside"); + // 25 px diagonally out from the corner (250,100) is within 30 px: inside. + assert!(img.get(267, 82)[3] > 0.99); + // The square's bounding corner (280,70) is 42 px from the corner: a miter would cover it. + assert!(img.get(277, 73)[3] < 0.01); +} From d309d6f1d0d59362eb6213f4164ef777a3f0a510 Mon Sep 17 00:00:00 2001 From: Lloyd Nicholson Date: Tue, 13 Oct 2026 18:53:31 +0200 Subject: [PATCH 008/279] Effects & Presets: drag animation presets onto a layer Preset rows (text, built-in and user .ecpreset) were click-only, so dragging one onto a layer did nothing. They now start a DragPayload::Preset drag with a ghost, and the Timeline, Composition viewer and Effect Controls apply the preset to the layer it is dropped on, as they already do for effects and as After Effects does. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/effect_controls.rs | 10 ++-- crates/ui-egui/src/panels/effects_presets.rs | 41 +++++++-------- crates/ui-egui/src/panels/mod.rs | 52 ++++++++++++++++++++ crates/ui-egui/src/panels/timeline.rs | 16 ++++-- crates/ui-egui/src/panels/viewer_drop.rs | 19 +++++-- 5 files changed, 107 insertions(+), 31 deletions(-) diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index bad1db39f..4d3bf833e 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -1394,14 +1394,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.set_clip_rect(panel_clip); let content_h = y + scroll.offset - body.min.y; scroll.end(ui, &mut app.auto, "effectControls.scroll", content_h, &t); - // Drop effects here. + // Drop effects and animation presets here. if let Some(payload) = egui::DragAndDrop::payload::(&ctx) && ui.rect_contains_pointer(rect) - && let crate::panels::DragPayload::Effect(e) = payload.as_ref() + && let Some((id, params)) = match payload.as_ref() { + crate::panels::DragPayload::Effect(e) => Some(("effect.apply", json!({"effect": e, "layers": [layer.id.0]}))), + crate::panels::DragPayload::Preset { preset, .. } => Some(preset.apply_to(layer.id.0)), + _ => None, + } { p.rect_stroke(rect, 0.0, Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); if ctx.input(|i| i.pointer.any_released()) { - actions.push(("effect.apply".into(), json!({"effect": e, "layers": [layer.id.0]}))); + actions.push((id.into(), params)); egui::DragAndDrop::clear_payload(&ctx); } } diff --git a/crates/ui-egui/src/panels/effects_presets.rs b/crates/ui-egui/src/panels/effects_presets.rs index d3921cd85..a9410d98e 100644 --- a/crates/ui-egui/src/panels/effects_presets.rs +++ b/crates/ui-egui/src/panels/effects_presets.rs @@ -2,15 +2,15 @@ //! filters, animation presets on/off, categories or alphabetical, refresh), Favorites (star an //! effect), Recently Used, "* Animation Presets" (EffectCraft's own text animation and transition //! presets and the user's `.ecpreset` files) and the effect categories. Double-click applies to the -//! selected layers; dragging an effect onto a layer (timeline, viewer, Effect Controls) applies -//! it there. +//! selected layers; dragging an effect or an animation preset onto a layer (timeline, viewer, +//! Effect Controls) applies it there. use crate::i18n::tr; use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; use serde_json::json; use crate::icons::{self, Icon}; -use crate::panels::DragPayload; +use crate::panels::{DragPayload, PresetRef}; use crate::theme::Tokens; use crate::{EffectcraftApp, widgets}; @@ -107,10 +107,8 @@ fn highlighted(p: &egui::Painter, pos: egui::Pos2, name: &str, query: &str, t: & enum Apply { Effect(String), - TextPreset(String), - /// A built-in animation preset (Transitions…) by id. - Builtin(String), - PresetFile(String), + /// A text, built-in (Transitions…) or user file animation preset. + Preset(PresetRef), } /// A folder row; returns whether it is open. @@ -217,7 +215,7 @@ fn effect_row( } } -/// A preset row (no drag; double-click applies). +/// A preset row: double-click applies to the selected layers; drag it onto a layer to apply it there. #[allow(clippy::too_many_arguments)] fn preset_row( app: &mut EffectcraftApp, @@ -228,7 +226,7 @@ fn preset_row( name: &str, auto_id: &str, query: &str, - on_apply: Apply, + preset: PresetRef, apply: &mut Option, ) { let t = app.tokens; @@ -237,15 +235,18 @@ fn preset_row( if r.max.y < list.min.y || r.min.y > list.max.y { return; } - let resp = ui.interact(r.intersect(list), egui::Id::new(("fxpreset", auto_id)), Sense::click()); + let resp = ui.interact(r.intersect(list), egui::Id::new(("fxpreset", auto_id)), Sense::click_and_drag()); if resp.hovered() { lp.rect_filled(r, 0.0, t.hover); } icons::paint(lp, Rect::from_center_size(pos2(r.min.x + 58.0, r.center().y), vec2(12.0, 12.0)), Icon::Keyframe, t.text_dim); highlighted(lp, pos2(r.min.x + 70.0, r.center().y), name, query, &t); app.auto.add(auto_id, r, name); + if resp.drag_started() { + egui::DragAndDrop::set_payload(ui.ctx(), DragPayload::Preset { preset: preset.clone(), name: name.to_string() }); + } if resp.double_clicked() { - *apply = Some(on_apply); + *apply = Some(Apply::Preset(preset)); } } @@ -395,7 +396,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if any && folder_row(app, ui, &lp, list, &mut y, "presets", "* Animation Presets", 0.0, searching, None) { if !text.is_empty() && folder_row(app, ui, &lp, list, &mut y, "presets/Text", "Text", 14.0, searching, None) { for (id, name) in text { - preset_row(app, ui, &lp, list, &mut y, &name, &format!("effects.preset.text.{id}"), &query, Apply::TextPreset(id.clone()), &mut apply); + preset_row(app, ui, &lp, list, &mut y, &name, &format!("effects.preset.text.{id}"), &query, PresetRef::Text(id.clone()), &mut apply); } } for cat in &categories { @@ -403,7 +404,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { continue; } for (id, name, _) in builtin.iter().filter(|(_, _, c)| c == cat) { - preset_row(app, ui, &lp, list, &mut y, name, &format!("effects.preset.builtin.{id}"), &query, Apply::Builtin(id.clone()), &mut apply); + preset_row(app, ui, &lp, list, &mut y, name, &format!("effects.preset.builtin.{id}"), &query, PresetRef::Builtin(id.clone()), &mut apply); } } if !files.is_empty() && folder_row(app, ui, &lp, list, &mut y, "presets/User", "User Presets", 14.0, searching, None) { @@ -417,7 +418,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { &f.name, &format!("effects.preset.file.{}", f.name), &query, - Apply::PresetFile(f.path.clone()), + PresetRef::File(f.path.clone()), &mut apply, ); } @@ -455,10 +456,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } let content = y + scroll.offset - list.min.y; scroll.end(ui, &mut app.auto, "effects.scroll", content, &t); - if let Some(DragPayload::Effect(id)) = egui::DragAndDrop::payload::(&ctx).as_deref() - && let Some(pos) = ctx.pointer_hover_pos() + if let Some(name) = egui::DragAndDrop::payload::(&ctx).and_then(|p| match p.as_ref() { + DragPayload::Effect(id) => Some(effectcraft_engine::effects::find(id).map(|e| crate::i18n::effects::name(e.id, e.name)).unwrap_or_default()), + DragPayload::Preset { name, .. } => Some(name.clone()), + _ => None, + }) && let Some(pos) = ctx.pointer_hover_pos() { - let name = effectcraft_engine::effects::find(id).map(|e| crate::i18n::effects::name(e.id, e.name)).unwrap_or_default(); let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("dnd-fx"))); let g = painter.layout_no_wrap(name, Tokens::ui(12.0), t.text); let r = Rect::from_min_size(pos + vec2(12.0, 8.0), g.size() + vec2(16.0, 8.0)); @@ -473,9 +476,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } let (cmd, params) = match &apply { Apply::Effect(id) => ("effect.apply", json!({"effect": id})), - Apply::TextPreset(id) => ("layer.applyTextPreset", json!({"preset": id})), - Apply::Builtin(id) => ("anim.applyBuiltinPreset", json!({"preset": id})), - Apply::PresetFile(path) => ("anim.applyPreset", json!({"path": path})), + Apply::Preset(preset) => preset.command(), }; match app.session.execute(cmd, params) { Err(e) => app.ui.status = e.to_string(), diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 1b6ef8c33..4718d79d0 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -75,12 +75,49 @@ pub enum DragPayload { Item(u64), /// An effect id from Effects & Presets. Effect(String), + /// An animation preset from Effects & Presets, with its display name for the drag ghost. + Preset { preset: PresetRef, name: String }, /// A property dragged from the timeline (to the Essential Graphics panel). Property { layer: u64, prop: u64 }, /// Files from the Media Browser (import; into the timeline: import and add). Files(Vec), } +/// An animation preset in Effects & Presets. +#[derive(Clone, Debug, PartialEq)] +pub enum PresetRef { + /// A text animation preset id (`layer.applyTextPreset`). + Text(String), + /// A built-in animation preset id, e.g. a transition (`anim.applyBuiltinPreset`). + Builtin(String), + /// A user `.ecpreset` file path (`anim.applyPreset`). + File(String), +} + +impl PresetRef { + /// The command and params that apply the preset to the selected layers. + pub fn command(&self) -> (&'static str, serde_json::Value) { + match self { + PresetRef::Text(id) => ("layer.applyTextPreset", serde_json::json!({"preset": id})), + PresetRef::Builtin(id) => ("anim.applyBuiltinPreset", serde_json::json!({"preset": id})), + PresetRef::File(path) => ("anim.applyPreset", serde_json::json!({"path": path})), + } + } + + /// The command and params that apply the preset to `layer` (a drop target). + pub fn apply_to(&self, layer: u64) -> (&'static str, serde_json::Value) { + let (id, mut params) = self.command(); + if let Some(obj) = params.as_object_mut() { + // `layer.applyTextPreset` takes one `layer`; the animation preset commands take `layers`. + match self { + PresetRef::Text(_) => obj.insert("layer".into(), serde_json::json!(layer)), + PresetRef::Builtin(_) | PresetRef::File(_) => obj.insert("layers".into(), serde_json::json!([layer])), + }; + } + (id, params) + } +} + /// Layers carried by a Project item drag. Dragging a selected item uses the /// complete selection, while dragging an unselected item uses only that item. /// Folders and data files are not composition layers. @@ -249,3 +286,18 @@ pub fn panel_menu_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui) { ui.ctx().data_mut(|d| d.remove::<(PanelKind, egui::Pos2)>(id)); } } + +#[cfg(test)] +mod preset_drop_tests { + use super::PresetRef; + use serde_json::json; + + #[test] + fn preset_drops_target_the_layer_with_each_commands_param() { + assert_eq!(PresetRef::Text("typewriter".into()).apply_to(7), ("layer.applyTextPreset", json!({"preset": "typewriter", "layer": 7}))); + assert_eq!(PresetRef::Builtin("fadeIn".into()).apply_to(7), ("anim.applyBuiltinPreset", json!({"preset": "fadeIn", "layers": [7]}))); + assert_eq!(PresetRef::File("/p/a.ecpreset".into()).apply_to(7), ("anim.applyPreset", json!({"path": "/p/a.ecpreset", "layers": [7]}))); + // Double-click applies to the selection: no layer given. + assert_eq!(PresetRef::Text("typewriter".into()).command(), ("layer.applyTextPreset", json!({"preset": "typewriter"}))); + } +} diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 163e6507d..b59a5d96f 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -3320,7 +3320,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } - // Drop targets: effects from Effects & Presets go on the layer under the pointer. Footage and + // Drop targets: effects and animation presets from Effects & Presets go on the layer under the + // pointer. Footage and // comps from the Project panel and files from the Media Browser go where they are dropped: a // line shows the place in the stack, and over the time graph a marker shows the In point too // (Shift: at the current time). @@ -3329,11 +3330,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { && let Some(ptr) = ctx.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(rows_rect)) { let released = ctx.input(|i| i.pointer.any_released()); - if let crate::panels::DragPayload::Effect(e) = payload.as_ref() { + if matches!(payload.as_ref(), crate::panels::DragPayload::Effect(_) | crate::panels::DragPayload::Preset { .. }) { p.rect_stroke(rows_rect, 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside); let target = hit_rows.iter().find(|(r, _)| ptr.y >= r.min.y && ptr.y < r.max.y).map(|(_, row)| row.layer); if released && let Some(l) = target { - actions.push(("effect.apply".into(), json!({"effect": e, "layers": [l.0]}))); + match payload.as_ref() { + crate::panels::DragPayload::Effect(e) => actions.push(("effect.apply".into(), json!({"effect": e, "layers": [l.0]}))), + crate::panels::DragPayload::Preset { preset, .. } => { + let (id, params) = preset.apply_to(l.0); + actions.push((id.into(), params)); + } + _ => {} + } } } else { let layer_rows: Vec<(Rect, LayerId)> = hit_rows.iter().filter(|(_, r)| matches!(r.kind, RowKind::Layer)).map(|(rr, r)| (*rr, r.layer)).collect(); @@ -3369,7 +3377,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { crate::panels::DragPayload::Files(paths) => { actions.push(("mediaBrowser.import".into(), json!({"paths": paths, "addToComp": true, "index": index, "time": time}))) } - crate::panels::DragPayload::Effect(_) | crate::panels::DragPayload::Property { .. } => {} + crate::panels::DragPayload::Effect(_) | crate::panels::DragPayload::Preset { .. } | crate::panels::DragPayload::Property { .. } => {} } } } diff --git a/crates/ui-egui/src/panels/viewer_drop.rs b/crates/ui-egui/src/panels/viewer_drop.rs index 2d42eb2c9..44364a49d 100644 --- a/crates/ui-egui/src/panels/viewer_drop.rs +++ b/crates/ui-egui/src/panels/viewer_drop.rs @@ -1,8 +1,8 @@ //! Dropping onto the Composition viewer: footage, comps and solids from the //! Project panel and files from the Media Browser become layers centred where they are dropped -//! (#85), and an effect from Effects & Presets goes on the layer under the pointer, outlined -//! while it is dragged (#88). Agents do the same with `layer.addItem {position}`, -//! `mediaBrowser.import {position}` and `effect.apply {layers}`. +//! (#85), and an effect or animation preset from Effects & Presets goes on the layer under the +//! pointer, outlined while it is dragged (#88). Agents do the same with `layer.addItem {position}`, +//! `mediaBrowser.import {position}`, `effect.apply {layers}` and the preset commands. use effectcraft_engine::project::{ItemId, ItemKind}; use effectcraft_engine::render::EvalCtx; @@ -33,6 +33,15 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &egui::Ui, painter: &egui::Pain }) .into_iter() .collect(), + DragPayload::Preset { preset, .. } => layer_at(ectx, at) + .map(|l| { + if let Some((_, q, _)) = layer_quad(ectx, l) { + painter.add(egui::Shape::closed_line(q.iter().map(|c| map.to_screen(*c)).collect(), stroke)); + } + preset.apply_to(l.id.0) + }) + .into_iter() + .collect(), DragPayload::Item(item) => { // The layer's frame, centred on the pointer. if let Some([w, h]) = app.session.project.item(ItemId(*item)).and_then(|it| frame_size(&it.kind, ectx.comp.pixel_aspect)) { @@ -45,7 +54,9 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &egui::Ui, painter: &egui::Pain DragPayload::Files(paths) => vec![("mediaBrowser.import", json!({"paths": paths, "addToComp": true, "position": at}))], DragPayload::Property { .. } => vec![], }; - if actions.first().is_some_and(|(id, _)| *id != "effect.apply") { + // Layer drops outline the target layer; the others outline the whole viewer. + let layer_drop = matches!(payload.as_ref(), DragPayload::Effect(_) | DragPayload::Preset { .. }); + if !layer_drop && !actions.is_empty() { painter.rect_stroke(map.area, 0.0, stroke, StrokeKind::Inside); } if ctx.input(|i| i.pointer.any_released()) { From c979f585ac4cb3c456394dda151889af5a9e5846 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 13:47:22 -0400 Subject: [PATCH 009/279] Timeline: focus newly added expressions after Alt-click (refs #1072) Alt-clicking a stopwatch added an expression, but its editor needed another click before typing went into it. In After Effects the new expression's field takes the keyboard at once, its text selected. The pick whip's one-shot refocus (the editor takes the keyboard back after a reference goes in) is now an ExprFocus request for any property: which text to select, counted from the end, and the pass it was asked for in. An Alt-click that adds an expression asks for it with the whole text selected, so typing replaces it; the expression row is opened if it was closed and scrolled into view (it is added below the stopwatch's row, which can be the last one shown). The request is taken once, when the editor is drawn, and dropped if its expression doesn't appear or it isn't taken within a few passes, so it never takes the keyboard later; an Alt-click that removes the expression clears it. Tests (ui_inline_edit): typing straight after the Alt-click replaces the new expression; a field clicked right after keeps the keyboard; after removing it nothing has the keyboard; an expression added at the Timeline's bottom brings its editor into view with the keyboard. Both fail on main. --- crates/ui-egui/src/panels/timeline.rs | 58 +++++++++++++--- crates/ui-egui/tests/ui_inline_edit.rs | 92 ++++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 163e6507d..72c071107 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -265,10 +265,25 @@ fn whip_into_id(prop: u64) -> egui::Id { egui::Id::new(("tl-pickwhip-into", prop)) } -/// After a pick whip into the expression being edited: its editor takes the focus back, the -/// cursor this many characters before the end (just after the reference). -fn expr_refocus_id(prop: u64) -> egui::Id { - egui::Id::new(("tl-expr-refocus", prop)) +/// An expression editor to take the keyboard when it is next drawn: its property, the text to +/// select as counts of characters before the end (so it fits the expression as it will be then) +/// and the pass it was asked for in. After a pick whip into the expression being edited, the +/// cursor goes just after the reference; after an Alt-click on a stopwatch adds an expression, +/// its whole text is selected, so typing replaces it. +#[derive(Clone, Copy, Debug)] +struct ExprFocus { + prop: u64, + select: [usize; 2], + pass: u64, +} + +fn expr_focus_id() -> egui::Id { + egui::Id::new("tl-expr-focus") +} + +fn focus_expression(ctx: &egui::Context, prop: u64, select: [usize; 2]) { + let pass = ctx.cumulative_pass_nr(); + ctx.data_mut(|d| d.insert_temp(expr_focus_id(), ExprFocus { prop, select, pass })); } #[derive(Clone, Debug)] @@ -1888,6 +1903,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let rh = t.row_h; let total_h: f32 = rows.iter().map(|r| row_height(r, rh)).sum(); let max_scroll = (total_h - rows_rect.height() + rh).max(0.0); + // An expression editor asked to take the keyboard comes into view; a request whose + // expression didn't appear, or that wasn't taken within a few passes, is dropped (it never + // takes the keyboard later). + if let Some(f) = ctx.data(|d| d.get_temp::(expr_focus_id())) { + match rows.iter().position(|r| matches!(r.kind, RowKind::Expr { uid, .. } if uid == f.prop)) { + Some(i) if ctx.cumulative_pass_nr() <= f.pass.saturating_add(8) => { + let top: f32 = rows.iter().take(i).map(|r| row_height(r, rh)).sum(); + let fits = (top + rows.get(i).map_or(rh, |r| row_height(r, rh)) - rows_rect.height()).max(0.0).min(top); + app.ui.timeline.scroll_y = app.ui.timeline.scroll_y.min(top).max(fits); + } + _ => ctx.data_mut(|d| d.remove::(expr_focus_id())), + } + } if scroll_to == Some(crate::state::ScrollTo::Layer) { // The topmost selected layer, in stack order. let sel = &app.session.state.selected_layers; @@ -2630,12 +2658,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, t.expr_bg); let er = Rect::from_min_max(pos2(graph_x0 + 8.0, r.min.y + 3.0), pos2(rect.max.x - 14.0, r.max.y - 3.0)); let mut buf: String = ctx.data(|d| d.get_temp(buf_id)).unwrap_or_else(|| ex.text.clone()); - if let Some(after) = ctx.data_mut(|d| d.remove_temp::(expr_refocus_id(*uid))) { - // A pick whip just put a reference in: editing goes on in the new text, - // the cursor after the reference. + if let Some(f) = ctx.data(|d| d.get_temp::(expr_focus_id())).filter(|f| f.prop == *uid) { + // Asked for once (a pick whip put a reference in, or an Alt-click added the + // expression): editing starts in the expression as it is now. + ctx.data_mut(|d| d.remove::(expr_focus_id())); buf = ex.text.clone(); + let n = buf.chars().count(); + let [a, b] = f.select.map(|before_end| egui::text::CCursor::new(n.saturating_sub(before_end))); let mut st = egui::text_edit::TextEditState::load(&ctx, edit_id).unwrap_or_default(); - st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(buf.chars().count().saturating_sub(after))))); + st.cursor.set_char_range(Some(egui::text::CCursorRange::two(a, b))); st.store(&ctx, edit_id); ctx.memory_mut(|m| m.request_focus(edit_id)); } @@ -2733,6 +2764,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if ui.input(|i| i.modifiers.alt) { if resp.clicked() { actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid}))); + if prop.expr.is_none() { + // Adding one: its editor opens, comes into view and takes the + // keyboard with the new expression selected (#1072). + app.ui.timeline.expr_closed.remove(uid); + focus_expression(&ctx, *uid, [usize::MAX, 0]); + } else if ctx.data(|d| d.get_temp::(expr_focus_id())).is_some_and(|f| f.prop == *uid) { + // Removing it: nothing is left to take the keyboard. + ctx.data_mut(|d| d.remove::(expr_focus_id())); + } } } else if let Some((value, merge)) = switch_gesture(ui, &resp, r, "stopwatch", prop.is_animated()) { actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid, "value": value, "merge": merge}))); @@ -3057,7 +3097,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { params["expression"] = json!(text); params["range"] = json!([a, b]); let after = text.chars().count().saturating_sub(b); - ctx.data_mut(|d| d.insert_temp(expr_refocus_id(src_prop), after)); + focus_expression(&ctx, src_prop, [after, after]); } actions.push(("prop.pickWhip".into(), params)); } diff --git a/crates/ui-egui/tests/ui_inline_edit.rs b/crates/ui-egui/tests/ui_inline_edit.rs index dd20e55b0..7bc5505ed 100644 --- a/crates/ui-egui/tests/ui_inline_edit.rs +++ b/crates/ui-egui/tests/ui_inline_edit.rs @@ -539,3 +539,95 @@ fn arrow_keys_move_the_caret_in_rename_fields() { let layer_name = h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(layer)).unwrap().name.clone(); assert_eq!(layer_name, "Pla-te"); } + +/// A solid named `name` with its Transform group open in the Timeline; returns the layer, its +/// Opacity uid and its Rotation uid. +fn open_transform(h: &mut Harness<'_, EffectcraftApp>, name: &str) -> (u64, u64, u64) { + let (layer, opacity, rotation, transform) = { + let s = &mut h.state_mut().session; + let l = s.execute("layer.newSolid", json!({"name": name, "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + let tr = s.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(l)).unwrap().props.sub("transform").unwrap().clone(); + (l, tr.get("opacity").unwrap().uid, tr.get("rotation").unwrap().uid, tr.uid) + }; + h.state_mut().ui.timeline.open_layers.insert(layer); + h.state_mut().ui.timeline.open_groups.insert(transform); + h.run_steps(3); + (layer, opacity, rotation) +} + +fn expression(h: &Harness<'_, EffectcraftApp>, layer: u64, prop: u64) -> Option { + let comp = h.state().session.active_comp().unwrap(); + comp.layer(effectcraft_engine::project::LayerId(layer)).unwrap().props.find(prop).unwrap().expr.as_ref().map(|e| e.text.clone()) +} + +fn commit_expression(h: &mut Harness<'_, EffectcraftApp>) { + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::COMMAND)); + h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }); + h.step(); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(3); +} + +/// #1072: Alt-clicking a stopwatch adds an expression and its editor takes the keyboard at once, +/// the new expression selected, so typing replaces it (it took another click). That happens +/// once: a field clicked next keeps the keyboard, and removing the expression with another +/// Alt-click leaves nothing to take it. +#[test] +fn alt_clicking_a_stopwatch_focuses_the_new_expression() { + let (mut h, _) = harness(); + let (layer, opacity, rotation) = open_transform(&mut h, "Plate"); + let alt = Modifiers { alt: true, ..Modifiers::NONE }; + let stopwatch = |h: &Harness<'_, EffectcraftApp>, prop: u64| center(h, &format!("timeline.prop.{prop}.stopwatch")); + let at = stopwatch(&h, opacity); + click_with(&mut h, at, alt); + assert!(expression(&h, layer, opacity).is_some(), "an expression was added"); + type_text(&mut h, "50"); + commit_expression(&mut h); + assert_eq!(expression(&h, layer, opacity).as_deref(), Some("50"), "typing replaced the new expression"); + // A field clicked right after an Alt-click keeps the keyboard. + let at = stopwatch(&h, rotation); + click_with(&mut h, at, alt); + let added = expression(&h, layer, rotation).expect("an expression on Rotation"); + let p = center(&h, "timeline.timecode"); + click(&mut h, p, 2); + type_text(&mut h, "0:00:01:00"); + key(&mut h, Key::Enter); + let frame = h.state().session.active_comp().unwrap().frame_rate.frame_at(h.state().session.time()); + assert_eq!((frame, expression(&h, layer, rotation)), (24, Some(added)), "the time took the typing, not the expression"); + // Alt-click again removes the expression; nothing takes the keyboard after it. + let at = stopwatch(&h, opacity); + click_with(&mut h, at, alt); + h.run_steps(5); + assert_eq!(expression(&h, layer, opacity), None); + assert!(!h.ctx.egui_wants_keyboard_input(), "nothing has the keyboard"); +} + +/// #1072: an expression added at the bottom of the Timeline brings its editor into view to take +/// the keyboard (its row is added below the last one shown). +#[test] +fn an_expression_added_at_the_bottom_of_the_timeline_comes_into_view() { + let (mut h, _) = harness(); + let (layer, opacity, _) = open_transform(&mut h, "Bottom"); + for i in 0..40 { + h.state_mut().session.execute("layer.newSolid", json!({"name": format!("Layer {i}"), "color": "#406080"})).unwrap(); + } + h.run_steps(3); + // Scroll to the end (the layer is at the bottom of the stack), then until Opacity is the + // last row shown. + h.state_mut().ui.timeline.scroll_y = 1.0e6; + h.run_steps(3); + let rows = h.state().auto.find("timeline.scroll").unwrap().rect; + let bottom = rows[1] + rows[3]; + let row = h.state().auto.find(&format!("timeline.prop.{opacity}.row")).unwrap().rect; + h.state_mut().ui.timeline.scroll_y += row[1] + row[3] - bottom; + h.run_steps(3); + let row = h.state().auto.find(&format!("timeline.prop.{opacity}.row")).unwrap().rect; + assert!((row[1] + row[3] - bottom).abs() < 1.0, "Opacity is at the bottom: {row:?}"); + let at = center(&h, &format!("timeline.prop.{opacity}.stopwatch")); + click_with(&mut h, at, Modifiers { alt: true, ..Modifiers::NONE }); + let editor = h.state().auto.find(&format!("timeline.prop.{opacity}.expression")).expect("the editor is drawn").rect; + assert!(editor[1] >= rows[1] - 0.5 && editor[1] + editor[3] <= bottom + 0.5, "the editor is in view: {editor:?} in {rows:?}"); + type_text(&mut h, "25"); + commit_expression(&mut h); + assert_eq!(expression(&h, layer, opacity).as_deref(), Some("25")); +} From 3a953859f227be26b85d6cb9c1f9d127fd9da6fc Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 14:28:49 -0400 Subject: [PATCH 010/279] Ease Presets: ease the incoming, outgoing or both sides of selected keyframes (#1329) The panel eased only pairs of neighbouring selected keyframes, so a single selected key (or keys that aren't neighbours) changed nothing. An Ease selector (In, Out, In-Out; In-Out by default) now chooses the sides of each selected key to ease. In eases a key's incoming side with the curve's in side, Out its outgoing side with the curve's out side. A side is eased when it has a neighbouring key, selected or not; the neighbour is left as it is. Each side's speeds scale to the segment on that side, so a key between a +100/s and a -40/s segment gets each its own. The side not eased keeps playing as it did (easeSide, unchanged). Sides are measured before any key changes, and no undo step opens when no selected key has a segment on the chosen side; the status says which neighbour is missing. A selected pair in In-Out still gets the whole curve; its outer sides now ease too when they face an unselected key. From Keys still reads the curve of a selected pair. --- crates/script/src/tests_ease_presets.rs | 5 +- extensions/scriptui-panels/Ease Presets.jsx | 83 ++++++++++++++------- 2 files changed, 61 insertions(+), 27 deletions(-) diff --git a/crates/script/src/tests_ease_presets.rs b/crates/script/src/tests_ease_presets.rs index c803c27a5..1006d1bf1 100644 --- a/crates/script/src/tests_ease_presets.rs +++ b/crates/script/src/tests_ease_presets.rs @@ -180,7 +180,8 @@ fn the_ease_presets_panel_eases_every_selected_pair_in_one_undo_step() { assert!(list.as_array().unwrap().iter().any(|e| e["name"] == PANEL && e["panel"] == true && e["source"] == "extension")); let steps = s.history.undo.len(); set(&mut s, win, "presets", json!("Ease In-Out")); - assert_eq!(status(&mut s, win), "Eased 3 keyframe pair(s) with \u{201c}Ease In-Out\u{201d}"); + // In-Out (the default) on every key: each pair gets the whole curve, as before. + assert_eq!(status(&mut s, win), "Eased 5 keyframe(s) (In-Out) with \u{201c}Ease In-Out\u{201d}"); assert_eq!(s.history.undo.len(), steps + 1, "one undo step"); assert_eq!(s.history.undo.last().map(|(label, _)| label.as_str()), Some("Apply Ease Preset")); assert_eq!(s.state.selected_keys, selected, "the keys stay selected for the next preset"); @@ -203,7 +204,7 @@ fn the_ease_presets_panel_eases_every_selected_pair_in_one_undo_step() { s.execute("edit.deselectAll", json!({})).unwrap(); let steps = s.history.undo.len(); set(&mut s, win, "presets", json!("Linear")); - assert_eq!(status(&mut s, win), "Select two or more neighbouring keyframes of a property"); + assert_eq!(status(&mut s, win), "Select keyframes with another keyframe next to it"); assert_eq!(s.history.undo.len(), steps); } diff --git a/extensions/scriptui-panels/Ease Presets.jsx b/extensions/scriptui-panels/Ease Presets.jsx index a08fdbcc2..0dd3f43ae 100644 --- a/extensions/scriptui-panels/Ease Presets.jsx +++ b/extensions/scriptui-panels/Ease Presets.jsx @@ -1,4 +1,4 @@ -// Ease Presets: easing curves kept by name and applied to pairs of neighbouring selected keyframes. +// Ease Presets: easing curves kept by name and applied to the sides of selected keyframes. // // A ScriptUI panel that ships with EffectCraft as an optional extension (Window ▸ Ease // Presets.jsx). It uses only After Effects' documented scripting API: comp.selectedProperties, @@ -12,6 +12,11 @@ // speeds are scaled to it: per dimension, along the motion path for spatial properties, or as // overall progress for shapes. // +// Applied to selected keyframes, In eases each key's incoming side with the curve's in side, Out +// its outgoing side with the curve's out side, In-Out both: a side is eased when it has a +// neighbouring key (selected or not; the neighbour isn't changed), its speeds scaled to the +// segment on that side. So a selected pair gets the whole curve, and a single key works too. +// // User presets are kept with app.settings (section "Ease Presets", key "userPresets") as JSON: // {"version": 1, "presets": [{"name": …, "curve": {"outInfluence", "outSpeed", "inInfluence", // "inSpeed"}}]}. @@ -98,9 +103,8 @@ // ------------------------------------------------------------------ keyframes - // Pairs of neighbouring selected keyframes of the active comp: {prop, key} (the earlier key's - // index), by property. - function selectedPairs() { + // Selected properties of the active comp whose keys can ease: {prop, keys} (their selected keys). + function selectedKeyed() { var comp = app.project.activeItem; var out = []; if (!(comp instanceof CompItem)) return out; @@ -109,7 +113,17 @@ var p = props[i]; if (p.propertyType !== PropertyType.PROPERTY || p.numKeys < 2 || !p.isInterpolationTypeValid(BEZIER)) continue; var keys = p.selectedKeys; - for (var k = 0; k + 1 < keys.length; k++) if (keys[k + 1] === keys[k] + 1) out.push({ prop: p, key: keys[k] }); + if (keys.length) out.push({ prop: p, keys: keys }); + } + return out; + } + + // Pairs of neighbouring selected keyframes: {prop, key} (the earlier key's index), by property. + function selectedPairs() { + var keyed = selectedKeyed(), out = []; + for (var i = 0; i < keyed.length; i++) { + var keys = keyed[i].keys; + for (var k = 0; k + 1 < keys.length; k++) if (keys[k + 1] === keys[k] + 1) out.push({ prop: keyed[i].prop, key: keys[k] }); } return out; } @@ -175,29 +189,39 @@ } } - // Ease every pair of neighbouring selected keyframes with `c` (one undo step). Returns the - // number of pairs eased. - function applyCurve(c) { - var pairs = selectedPairs(), n = 0; - if (!pairs.length) return 0; + // The eases of one side with `speed` (relative to the segment's average) and `influence`. + function eases(s, speed, influence) { + var out = []; + for (var d = 0; d < s.length; d++) out.push(new KeyframeEase(speed * s[d], influence)); + return out; + } + + // Ease the sides `mode` names ("In", "Out" or "In-Out") of every selected keyframe with `c`, in + // one undo step: a side with a neighbouring key, scaled to the segment on that side. Returns the + // number of keyframes eased. + function applyCurve(c, mode) { + var keyed = selectedKeyed(), sides = [], keys = 0; + var doIn = mode !== "Out", doOut = mode !== "In"; + // Every side and its eases first: the segments are measured before any key changes. + for (var i = 0; i < keyed.length; i++) { + var p = keyed[i].prop; + for (var j = 0; j < keyed[i].keys.length; j++) { + var k = keyed[i].keys[j], before = sides.length; + var s = doIn && k > 1 ? slopes(p, k - 1) : null; + if (s) sides.push({ prop: p, key: k, out: false, eases: eases(s, c.inSpeed, c.inInfluence) }); + s = doOut && k < p.numKeys ? slopes(p, k) : null; + if (s) sides.push({ prop: p, key: k, out: true, eases: eases(s, c.outSpeed, c.outInfluence) }); + if (sides.length > before) keys++; + } + } + if (!sides.length) return 0; app.beginUndoGroup("Apply Ease Preset"); try { - for (var i = 0; i < pairs.length; i++) { - var p = pairs[i].prop, a = pairs[i].key, s = slopes(p, a); - if (!s) continue; - var outE = [], inE = []; - for (var d = 0; d < s.length; d++) { - outE.push(new KeyframeEase(c.outSpeed * s[d], c.outInfluence)); - inE.push(new KeyframeEase(c.inSpeed * s[d], c.inInfluence)); - } - easeSide(p, a, true, outE); - easeSide(p, a + 1, false, inE); - n++; - } + for (var e = 0; e < sides.length; e++) easeSide(sides[e].prop, sides[e].key, sides[e].out, sides[e].eases); } finally { app.endUndoGroup(); } - return n; + return keys; } // The curve between the first selected pair of keyframes (measured on the dimension that @@ -288,6 +312,13 @@ var inInfluence = field(inRow, "In %", "inInfluence"); var inSpeed = field(inRow, "Speed", "inSpeed"); + var modeRow = ui.add("group"); + modeRow.add("statictext", undefined, "Ease"); + var MODES = ["In", "Out", "In-Out"]; + var mode = modeRow.add("dropdownlist", undefined, MODES, { name: "mode" }); + mode.selection = 2; + mode.helpTip = "The sides of each selected keyframe to ease: incoming, outgoing or both"; + var actions = ui.add("group"); var applyButton = actions.add("button", undefined, "Apply", { name: "apply" }); var fromKeys = actions.add("button", undefined, "From Keys", { name: "fromKeys" }); @@ -346,8 +377,10 @@ } function apply(c, what) { - var n = applyCurve(c); - status.text = n > 0 ? "Eased " + n + " keyframe pair(s) with " + what : "Select two or more neighbouring keyframes of a property"; + var m = mode.selection ? mode.selection.text : "In-Out"; + var n = applyCurve(c, m); + var side = m === "In" ? "before it" : m === "Out" ? "after it" : "next to it"; + status.text = n > 0 ? "Eased " + n + " keyframe(s) (" + m + ") with " + what : "Select keyframes with another keyframe " + side; return n; } From af486dc44a86dd639ca65f1d465061f50add6ebb Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 14:28:50 -0400 Subject: [PATCH 011/279] Ease Presets: tests for easing single keyframes In, Out and In-Out (#1329) One middle key between unequal segments in each mode, with its neighbours, times, values and selection unchanged and one undo restoring it; first and last keys, keys that aren't neighbours and keys of several properties; no undo step when nothing can ease; a Hold and an auto-Bezier side kept as they play; per-dimension and motion-path speeds on Scale and Position; and the pair's outer sides under In-Out and Out. All but the unchanged API tests fail on main. --- crates/script/src/tests_ease_presets.rs | 194 ++++++++++++++++++++++++ 1 file changed, 194 insertions(+) diff --git a/crates/script/src/tests_ease_presets.rs b/crates/script/src/tests_ease_presets.rs index 1006d1bf1..4d05d7c6f 100644 --- a/crates/script/src/tests_ease_presets.rs +++ b/crates/script/src/tests_ease_presets.rs @@ -208,6 +208,200 @@ fn the_ease_presets_panel_eases_every_selected_pair_in_one_undo_step() { assert_eq!(s.history.undo.len(), steps); } +/// Select only the keys at `keys` (property path, time in seconds). +fn select_keys(s: &mut Session, l: u64, keys: &[(&str, f64)]) { + s.execute("edit.deselectAll", json!({})).unwrap(); + let keys: Vec = keys.iter().map(|(path, t)| json!({"layer": l, "path": path, "time": t})).collect(); + s.execute("keys.select", json!({"keys": keys})).unwrap(); +} + +/// Type a curve (Out %, Out speed, In %, In speed), choose the sides to ease and click Apply. +fn apply_typed(s: &mut Session, win: u64, mode: &str, c: [f64; 4]) { + for (name, v) in ["outInfluence", "outSpeed", "inInfluence", "inSpeed"].iter().zip(c) { + set(s, win, name, json!(v.to_string())); + } + set(s, win, "mode", json!(mode)); + click(s, win, "apply"); +} + +fn eased(e: Ease, speed: f64, influence: f64) -> bool { + close(e.speed, speed, 1e-9) && close(e.influence, influence, 1e-9) +} + +/// #1329: one selected keyframe eases its incoming side (In), its outgoing side (Out) or both, +/// each scaled to the segment on that side (+100/s in, −40/s out here), without changing its +/// neighbours, key times, values or the selection; one undo step restores it. +#[test] +fn one_selected_keyframe_eases_its_in_out_or_both_sides() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]); + let win = open_panel(&mut s); + // In-Out is the default. + assert_eq!(control(&mut s, win, "mode")["selection"], json!([2])); + let before = prop(&s, l, "transform/opacity"); + for mode in ["In", "Out", "In-Out"] { + select_keys(&mut s, l, &[("transform/opacity", 1.0)]); + let selected = s.state.selected_keys.clone(); + let steps = s.history.undo.len(); + apply_typed(&mut s, win, mode, [30.0, 0.5, 60.0, 2.0]); + assert_eq!(status(&mut s, win), format!("Eased 1 keyframe(s) ({mode}) with the curve")); + assert_eq!(s.history.undo.len(), steps + 1, "{mode}: one undo step"); + assert_eq!(s.state.selected_keys, selected, "{mode}: the selection stays"); + let op = prop(&s, l, "transform/opacity"); + assert_eq!((&op.keys[0], &op.keys[2]), (&before.keys[0], &before.keys[2]), "{mode}: the neighbours don't change"); + assert_eq!((op.keys[1].time, op.keys[1].value.as_f64()), (before.keys[1].time, 100.0), "{mode}"); + let k = &op.keys[1]; + let (ins, outs) = (mode != "Out", mode != "In"); + assert_eq!(k.in_interp, if ins { Interp::Bezier } else { Interp::Linear }, "{mode}"); + assert_eq!(k.out_interp, if outs { Interp::Bezier } else { Interp::Linear }, "{mode}"); + if ins { + assert!(eased(k.in_ease[0], 2.0 * 100.0, 0.6), "{mode}: {:?}", k.in_ease); + } else { + assert!(close(op.value_at(at(0.5)).as_f64(), 50.0, 1e-6), "{mode}: the in side still plays linearly"); + } + if outs { + assert!(eased(k.out_ease[0], 0.5 * -40.0, 0.3), "{mode}: {:?}", k.out_ease); + } else { + assert!(close(op.value_at(at(2.0)).as_f64(), 60.0, 1e-6), "{mode}: the out side still plays linearly"); + } + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(prop(&s, l, "transform/opacity").keys, before.keys, "{mode}: undone"); + } +} + +/// A side without a neighbouring key isn't eased; when no selected key has one, nothing changes +/// (no undo step) and the panel says why. Keys that aren't neighbours, and keys of several +/// properties, each ease on their own. +#[test] +fn first_last_and_scattered_keyframes_ease_the_sides_that_have_a_segment() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]); + animate(&mut s, l, "transform/rotation", &[(0.0, json!(0)), (2.0, json!(90))]); + let win = open_panel(&mut s); + let before = prop(&s, l, "transform/opacity"); + let c = [30.0, 0.5, 60.0, 2.0]; + for (mode, t, why) in [("In", 0.0, "before it"), ("Out", 3.0, "after it")] { + select_keys(&mut s, l, &[("transform/opacity", t)]); + let steps = s.history.undo.len(); + apply_typed(&mut s, win, mode, c); + assert_eq!(status(&mut s, win), format!("Select keyframes with another keyframe {why}")); + assert_eq!(s.history.undo.len(), steps, "{mode}: no undo step"); + assert_eq!(prop(&s, l, "transform/opacity").keys, before.keys); + } + // In-Out on the first key eases only its out side. + select_keys(&mut s, l, &[("transform/opacity", 0.0)]); + apply_typed(&mut s, win, "In-Out", c); + let op = prop(&s, l, "transform/opacity"); + assert_eq!((op.keys[0].in_interp, op.keys[0].out_interp), (Interp::Linear, Interp::Bezier)); + assert!(eased(op.keys[0].out_ease[0], 0.5 * 100.0, 0.3), "{:?}", op.keys[0].out_ease); + assert_eq!(op.keys[1..], before.keys[1..]); + s.execute("edit.undo", json!({})).unwrap(); + // The first and last Opacity keys (not neighbours) and the last Rotation key. + select_keys(&mut s, l, &[("transform/opacity", 0.0), ("transform/opacity", 3.0), ("transform/rotation", 2.0)]); + let steps = s.history.undo.len(); + apply_typed(&mut s, win, "In-Out", c); + assert_eq!(status(&mut s, win), "Eased 3 keyframe(s) (In-Out) with the curve"); + assert_eq!(s.history.undo.len(), steps + 1); + let op = prop(&s, l, "transform/opacity"); + assert!(eased(op.keys[0].out_ease[0], 0.5 * 100.0, 0.3), "{:?}", op.keys[0].out_ease); + assert!(eased(op.keys[2].in_ease[0], 2.0 * -40.0, 0.6), "{:?}", op.keys[2].in_ease); + assert_eq!(op.keys[1], before.keys[1], "the unselected middle key"); + let rot = prop(&s, l, "transform/rotation"); + assert!(eased(rot.keys[1].in_ease[0], 2.0 * 45.0, 0.6), "{:?}", rot.keys[1].in_ease); + assert_eq!(rot.keys[0].out_interp, Interp::Linear, "the unselected neighbour"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(prop(&s, l, "transform/opacity").keys, before.keys); + assert!(prop(&s, l, "transform/rotation").keys.iter().all(|k| k.in_interp == Interp::Linear && k.out_interp == Interp::Linear)); +} + +/// A change from the pair-only panel: In-Out on a selected pair also eases the pair's outer sides +/// that face an unselected key (the selected keys' own sides), and Out on the pair eases only +/// out sides. +#[test] +fn a_selected_pair_eases_the_sides_its_mode_names() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]); + let win = open_panel(&mut s); + let before = prop(&s, l, "transform/opacity"); + let c = [30.0, 0.5, 60.0, 2.0]; + select_keys(&mut s, l, &[("transform/opacity", 0.0), ("transform/opacity", 1.0)]); + apply_typed(&mut s, win, "In-Out", c); + let op = prop(&s, l, "transform/opacity"); + assert!(eased(op.keys[0].out_ease[0], 50.0, 0.3) && eased(op.keys[1].in_ease[0], 200.0, 0.6)); + assert!(eased(op.keys[1].out_ease[0], -20.0, 0.3), "the side facing the unselected key: {:?}", op.keys[1].out_ease); + assert_eq!(op.keys[2], before.keys[2]); + s.execute("edit.undo", json!({})).unwrap(); + apply_typed(&mut s, win, "Out", c); + let op = prop(&s, l, "transform/opacity"); + assert!(eased(op.keys[0].out_ease[0], 50.0, 0.3) && eased(op.keys[1].out_ease[0], -20.0, 0.3)); + assert_eq!((op.keys[0].in_interp, op.keys[1].in_interp), (Interp::Linear, Interp::Linear)); +} + +/// Easing one side of a key leaves the other playing as it did: a Hold stays a Hold, and an +/// auto-Bezier side keeps its shape. A preset click uses the chosen sides too. +#[test] +fn the_side_not_eased_keeps_playing_hold_and_auto_bezier() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]); + animate(&mut s, l, "transform/rotation", &[(0.0, json!(0)), (1.0, json!(90)), (3.0, json!(30))]); + ok( + &mut s, + r#"var t = app.project.activeItem.layer(1).transform, K = KeyframeInterpolationType; + t.opacity.setInterpolationTypeAtKey(2, K.LINEAR, K.HOLD); + t.rotation.setInterpolationTypeAtKey(2, K.BEZIER, K.BEZIER); + t.rotation.setTemporalAutoBezierAtKey(2, true);"#, + ); + let rot = prop(&s, l, "transform/rotation"); + let after: Vec = [1.5, 2.0, 2.5].iter().map(|t| rot.value_at(at(*t)).as_f64()).collect(); + let win = open_panel(&mut s); + set(&mut s, win, "mode", json!("In")); + select_keys(&mut s, l, &[("transform/opacity", 1.0), ("transform/rotation", 1.0)]); + set(&mut s, win, "presets", json!("Ease In-Out")); + assert_eq!(status(&mut s, win), "Eased 2 keyframe(s) (In) with \u{201c}Ease In-Out\u{201d}"); + let op = prop(&s, l, "transform/opacity"); + assert_eq!((op.keys[1].in_interp, op.keys[1].out_interp), (Interp::Bezier, Interp::Hold)); + assert!(eased(op.keys[1].in_ease[0], 0.0, 0.5), "{:?}", op.keys[1].in_ease); + assert_eq!(op.value_at(at(2.0)).as_f64(), 100.0, "still holds"); + let eased_rot = prop(&s, l, "transform/rotation"); + assert!(eased(eased_rot.keys[1].in_ease[0], 0.0, 0.5), "{:?}", eased_rot.keys[1].in_ease); + for (t, want) in [1.5, 2.0, 2.5].iter().zip(after) { + assert!(close(eased_rot.value_at(at(*t)).as_f64(), want, 1e-6), "the auto-Bezier out side at {t}"); + } + assert!(!close(eased_rot.value_at(at(0.25)).as_f64(), rot.value_at(at(0.25)).as_f64(), 1e-3), "the in side changed"); +} + +/// On one key, each side scales to its own segment: per dimension, and along the motion path. +#[test] +fn single_keys_scale_each_side_to_its_dimensions_and_motion_path() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + // Scale: +100/−50 per second into the key, 0/+50 per second out of it. + animate(&mut s, l, "transform/scale", &[(0.0, json!([100, 100])), (1.0, json!([200, 50])), (3.0, json!([200, 150]))]); + // Position on a straight path: 100 px/s into the key, 50 px/s out of it. + animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0, 0])), (1.0, json!([100, 0, 0])), (3.0, json!([200, 0, 0]))]); + let win = open_panel(&mut s); + select_keys(&mut s, l, &[("transform/scale", 1.0), ("transform/position", 1.0)]); + apply_typed(&mut s, win, "In-Out", [40.0, 1.5, 50.0, 2.0]); + assert_eq!(status(&mut s, win), "Eased 2 keyframe(s) (In-Out) with the curve"); + let k = prop(&s, l, "transform/scale").keys[1].clone(); + assert!(eased(k.in_ease[0], 200.0, 0.5) && eased(k.in_ease[1], -100.0, 0.5), "{:?}", k.in_ease); + assert!(eased(k.out_ease[0], 0.0, 0.4) && eased(k.out_ease[1], 75.0, 0.4), "{:?}", k.out_ease); + let k = prop(&s, l, "transform/position").keys[1].clone(); + assert!(k.in_ease.iter().all(|e| close(e.speed, 200.0, 0.5) && close(e.influence, 0.5, 1e-9)), "{:?}", k.in_ease); + assert!(k.out_ease.iter().all(|e| close(e.speed, 75.0, 0.5) && close(e.influence, 0.4, 1e-9)), "{:?}", k.out_ease); + let pos = prop(&s, l, "transform/position"); + assert_eq!(pos.value_at(at(1.0)).as_vec2()[0], 100.0, "the key's value stays"); +} + #[test] fn a_typed_curve_scales_to_each_dimension_and_follows_motion_paths() { let store = Arc::new(MemoryConfig::default()); From c0f919a8589ad49e6dd0603cd9e2ab5b5e35d240 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 14:28:50 -0400 Subject: [PATCH 012/279] Docs: plugins.md describes the Ease Presets In, Out and In-Out sides (#1329) --- docs/plugins.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index fc7c0b8ff..861739b8b 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -103,17 +103,22 @@ name in your Scripts folders takes their place. ### Ease Presets (`extensions/scriptui-panels/Ease Presets.jsx`) -Window ▸ Ease Presets.jsx: easing curves kept by name and applied to pairs of neighbouring -selected keyframes (#254; it was a core panel in v0.6.0). A curve is the out side of a segment's +Window ▸ Ease Presets.jsx: easing curves kept by name and applied to the sides of selected +keyframes (#254; it was a core panel in v0.6.0). A curve is the out side of a segment's first key and the in side of its second, in the Keyframe Velocity dialog's terms: influence in percent, and speed relative to the segment's average speed (1 = as fast as a straight line, 0 = at rest), so one curve fits any duration and change of value. -* Click a preset to apply it to every pair of neighbouring selected keyframes of each selected - property, in one undo step (per dimension, and along the motion path for spatial properties; - the keys' other sides keep playing as they did). The keys stay selected, so you can try another. +* Click a preset to apply it to the selected keyframes of each selected property, in one undo + step. **Ease** chooses the sides (#1329): **In** eases each key's incoming side with the curve's + in side, **Out** its outgoing side with the curve's out side, **In-Out** (the default) both. A + side is eased when it has a neighbouring keyframe, selected or not (the neighbour doesn't + change), with its speeds scaled to the segment on that side: per dimension, and along the + motion path for spatial properties. So a single keyframe works, a selected pair gets the whole + curve, and the first and last keys ease the side that has a segment. The side not eased keeps + playing as it did (a Hold stays a Hold). The keys stay selected, so you can try another. * The drawing shows the working curve; type its numbers (Out % and Speed, In % and Speed) and - click Apply, or click From Keys to read the curve between the first selected pair. + click Apply, or click From Keys to read the curve between the first selected pair of neighbouring keys. * Save keeps the working curve under the typed name (replacing a preset of that name); Rename and Delete act on the selected preset of your own. The twelve built-in presets can't change. * User presets are kept with `app.settings` (section `Ease Presets`, key `userPresets`, in From f62af4ca73bbb33a4d1e1ab5602cfac101e29683 Mon Sep 17 00:00:00 2001 From: Themolx <183303841+Themolx@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:55:43 +0200 Subject: [PATCH 013/279] Guide layers: hidden in nested comps, and in the Timeline context menu, as in After Effects Guide layers render only in their own comp's viewer: a nested renderer (precomp, collapsed precomp in 2D, Classic 3D and Advanced 3D, comps read by effects, aux passes, the GPU walk's resident precomps) now runs with guides off (Renderer::nested_opts), so a precomp's guide layers no longer show in the parent comp's viewer or in a "Current Settings" render. The Timeline layer and Composition viewer right-click menus (shared layer_menu) gain a Guide Layer item (a plain toggle, no check mark) acting on the selection, and guide layers show a guide mark before their name in the Timeline. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/lib.rs | 18 +++- crates/render/src/tests_guides.rs | 108 ++++++++++++++++++++ crates/render/src/three_d/adv/scene.rs | 2 +- crates/render/src/three_d/compose.rs | 2 +- crates/ui-egui/src/i18n/ui.rs | 7 ++ crates/ui-egui/src/panels/timeline.rs | 48 +++++++-- crates/ui-egui/src/panels/viewer.rs | 2 +- crates/ui-egui/tests/ui_guide_layers.rs | 129 ++++++++++++++++++++++++ 8 files changed, 305 insertions(+), 11 deletions(-) create mode 100644 crates/render/src/tests_guides.rs create mode 100644 crates/ui-egui/tests/ui_guide_layers.rs diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 41036fe6b..45581d7f3 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -533,7 +533,9 @@ pub struct RenderOpts { /// Output scale relative to comp pixels (1 = Full, 0.5 = Half…). pub scale: f64, pub motion_blur: bool, - /// Render guide layers (viewer only). + /// Render guide layers (viewer only). Only the top-level comp's own guide layers: nested + /// comps (precomps, collapsed precomps, comps read by effects) never draw theirs, as in + /// After Effects (see [`Renderer::nested_opts`]). pub guides: bool, /// Draft quality: fewer motion-blur samples, bilinear only. pub draft: bool, @@ -737,9 +739,16 @@ impl<'a> Renderer<'a> { sub } + /// The render options one level down (precomps, collapsed precomps, comps read by effects): + /// guide layers render only in their own comp, never when it is nested. + fn nested_opts(&self) -> RenderOpts { + RenderOpts { guides: false, ..self.opts } + } + /// A renderer for nested work (precomps, layers read by effects): one level deeper. fn nested(&self) -> Renderer<'a> { Renderer { + opts: self.nested_opts(), depth: self.depth + 1, outer: None, opacity_mul: 1.0, @@ -830,7 +839,10 @@ impl<'a> Renderer<'a> { if let Some(slot) = slot { *slot = Some(link); } - Some((Renderer { depth: self.depth + 1, outer, opacity_mul: opacity, collapse3d: Some(c3), through, through_lost, ..*self }, nctx)) + Some(( + Renderer { opts: self.nested_opts(), depth: self.depth + 1, outer, opacity_mul: opacity, collapse3d: Some(c3), through, through_lost, ..*self }, + nctx, + )) } pub(crate) fn opacity_mul(&self) -> f32 { @@ -2388,6 +2400,8 @@ mod tests_footage_size; #[cfg(test)] mod tests_frame_blend; #[cfg(test)] +mod tests_guides; +#[cfg(test)] mod tests_mask_blur; #[cfg(test)] mod tests_nested; diff --git a/crates/render/src/tests_guides.rs b/crates/render/src/tests_guides.rs new file mode 100644 index 000000000..cb9851250 --- /dev/null +++ b/crates/render/src/tests_guides.rs @@ -0,0 +1,108 @@ +//! Guide layers: drawn in their own comp's viewer (`RenderOpts::guides`), never when that comp is +//! nested in another (precomp, collapsed precomp, 2D or 3D), and never in final renders. + +use effectcraft_color::Label; +use effectcraft_keyframe::Value; +use effectcraft_project::build; +use effectcraft_project::{BitDepth, Comp, ItemId, ItemKind, Layer, LayerSource, Project, Solid}; +use effectcraft_time::{FrameRate, Tick}; + +use crate::{NoFootage, RenderOpts, Renderer}; + +fn solid(p: &mut Project, comp: &Comp, color: [f32; 3], size: (u32, u32), three_d: bool) -> Layer { + let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color, width: size.0, height: size.1, pixel_aspect: 1.0 })); + let mut l = build::layer(p, comp, "S", LayerSource::Solid { item: sid }, size, None); + l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([50.0, 50.0, 0.0]); + l.switches.three_d = three_d; + l +} + +#[derive(Clone, Copy, Debug)] +enum Nest { + /// A plain precomp layer (the nested comp rendered on its own, then composited). + Precomp, + /// Collapse Transformations on a 2D precomp layer. + Collapsed, + /// A collapsed 3D precomp in a Classic 3D comp. + Collapsed3d, + /// A collapsed 3D precomp in an Advanced 3D comp. + CollapsedAdvanced, +} + +/// "Inner": a red 100×100 background with a green 40×40 guide layer in the middle; "Outer": the +/// same size, holding only Inner nested as `nest`. +fn scene(nest: Nest) -> (Project, ItemId, ItemId) { + let three_d = matches!(nest, Nest::Collapsed3d | Nest::CollapsedAdvanced); + let mut p = Project::default(); + p.settings.bit_depth = BitDepth::Bpc32; + let inner = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let mut guide = solid(&mut p, &inner, [0.0, 1.0, 0.0], (40, 40), three_d); + guide.switches.guide = true; + let bg = solid(&mut p, &inner, [1.0, 0.0, 0.0], (100, 100), three_d); + let mut inner = inner; + inner.layers = vec![guide, bg]; + let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into())); + let mut outer = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + if matches!(nest, Nest::CollapsedAdvanced) { + outer.renderer = effectcraft_project::Renderer::Advanced3D; + } + let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (100, 100), None); + pre.switches.collapse = !matches!(nest, Nest::Precomp); + pre.switches.three_d = three_d; + outer.layers = vec![pre]; + let oid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(outer.into())); + (p, iid, oid) +} + +/// The centre pixel of `comp` rendered with or without guide layers. +fn centre(p: &Project, comp: ItemId, guides: bool) -> [f32; 4] { + Renderer::new(p, &NoFootage, RenderOpts { guides, ..Default::default() }).comp_frame(comp, Tick::ZERO).get(50, 50) +} + +fn is_green(c: [f32; 4]) -> bool { + c[1] > 0.9 && c[0] < 0.1 +} + +fn is_red(c: [f32; 4]) -> bool { + c[0] > 0.9 && c[1] < 0.1 +} + +#[test] +fn guide_layer_renders_in_its_own_comp_with_guides_on() { + let (p, inner, _) = scene(Nest::Precomp); + let c = centre(&p, inner, true); + assert!(is_green(c), "guide layer drawn in its own comp's viewer: {c:?}"); +} + +#[test] +fn final_renders_leave_guide_layers_out_by_default() { + assert!(!RenderOpts::default().guides); + let (p, inner, _) = scene(Nest::Precomp); + let c = centre(&p, inner, false); + assert!(is_red(c), "guide layer left out of final renders: {c:?}"); +} + +#[test] +fn guide_layers_of_a_nested_comp_never_render_in_the_parent() { + for nest in [Nest::Precomp, Nest::Collapsed, Nest::Collapsed3d, Nest::CollapsedAdvanced] { + let (p, inner, outer) = scene(nest); + // The nested comp's own viewer still shows them… + let own = centre(&p, inner, true); + assert!(is_green(own), "{nest:?}: guide layer in its own comp: {own:?}"); + // …but not the parent's viewer (guides on) nor its final render. + for guides in [true, false] { + let c = centre(&p, outer, guides); + assert!(is_red(c), "{nest:?}, guides {guides}: nested guide layer drawn in the parent: {c:?}"); + } + } +} + +#[test] +fn a_guide_precomp_layer_of_the_top_comp_follows_the_guides_option() { + // The precomp layer itself is a guide layer in the top comp: shown in the viewer, not in + // final renders. + let (mut p, _, outer) = scene(Nest::Precomp); + p.comp_mut(outer).unwrap().layers[0].switches.guide = true; + assert!(is_red(centre(&p, outer, true))); + assert!(centre(&p, outer, false)[3] < 0.01, "nothing drawn without guides"); +} diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index 5b5ae956a..f1f4c185f 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -879,7 +879,7 @@ fn collapsed_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, item: || l.is_light() || matches!(l.source, LayerSource::Camera) || (any_solo && !l.switches.solo) - || (!r.opts.guides && l.switches.guide) + || (!sub.opts.guides && l.switches.guide) { continue; } diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 0d70b32fa..ca7998fe0 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -503,7 +503,7 @@ fn collapsed_items<'a>( .layers .iter() .rev() - .filter(|l| l.is_active_at(t) && l.has_video() && !l.switches.adjustment && (!any_solo || l.switches.solo) && (r.opts.guides || !l.switches.guide)) + .filter(|l| l.is_active_at(t) && l.has_video() && !l.switches.adjustment && (!any_solo || l.switches.solo) && (sub.opts.guides || !l.switches.guide)) .collect(); let mut items = Vec::new(); for (k, l) in nested.into_iter().enumerate() { diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 3a4dd4aff..64a1b6d1a 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -262,6 +262,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Grab bag", "いろいろ"), ("Gradient Editor", "グラデーションエディター"), ("Grid", "グリッド"), + ("Guide Layer", "ガイドレイヤー"), ("Guide Layers: {}", "ガイドレイヤー:{}"), ("Guides", "ガイド"), ("Height:", "高さ:"), @@ -806,6 +807,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Grab bag", "列表"), ("Gradient Editor", "渐变编辑器"), ("Grid", "网格"), + ("Guide Layer", "参考线图层"), ("Guide Layers: {}", "参考图层:{}"), ("Guides", "参考线"), ("Height:", "高度:"), @@ -1538,6 +1540,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Grab bag", "清單"), ("Gradient Editor", "漸變編輯器"), ("Grid", "格點"), + ("Guide Layer", "參考圖層"), ("Guide Layers: {}", "參考圖層:{}"), ("Guides", "參考線"), ("Height:", "高度:"), @@ -2273,6 +2276,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Grab bag", "Список"), ("Gradient Editor", "Редактор градієнта"), ("Grid", "Сітка"), + ("Guide Layer", "Допоміжний шар"), ("Guide Layers: {}", "Допоміжні шари: {}"), ("Guides", "Напрямні"), ("Height:", "Висота:"), @@ -2823,6 +2827,7 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("Frame Target:", "Destino do quadro:"), ("Grab bag", "Lista"), ("Grid", "Grade"), + ("Guide Layer", "Camada de guia"), ("Guide Layers: {}", "Camadas de guia: {}"), ("Guides", "Guias"), ("Height:", "Altura:"), @@ -3274,6 +3279,7 @@ const CZECH: &[(&str, &str)] = &[ ("Freeze", "Zmrazit"), ("Grab bag", "Mozaika"), ("Grid", "Mřížka"), + ("Guide Layer", "Pomocná vrstva"), ("Guide Layers: {}", "Pomocné vrstvy: {}"), ("Guides", "Vodítka"), ("Height:", "Výška:"), @@ -4310,6 +4316,7 @@ mod tests { set_current("cs"); assert_eq!(tr("Composition Name:"), "Název kompozice:"); assert_eq!(tr("Cancel"), "Zrušit"); + assert_eq!(tr("Guide Layer"), "Pomocná vrstva", "the layer context menus' item, as Layer ▸ Guide Layer"); assert_eq!(tr("my layer.mp4"), "my layer.mp4"); assert_eq!(tr_args("Proxy Use: {}", &[&"Best"]), "Použití zástupných souborů: Best"); assert_eq!(tr_args("Step {} of {}: {}", &[&1, &3, &"Colour"]), "Krok 1 z 3: Colour"); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 163e6507d..d00dbd5e7 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2042,7 +2042,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } app.auto.add(&format!("timeline.layer.{}.twirl", layer.id.0), tw, "twirl"); icons::paint(&lp, Rect::from_center_size(pos2(cw.name + 24.0, cy), vec2(13.0, 13.0)), layer_icon(layer, &app.session.project), t.text_dim); - let name_rect = Rect::from_min_max(pos2(cw.name + 34.0, r.min.y), pos2(cw.name_end - 4.0, r.max.y)); + // A guide layer carries a guide mark before its name (After Effects). + let name_x = if layer.switches.guide { + let mark = Rect::from_center_size(pos2(cw.name + 40.0, cy), vec2(11.0, 11.0)); + icons::paint(&lp, mark, Icon::Grid, t.text_dim); + app.auto.add(&format!("timeline.layer.{}.guide", layer.id.0), mark, "Guide Layer"); + cw.name + 50.0 + } else { + cw.name + 34.0 + }; + let name_rect = Rect::from_min_max(pos2(name_x, r.min.y), pos2(cw.name_end - 4.0, r.max.y)); let rename_id = egui::Id::new("tl-rename"); let renaming: Option<(u64, String)> = ctx.data(|d| d.get_temp(rename_id)); if let Some((rl, mut buf)) = renaming.filter(|(rl, _)| *rl == layer.id.0) { @@ -2103,7 +2112,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { None => start_rename(&ctx, layer.id.0, &layer.name), } } - layer_context_menu(&row_resp, &comp, layer, &selected, LayerMenuCaps::of(&app.session.project, layer), &mut actions); + layer_context_menu(&row_resp, &comp, layer, &selected, LayerMenuCaps::of(&app.session.project, layer), &mut app.auto, &mut actions); // Switches. let sws: [(Icon, bool, &str, bool); 8] = [ (Icon::Shy, sw.shy, "shy", true), @@ -2324,7 +2333,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { set_anchor(&ctx, cid, layer.id); } } - layer_context_menu(&body, &comp, layer, &selected, LayerMenuCaps::of(&app.session.project, layer), &mut actions); + layer_context_menu(&body, &comp, layer, &selected, LayerMenuCaps::of(&app.session.project, layer), &mut app.auto, &mut actions); let drag_key = format!("bar-{}", layer.id.0); // Ctrl/Cmd+Alt dragging a selected bar: Quick Offset (#1012), chosen as the // drag starts and followed below the rows until the button is released. @@ -3857,12 +3866,14 @@ impl LayerMenuCaps { /// A layer row's or bar's context menu. As in After Effects, right-clicking a layer that is part /// of the selection keeps the selection and the menu acts on all of it; right-clicking an /// unselected layer selects only that layer. +#[allow(clippy::too_many_arguments)] fn layer_context_menu( resp: &egui::Response, comp: &Comp, layer: &Layer, selected: &[LayerId], caps: LayerMenuCaps, + auto: &mut crate::automation::Registry, actions: &mut Vec<(String, serde_json::Value)>, ) { let in_selection = selected.contains(&layer.id); @@ -3870,12 +3881,24 @@ fn layer_context_menu( actions.push(("layer.select".into(), json!({"layers": [layer.id.0]}))); } let targets: Vec = if in_selection { comp.layers.iter().filter(|l| selected.contains(&l.id)).map(|l| l.id.0).collect() } else { vec![layer.id.0] }; - crate::widgets::context_menu(resp, |ui| layer_menu(ui, &targets, caps, actions)); + crate::widgets::context_menu(resp, |ui| layer_menu(ui, &targets, caps, (auto, "timeline.layerMenu"), actions)); } -/// Shared layer actions for Timeline rows and Composition context menus. -pub(crate) fn layer_menu(ui: &mut egui::Ui, layers: &[u64], caps: LayerMenuCaps, actions: &mut Vec<(String, serde_json::Value)>) { +/// The layer switches the layer context menus toggle (After Effects lists them after Time): +/// `layer.setSwitch` name and label. +const MENU_SWITCHES: [(&str, &str); 1] = [("guide", "Guide Layer")]; + +/// Shared layer actions for Timeline rows and Composition context menus. `auto` is the automation +/// registry and the id prefix of the menu's items. +pub(crate) fn layer_menu( + ui: &mut egui::Ui, + layers: &[u64], + caps: LayerMenuCaps, + auto: (&mut crate::automation::Registry, &str), + actions: &mut Vec<(String, serde_json::Value)>, +) { let Some(&id) = layers.first() else { return }; + let (auto, prefix) = auto; let mut item = |ui: &mut egui::Ui, label: &str, cmd: &str, params: serde_json::Value| { if ui.button(label).clicked() { actions.push(("layer.select".into(), json!({"layers": layers}))); @@ -3921,6 +3944,18 @@ pub(crate) fn layer_menu(ui: &mut egui::Ui, layers: &[u64], caps: LayerMenuCaps, ui.menu_button(crate::i18n::tr("Markers"), |ui| { item(ui, "Add Marker", "layer.addMarker", json!({"layers": layers})); }); + // A click toggles the switch on the target layers, as Layer ▸ Guide Layer does. + // (Pushed after the other items: `item` holds `actions` until its last use.) + let mut switch_actions: Vec<(String, serde_json::Value)> = Vec::new(); + for (switch, label) in MENU_SWITCHES { + let response = ui.button(crate::i18n::tr(label)); + auto.add(&format!("{prefix}.switch.{switch}"), response.rect, label); + if response.clicked() { + switch_actions.push(("layer.select".into(), json!({"layers": layers}))); + switch_actions.push(("layer.setSwitch".into(), json!({"layers": layers, "switch": switch}))); + ui.close(); + } + } ui.menu_button(crate::i18n::tr("Blending Mode"), |ui| { widgets::menu_scroll(ui, |ui| { for m in BlendMode::ALL { @@ -3943,6 +3978,7 @@ pub(crate) fn layer_menu(ui: &mut egui::Ui, layers: &[u64], caps: LayerMenuCaps, item(ui, "Duplicate", "edit.duplicate", json!({"layers": layers})); item(ui, "Split Layer", "edit.splitLayer", json!({"layers": layers})); item(ui, "Delete", "edit.clear", json!({"layers": layers})); + actions.extend(switch_actions); } /// The layer context menu's Effects submenu: every effect from the registry grouped by category, diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index f4bb5a881..324ed3dd8 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1274,7 +1274,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { caps }, ); - super::timeline::layer_menu(ui, &context_layers, caps, &mut context_actions); + super::timeline::layer_menu(ui, &context_layers, caps, (&mut app.auto, "viewer.layerMenu"), &mut context_actions); } ui.separator(); if ui.button(copy_frame).clicked() { diff --git a/crates/ui-egui/tests/ui_guide_layers.rs b/crates/ui-egui/tests/ui_guide_layers.rs new file mode 100644 index 000000000..37158658f --- /dev/null +++ b/crates/ui-egui/tests/ui_guide_layers.rs @@ -0,0 +1,129 @@ +//! Guide Layer in the layer context menus (Timeline rows and the Composition viewer), a plain item +//! (no check mark) that toggles the switch on the selection, and the guide mark by a guide layer's +//! name. +use effectcraft_engine::Session; +use effectcraft_engine::project::LayerId; +use effectcraft_ui_egui::EffectcraftApp; +use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT as _, Queryable as _}; +use serde_json::json; + +/// A 640×360 comp with two 200×200 solids, "Left" at (160, 180) and "Right" at (480, 180). +fn harness() -> (Harness<'static, EffectcraftApp>, LayerId, LayerId) { + let mut session = Session::default(); + session.execute("comp.new", json!({"name": "Guides", "width": 640, "height": 360, "duration": 4})).unwrap(); + let mut solid = |name: &str, x: f64| { + let id = session.execute("layer.newSolid", json!({"name": name, "color": "#406080", "width": 200, "height": 200})).unwrap()["layer"].as_u64().unwrap(); + session.execute("prop.set", json!({"layer": id, "path": "transform/position", "value": [x, 180.0]})).unwrap(); + LayerId(id) + }; + let left = solid("Left", 160.0); + let right = solid("Right", 480.0); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(session)); + h.run_steps(3); + (h, left, right) +} + +fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}")); + Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])) +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, button: PointerButton) { + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(3); +} + +fn guide(h: &Harness<'_, EffectcraftApp>, id: LayerId) -> bool { + h.state().session.active_comp().unwrap().layer(id).unwrap().switches.guide +} + +fn snapshot(h: &mut Harness<'_, EffectcraftApp>, name: &str) { + if let Ok(dir) = std::env::var("GUIDE_LAYER_SNAPSHOTS") { + h.render().unwrap().save(format!("{dir}/{name}.png")).unwrap(); + } +} + +/// The open menu's Guide Layer item carries no check mark (a plain toggle). +fn plain(h: &Harness<'_, EffectcraftApp>) -> bool { + !h.get_by_label_contains("Guide Layer").accesskit_node().label().unwrap_or_default().contains('✔') +} + +fn row(h: &Harness<'_, EffectcraftApp>, id: LayerId) -> Pos2 { + let r = rect(h, &format!("timeline.layer.{}.row", id.0)); + pos2(r.min.x + 210.0, r.center().y) +} + +/// The Guide Layer item of the open menu `prefix`. +fn guide_item(h: &Harness<'_, EffectcraftApp>, prefix: &str) -> Rect { + rect(h, &format!("{prefix}.switch.guide")) +} + +#[test] +fn timeline_layer_menu_toggles_guide_layer() { + let (mut h, left, right) = harness(); + assert!(h.state().auto.find(&format!("timeline.layer.{}.guide", left.0)).is_none(), "no guide mark yet"); + let p = row(&h, left); + click(&mut h, p, PointerButton::Secondary); + assert_eq!(h.state().session.state.selected_layers, vec![left], "right-click selects the row"); + assert!(plain(&h), "no check mark"); + let item = guide_item(&h, "timeline.layerMenu"); + click(&mut h, item.center(), PointerButton::Primary); + assert!(guide(&h, left), "Guide Layer turned on"); + assert!(!guide(&h, right), "only the target layer"); + // The guide mark shows by the name. + h.run_steps(2); + assert!(h.state().auto.find(&format!("timeline.layer.{}.guide", left.0)).is_some(), "guide mark by the name"); + // Again: off. Undo brings it back. + let p = row(&h, left); + click(&mut h, p, PointerButton::Secondary); + assert!(plain(&h), "no check mark on a guide layer either"); + snapshot(&mut h, "timeline-menu"); + let item = guide_item(&h, "timeline.layerMenu"); + click(&mut h, item.center(), PointerButton::Primary); + assert!(!guide(&h, left), "Guide Layer turned off"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert!(guide(&h, left)); +} + +#[test] +fn timeline_layer_menu_guide_layer_acts_on_the_selection() { + let (mut h, left, right) = harness(); + h.state_mut().session.execute("layer.select", json!({"layers": [left.0, right.0]})).unwrap(); + h.run_steps(3); + let p = row(&h, right); + click(&mut h, p, PointerButton::Secondary); + let item = guide_item(&h, "timeline.layerMenu"); + click(&mut h, item.center(), PointerButton::Primary); + assert!(guide(&h, left) && guide(&h, right), "both selected layers become guide layers"); + let p = row(&h, left); + click(&mut h, p, PointerButton::Secondary); + let item = guide_item(&h, "timeline.layerMenu"); + click(&mut h, item.center(), PointerButton::Primary); + assert!(!guide(&h, left) && !guide(&h, right), "and back together"); +} + +#[test] +fn viewer_layer_menu_toggles_guide_layer() { + let (mut h, left, right) = harness(); + let comp = rect(&h, "viewer.comp"); + let k = comp.width() / 640.0; + let at = |x: f32| comp.min + vec2(x, 180.0) * k; + click(&mut h, at(480.0), PointerButton::Secondary); + assert_eq!(h.state().session.state.selected_layers, vec![right], "right-click picks the layer under the pointer"); + let item = guide_item(&h, "viewer.layerMenu"); + click(&mut h, item.center(), PointerButton::Primary); + assert!(guide(&h, right) && !guide(&h, left)); + // The Timeline and viewer menus share the item: the viewer menu turns it off again. + click(&mut h, at(480.0), PointerButton::Secondary); + assert!(plain(&h), "no check mark"); + snapshot(&mut h, "viewer-menu"); + let item = guide_item(&h, "viewer.layerMenu"); + click(&mut h, item.center(), PointerButton::Primary); + assert!(!guide(&h, right)); +} From 9b74dba2a89d647be8b7300c91d4278b4378bbc7 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 15:04:19 -0400 Subject: [PATCH 014/279] Ease Presets: skip roving keyframes and say so, rather than reporting them eased (#1329) A roving key passes speed straight through: the engine keeps both its sides linear whenever the keys are edited (retime_roving), so an ease set on one never plays. The panel still counted it as eased, so a selected roving key showed "Eased 1 keyframe(s)" and stayed linear. The previous pair-only panel did the same to a pair holding a roving key. Selected roving keys are now left out of the work and counted. The status names how many were skipped and that turning off Roving lets them ease; when nothing else was selected, nothing changes and no undo step opens. Fixed keys next to a roving key still ease. --- crates/script/src/tests_ease_presets.rs | 35 +++++++++++++++++++++ extensions/scriptui-panels/Ease Presets.jsx | 22 ++++++++----- 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/crates/script/src/tests_ease_presets.rs b/crates/script/src/tests_ease_presets.rs index 4d05d7c6f..c9871d14a 100644 --- a/crates/script/src/tests_ease_presets.rs +++ b/crates/script/src/tests_ease_presets.rs @@ -378,6 +378,41 @@ fn the_side_not_eased_keeps_playing_hold_and_auto_bezier() { assert!(!close(eased_rot.value_at(at(0.25)).as_f64(), rot.value_at(at(0.25)).as_f64(), 1e-3), "the in side changed"); } +/// Roving keys pass speed straight through (the engine keeps their sides linear), so an ease set +/// on one wouldn't play: they are skipped and the panel says so, rather than reporting them eased. +#[test] +fn roving_keyframes_are_skipped_and_reported() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0, 0])), (1.0, json!([100, 0, 0])), (2.0, json!([200, 0, 0]))]); + animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]); + ok(&mut s, "app.project.activeItem.layer(1).transform.position.setRovingAtKey(2, true)"); + let pos = prop(&s, l, "transform/position"); + assert!(pos.keys[1].roving); + let roving_at = pos.keys[1].time.seconds(); + let win = open_panel(&mut s); + // Only the roving key: nothing changes, no undo step. + select_keys(&mut s, l, &[("transform/position", roving_at)]); + let steps = s.history.undo.len(); + set(&mut s, win, "presets", json!("Ease In-Out")); + assert_eq!(status(&mut s, win), "Nothing eased: skipped 1 roving keyframe(s); turn off Roving to ease them"); + assert_eq!(s.history.undo.len(), steps); + assert_eq!(prop(&s, l, "transform/position").keys, pos.keys); + // With an ordinary key: that one is eased, the roving one is counted as skipped. + select_keys(&mut s, l, &[("transform/position", roving_at), ("transform/opacity", 1.0)]); + set(&mut s, win, "presets", json!("Linear")); + set(&mut s, win, "presets", json!("Ease In-Out")); + assert_eq!( + status(&mut s, win), + "Eased 1 keyframe(s) (In-Out) with \u{201c}Ease In-Out\u{201d}; skipped 1 roving keyframe(s); turn off Roving to ease them" + ); + let k = &prop(&s, l, "transform/position").keys[1]; + assert!(k.roving && k.in_interp == Interp::Linear && k.out_interp == Interp::Linear, "{k:?}"); + let op = prop(&s, l, "transform/opacity"); + assert!(eased(op.keys[1].in_ease[0], 0.0, 0.5) && eased(op.keys[1].out_ease[0], 0.0, 0.5)); +} + /// On one key, each side scales to its own segment: per dimension, and along the motion path. #[test] fn single_keys_scale_each_side_to_its_dimensions_and_motion_path() { diff --git a/extensions/scriptui-panels/Ease Presets.jsx b/extensions/scriptui-panels/Ease Presets.jsx index 0dd3f43ae..6f6eadae5 100644 --- a/extensions/scriptui-panels/Ease Presets.jsx +++ b/extensions/scriptui-panels/Ease Presets.jsx @@ -16,6 +16,8 @@ // its outgoing side with the curve's out side, In-Out both: a side is eased when it has a // neighbouring key (selected or not; the neighbour isn't changed), its speeds scaled to the // segment on that side. So a selected pair gets the whole curve, and a single key works too. +// Roving keys are skipped: they pass speed straight through (their sides stay linear), so an ease +// set on them wouldn't play. // // User presets are kept with app.settings (section "Ease Presets", key "userPresets") as JSON: // {"version": 1, "presets": [{"name": …, "curve": {"outInfluence", "outSpeed", "inInfluence", @@ -197,16 +199,20 @@ } // Ease the sides `mode` names ("In", "Out" or "In-Out") of every selected keyframe with `c`, in - // one undo step: a side with a neighbouring key, scaled to the segment on that side. Returns the - // number of keyframes eased. + // one undo step: a side with a neighbouring key, scaled to the segment on that side. Returns + // {eased, roving}: the number of keyframes eased, and of selected roving keyframes skipped. function applyCurve(c, mode) { - var keyed = selectedKeyed(), sides = [], keys = 0; + var keyed = selectedKeyed(), sides = [], keys = 0, roving = 0; var doIn = mode !== "Out", doOut = mode !== "In"; // Every side and its eases first: the segments are measured before any key changes. for (var i = 0; i < keyed.length; i++) { var p = keyed[i].prop; for (var j = 0; j < keyed[i].keys.length; j++) { var k = keyed[i].keys[j], before = sides.length; + if (p.keyRoving(k)) { + roving++; + continue; + } var s = doIn && k > 1 ? slopes(p, k - 1) : null; if (s) sides.push({ prop: p, key: k, out: false, eases: eases(s, c.inSpeed, c.inInfluence) }); s = doOut && k < p.numKeys ? slopes(p, k) : null; @@ -214,14 +220,14 @@ if (sides.length > before) keys++; } } - if (!sides.length) return 0; + if (!sides.length) return { eased: 0, roving: roving }; app.beginUndoGroup("Apply Ease Preset"); try { for (var e = 0; e < sides.length; e++) easeSide(sides[e].prop, sides[e].key, sides[e].out, sides[e].eases); } finally { app.endUndoGroup(); } - return keys; + return { eased: keys, roving: roving }; } // The curve between the first selected pair of keyframes (measured on the dimension that @@ -378,9 +384,11 @@ function apply(c, what) { var m = mode.selection ? mode.selection.text : "In-Out"; - var n = applyCurve(c, m); + var r = applyCurve(c, m), n = r.eased; var side = m === "In" ? "before it" : m === "Out" ? "after it" : "next to it"; - status.text = n > 0 ? "Eased " + n + " keyframe(s) (" + m + ") with " + what : "Select keyframes with another keyframe " + side; + var skipped = r.roving > 0 ? "skipped " + r.roving + " roving keyframe(s); turn off Roving to ease them" : ""; + if (n > 0) status.text = "Eased " + n + " keyframe(s) (" + m + ") with " + what + (skipped ? "; " + skipped : ""); + else status.text = skipped ? "Nothing eased: " + skipped : "Select keyframes with another keyframe " + side; return n; } From 4dddb34cbab47076a5e1f01e8bc90db526b4c28c Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 15:04:19 -0400 Subject: [PATCH 015/279] Ease Presets: tests for Out alone through undo and redo, and a single key on a curved path (#1329) Out on an auto-Bezier key leaves its in side playing the same values, and undo and redo restore each state. On a curved Position path with unequal segment lengths and durations, In sets the key's speed from the incoming path length, playback arrives faster than a constant speed, and the out side plays as before. --- crates/script/src/tests_ease_presets.rs | 59 +++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/crates/script/src/tests_ease_presets.rs b/crates/script/src/tests_ease_presets.rs index c9871d14a..009cc59c6 100644 --- a/crates/script/src/tests_ease_presets.rs +++ b/crates/script/src/tests_ease_presets.rs @@ -413,6 +413,65 @@ fn roving_keyframes_are_skipped_and_reported() { assert!(eased(op.keys[1].in_ease[0], 0.0, 0.5) && eased(op.keys[1].out_ease[0], 0.0, 0.5)); } +/// Out alone leaves an auto-Bezier in side playing as it did, and undo and redo restore each state. +#[test] +fn out_alone_keeps_the_in_side_playing_through_undo_and_redo() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + animate(&mut s, l, "transform/rotation", &[(0.0, json!(0)), (1.0, json!(90)), (3.0, json!(30))]); + ok( + &mut s, + r#"var r = app.project.activeItem.layer(1).transform.rotation, K = KeyframeInterpolationType; + r.setInterpolationTypeAtKey(2, K.BEZIER, K.BEZIER); + r.setTemporalAutoBezierAtKey(2, true);"#, + ); + let before = prop(&s, l, "transform/rotation"); + let win = open_panel(&mut s); + select_keys(&mut s, l, &[("transform/rotation", 1.0)]); + apply_typed(&mut s, win, "Out", [60.0, 0.0, 33.0, 1.0]); + let after = prop(&s, l, "transform/rotation"); + assert!(eased(after.keys[1].out_ease[0], 0.0, 0.6), "{:?}", after.keys[1].out_ease); + for t in [0.25, 0.5, 0.75] { + assert!(close(after.value_at(at(t)).as_f64(), before.value_at(at(t)).as_f64(), 1e-6), "the in side at {t}"); + } + assert!(!close(after.value_at(at(2.0)).as_f64(), before.value_at(at(2.0)).as_f64(), 1e-3), "the out side changed"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(prop(&s, l, "transform/rotation").keys, before.keys); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(prop(&s, l, "transform/rotation").keys, after.keys); +} + +/// On a curved path, a single key's in and out speeds follow the length of the path on each side +/// (unequal lengths and durations), and the side not eased plays as it did. +#[test] +fn a_single_key_on_a_curved_path_eases_along_each_side() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0, 0])), (1.0, json!([100, 0, 0])), (3.0, json!([100, 300, 0]))]); + let before = prop(&s, l, "transform/position"); + let (len_in, len_out) = (effectcraft_keyframe::spatial_segment_length(&before.keys, 0), effectcraft_keyframe::spatial_segment_length(&before.keys, 1)); + let win = open_panel(&mut s); + select_keys(&mut s, l, &[("transform/position", 1.0)]); + apply_typed(&mut s, win, "In", [40.0, 1.5, 50.0, 2.0]); + let pos = prop(&s, l, "transform/position"); + let k = &pos.keys[1]; + // In speed: twice the incoming segment's average (its path length over 1 s). + assert!(k.in_ease.iter().all(|e| close(e.speed, 2.0 * len_in, 0.05 * len_in) && close(e.influence, 0.5, 1e-9)), "{:?} vs {len_in}", k.in_ease); + // The out side (2 s along a longer path) plays as before. + for t in [1.5, 2.0, 2.5] { + let (a, b) = (pos.value_at(at(t)).as_vec2(), before.value_at(at(t)).as_vec2()); + assert!(close(a[0], b[0], 1e-6) && close(a[1], b[1], 1e-6), "out side at {t}: {a:?} vs {b:?}"); + } + // Playback on the in side: arriving at twice the average speed, the last tenth of a second + // covers more of the path than a constant speed would (len_in / 10). + let (p9, p10) = (pos.value_at(at(0.9)).as_vec2(), pos.value_at(at(1.0)).as_vec2()); + let step = ((p10[0] - p9[0]).powi(2) + (p10[1] - p9[1]).powi(2)).sqrt(); + assert!(step > len_in / 10.0 * 1.2, "{step} vs {len_in}"); + assert!(len_out > len_in, "{len_out} {len_in}"); +} + /// On one key, each side scales to its own segment: per dimension, and along the motion path. #[test] fn single_keys_scale_each_side_to_its_dimensions_and_motion_path() { From a37357f082a77df31d8c438cb2e2f9560142ee50 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 15:04:19 -0400 Subject: [PATCH 016/279] Docs: plugins.md notes that Ease Presets skips roving keyframes (#1329) --- docs/plugins.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/plugins.md b/docs/plugins.md index 861739b8b..a688a89b0 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -116,7 +116,9 @@ rest), so one curve fits any duration and change of value. change), with its speeds scaled to the segment on that side: per dimension, and along the motion path for spatial properties. So a single keyframe works, a selected pair gets the whole curve, and the first and last keys ease the side that has a segment. The side not eased keeps - playing as it did (a Hold stays a Hold). The keys stay selected, so you can try another. + playing as it did (a Hold stays a Hold). Roving keyframes are skipped, and the status says how + many: they pass speed straight through, so an ease on one wouldn't play (turn off Roving to + ease it). The keys stay selected, so you can try another. * The drawing shows the working curve; type its numbers (Out % and Speed, In % and Speed) and click Apply, or click From Keys to read the curve between the first selected pair of neighbouring keys. * Save keeps the working curve under the typed name (replacing a preset of that name); Rename From da73df4b2eba41f2882a0a2eb283e6f770c6eb51 Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Tue, 13 Oct 2026 16:03:53 -0600 Subject: [PATCH 017/279] Script sockets: per-socket locks, a write deadline, and close interrupts (#1248) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every socket operation locked one global table and held it through the blocking write_all/flush, and streams had no write timeout (accepted ones were explicitly blocking). A peer that stopped reading kept the script blocked for good, and every other socket call, close included, waited behind it. - The table maps handles to Arc, each with its own Mutex. The table is locked only to look a socket up, add or remove one, never during network I/O, so one stalled socket doesn't hold up the others. - `write` sets a write timeout from the socket's `timeout` (10 s by default, as reads) and reports "write timed out … the other side isn't reading" instead of blocking. Failures to set the read or write timeout, or to make an accepted stream blocking, are reported instead of ignored. - `close` removes the socket and shuts its stream down through a second handle, so a call blocked on it fails at once. Tests (socket.rs, native only): a 64 MB write to a loopback peer that never reads fails with "timed out" after a 1 s timeout; while one socket is blocked writing, another answers at once, and closing the blocked one returns at once and ends its write. Both fail on main (the write never gives up; the second socket waits on the table lock). Co-Authored-By: Claude Opus 5.5 --- crates/script/src/socket.rs | 168 +++++++++++++++++++++++++++++++----- 1 file changed, 145 insertions(+), 23 deletions(-) diff --git a/crates/script/src/socket.rs b/crates/script/src/socket.rs index 6ac0d41d2..4030332de 100644 --- a/crates/script/src/socket.rs +++ b/crates/script/src/socket.rs @@ -50,8 +50,8 @@ mod imp { mod imp { use std::collections::BTreeMap; use std::io::{ErrorKind, Read, Write}; - use std::net::{TcpListener, TcpStream, ToSocketAddrs}; - use std::sync::Mutex; + use std::net::{Shutdown, TcpListener, TcpStream, ToSocketAddrs}; + use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use super::*; @@ -61,9 +61,18 @@ mod imp { Listener(TcpListener), } + /// One open socket. Reading, writing and polling lock only it, never the whole table, so a + /// socket blocked on a slow peer doesn't hold up the others (#1248). + struct Entry { + sock: Mutex, + /// A second handle on a stream, so `close` can shut it down while another call is + /// blocked reading or writing it. + wake: Option, + } + struct Table { next: u32, - socks: BTreeMap, + socks: BTreeMap>, } fn table() -> &'static Mutex { @@ -72,13 +81,31 @@ mod imp { } fn add(s: Sock) -> u32 { + let wake = match &s { + Sock::Stream { s, .. } => s.try_clone().ok(), + Sock::Listener(_) => None, + }; + let entry = Arc::new(Entry { sock: Mutex::new(s), wake }); let mut t = table().lock().unwrap_or_else(|e| e.into_inner()); let id = t.next; t.next += 1; - t.socks.insert(id, s); + t.socks.insert(id, entry); id } + /// The socket `id`, if open. The table is locked only for the lookup. + fn get(id: u32) -> Option> { + table().lock().unwrap_or_else(|e| e.into_inner()).socks.get(&id).cloned() + } + + /// Forget socket `id`; a call blocked on it fails at once. + fn close(id: u32) { + let entry = table().lock().unwrap_or_else(|e| e.into_inner()).socks.remove(&id); + if let Some(w) = entry.as_ref().and_then(|e| e.wake.as_ref()) { + let _ = w.shutdown(Shutdown::Both); + } + } + fn timeout(p: &J) -> Duration { Duration::from_secs_f64(p.get("timeout").and_then(J::as_f64).unwrap_or(10.0).clamp(0.0, 3600.0)) } @@ -98,8 +125,9 @@ mod imp { if utf8 { String::from_utf8_lossy(b).into_owned() } else { b.iter().map(|&c| c as char).collect() } } - /// Fill a stream's buffer until `done(buf)` or the timeout / end of stream. - fn fill(s: &mut TcpStream, buf: &mut Vec, eof: &mut bool, limit: Duration, done: impl Fn(&[u8]) -> bool) { + /// Fill a stream's buffer until `done(buf)` or the timeout / end of stream. Fails only when + /// the read deadline can't be set. + fn fill(s: &mut TcpStream, buf: &mut Vec, eof: &mut bool, limit: Duration, done: impl Fn(&[u8]) -> bool) -> std::io::Result<()> { let end = Instant::now() + limit; let mut chunk = [0u8; 4096]; while !done(buf) && !*eof { @@ -107,7 +135,7 @@ mod imp { if left.is_zero() { break; } - let _ = s.set_read_timeout(Some(left.max(Duration::from_millis(1)))); + s.set_read_timeout(Some(left.max(Duration::from_millis(1))))?; match s.read(&mut chunk) { Ok(0) => *eof = true, Ok(n) => buf.extend_from_slice(&chunk[..n]), @@ -119,6 +147,7 @@ mod imp { Err(_) => *eof = true, } } + Ok(()) } pub(super) fn run(op: &str, id: u32, p: &J) -> J { @@ -156,29 +185,27 @@ mod imp { Err(e) => err(format!("cannot listen on port {port}: {e}")), } } + "close" => { + close(id); + json!({}) + } _ => { - let mut t = table().lock().unwrap_or_else(|e| e.into_inner()); - if op == "close" { - t.socks.remove(&id); - return json!({}); - } - let Some(sock) = t.socks.get_mut(&id) else { + let Some(entry) = get(id) else { return match op { "connected" => json!({"value": false}), "eof" => json!({"value": true}), _ => err("the socket is not open"), }; }; - match (op, sock) { + let mut sock = entry.sock.lock().unwrap_or_else(|e| e.into_inner()); + match (op, &mut *sock) { ("poll", Sock::Listener(l)) => match l.accept() { Ok((s, peer)) => { - let _ = s.set_nonblocking(false); + if let Err(e) = s.set_nonblocking(false) { + return err(format!("cannot use the connection from {peer}: {e}")); + } let host = peer.to_string(); - let new = Sock::Stream { s, buf: vec![], eof: false }; - let nid = t.next; - t.next += 1; - t.socks.insert(nid, new); - json!({"id": nid, "host": host}) + json!({"id": add(Sock::Stream { s, buf: vec![], eof: false }), "host": host}) } Err(_) => json!({"id": null}), }, @@ -202,19 +229,25 @@ mod imp { let count = p.get("count").and_then(J::as_i64).unwrap_or(-1); let limit = timeout(p); if count < 0 { - fill(s, buf, eof, limit, |_| false); + if let Err(e) = fill(s, buf, eof, limit, |_| false) { + return err(format!("cannot set the read timeout: {e}")); + } let all = std::mem::take(buf); json!({"data": decode(&all, utf8)}) } else { let n = count as usize; - fill(s, buf, eof, limit, |b| b.len() >= n); + if let Err(e) = fill(s, buf, eof, limit, |b| b.len() >= n) { + return err(format!("cannot set the read timeout: {e}")); + } let take = n.min(buf.len()); let out: Vec = buf.drain(..take).collect(); json!({"data": decode(&out, utf8)}) } } ("readln", Sock::Stream { s, buf, eof }) => { - fill(s, buf, eof, timeout(p), |b| b.contains(&b'\n')); + if let Err(e) = fill(s, buf, eof, timeout(p), |b| b.contains(&b'\n')) { + return err(format!("cannot set the read timeout: {e}")); + } let line: Vec = match buf.iter().position(|&c| c == b'\n') { Some(i) => { let mut l: Vec = buf.drain(..=i).collect(); @@ -229,9 +262,19 @@ mod imp { json!({"data": decode(&line, utf8)}) } ("write", Sock::Stream { s, .. }) => { + // A peer that stops reading fills the buffers: give up after `timeout` + // instead of blocking the script for good (#1248). + let limit = timeout(p).max(Duration::from_millis(1)); + if let Err(e) = s.set_write_timeout(Some(limit)) { + return err(format!("cannot set the write timeout: {e}")); + } let data = p.get("data").and_then(J::as_str).unwrap_or(""); match s.write_all(&encode(data, utf8)).and_then(|_| s.flush()) { Ok(()) => json!({"ok": true}), + Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => err(format!( + "write timed out after {} s: the other side isn't reading (some of the data may have been sent)", + limit.as_secs_f64() + )), Err(e) => err(e), } } @@ -242,3 +285,82 @@ mod imp { } } } + +#[cfg(test)] +#[cfg(not(target_arch = "wasm32"))] +mod tests { + use std::net::{TcpListener, TcpStream}; + use std::sync::mpsc; + use std::time::{Duration, Instant}; + + use serde_json::{Value as J, json}; + + use super::imp::run; + + /// A loopback peer that accepts one connection and never reads from it; returns its + /// `host:port` and the accepted stream's keeper (drop it to let the connection go). + fn stalled_peer() -> (String, mpsc::Sender<()>) { + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + let host = l.local_addr().unwrap().to_string(); + let (stop, wait) = mpsc::channel::<()>(); + std::thread::spawn(move || { + let (_s, _): (TcpStream, _) = l.accept().unwrap(); + let _ = wait.recv_timeout(Duration::from_secs(120)); + }); + (host, stop) + } + + fn open(host: &str) -> u32 { + let r = run("open", 0, &json!({"host": host, "timeout": 5})); + r["id"].as_u64().unwrap_or_else(|| panic!("open {host}: {r}")) as u32 + } + + /// More than the loopback buffers hold, so a peer that doesn't read stops the write. + fn big() -> String { + "x".repeat(64 * 1024 * 1024) + } + + /// Run `f` on a thread; its result, or `None` if it takes longer than `limit`. + fn within(limit: Duration, f: impl FnOnce() -> T + Send + 'static) -> Option { + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(f()); + }); + rx.recv_timeout(limit).ok() + } + + #[test] + fn a_write_to_a_peer_that_stops_reading_fails_after_the_timeout() { + // #1248: it used to block for good. + let (host, _keep) = stalled_peer(); + let id = open(&host); + let data = big(); + let t0 = Instant::now(); + let r = within(Duration::from_secs(20), move || run("write", id, &json!({"data": data, "timeout": 1}))).expect("the write gave up"); + assert!(r["error"].as_str().is_some_and(|e| e.contains("timed out")), "{r}"); + assert!(t0.elapsed() < Duration::from_secs(15), "{:?}", t0.elapsed()); + run("close", id, &J::Null); + } + + #[test] + fn a_blocked_socket_doesnt_hold_up_the_others_and_close_interrupts_it() { + let (host_a, _keep_a) = stalled_peer(); + let (host_b, _keep_b) = stalled_peer(); + let (a, b) = (open(&host_a), open(&host_b)); + // A write on A that would wait a minute. + let data = big(); + let (tx, blocked) = mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(run("write", a, &json!({"data": data, "timeout": 60}))); + }); + std::thread::sleep(Duration::from_millis(500)); + // Socket B answers meanwhile (the table isn't locked by A's write). + let r = within(Duration::from_secs(3), move || run("connected", b, &json!({}))).expect("socket B answered while A was blocked"); + assert_eq!(r["value"], true, "{r}"); + // Closing A returns at once and ends A's write. + within(Duration::from_secs(3), move || run("close", a, &J::Null)).expect("close returned while A was blocked"); + let r = blocked.recv_timeout(Duration::from_secs(5)).expect("A's write ended when A was closed"); + assert!(r.get("error").is_some(), "{r}"); + run("close", b, &J::Null); + } +} From b26a340a7e653cd2e0e70408a341e1ebbe5bc67d Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Tue, 13 Oct 2026 16:26:42 -0600 Subject: [PATCH 018/279] Project: comp_contains walks precomps with a visited set, not recursion (#1237) Project::comp_contains recursed through precomp layers with no visited set or depth limit. A damaged project with a precomp cycle that doesn't pass the queried comp (A and B nesting each other, C unrelated) recursed until the stack ran out, which aborts the process ("stack overflow, aborting"): it can't be caught as a command error, so unsaved work is lost. layer.addItem checks it before adding a precomp, and the renderer calls it for every nested comp and its audio. A long chain used the stack the same way, and comps shared by many paths were walked once per path (exponential for nested diamonds). It is now an explicit stack with a visited set: same answers (a comp contains itself; a target reached through a cycle is found), each comp visited once, no recursion. The renderer already skips a precomp that contains the comp being rendered, so a cyclic project now renders instead of crashing; nothing is repaired on load. Tests: project comp_contains_ends_on_cycles_long_chains_and_shared_ precomps (cycle with an unrelated target, self-nesting, a target through a cycle, 40 levels of diamonds, a 20 000-comp chain); engine a_project_with_a_precomp_cycle_opens_adds_and_renders_without_running_ out_of_stack (file.open, layer.addItem both ways, render). On main the engine test aborts with a stack overflow. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests_limits.rs | 35 ++++++++++++++++++++ crates/project/src/lib.rs | 25 ++++++++++++-- crates/project/src/tests.rs | 55 +++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/tests_limits.rs b/crates/engine/src/tests_limits.rs index 68f2c04c0..524719bbf 100644 --- a/crates/engine/src/tests_limits.rs +++ b/crates/engine/src/tests_limits.rs @@ -102,3 +102,38 @@ fn commands_cap_sizes_at_30000_px() { let f = s.project.items.values().find_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.clone()) } else { None }).unwrap(); assert_eq!((f.width, f.height, f.frame_rate), (limits::MAX_SIZE, 8, FrameRate::FPS_29_97)); } + +#[test] +fn a_project_with_a_precomp_cycle_opens_adds_and_renders_without_running_out_of_stack() { + // Comps A and B nest each other (a damaged file); C is unrelated. Checking whether A + // contains C walked the A ⇄ B cycle until the stack ran out (#1237). + let mut s = Session::default(); + let mut ids = vec![]; + for name in ["A", "B", "C"] { + s.execute("comp.new", json!({"name": name, "width": 32, "height": 32, "frameRate": 30, "duration": 1})).unwrap(); + ids.push(s.active_comp_id().unwrap()); + } + let (a, b, c) = (ids[0], ids[1], ids[2]); + { + let proj = std::sync::Arc::make_mut(&mut s.project); + for (outer, inner) in [(a, b), (b, a)] { + let comp = proj.comp(outer).unwrap().clone(); + let l = effectcraft_project::build::layer(proj, &comp, "pre", effectcraft_project::LayerSource::Comp { item: inner }, (32, 32), None); + let ItemKind::Comp(cc) = &mut proj.item_mut(outer).unwrap().kind else { panic!("comp") }; + std::sync::Arc::make_mut(cc).layers.push(l); + } + } + let path = tmp("cycle.ecproj"); + std::fs::write(&path, s.project.to_file_json().unwrap()).unwrap(); + let mut t = Session::default(); + t.execute("file.open", json!({"path": path})).unwrap(); + t.open_comp(c); + // Adding A into C asks whether A contains C: it doesn't, so the layer goes in. + let r = t.execute("layer.addItem", json!({"comp": c.0, "item": a.0})).unwrap(); + assert!(r["layer"].as_u64().is_some(), "{r}"); + // Adding C into A would nest C in itself through A: refused, as before. + assert!(t.execute("layer.addItem", json!({"comp": a.0, "item": c.0})).is_err()); + // C renders: inside A the renderer skips B's copy of A instead of looping. + let img = t.render(c, Tick::ZERO, RenderOpts { scale: 1.0, ..Default::default() }); + assert_eq!((img.width, img.height), (32, 32)); +} diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 16d782165..27f8870d4 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -1466,11 +1466,32 @@ impl Project { } /// Whether comp `inner` is (transitively) nested inside comp `outer` (to prevent cycles). pub fn comp_contains(&self, outer: ItemId, inner: ItemId) -> bool { + // A walk with a stack and a visited set rather than recursion (#1237): a damaged project + // may hold a precomp cycle that doesn't pass `inner` (recursion never ended), a long + // chain must not use up the call stack, and comps shared by many paths are visited once + // (recursion re-walked them for every path). if outer == inner { return true; } - let Some(c) = self.comp(outer) else { return false }; - c.layers.iter().any(|l| matches!(l.source, LayerSource::Comp { item } if self.comp_contains(item, inner))) + let mut seen = std::collections::HashSet::new(); + let mut stack = vec![outer]; + while let Some(id) = stack.pop() { + if !seen.insert(id) { + continue; + } + let Some(c) = self.comp(id) else { continue }; + for l in &c.layers { + if let LayerSource::Comp { item } = l.source { + if item == inner { + return true; + } + if !seen.contains(&item) { + stack.push(item); + } + } + } + } + false } /// Make sure `next_id` exceeds every id in the project (after loading or merging). pub fn fix_next_id(&mut self) { diff --git a/crates/project/src/tests.rs b/crates/project/src/tests.rs index c19d81ee2..508cb743d 100644 --- a/crates/project/src/tests.rs +++ b/crates/project/src/tests.rs @@ -206,3 +206,58 @@ fn item_users_count_layers_in_every_comp() { assert!(p.item_users(unused).is_empty()); assert!(p.item_users(main).is_empty()); } + +/// A project of `n` empty comps; `links` adds a precomp layer of comp `b` inside comp `a` +/// (indexes into the returned ids). +fn comp_graph(n: usize, links: &[(usize, usize)]) -> (Project, Vec) { + let mut p = Project::default(); + let ids: Vec = (0..n) + .map(|i| { + p.add_item( + &format!("C{i}"), + effectcraft_color::Label::None, + None, + ItemKind::Comp(Comp::new(8, 8, FrameRate::FPS_25, Tick::from_seconds_f64(1.0)).into()), + ) + }) + .collect(); + for &(a, b) in links { + let outer = p.comp(ids[a]).unwrap().clone(); + let l = build::layer(&mut p, &outer, "pre", LayerSource::Comp { item: ids[b] }, (8, 8), None); + let ItemKind::Comp(c) = &mut p.item_mut(ids[a]).unwrap().kind else { panic!("comp") }; + std::sync::Arc::make_mut(c).layers.push(l); + } + (p, ids) +} + +#[test] +fn comp_contains_ends_on_cycles_long_chains_and_shared_precomps() { + // A ⇄ B cycle, C unrelated: the walk used to recurse until the stack ran out (#1237). + let (p, c) = comp_graph(3, &[(0, 1), (1, 0)]); + assert!(!p.comp_contains(c[0], c[2])); + assert!(p.comp_contains(c[0], c[1]) && p.comp_contains(c[1], c[0])); + // A comp nesting itself. + let (p, c) = comp_graph(2, &[(0, 0)]); + assert!(!p.comp_contains(c[0], c[1])); + assert!(p.comp_contains(c[0], c[0])); + // A target reached through a cycle. + let (p, c) = comp_graph(3, &[(0, 1), (1, 0), (1, 2)]); + assert!(p.comp_contains(c[0], c[2])); + assert!(!p.comp_contains(c[2], c[0])); + // 40 levels of diamonds: 2^40 paths, each comp visited once. + let mut links = vec![]; + for i in 0..40 { + let (top, l, r, bottom) = (i * 3, i * 3 + 1, i * 3 + 2, i * 3 + 3); + links.extend([(top, l), (top, r), (l, bottom), (r, bottom)]); + } + let (p, c) = comp_graph(121, &links); + let t0 = std::time::Instant::now(); + assert!(p.comp_contains(c[0], c[120])); + assert!(!p.comp_contains(c[120], c[0])); + assert!(t0.elapsed() < std::time::Duration::from_secs(1), "{:?}", t0.elapsed()); + // A chain 20 000 comps deep (a deep recursion's stack frames). + let n = 20_000; + let (p, c) = comp_graph(n, &(0..n - 1).map(|i| (i, i + 1)).collect::>()); + assert!(p.comp_contains(c[0], c[n - 1])); + assert!(!p.comp_contains(c[n - 1], c[0])); +} From 79ebd019352508b0b461b557dc5110d328f61839 Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Tue, 13 Oct 2026 16:59:08 -0600 Subject: [PATCH 019/279] svg: bound expansion by an element budget and skip reference cycles (#1236) Depth-limited expansion still let a few kilobytes of branching references expand to 2^N elements. The parser now counts every element it converts, copies included, against a budget of max(500 000, twice the file's own elements) and fails the import with Error::Xml once it is spent. Targets currently being expanded are tracked, so mutual references stop at the first repeat instead of nesting to the depth limit. Fixes #1236 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01N9kwMcw2pYQ5fSjKa3ZPnh --- crates/svg/src/lib.rs | 26 +++++++++++++++--- crates/svg/src/tests.rs | 60 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 7310814bf..b5ddd98e6 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -486,6 +486,12 @@ struct Parser<'a, 'i> { vw: f64, vh: f64, skipped: Vec, + /// Elements converted so far, `` copies included, and how many may be (#1236). + work: usize, + budget: usize, + /// The `` targets being expanded, outermost first: a reference back into one is a + /// cycle and is skipped. + using: Vec, } fn is_svg_ns(n: &roxmltree::Node) -> bool { @@ -748,9 +754,12 @@ impl<'a, 'i> Parser<'a, 'i> { /// Convert an element (and its subtree) into nodes appended to `out`. fn node(&mut self, n: roxmltree::Node<'a, 'i>, parent: &Style, out: &mut Vec, depth: usize) { - if depth > 64 || !is_svg_ns(&n) { + if depth > 64 || !is_svg_ns(&n) || self.work > self.budget { return; } + // Every element converted counts, each `` copy again: a few references to groups + // that reference each other several times can multiply into billions (#1236). + self.work += 1; let tag = n.tag_name().name(); match tag { "defs" | "title" | "desc" | "metadata" | "style" | "linearGradient" | "radialGradient" | "symbol" | "clipPath" | "mask" | "pattern" | "marker" @@ -816,9 +825,10 @@ impl<'a, 'i> Parser<'a, 'i> { } "use" => { let Some(target) = self.href(n) else { return }; - if target == n || n.ancestors().any(|a| a == target) { + if target == n || n.ancestors().any(|a| a == target) || self.using.contains(&target.id()) { return; } + self.using.push(target.id()); let x = self.attr_len(n, "x", 'x', &st); let y = self.attr_len(n, "y", 'y', &st); transform *= Affine::translate((x, y)); @@ -847,6 +857,7 @@ impl<'a, 'i> Parser<'a, 'i> { } else { self.node(target, &st, &mut children, depth + 1); } + self.using.pop(); out.push(Node::Group(Group { name: Self::element_name(n), transform, @@ -1048,6 +1059,9 @@ pub fn parse_transform(s: &str) -> Affine { /// 2 MiB thread's stack (aborting the app) at a few thousand levels; real documents stay far /// below this. const MAX_XML_DEPTH: usize = 256; +/// The most elements an import converts, `` copies included, unless the file itself has +/// more than half as many (#1236). +const MAX_EXPANDED_ELEMENTS: usize = 500_000; /// The deepest element nesting of `text` (a quick scan that skips comments, CDATA, processing /// instructions, declarations and quoted attribute values). @@ -1121,7 +1135,9 @@ pub fn parse(bytes: &[u8]) -> Result { } } let vb = viewbox(root); - let mut p = Parser { ids, rules, vw: vb.map_or(300.0, |v| v.1), vh: vb.map_or(150.0, |v| v.2), skipped: vec![] }; + // Room for generous reuse, and always for every element of the file itself. + let budget = MAX_EXPANDED_ELEMENTS.max(root.descendants().filter(|n| n.is_element()).count().saturating_mul(2)); + let mut p = Parser { ids, rules, vw: vb.map_or(300.0, |v| v.1), vh: vb.map_or(150.0, |v| v.2), skipped: vec![], work: 0, budget, using: vec![] }; let st = Style::default(); let pct_or = |name: &str, axis: char, def: f64| -> f64 { match root.attribute(name) { @@ -1157,6 +1173,10 @@ pub fn parse(bytes: &[u8]) -> Result { for c in root.children() { p.node(c, &rst, &mut children, 1); } + if p.work > p.budget { + // Never a silently partial drawing. + return Err(Error::Xml(format!("its references expand to more than {} elements", p.budget))); + } let transform = viewbox_transform(root, dw, dh); Ok(Doc { width: dw, diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs index ed1ea873e..93db715c1 100644 --- a/crates/svg/src/tests.rs +++ b/crates/svg/src/tests.rs @@ -217,3 +217,63 @@ fn deep_nesting_errors_instead_of_overflowing_the_stack() { assert_eq!(crate::xml_depth(&s), 2); assert!(crate::parse(s.as_bytes()).is_ok()); } + +/// How deep `g`'s groups nest, and how many shapes it holds. +fn depth_and_shapes(g: &Group) -> (usize, usize) { + let mut depth = 0; + let mut shapes = 0; + for c in &g.children { + match c { + Node::Group(sub) => { + let (d, s) = depth_and_shapes(sub); + depth = depth.max(d + 1); + shapes += s; + } + Node::Shape(_) => shapes += 1, + Node::Image(_) => {} + } + } + (depth, shapes) +} + +#[test] +fn branching_use_references_stop_at_the_expansion_budget() { + // Each group uses the one before twice: 30 levels are 2^30 copies of one rectangle, from a + // file of a few kilobytes (#1236). + let mut defs = String::from(r#""#); + for i in 1..=30 { + defs += &format!(r##""##, p = i - 1); + } + let svg = format!(r##"{defs}"##); + let t0 = std::time::Instant::now(); + let e = parse(svg.as_bytes()).unwrap_err(); + assert!(e.to_string().contains("expand to more than"), "{e}"); + assert!(t0.elapsed() < std::time::Duration::from_secs(10), "{:?}", t0.elapsed()); + // Below the budget the same pattern imports whole: 2^10 rectangles. + let small = svg.replace(r##""##, r##""##); + let doc = parse(small.as_bytes()).unwrap(); + assert_eq!(depth_and_shapes(&doc.root).1, 1024); +} + +#[test] +fn mutually_referencing_uses_are_cut_at_the_first_repeat() { + // A uses B and B uses A, neither inside the other: once each, not down to the depth limit. + let svg = r##" + + + "##; + let doc = parse(svg.as_bytes()).unwrap(); + let (depth, shapes) = depth_and_shapes(&doc.root); + assert_eq!(shapes, 2, "a's and b's rectangle, once each"); + assert!(depth <= 6, "{depth}"); +} + +#[test] +fn a_symbol_used_a_thousand_times_still_imports() { + let uses: String = (0..1000).map(|i| format!(r##""##)).collect(); + let svg = format!( + r##"{uses}"## + ); + let doc = parse(svg.as_bytes()).unwrap(); + assert_eq!(depth_and_shapes(&doc.root).1, 3000); +} From 3d45046132aa841fc768fde3d8f7b59dc4e23f7f Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Tue, 13 Oct 2026 17:35:25 -0600 Subject: [PATCH 020/279] model: validate glTF accessor spans before allocating, with checked arithmetic (#1235) Accessor counts sized the f32/u32 output before the buffer view was looked at, and offsets, strides and count * components used unchecked arithmetic, so a few hundred bytes of JSON could request a multi-gigabyte allocation (aborting the process) or overflow. - Doc::layout checks count * components, adds the view and accessor offsets, rejects a byteStride smaller than an element, and checks that the last element ends inside the buffer, all with checked arithmetic, before floats() or ints() allocate anything. - Accessors without a buffer view (all zeros) are capped at 2^24 components. - idx() and node index lists use usize::try_from instead of 'as usize'; an image bufferView's end and a GLB chunk's end are computed with checked_add. Fixes #1235 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01N9kwMcw2pYQ5fSjKa3ZPnh --- crates/model/src/gltf.rs | 87 +++++++++++++++++++++++++++++---------- crates/model/src/tests.rs | 72 ++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+), 22 deletions(-) diff --git a/crates/model/src/gltf.rs b/crates/model/src/gltf.rs index 20578d4f4..e17d014f6 100644 --- a/crates/model/src/gltf.rs +++ b/crates/model/src/gltf.rs @@ -39,13 +39,15 @@ pub fn split_glb(b: &[u8]) -> Result<(&[u8], Option<&[u8]>)> { while o + 8 <= total { let len = u32_at(o).unwrap_or(0) as usize; let ty = u32_at(o + 4).unwrap_or(0); - let data = b.get(o + 8..o + 8 + len).ok_or_else(|| perr("GLB chunk runs past the end of the file"))?; + let past_end = || perr("GLB chunk runs past the end of the file"); + let end = (o + 8).checked_add(len).ok_or_else(past_end)?; + let data = b.get(o + 8..end).ok_or_else(past_end)?; match ty { CHUNK_JSON if json.is_none() => json = Some(data), CHUNK_BIN if bin.is_none() => bin = Some(data), _ => {} } - o += 8 + len; + o = end; } Ok((json.ok_or_else(|| perr("GLB without a JSON chunk"))?, bin)) } @@ -115,8 +117,25 @@ struct Doc<'a> { fn arr<'v>(v: &'v Value, k: &str) -> &'v [Value] { v.get(k).and_then(Value::as_array).map(Vec::as_slice).unwrap_or(&[]) } +/// A non-negative integer field; one too large for `usize` counts as absent (#1235). fn idx(v: &Value, k: &str) -> Option { - v.get(k).and_then(Value::as_u64).map(|x| x as usize) + v.get(k).and_then(Value::as_u64).and_then(|x| usize::try_from(x).ok()) +} +/// Node indices in an array field (out-of-range ones are dropped). +fn indices(v: &Value, k: &str) -> Vec { + arr(v, k).iter().filter_map(Value::as_u64).filter_map(|c| usize::try_from(c).ok()).collect() +} + +/// The most components an accessor without a buffer view (all zeros) may have: nothing in the +/// file backs its count, so it is capped instead of checked against a buffer (#1235). +const MAX_ZERO_COMPONENTS: usize = 1 << 24; + +/// Where an accessor's elements lie in its buffer: `count` elements of `elem` bytes, `stride` +/// apart from `base`, all checked to be inside `buf`. +struct Span<'b> { + buf: &'b [u8], + base: usize, + stride: usize, } fn num(v: &Value, k: &str, d: f64) -> f64 { v.get(k).and_then(Value::as_f64).unwrap_or(d) @@ -132,6 +151,39 @@ fn nums(v: &Value, k: &str, d: [f64; N]) -> [f64; N] { } impl Doc<'_> { + /// The accessor's element count and total components (`count * n`), checked; and its span in + /// the buffer, validated before anything is allocated from the count. `None` for an + /// accessor without a buffer view, whose components are all zero. + fn layout(&self, acc: &Value, n: usize, csize: usize) -> Result<(usize, usize, Option>)> { + let count = match acc.get("count") { + None => 0, + Some(c) => c.as_u64().and_then(|c| usize::try_from(c).ok()).ok_or_else(|| perr("accessor count is not a valid size"))?, + }; + let total = count.checked_mul(n).ok_or_else(|| perr("accessor count is too large"))?; + let Some(bv) = idx(acc, "bufferView") else { + if total > MAX_ZERO_COMPONENTS { + return Err(perr(format!("accessor without a bufferView has {total} components (at most {MAX_ZERO_COMPONENTS})"))); + } + return Ok((count, total, None)); + }; + let view = arr(self.j, "bufferViews").get(bv).ok_or_else(|| perr(format!("no bufferView {bv}")))?; + let buf = self.buffers.get(idx(view, "buffer").unwrap_or(0)).ok_or_else(|| perr("bufferView buffer out of range"))?; + let past = || perr("accessor reads past its buffer"); + let base = idx(view, "byteOffset").unwrap_or(0).checked_add(idx(acc, "byteOffset").unwrap_or(0)).ok_or_else(past)?; + let elem = csize * n; + let stride = idx(view, "byteStride").filter(|s| *s > 0).unwrap_or(elem); + if stride < elem { + return Err(perr(format!("bufferView byteStride {stride} is smaller than its {elem}-byte elements"))); + } + if count > 0 { + let end = (count - 1).checked_mul(stride).and_then(|o| o.checked_add(base)).and_then(|o| o.checked_add(elem)).ok_or_else(past)?; + if end > buf.len() { + return Err(past()); + } + } + Ok((count, total, Some(Span { buf, base, stride }))) + } + /// An accessor as `f32` rows of `n` components (normalised integers mapped to 0..1 / -1..1). fn floats(&self, a: usize) -> Result<(Vec, usize)> { let acc = arr(self.j, "accessors").get(a).ok_or_else(|| perr(format!("no accessor {a}")))?; @@ -145,7 +197,6 @@ impl Doc<'_> { "MAT4" => 16, t => return Err(perr(format!("accessor type `{t}`"))), }; - let count = idx(acc, "count").unwrap_or(0); let ct = idx(acc, "componentType").unwrap_or(5126); let norm = acc.get("normalized").and_then(Value::as_bool).unwrap_or(false); let csize = match ct { @@ -154,17 +205,13 @@ impl Doc<'_> { 5125 | 5126 => 4, _ => return Err(perr(format!("componentType {ct}"))), }; - let mut out = vec![0.0f32; count * n]; - let Some(bv) = idx(acc, "bufferView") else { + let (count, total, span) = self.layout(acc, n, csize)?; + let mut out = vec![0.0f32; total]; + let Some(Span { buf, base, stride }) = span else { // No buffer view: all zeros (sparse substitution is not supported). return Ok((out, n)); }; - let view = arr(self.j, "bufferViews").get(bv).ok_or_else(|| perr(format!("no bufferView {bv}")))?; - let buf = self.buffers.get(idx(view, "buffer").unwrap_or(0)).ok_or_else(|| perr("bufferView buffer out of range"))?; - let base = idx(view, "byteOffset").unwrap_or(0) + idx(acc, "byteOffset").unwrap_or(0); - let elem = csize * n; // Matrices of 1/2-byte components pad columns to 4 bytes; ignored (rare). - let stride = idx(view, "byteStride").filter(|s| *s > 0).unwrap_or(elem); for i in 0..count { for c in 0..n { let o = base + i * stride + c * csize; @@ -207,18 +254,14 @@ impl Doc<'_> { "VEC2" => 2, _ => 1, }; - let count = idx(acc, "count").unwrap_or(0); let csize = match ct { 5120 | 5121 => 1, 5122 | 5123 => 2, _ => 4, }; - let Some(bv) = idx(acc, "bufferView") else { return Ok(vec![0; count * n]) }; - let view = arr(self.j, "bufferViews").get(bv).ok_or_else(|| perr(format!("no bufferView {bv}")))?; - let buf = self.buffers.get(idx(view, "buffer").unwrap_or(0)).ok_or_else(|| perr("bufferView buffer out of range"))?; - let base = idx(view, "byteOffset").unwrap_or(0) + idx(acc, "byteOffset").unwrap_or(0); - let stride = idx(view, "byteStride").filter(|s| *s > 0).unwrap_or(csize * n); - let mut out = Vec::with_capacity(count * n); + let (count, total, span) = self.layout(acc, n, csize)?; + let Some(Span { buf, base, stride }) = span else { return Ok(vec![0; total]) }; + let mut out = Vec::with_capacity(total); for i in 0..count { for c in 0..n { let o = base + i * stride + c * csize; @@ -268,7 +311,7 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result< arr(&j, "bufferViews").get(bv).and_then(|v| { let b = d.buffers.get(idx(v, "buffer").unwrap_or(0))?; let o = idx(v, "byteOffset").unwrap_or(0); - b.get(o..o + idx(v, "byteLength").unwrap_or(0)).map(<[u8]>::to_vec) + b.get(o..o.checked_add(idx(v, "byteLength").unwrap_or(0))?).map(<[u8]>::to_vec) }) } else { None @@ -366,13 +409,13 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result< skin: idx(n, "skin"), camera: idx(n, "camera"), light: n.get("extensions").and_then(|e| e.get("KHR_lights_punctual")).and_then(|e| idx(e, "light")), - children: arr(n, "children").iter().filter_map(Value::as_u64).map(|c| c as usize).collect(), + children: indices(n, "children"), }); } let scene = idx(&j, "scene").unwrap_or(0); let roots: Vec = match arr(&j, "scenes").get(scene) { - Some(s) => arr(s, "nodes").iter().filter_map(Value::as_u64).map(|c| c as usize).collect(), + Some(s) => indices(s, "nodes"), None => { // No scene: every node that is nobody's child. let mut child = vec![false; nodes.len()]; @@ -389,7 +432,7 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result< let mut skins = vec![]; for s in arr(&j, "skins") { - let joints: Vec = arr(s, "joints").iter().filter_map(Value::as_u64).map(|c| c as usize).collect(); + let joints: Vec = indices(s, "joints"); let inverse_bind = match idx(s, "inverseBindMatrices") { Some(a) => { let (f, _) = d.floats(a)?; diff --git a/crates/model/src/tests.rs b/crates/model/src/tests.rs index 9206ef83f..7c98c1ae7 100644 --- a/crates/model/src/tests.rs +++ b/crates/model/src/tests.rs @@ -492,3 +492,75 @@ fn stl_hostile_input_is_an_error() { let m = stl::parse(&binary_stl(b"", &[([1.0, 0.0, 0.0], [[0.0; 3]; 3], 0)])).unwrap(); assert_eq!(m.meshes[0].primitives[0].normals[0], [1.0, 0.0, 0.0]); } + +/// A one-triangle glTF whose POSITION is accessor 0, over a buffer of `bytes` zero bytes. +fn gltf_doc(accessors: &str, views: &str, bytes: usize) -> String { + let b64 = "A".repeat(bytes / 3 * 4) + ["", "AA==", "AAA="][bytes % 3]; + format!( + r#"{{"asset": {{"version": "2.0"}}, "scenes": [{{"nodes": [0]}}], "nodes": [{{"mesh": 0}}], + "meshes": [{{"primitives": [{{"attributes": {{"POSITION": 0}}}}]}}], + "buffers": [{{"byteLength": {bytes}, "uri": "data:application/octet-stream;base64,{b64}"}}], + "bufferViews": [{views}], "accessors": [{accessors}]}}"# + ) +} + +#[test] +fn gltf_accessor_counts_are_checked_against_the_buffer_before_allocating() { + // Untrusted counts, offsets and strides must fail the import, not allocate from the count or + // overflow (#1235). + let view = r#"{"buffer": 0, "byteLength": 36}"#; + let cases = [ + // A count far beyond the 36-byte buffer. + (r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 4000000000}"#, view, "past its buffer"), + // count * components overflows. + (r#"{"bufferView": 0, "componentType": 5126, "type": "MAT4", "count": 9223372036854775807}"#, view, "too large"), + // Offsets that overflow when added. + ( + r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3, "byteOffset": 16}"#, + r#"{"buffer": 0, "byteOffset": 18446744073709551610}"#, + "past its buffer", + ), + // A stride that overflows with the count. + (r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3000000000}"#, r#"{"buffer": 0, "byteStride": 252}"#, "past its buffer"), + // A stride smaller than an element would re-read the same bytes for a huge count. + (r#"{"bufferView": 0, "componentType": 5126, "type": "MAT4", "count": 30}"#, r#"{"buffer": 0, "byteStride": 1}"#, "smaller than"), + // No buffer view: nothing backs the count, so it is capped. + (r#"{"componentType": 5126, "type": "VEC3", "count": 1000000000}"#, view, "without a bufferView"), + ]; + let t0 = std::time::Instant::now(); + for (acc, v, want) in cases { + let doc = gltf_doc(acc, v, 36); + let e = crate::gltf::parse(doc.as_bytes(), &|_| None).err().unwrap_or_else(|| panic!("{acc} imported")); + assert!(e.to_string().contains(want), "{acc}: {e}"); + } + assert!(t0.elapsed() < std::time::Duration::from_secs(10), "{:?}", t0.elapsed()); +} + +#[test] +fn gltf_integer_accessors_are_checked_too() { + let doc = gltf_doc(r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3}"#, r#"{"buffer": 0, "byteLength": 36}"#, 36) + .replace(r#""attributes": {"POSITION": 0}"#, r#""attributes": {"POSITION": 0}, "indices": 1"#); + for idx in [ + r#"{"bufferView": 0, "componentType": 5125, "type": "SCALAR", "count": 4000000000}"#, + r#"{"componentType": 5125, "type": "SCALAR", "count": 4000000000}"#, + r#"{"bufferView": 0, "componentType": 5125, "type": "VEC4", "count": 9223372036854775807}"#, + ] { + let doc = doc.replace(r#""count": 3}]"#, &format!(r#""count": 3}}, {idx}]"#)); + assert!(crate::gltf::parse(doc.as_bytes(), &|_| None).is_err(), "{idx}"); + } +} + +#[test] +fn gltf_strided_and_buffer_less_accessors_still_import() { + // Interleaved positions (stride 24) ending exactly at the buffer's end, and a small all-zero + // accessor without a view. + let doc = gltf_doc(r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3}"#, r#"{"buffer": 0, "byteStride": 24}"#, 60); + let m = crate::gltf::parse(doc.as_bytes(), &|_| None).unwrap(); + assert_eq!(m.meshes[0].primitives[0].positions.len(), 3); + let doc = gltf_doc(r#"{"componentType": 5126, "type": "VEC3", "count": 6}"#, r#"{"buffer": 0}"#, 3); + let m = crate::gltf::parse(doc.as_bytes(), &|_| None).unwrap(); + assert_eq!(m.meshes[0].primitives[0].positions.len(), 6); + // One byte short of the third element is an error. + let doc = gltf_doc(r#"{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3}"#, r#"{"buffer": 0, "byteStride": 24}"#, 59); + assert!(crate::gltf::parse(doc.as_bytes(), &|_| None).is_err()); +} From 4b475f57815e3743c0a44db95d7410fe4150ce44 Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Tue, 13 Oct 2026 17:44:27 -0600 Subject: [PATCH 021/279] model: budget the memory an FBX import keeps, and share instanced meshes (#1234) The 1 GiB array limit counted wire bytes, but arrays are kept as f64/i64, so boolean arrays grew 8x and 32-bit arrays 2x past it, and nothing bounded the total across arrays. Every Model linked to a Geometry also built its own copy of the mesh. - A per-import Budget (2 GiB) is charged 8 bytes per array element before the array is decompressed or widened, ASCII files are charged for their parsed values, and each built mesh is charged for its vertex and index data. Going past it fails the import with a clear error instead of an allocation abort. parse_within/read_within take a lower limit for tests. - Models instancing the same geometry with the same materials share one mesh; a different material mapping still gets its own. Fixes #1234 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01N9kwMcw2pYQ5fSjKa3ZPnh --- crates/model/src/fbx.rs | 102 ++++++++++++++++++++++++++++++---- crates/model/src/fbx_tests.rs | 84 ++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+), 11 deletions(-) diff --git a/crates/model/src/fbx.rs b/crates/model/src/fbx.rs index e2b9e1bd0..8a3fd9f40 100644 --- a/crates/model/src/fbx.rs +++ b/crates/model/src/fbx.rs @@ -32,6 +32,57 @@ pub const BINARY_MAGIC: &[u8] = b"Kaydara FBX Binary \0\x1a\0"; const MAX_DEPTH: usize = 64; /// Largest decoded array accepted (bytes). const MAX_ARRAY_BYTES: usize = 1 << 30; +/// Most memory one import may keep: parsed arrays as stored (8 bytes per element, whatever the +/// wire type) plus the meshes built from them (#1234). +const MAX_IMPORT_BYTES: usize = 1 << 31; + +/// What is left of an import's memory budget. +struct Budget(std::cell::Cell); + +impl Budget { + fn new(bytes: usize) -> Budget { + Budget(std::cell::Cell::new(bytes)) + } + /// Take `bytes` (None: a size that overflowed) from the budget, or fail the import before + /// anything that large is allocated. + fn take(&self, bytes: Option, what: &str) -> Result<()> { + let left = self.0.get(); + match bytes.filter(|&b| b <= left) { + Some(b) => { + self.0.set(left - b); + Ok(()) + } + None => Err(perr(format!("FBX: {what} would take the import past its {} MiB memory limit", MAX_IMPORT_BYTES >> 20))), + } + } +} + +/// Bytes a mesh keeps. +fn mesh_bytes(m: &Mesh) -> Option { + m.primitives.iter().try_fold(0usize, |acc, p| { + let n = p.positions.len().checked_mul(12)?.checked_add(p.normals.len().checked_mul(12)?)?.checked_add(p.uvs.len().checked_mul(8)?)?; + let n = n.checked_add(p.tangents.len().checked_mul(16)?)?.checked_add(p.joints.len().checked_mul(8)?)?.checked_add(p.weights.len().checked_mul(16)?)?; + acc.checked_add(n)?.checked_add(p.indices.len().checked_mul(4)?) + }) +} + +/// Bytes the values under `nodes` keep. Used for ASCII files, which hold array elements as +/// one `Prop` each and are parsed before they can be counted (the text itself bounds them). +fn value_bytes(nodes: &[FbxNode]) -> Option { + nodes.iter().try_fold(0usize, |acc, n| { + let own = n.props.iter().try_fold(0usize, |a, p| { + let extra = match p { + Prop::Ints(v) => v.len().checked_mul(8)?, + Prop::Floats(v) => v.len().checked_mul(8)?, + Prop::Str(v) => v.len(), + Prop::Bytes(v) => v.len(), + Prop::Int(_) | Prop::Float(_) => 0, + }; + a.checked_add(std::mem::size_of::())?.checked_add(extra) + })?; + acc.checked_add(own)?.checked_add(value_bytes(&n.children)?) + }) +} /// One property value of an FBX node. #[derive(Clone, Debug, PartialEq)] @@ -126,7 +177,17 @@ impl FbxNode { /// Read an FBX file (binary or ASCII) into its top-level nodes and the file version /// (e.g. 7400). pub fn read(bytes: &[u8]) -> Result<(u32, Vec)> { - if bytes.starts_with(BINARY_MAGIC) { read_binary(bytes) } else { read_ascii(&String::from_utf8_lossy(bytes)) } + read_within(bytes, &Budget::new(MAX_IMPORT_BYTES)) +} + +fn read_within(bytes: &[u8], budget: &Budget) -> Result<(u32, Vec)> { + if bytes.starts_with(BINARY_MAGIC) { + read_binary(bytes, budget) + } else { + let (version, nodes) = read_ascii(&String::from_utf8_lossy(bytes))?; + budget.take(value_bytes(&nodes), "its values")?; + Ok((version, nodes)) + } } // --------------------------------------------------------------------------------------------- @@ -136,6 +197,7 @@ struct Bin<'a> { d: &'a [u8], /// 64-bit record offsets (version 7500 and later). wide: bool, + budget: &'a Budget, } impl Bin<'_> { @@ -207,7 +269,7 @@ impl Bin<'_> { } fn prop(&self, pos: &mut usize, pend: usize) -> Result { - let sub = Bin { d: self.d.get(..pend).ok_or_else(|| perr("FBX: truncated property"))?, wide: self.wide }; + let sub = Bin { d: self.d.get(..pend).ok_or_else(|| perr("FBX: truncated property"))?, wide: self.wide, budget: self.budget }; let t = sub.arr::<1>(pos)?[0]; Ok(match t { b'Y' => Prop::Int(i16::from_le_bytes(sub.arr(pos)?) as i64), @@ -234,6 +296,9 @@ impl Bin<'_> { _ => 1, }; let want = len.checked_mul(size).filter(|&n| n <= MAX_ARRAY_BYTES).ok_or_else(|| perr("FBX: array too large"))?; + // Elements are kept as f64/i64 (booleans and 32-bit values widen), so the budget + // is charged for what is kept, not for the wire bytes. + sub.budget.take(len.checked_mul(8), "an array")?; let data = sub.take(pos, clen)?; let raw = match enc { 0 => data.to_vec(), @@ -263,10 +328,10 @@ fn object_name(b: &[u8]) -> String { } } -fn read_binary(bytes: &[u8]) -> Result<(u32, Vec)> { +fn read_binary(bytes: &[u8], budget: &Budget) -> Result<(u32, Vec)> { let mut pos = BINARY_MAGIC.len(); - let version = Bin { d: bytes, wide: false }.u32(&mut pos)?; - let bin = Bin { d: bytes, wide: version >= 7500 }; + let version = Bin { d: bytes, wide: false, budget }.u32(&mut pos)?; + let bin = Bin { d: bytes, wide: version >= 7500, budget }; let mut nodes = vec![]; while pos < bytes.len() { match bin.record(&mut pos, 0) { @@ -728,7 +793,13 @@ fn short_name(full: &str) -> &str { /// Parse an FBX file into a model. `resolve(uri)` reads texture files next to it. pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result { - let (version, doc) = read(bytes)?; + parse_within(bytes, resolve, MAX_IMPORT_BYTES) +} + +/// [`parse`] keeping at most `limit` bytes of arrays and meshes. +fn parse_within(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>, limit: usize) -> Result { + let budget = Budget::new(limit); + let (version, doc) = read_within(bytes, &budget)?; if version != 0 && version < 7000 { return Err(ModelError::Unsupported(format!("FBX {}.{} (only FBX 7.x files are read)", version / 1000, version % 1000 / 100))); } @@ -835,19 +906,28 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result< Some(materials.len() - 1) }; - // One mesh per Model with Mesh geometry (material slots are per Model). + // One mesh per Model with Mesh geometry (material slots are per Model). Models instancing the + // same geometry with the same materials share one mesh rather than each building a copy. let mut meshes: Vec = vec![]; + let mut mesh_of: HashMap<(i64, Vec), usize> = HashMap::new(); for &id in &model_ids { let Some(&ni) = node_of.get(&id) else { continue }; let Some(gid) = links.iter().find(|l| l.1 == id && kind(l.0) == Some("Geometry")).map(|l| l.0) else { continue }; let Some(geom) = by_id.get(&gid) else { continue }; let mats: Vec = links.iter().filter(|l| l.1 == id && kind(l.0) == Some("Material")).filter_map(|l| material_index(l.0, &mut materials, &mut textures)).collect(); - let normal_mapped: Vec = materials.iter().map(|m| m.normal_tex.is_some()).collect(); let name = nodes.get(ni).map(|n| n.name.clone()).unwrap_or_default(); - let Some(mesh) = build_mesh(&name, geom, &mats, &|m| normal_mapped.get(m).copied().unwrap_or(false)) else { continue }; - meshes.push(mesh); - let mesh_index = meshes.len() - 1; + let mesh_index = match mesh_of.get(&(gid, mats.clone())) { + Some(&i) => i, + None => { + let normal_mapped: Vec = materials.iter().map(|m| m.normal_tex.is_some()).collect(); + let Some(mesh) = build_mesh(&name, geom, &mats, &|m| normal_mapped.get(m).copied().unwrap_or(false)) else { continue }; + budget.take(mesh_bytes(&mesh), &format!("mesh `{name}`"))?; + meshes.push(mesh); + mesh_of.insert((gid, mats), meshes.len() - 1); + meshes.len() - 1 + } + }; let geo = by_id.get(&id).and_then(|o| geometric(&Props70::of(o))); match geo { Some(matrix) => { diff --git a/crates/model/src/fbx_tests.rs b/crates/model/src/fbx_tests.rs index 3cd8ba5a1..1ba1978ba 100644 --- a/crates/model/src/fbx_tests.rs +++ b/crates/model/src/fbx_tests.rs @@ -325,3 +325,87 @@ fn hostile_fbx_files_fail_cleanly() { assert!(matches!(parse(v6.as_bytes(), &|_| None), Err(ModelError::Unsupported(_)))); assert!(parse(b"Objects: {\n Model: 1, \"Model::Empty\", \"Null\" {\n }\n}\n", &|_| None).is_err()); } + +/// A version-7400 binary FBX holding one top-level node `A` with uncompressed arrays of type +/// `t` (`b'b'`, `b'i'`, `b'f'`, `b'd'`, `b'l'`) and `len` elements each. +fn binary_arrays(arrays: &[(u8, usize)]) -> Vec { + let mut props = vec![]; + for &(t, len) in arrays { + let size = match t { + b'd' | b'l' => 8, + b'f' | b'i' => 4, + _ => 1, + }; + props.push(t); + props.extend((len as u32).to_le_bytes()); + props.extend(0u32.to_le_bytes()); + props.extend(((len * size) as u32).to_le_bytes()); + props.extend(std::iter::repeat_n(1u8, len * size)); + } + let mut out = BINARY_MAGIC.to_vec(); + out.extend(7400u32.to_le_bytes()); + let end = out.len() + 13 + 1 + props.len(); + out.extend((end as u32).to_le_bytes()); + out.extend((arrays.len() as u32).to_le_bytes()); + out.extend((props.len() as u32).to_le_bytes()); + out.push(1); + out.push(b'A'); + out.extend(props); + out +} + +#[test] +fn fbx_arrays_are_charged_for_the_memory_they_keep() { + // A boolean array is one byte per element on the wire but eight once kept as `Prop::Ints`: + // the budget counts the eight (#1234). + let n = 10_000; + let bin = binary_arrays(&[(b'b', n)]); + assert!(read_within(&bin, &Budget::new(n * 8)).is_ok()); + let e = read_within(&bin, &Budget::new(n * 8 - 1)).unwrap_err(); + assert!(e.to_string().contains("memory limit"), "{e}"); + // 32-bit floats widen to f64 the same way. + assert!(read_within(&binary_arrays(&[(b'f', n)]), &Budget::new(n * 4)).is_err()); + // Several arrays each within the budget but not together. + let bin = binary_arrays(&[(b'i', n), (b'd', n), (b'b', n)]); + assert!(read_within(&bin, &Budget::new(n * 24)).is_ok()); + assert!(read_within(&bin, &Budget::new(n * 20)).is_err()); + // ASCII values count too. + let (_, doc) = read(ASCII.as_bytes()).unwrap(); + let values = value_bytes(&doc).unwrap(); + assert!(values > 45 * std::mem::size_of::(), "{values}"); + assert!(parse_within(ASCII.as_bytes(), &|_| None, values - 1).unwrap_err().to_string().contains("memory limit")); +} + +/// [`ASCII`] with two more Models instancing its geometry: `Copy` with the same two materials, +/// `Red only` with just the first. +fn instanced_scene() -> String { + ASCII + .replace( + "\tMaterial: 400,", + "\tModel: 301, \"Model::Copy\", \"Mesh\" {\n\t}\n\tModel: 302, \"Model::Red only\", \"Mesh\" {\n\t}\n\tMaterial: 400,", + ) + .replace( + "\tC: \"OO\",500,300\n", + "\tC: \"OO\",500,300\n\tC: \"OO\",301,0\n\tC: \"OO\",100,301\n\tC: \"OO\",400,301\n\tC: \"OO\",500,301\n\tC: \"OO\",302,0\n\tC: \"OO\",100,302\n\tC: \"OO\",400,302\n", + ) +} + +#[test] +fn fbx_models_instancing_one_geometry_share_its_mesh() { + let m = parse(instanced_scene().as_bytes(), &|_| None).unwrap(); + let mesh_of = |name: &str| { + let n = m.nodes.iter().position(|n| n.name == name).unwrap(); + // The mesh sits on the node, or on its geometric-offset child. + m.nodes[n].mesh.or_else(|| m.nodes[n].children.iter().find_map(|&c| m.nodes[c].mesh)).unwrap() + }; + assert_eq!(mesh_of("Shape"), mesh_of("Copy"), "same geometry and materials: one mesh"); + assert_ne!(mesh_of("Shape"), mesh_of("Red only"), "different materials: its own mesh"); + assert_eq!(m.meshes.len(), 2); + // A budget with room for one copy of the geometry's mesh, but not three, still imports. + let one = parse(ASCII.as_bytes(), &|_| None).unwrap(); + let mesh = mesh_bytes(&one.meshes[0]).unwrap(); + let (_, doc) = read(instanced_scene().as_bytes()).unwrap(); + let values = value_bytes(&doc).unwrap(); + assert!(parse_within(instanced_scene().as_bytes(), &|_| None, values + 2 * mesh).is_ok()); + assert!(parse_within(instanced_scene().as_bytes(), &|_| None, values + mesh).is_err()); +} From fa6c9ffccd4fbb83fff943db7cdde86a7cea5e82 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 21:38:54 -0400 Subject: [PATCH 022/279] Web: IndexedDB puts and deletes resolve when their transaction commits (#1242) --- apps/effectcraft-web/js/host.js | 24 +++- apps/effectcraft-web/tests/storage.mjs | 180 +++++++++++++++++++++++++ 2 files changed, 202 insertions(+), 2 deletions(-) create mode 100644 apps/effectcraft-web/tests/storage.mjs diff --git a/apps/effectcraft-web/js/host.js b/apps/effectcraft-web/js/host.js index 3db6ad35c..f113c2a2d 100644 --- a/apps/effectcraft-web/js/host.js +++ b/apps/effectcraft-web/js/host.js @@ -57,6 +57,26 @@ function req(r) { return new Promise((res, rej) => { r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); }); } +// Run `op` on `store` in a readwrite transaction of its own; resolves once the transaction has +// committed. A request's success doesn't mean the write is stored: the transaction can still +// abort afterwards (quota at commit, the browser closing), rolling it back, and a write +// reported as saved then is never retried (#1242). A failed request aborts the transaction +// with its error; an explicit abort leaves no error, hence the AbortError. +function write(db, store, op) { + return new Promise((res, rej) => { + let tx = null; + try { + tx = db.transaction(store, "readwrite"); + tx.oncomplete = () => res(); + tx.onabort = () => rej(tx.error ?? new DOMException("the storage transaction was aborted", "AbortError")); + op(tx.objectStore(store)); + } catch (e) { + try { tx?.abort(); } catch {} + rej(e); + } + }); +} + async function idb() { if (!self.indexedDB) throw new Error("no IndexedDB"); const open = indexedDB.open(DB, 1); @@ -70,10 +90,10 @@ async function idb() { return all.map((r) => ({ key: r.key, data: new Uint8Array(r.data), modified: r.modified })); }, async put(key, data, modified) { - await req(tx("readwrite").put({ key, data: data.slice().buffer, modified })); + await write(db, STORE, (s) => s.put({ key, data: data.slice().buffer, modified })); }, async del(key) { - await req(tx("readwrite").delete(key)); + await write(db, STORE, (s) => s.delete(key)); }, }; } diff --git a/apps/effectcraft-web/tests/storage.mjs b/apps/effectcraft-web/tests/storage.mjs new file mode 100644 index 000000000..0855c1674 --- /dev/null +++ b/apps/effectcraft-web/tests/storage.mjs @@ -0,0 +1,180 @@ +// The IndexedDB storage backend of the web app (js/host.js) under Node with a fake IndexedDB, no +// browser or Wasm build needed: +// +// node apps/effectcraft-web/tests/storage.mjs +// +// A write is acknowledged only once its transaction has committed, not when its request +// succeeded: a transaction that aborts afterwards rolls the write back, and the write must be +// reported as failed so the flush keeps it for a retry (#1242). Exits non-zero on failure. +import assert from "node:assert/strict"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const tick = () => new Promise((r) => setTimeout(r, 0)); +globalThis.self = globalThis; + +// A small IndexedDB: requests succeed (or fail) one task after they're made, and a readwrite +// transaction commits once its requests are done, unless it was aborted (which rolls back its +// writes). `fault` makes the next request do something else. +let fault = null; // "abortAfterSuccess" | "requestError" | "throw" +const databases = new Map(); + +class Request { + constructor(tx) { + this.transaction = tx; + this.result = undefined; + this.error = null; + } +} + +class Store { + constructor(tx, rows) { + this.tx = tx; + this.rows = rows; + } + request(apply) { + const tx = this.tx; + if (tx.finished) throw new DOMException("the transaction has finished", "TransactionInactiveError"); + const f = fault; + fault = null; + if (f === "throw") throw new DOMException("the value can't be cloned", "DataCloneError"); + const r = new Request(tx); + tx.pending++; + setTimeout(() => { + tx.pending--; + if (tx.finished) return; + if (f === "requestError") { + r.error = new DOMException("the key already exists", "ConstraintError"); + const e = { target: r, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } }; + r.onerror?.(e); + tx.onerror?.(e); + if (!e.defaultPrevented) tx.fail(r.error); + return; + } + r.result = apply(tx.staged); + r.onsuccess?.({ target: r }); + if (f === "abortAfterSuccess") tx.abort(); + tx.maybeCommit(); + }, 0); + return r; + } + put(v) { + return this.request((rows) => { rows.set(v[this.keyPath], structuredClone(v)); return v[this.keyPath]; }); + } + delete(k) { + return this.request((rows) => { rows.delete(k); }); + } + get(k) { + return this.request((rows) => rows.get(k)); + } + getAll() { + return this.request((rows) => [...rows.values()]); + } +} + +class Transaction { + constructor(db, name) { + this.db = db; + this.name = name; + this.staged = new Map(db.stores.get(name)); + this.pending = 0; + this.finished = false; + this.error = null; + setTimeout(() => this.maybeCommit(), 0); + } + objectStore(name) { + const s = new Store(this, this.staged); + s.keyPath = this.db.keyPaths.get(name); + return s; + } + maybeCommit() { + if (this.finished || this.pending > 0) return; + this.finished = true; + this.db.stores.set(this.name, this.staged); + setTimeout(() => this.oncomplete?.(), 0); + } + fail(error) { + this.error = error; + this.abort(); + } + abort() { + if (this.finished) throw new DOMException("the transaction has finished", "InvalidStateError"); + this.finished = true; + setTimeout(() => this.onabort?.(), 0); + } +} + +class Database { + constructor() { + this.stores = new Map(); + this.keyPaths = new Map(); + } + createObjectStore(name, { keyPath }) { + this.stores.set(name, new Map()); + this.keyPaths.set(name, keyPath); + } + transaction(name) { + if (!this.stores.has(name)) throw new DOMException(`no store ${name}`, "NotFoundError"); + return new Transaction(this, name); + } +} + +globalThis.indexedDB = { + open(name) { + const r = new Request(null); + setTimeout(() => { + let db = databases.get(name); + if (!db) { + db = new Database(); + databases.set(name, db); + r.result = db; + r.onupgradeneeded?.(); + } + r.result = db; + r.onsuccess?.({ target: r }); + }, 0); + return r; + }, +}; + +const host = await import(pathToFileURL(join(here, "../js/host.js"))); +assert.equal(await host.storeOpen("indexeddb"), "indexeddb"); +const stored = (key) => databases.get("effectcraft").stores.get("entries").get(key); +const bytes = (s) => new TextEncoder().encode(s); +const text = (row) => row && new TextDecoder().decode(new Uint8Array(row.data)); + +// A healthy write resolves once committed, and the store has it then. +await host.storePut("project.json", bytes("v1"), 1); +assert.equal(text(stored("project.json")), "v1", "a resolved put is committed"); + +// The put's request succeeds, then the transaction aborts: the put fails, the old value stays. +fault = "abortAfterSuccess"; +await assert.rejects(host.storePut("project.json", bytes("v2"), 2), { name: "AbortError" }, "an aborted put is reported"); +assert.equal(text(stored("project.json")), "v1", "the aborted put was rolled back"); + +// The same for a delete: it fails and the entry stays. +fault = "abortAfterSuccess"; +await assert.rejects(host.storeDelete("project.json"), { name: "AbortError" }, "an aborted delete is reported"); +assert.equal(text(stored("project.json")), "v1", "the aborted delete was rolled back"); + +// A failed request aborts its transaction with the request's error. +fault = "requestError"; +await assert.rejects(host.storePut("project.json", bytes("v3"), 3), { name: "ConstraintError" }); +assert.equal(text(stored("project.json")), "v1"); + +// A request that throws at once (a value that can't be stored) fails the write. +fault = "throw"; +await assert.rejects(host.storePut("project.json", bytes("v4"), 4), { name: "DataCloneError" }); +assert.equal(text(stored("project.json")), "v1"); + +// The retry after a failure commits the latest value, and a later delete removes it. +await host.storePut("project.json", bytes("v5"), 5); +assert.equal(text(stored("project.json")), "v5"); +await host.storeDelete("project.json"); +assert.equal(stored("project.json"), undefined, "a resolved delete is committed"); +const all = await host.storeLoadAll(); +assert.deepEqual(all, [], "nothing else was written"); + +await tick(); +console.log("storage: ok"); From f61f8c995846d5c641128cd5c46b7485e7e4e0c7 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 21:38:54 -0400 Subject: [PATCH 023/279] Web: remembering and forgetting Media Browser folders waits for the commit too (#1242) --- apps/effectcraft-web/js/host.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/effectcraft-web/js/host.js b/apps/effectcraft-web/js/host.js index f113c2a2d..bab20f75a 100644 --- a/apps/effectcraft-web/js/host.js +++ b/apps/effectcraft-web/js/host.js @@ -570,7 +570,7 @@ async function openFolder(handle, remember) { if (remember) { try { const db = await handlesDb(); - await req(db.transaction("folders", "readwrite").objectStore("folders").put({ name, handle })); + await write(db, "folders", (s) => s.put({ name, handle })); } catch (e) { console.info("media browser: cannot remember the folder", e); } } return { name, entries }; @@ -622,7 +622,7 @@ export async function browseForget(name) { folders.delete(name); try { const db = await handlesDb(); - await req(db.transaction("folders", "readwrite").objectStore("folders").delete(name)); + await write(db, "folders", (s) => s.delete(name)); } catch {} } From c03cd79b5910d69ec05155866275d75661cbf25a Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 21:38:54 -0400 Subject: [PATCH 024/279] Docs: the IndexedDB storage test (#1242) --- docs/web.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/web.md b/docs/web.md index e73f7a899..7b5cd5c5b 100644 --- a/docs/web.md +++ b/docs/web.md @@ -432,6 +432,12 @@ a fake DOM: a picked file that can't be read is reported while the others are ad device-lost notice saves, flushes before reloading and reports a failed flush (`node apps/effectcraft-web/tests/page.mjs`). +`apps/effectcraft-web/tests/storage.mjs` checks the IndexedDB storage backend of `js/host.js` +with a fake IndexedDB: a put or delete resolves only once its transaction has committed, and one +whose transaction aborts after its request succeeded (or whose request fails or throws) rejects +and leaves the stored entry as it was, so the flush keeps it for a retry (#1242) +(`node apps/effectcraft-web/tests/storage.mjs`). + It also checks the M13.10 paths: viewer frames rendered in frame workers while scrubbing with the CPU renderer (project synced as diffs, event-loop gaps under 400 ms), a `wait: true` render that replies when done while the page keeps running, and the Media Browser over browser storage; and From 1b44c5c98aac86b3e2d9f5d7b33b7125c292cc63 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 09:15:08 +0200 Subject: [PATCH 025/279] Scripting: Stroke and Hue/Saturation parameters use After Effects' match-name numbers (part of #1405) --- crates/script/src/matchnames.rs | 44 ++++++++++++++++++++---- crates/script/src/tests.rs | 59 +++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 6 deletions(-) diff --git a/crates/script/src/matchnames.rs b/crates/script/src/matchnames.rs index 60a40e695..205697b60 100644 --- a/crates/script/src/matchnames.rs +++ b/crates/script/src/matchnames.rs @@ -399,7 +399,8 @@ pub fn shape_kind(name: &str) -> Option<&'static str> { } /// Effects whose documented parameter match names are not numbered in display order: -/// (effect id, param match id, number). Fill lists All Masks second but it is `-0007`. +/// (effect id, param match id, number). Fill lists All Masks second but it is `-0007`. Params of +/// these effects that After Effects doesn't have are numbered after the listed ones (#1405). const PARAM_NUMBERS: &[(&str, &str, usize)] = &[ ("ec.generate.fill", "fillMask", 1), ("ec.generate.fill", "color", 2), @@ -408,8 +409,42 @@ const PARAM_NUMBERS: &[(&str, &str, usize)] = &[ ("ec.generate.fill", "opacity", 5), ("ec.generate.fill", "invert", 6), ("ec.generate.fill", "allMasks", 7), + ("ec.generate.stroke", "path", 1), + ("ec.generate.stroke", "color", 2), + ("ec.generate.stroke", "brushSize", 3), + ("ec.generate.stroke", "brushHardness", 4), + ("ec.generate.stroke", "opacity", 5), + ("ec.generate.stroke", "spacing", 6), + ("ec.generate.stroke", "paintStyle", 7), + ("ec.generate.stroke", "start", 8), + ("ec.generate.stroke", "end", 9), + ("ec.generate.stroke", "allMasks", 10), + ("ec.generate.stroke", "strokeSequentially", 11), + // -0001 is a hidden parameter and -0003 Channel Range, which we don't have; the per-channel + // controls are ours (After Effects edits the selected channel with the Master ones). + ("ec.color.huesaturation", "channelControl", 2), + ("ec.color.huesaturation", "hue", 4), + ("ec.color.huesaturation", "saturation", 5), + ("ec.color.huesaturation", "lightness", 6), + ("ec.color.huesaturation", "colorize", 7), + ("ec.color.huesaturation", "colorizeHue", 8), + ("ec.color.huesaturation", "colorizeSaturation", 9), + ("ec.color.huesaturation", "colorizeLightness", 10), ]; +/// The `-NNNN` number of the param `id` at 0-based `pos` among `fx`'s `params`. +fn param_number(fx: &str, params: &[Node], id: &str, pos: usize) -> usize { + let listed = |p: &str| PARAM_NUMBERS.iter().find(|(f, q, _)| *f == fx && *q == p).map(|(_, _, n)| *n); + if let Some(n) = listed(id) { + return n; + } + let Some(last) = PARAM_NUMBERS.iter().filter(|(f, _, _)| *f == fx).map(|(_, _, n)| *n).max() else { + return pos + 1; + }; + let unlisted_before = params.iter().take(pos).filter(|c| listed(c.match_id()).is_none()).count(); + last + unlisted_before + 1 +} + /// Match name of the node at the end of `chain` (layer root's children → … → node). `effect` /// params are numbered `-0001…` in parameter order, like After Effects. pub fn node(chain: &[&Node]) -> String { @@ -432,11 +467,8 @@ pub fn node(chain: &[&Node]) -> String { let fx = parent.and_then(Node::as_group); match fx { Some(g) => { - let i = PARAM_NUMBERS - .iter() - .find(|(fx, p, _)| *fx == g.match_id && *p == id) - .map(|(_, _, n)| *n) - .unwrap_or_else(|| g.children.iter().position(|c| c.uid() == last.uid()).unwrap_or(0) + 1); + let pos = g.children.iter().position(|c| c.uid() == last.uid()).unwrap_or(0); + let i = param_number(&g.match_id, &g.children, id, pos); format!("{}-{i:04}", effect(&g.match_id)) } None => own(), diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 2c821e6ad..f47c212f0 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -126,6 +126,65 @@ fn fill_parameters_use_documented_match_names() { assert_eq!(o.result, json!("Color|All Masks|ADBE Fill-0007")); } +#[test] +fn stroke_parameters_use_after_effects_match_names() { + // #1405: `ADBE Stroke-0002` is Stroke's Color in After Effects, not All Masks. + let mut s = session(); + let o = ok( + &mut s, + r#"var c = app.project.items.addComp("S", 100, 100, 1, 1, 12); var l = c.layers.addSolid([1,1,1], "S", 100, 100, 1); + var fx = l.property("ADBE Effect Parade").addProperty("ADBE Stroke"), out = []; + for (var i = 1; i <= 11; i++) out.push(fx.property("ADBE Stroke-" + ("000" + i).slice(-4)).name); + out.push(fx.property("Color").matchName); out"#, + ); + assert_eq!( + o.result, + json!([ + "Path", + "Color", + "Brush Size", + "Brush Hardness", + "Opacity", + "Spacing", + "Paint Style", + "Start", + "End", + "All Masks", + "Stroke Sequentially", + "ADBE Stroke-0002" + ]) + ); +} + +#[test] +fn hue_saturation_parameters_use_after_effects_match_names() { + // #1405: `ADBE HUE SATURATION-0004` is Master Hue in After Effects. The per-channel controls, + // which After Effects doesn't expose, are numbered after its parameters so none takes an AE number. + let mut s = session(); + let o = ok( + &mut s, + r#"var c = app.project.items.addComp("H", 100, 100, 1, 1, 12); var l = c.layers.addSolid([1,1,1], "S", 100, 100, 1); + var fx = l.property("ADBE Effect Parade").addProperty("ADBE HUE SATURATION"), out = []; + var n = [2, 4, 5, 6, 7, 8, 9, 10]; + for (var i = 0; i < n.length; i++) out.push(fx.property("ADBE HUE SATURATION-" + ("000" + n[i]).slice(-4)).name); + out.push(fx.property("Red Hue").matchName); out"#, + ); + assert_eq!( + o.result, + json!([ + "Channel Control", + "Master Hue", + "Master Saturation", + "Master Lightness", + "Colorize", + "Colorize Hue", + "Colorize Saturation", + "Colorize Lightness", + "ADBE HUE SATURATION-0011" + ]) + ); +} + #[test] fn undo_steps_without_a_group() { let mut s = session(); From 69d17d89afead1ce66d10429c661fb74be5571bc Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 13:25:46 +0200 Subject: [PATCH 026/279] Keyframes: a path pasted onto Position becomes a motion path (fixes #1425) --- crates/engine/src/commands/anim.rs | 91 +++++++++++++++++++++++++++- crates/engine/src/commands/edit.rs | 7 +++ crates/engine/src/tests_menu_cmds.rs | 54 +++++++++++++++++ docs/target-app-parity.md | 3 + 4 files changed, 153 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/commands/anim.rs b/crates/engine/src/commands/anim.rs index 3ea27b0df..325845115 100644 --- a/crates/engine/src/commands/anim.rs +++ b/crates/engine/src/commands/anim.rs @@ -1,8 +1,8 @@ //! Keyframe clipboard, selection, nudging, direct key/ease edits (Graph Editor handles) and key //! inspection for the Keyframe Velocity / Keyframe Interpolation dialogs. -use effectcraft_keyframe::{Ease, Interp, Keyframe, key_at, retime_roving, side_ease}; -use effectcraft_project::{LayerId, Property, Uid}; +use effectcraft_keyframe::{Ease, Interp, Keyframe, ShapePath, Value as KValue, key_at, retime_roving, side_ease}; +use effectcraft_project::{ItemId, LayerId, Property, Uid}; use effectcraft_time::Tick; use serde_json::{Value, json}; @@ -61,6 +61,13 @@ fn paste(s: &mut Session, p: &Value) -> Result { if clip.is_empty() { return Err(bad("keys.paste", "no keyframes have been copied")); } + // A copied Mask Path / shape Path key pasted onto Position becomes a motion path (#1425). + if let [c] = clip.as_slice() + && let Some(KValue::Path(path)) = c.keys.first().map(|k| &k.value) + && let Some(target) = path_target(s, p)? + { + return paste_path(s, p, target, path); + } let cid = comp_id(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let t = time_p(s, p, Some(comp)); @@ -134,6 +141,86 @@ fn paste(s: &mut Session, p: &Value) -> Result { }) } +/// The spatial property (Position, Anchor Point…) a copied path pastes onto as a motion path: +/// the `prop` / `path` given, else a selected one of the target layers. `None` when there's none. +pub(super) fn path_target(s: &Session, p: &Value) -> Result> { + let (cid, targets) = layers_p(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let motion = |pr: &Property| pr.spatial && !pr.static_only && matches!(pr.value, KValue::Vec2(_) | KValue::Vec3(_)); + let explicit = targets.first().and_then(|lid| { + let l = comp.layer(*lid)?; + let pr = match p.get("prop").and_then(Value::as_u64) { + Some(u) => l.props.find(u), + None => l.props.prop(str_p(p, "path")?), + }?; + Some((*lid, pr)) + }); + if p.get("prop").is_some() || p.get("path").is_some() { + return Ok(explicit.filter(|(_, pr)| motion(pr)).map(|(lid, pr)| (cid, lid, pr.uid))); + } + let selected = s + .state + .selected_props + .iter() + .filter(|(lid, _)| targets.contains(lid)) + .find(|(lid, uid)| comp.layer(*lid).and_then(|l| l.props.find(*uid)).is_some_and(motion)); + Ok(selected.map(|(lid, uid)| (cid, *lid, *uid))) +} + +/// Paste a mask or shape path onto a spatial property as After Effects does: a keyframe per +/// vertex with the path's tangents, from the CTI over 2 s (a closed path ends back on its first +/// vertex), the inner keys roving so the layer moves at one speed. Like After Effects, the +/// vertices are pasted as they are (layer-space numbers, not converted to the target's space). +pub(super) fn paste_path(s: &mut Session, p: &Value, (cid, lid, uid): (ItemId, LayerId, Uid), path: &ShapePath) -> Result { + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let t = time_p(s, p, Some(comp)); + let target = comp.layer(lid).ok_or(EngineError::NoComp)?; + let mut order: Vec = (0..path.vertices.len()).collect(); + if path.closed && !order.is_empty() { + order.push(0); + } + if order.is_empty() { + return Err(bad("keys.paste", "the copied path has no points")); + } + let span = Tick::from_seconds_f64(2.0).0 / i64::try_from(order.len().saturating_sub(1).max(1)).unwrap_or(i64::MAX); + let at = |xy: Option<&[f64; 2]>| { + let [x, y] = xy.copied().unwrap_or_default(); + [x, y, 0.0] + }; + let last = order.len() - 1; + let keys: Vec<(Tick, KValue, [f64; 3], [f64; 3], bool)> = order + .iter() + .enumerate() + .map(|(i, &v)| { + let [x, y, z] = at(path.vertices.get(v)); + let value = match target.props.find(uid).map(|pr| &pr.value) { + Some(KValue::Vec2(_)) => KValue::Vec2([x, y]), + _ => KValue::Vec3([x, y, z]), + }; + let time = target.layer_time(t + Tick(span.saturating_mul(i64::try_from(i).unwrap_or(i64::MAX)))); + (time, value, at(path.in_tangents.get(v)), at(path.out_tangents.get(v)), i != 0 && i != last) + }) + .collect(); + s.edit("Paste Keyframes", merge_p(p), |proj, st| { + let l = super::layer_mut(proj, cid, lid)?; + let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.paste", "no property"))?; + let mut sel = vec![]; + for (time, value, spatial_in, spatial_out, roving) in keys { + let k = Keyframe { spatial_in, spatial_out, spatial_auto: false, roving, ..Keyframe::new(time, value) }; + match pr.keys.binary_search_by(|x| x.time.cmp(&time)) { + Ok(i) => pr.keys[i] = k, + Err(i) => pr.keys.insert(i, k), + } + sel.push(time); + } + // Re-timing keeps the keys' order, so the pasted keys are found again by index. + let pasted: Vec = pr.keys.iter().enumerate().filter(|(_, k)| sel.contains(&k.time)).map(|(i, _)| i).collect(); + retime_roving(&mut pr.keys, pr.spatial); + st.selected_keys = pasted.iter().filter_map(|&i| pr.keys.get(i)).map(|k| KeyRef { layer: lid, prop: uid, time: k.time }).collect(); + Ok(json!(st.selected_keys.len())) + }) +} + /// Select every keyframe of the selected properties, or of the given / selected layers. fn select_all(s: &mut Session, p: &Value) -> Result { let (cid, mut layers) = layers_p(s, p)?; diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index 06ba43b1c..10bb10d15 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -495,6 +495,13 @@ fn paste(s: &mut Session, p: &Value) -> Result { if let Some(clip) = s.state.link_clipboard.clone() { return paste_links(s, clip); } + // A copied Mask Path / shape Path pasted onto Position becomes a motion path (#1425). + if let [(_, effectcraft_keyframe::Value::Path(path))] = s.state.value_clipboard.as_slice() + && let Some(target) = super::anim::path_target(s, p)? + { + let path = path.clone(); + return super::anim::paste_path(s, p, target, &path); + } if !s.state.value_clipboard.is_empty() { return paste_properties(s, p); } diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index ec63651f6..6310e2520 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -1396,6 +1396,60 @@ fn keys_from_several_layers_paste_in_order_and_paste_reversed_respects_stretch() assert_eq!(vals, vec![10.0, 0.0], "reversed"); } +/// #1425: a mask path (its value, or a keyframe of it) pasted onto Position becomes a motion +/// path, as in After Effects: a key per vertex with the path's tangents, over 2 s from the CTI, +/// the inner keys roving; a closed path returns to its start. AE pastes the layer-space numbers +/// as they are, so the source layer's and the target parent's transforms don't move them. +#[test] +fn a_copied_path_pastes_onto_position_as_a_motion_path() { + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + // A moved and scaled source: converting to comp space would give 2v + (200, 100). + s.execute("prop.set", json!({"layer": a, "path": "transform/position", "value": [400, 200]})).unwrap(); + s.execute("prop.set", json!({"layer": a, "path": "transform/scale", "value": [200, 200]})).unwrap(); + let rect = json!([[0, 0], [40, 0], [40, 40], [0, 40]]); + s.execute("mask.new", json!({"layer": a, "vertices": rect.clone(), "outTangents": [[10, 0]], "closed": true})).unwrap(); + // B's parent would shift a converted Position by (0, −50). + let parent = solid(&mut s, "#0000ff"); + s.execute("prop.set", json!({"layer": parent, "path": "transform/position", "value": [100, 100]})).unwrap(); + let b = solid(&mut s, "#00ff00"); + s.execute("layer.setParent", json!({"layers": [b], "parent": parent})).unwrap(); + let motion = |s: &Session, id: u64| { + let l = layer(s, id); + let pr = l.props.prop("transform/position").unwrap().clone(); + pr.keys.iter().map(|k| (l.comp_time(k.time).seconds(), k.value.as_vec3(), k.spatial_out, k.roving)).collect::>() + }; + let check = |keys: Vec<(f64, [f64; 3], [f64; 3], bool)>, t0: f64| { + let want = [[0.0, 0.0], [40.0, 0.0], [40.0, 40.0], [0.0, 40.0], [0.0, 0.0]]; + assert_eq!(keys.len(), 5, "a key per vertex and the closing one: {keys:?}"); + for (i, ((t, v, _, roving), w)) in keys.iter().zip(want).enumerate() { + assert!((v[0] - w[0]).abs() < 1e-6 && (v[1] - w[1]).abs() < 1e-6, "key {i}: {v:?}"); + assert_eq!(*roving, i != 0 && i != 4, "key {i} roving"); + assert!(*t > t0 - 1e-6 && *t < t0 + 2.0 + 1e-6, "key {i} at {t}"); + } + assert!((keys[0].0 - t0).abs() < 1e-6 && (keys[4].0 - t0 - 2.0).abs() < 1e-6, "2 s from the CTI"); + assert!(keys.windows(2).all(|w| w[0].0 < w[1].0)); + assert_eq!(keys[0].2[..2], [10.0, 0.0], "the path's tangent"); + }; + // A Mask Path keyframe, pasted at 1 s. + s.execute("prop.toggleAnimation", json!({"layer": a, "path": "masks/#1/path"})).unwrap(); + s.execute("prop.select", json!({"layer": a, "path": "masks/#1/path"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + s.execute("time.set", json!({"time": 1.0})).unwrap(); + s.execute("prop.select", json!({"layer": b, "path": "transform/position"})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + check(motion(&s, b), 1.0); + // The same path as an unanimated mask, pasted at 5 s onto an unparented layer. + s.execute("mask.new", json!({"layer": a, "vertices": rect, "outTangents": [[10, 0]], "closed": true})).unwrap(); + s.execute("prop.select", json!({"layer": a, "path": "masks/#2/path"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + let c = solid(&mut s, "#ffffff"); + s.execute("time.set", json!({"time": 5.0})).unwrap(); + s.execute("prop.select", json!({"layer": c, "path": "transform/position"})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + check(motion(&s, c), 5.0); +} + #[test] fn j_and_k_stop_at_keys_markers_and_the_work_area() { let mut s = comp(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 89464bcd8..2301fae7c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -746,6 +746,9 @@ them: scripts, ScriptUI panels, effect plug-ins, with a guide to writing a panel way and move them however slowly they are dragged; the same Shift / Ctrl / Ctrl+Alt clicks. - **Paste** (M5.13): keys copied from several layers paste onto as many layers in order; Paste Reversed Keyframes follows Paste's rules (stretch, target property, Hold-only properties). + A Mask Path or shape Path (its value or a key) pasted onto Position becomes a motion path: a + key per vertex with the path's tangents, over 2 s from the CTI, the inner keys roving, the + vertices pasted as they are (layer space, as in After Effects) (#1425). - **J / K and Info** (M5.14): J / K also stop at the work area; the Info panel shows a selected key's property, time and value. From 66a29505a580d91c7ee6b4eba123e3cc7c88a9fa Mon Sep 17 00:00:00 2001 From: zitann2020 Date: Thu, 15 Oct 2026 00:19:47 +0900 Subject: [PATCH 027/279] i18n(ja): the Settings dialog in Japanese Co-Authored-By: DeepSeek V4.1 Flash --- crates/ui-egui/src/i18n/ui.rs | 183 ++++++++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 3a4dd4aff..7ac3943de 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -631,6 +631,189 @@ const JAPANESE: &[(&str, &str)] = &[ ("After Effects project", "After Effectsプロジェクト"), ("Lottie animation", "Lottieアニメーション"), ("Project", "プロジェクト"), + ("General", "一般"), + ("Language", "言語"), + ("Levels of Undo", "取り消しレベル"), + ("Path Point and Handle Size", "パスポイントとハンドルサイズ"), + ("Recent Projects Shown", "最近使用したプロジェクトの表示数"), + ("Options", "オプション"), + ("Show Tool Tips", "ツールヒントを表示"), + ("Create Layers at Composition Start Time", "レイヤーをコンポジションの開始時間に作成"), + ("Switches Affect Nested Comps", "ネストしたコンポジションにスイッチを適用"), + ("Default Spatial Interpolation to Linear", "初期設定の空間補間法にリニアを使用"), + ("Preserve Constant Vertex and Feather Point Count when Editing Masks", "マスク編集時に一定の頂点数とぼかしポイント数を保持"), + ("Synchronize Time of All Related Items", "すべての関連アイテムの時間を同期"), + ("Expression Pick Whip Writes Compact English", "ピックウィップによる簡潔な英語でのエクスプレッションの記述"), + ("Create Split Layers Above Original Layer", "元のレイヤーの上に分割したレイヤーを作成"), + ("Use System Color Picker", "システムのカラーピッカーを使用"), + ( + "EffectCraft always uses its own colour picker (the same on every platform and the web).", + "EffectCraftは常に独自のカラーピッカーを使用します(すべてのプラットフォームとWeb版で同じです)。", + ), + ("Startup & Repair", "起動と修復"), + ("Show Home Screen When Launching", "起動時にホーム画面を表示"), + ("Show Home Screen When Opening a Project", "プロジェクトを開くときにホーム画面を表示"), + ("Offer to Open the Latest Auto-Save After a Crash", "クラッシュ後に最新の自動保存を開くか確認する"), + ("Window Graphics", "ウィンドウグラフィックス"), + ("Automatic", "自動"), + ( + "Window Graphics applies from the next launch. When the graphics driver stops EffectCraft as its window opens, the next launch switches to OpenGL.", + "ウィンドウグラフィックスは次回の起動から適用されます。ウィンドウを開くときにグラフィックスドライバーがEffectCraftを停止させた場合、次回の起動ではOpenGLに切り替わります。", + ), + ("Repair", "修復"), + ("Reset Settings", "設定をリセット"), + ("Reset Keyboard Shortcuts", "キーボードショートカットをリセット"), + ("Empty Disk Cache", "ディスクキャッシュを空にする"), + ("New Project Loads Template", "新規プロジェクト読み込みテンプレート"), + ("Template Project", "テンプレートプロジェクト"), + ("Auto-Save", "自動保存"), + ("Automatically Save Projects", "プロジェクトを自動保存"), + ("Save Every", "保存の間隔"), + ("Minutes", "分"), + ("Maximum Project Versions", "プロジェクトバージョンの最大数"), + ("Auto-Save Location", "自動保存の場所"), + ("Next to Project", "プロジェクトの横"), + ("Custom Location", "ユーザー定義の場所"), + ("Save When Starting Render Queue", "レンダーキューの開始時に保存"), + ("Save Now", "今すぐ保存"), + ("Motion Path", "モーションパス"), + ("All Keyframes", "すべてのキーフレーム"), + ("No Motion Path", "モーションパスなし"), + ("No More Than (Seconds)", "次の秒数以下"), + ("No More Than (Keyframes)", "次のキーフレーム以下"), + ("Seconds", "秒"), + ("Keyframes", "キーフレーム"), + ("Display", "表示"), + ("Show Rendering Progress in Info Panel and Flowchart", "情報パネルとフローチャートにレンダリングの進行状況を表示"), + ("Hardware Accelerate Composition, Layer and Footage Panels", "ハードウェアによるコンポジション、レイヤー、フッテージパネルの高速化"), + ("Panels are always drawn by the GPU (wgpu).", "パネルは常にGPU(wgpu)で描画されます。"), + ("Previews", "プレビュー"), + ("Adaptive Resolution Limit", "適応解像度の上限"), + ("Show Internal Wireframes", "内部ワイヤーフレームを表示"), + ("Cache Frames When Idle", "アイドル時にフレームをキャッシュ"), + ("Viewer Zoom Quality", "ズームの画質"), + ("Faster", "高速"), + ("More Accurate", "精度を優先"), + ("Display Color Space", "表示色空間"), + ("GPU Information...", "GPU 情報..."), + ("Appearance", "外観"), + ("Brightness", "明るさ"), + ("UI Scale", "UI スケール"), + ("Labels and Colors", "ラベルとカラー"), + ("Use Label Color for Layer Handles and Paths", "レイヤーハンドルとパスにラベルカラーを使用"), + ("Use Label Color for Related Tabs", "ラベルカラーを関連するタブに使用"), + ("Cycle Mask Colors", "新規マスクに別のカラーを使用"), + ("Use Gradients", "グラデーションを使用"), + ("Menu Bar", "メニューバー"), + ("Grids & Guides", "グリッド&ガイド"), + ("Gridline Every", "グリッドの間隔"), + ("Subdivisions", "分割数"), + ("Style", "スタイル"), + ("Lines", "線"), + ("Dashed Lines", "破線"), + ("Dots", "ドット"), + ("Horizontal", "水平方向"), + ("Vertical", "垂直方向"), + ("Safe Margins", "セーフマージン"), + ("Action-safe", "アクションセーフ"), + ("Title-safe", "タイトルセーフ"), + ("Labels", "ラベル"), + ("Text Engine", "テキストエンジン"), + ("Latin", "ラテン"), + ("South Asian and Middle Eastern", "南アジアおよび中東"), + ("Show Font Preview", "フォントのプレビューを表示"), + ("Number of Recent Fonts to Display", "最近使用したフォントの表示数"), + ("Show Font Names in English", "フォント名を英語で表示"), + ("Still Footage", "静止画フッテージ"), + ("Length of Composition", "コンポジションの長さ"), + ("Duration (Seconds)", "デュレーション(秒)"), + ("Still Duration", "静止画のデュレーション"), + ("Sequence Footage", "シーケンスフッテージ"), + ("Report Missing Frames", "不明なフレームをレポート"), + ("Automatically Reload Footage", "フッテージの自動再読み込み"), + ("Off", "オフ"), + ("Non-Sequence Footage", "非シーケンスフッテージ"), + ("All Footage", "すべてのフッテージ"), + ("Ask User", "ユーザーに確認"), + ("Guess", "推測"), + ("Ignore", "無視"), + ("Straight", "そのまま"), + ("Premultiplied", "プリマルチプライ"), + ("Default Drag Import As", "複数アイテムのドラッグ読み込みの初期設定"), + ("Footage", "フッテージ"), + ("Composition - Retain Layer Sizes", "コンポジション - レイヤーサイズを保持"), + ("Export", "書き出し"), + ("Default Output Folder", "既定の出力フォルダー"), + ("Segment Sequences", "シーケンスをセグメント化"), + ("Files per Segment", "セグメントあたりのファイル数"), + ("files", "ファイル"), + ("Segment Movie Files", "ムービーファイルをセグメント化"), + ("Segment Size", "セグメントサイズ"), + ("Append Bit Depth to File Name", "ファイル名にビット深度を追加"), + ("Audio", "オーディオ"), + ("Audio Hardware", "オーディオハードウェア"), + ("Preview Sample Rate", "サンプルレート"), + ("Audio Output Mapping", "オーディオ出力マッピング"), + ("Left", "左"), + ("Right", "右"), + ("channel", "チャンネル"), + ("Disk", "ディスク"), + ("Disk Cache", "ディスクキャッシュ"), + ("Enable Disk Cache", "ディスクキャッシュを有効にする"), + ("Maximum Disk Cache Size", "最大ディスクキャッシュサイズ"), + ("Disk Cache Folder", "ディスクキャッシュフォルダー"), + ("Media Cache", "メディアキャッシュ"), + ("Database and Cache Folder", "データベースとキャッシュのフォルダー"), + ("Conformed Audio Folder", "適合オーディオフォルダー"), + ("Memory & CPU", "メモリと CPU"), + ("RAM Reserved for Other Applications", "他のアプリケーション用に確保する RAM"), + ("Cache Budgets", "キャッシュの割り当て"), + ("Layer Cache", "レイヤーキャッシュ"), + ("Footage Frame Cache", "フッテージフレームキャッシュ"), + ("Preview (RAM) Cache", "プレビュー(RAM)キャッシュ"), + ("Reduce Cache Size When System Is Low on Memory", "システムのメモリが不足しているときにキャッシュサイズを減らす"), + ("Video", "ビデオ"), + ("Enable Video Preview Output", "ビデオプレビュー出力を有効にする"), + ("Video Device", "ビデオデバイス"), + ("Floating Window", "フローティングウィンドウ"), + ("Full Screen", "全画面"), + ("Video Output During Playback", "再生中のビデオ出力"), + ("Mirror on Computer Monitor", "コンピューターモニターにミラーリング"), + ("Disable Video Output When in Background", "バックグラウンド動作時のビデオ出力を無効にする"), + ("Default 3D Renderer", "既定の 3D レンダラー"), + ("Show 3D Reference Axes", "3D参照軸を表示"), + ("Extended Viewer", "拡張ビューアー"), + ("Realtime Shadows in Draft", "ドラフトのリアルタイムシャドウ"), + ("Scripting & Expressions", "スクリプトとエクスプレッション"), + ("Application Scripting", "アプリケーションのスクリプト"), + ("Allow Scripts to Write Files and Access Network", "スクリプトによるファイルへの書き込みとネットワークへのアクセスを許可"), + ("Warn User When Executing Files", "ファイルの実行時にカーソルに警告"), + ("Enable JavaScript Debugger", "JavaScript デバッガーを使用"), + ("Expressions Editor", "エクスプレッションエディター"), + ("Font Size", "フォントサイズ"), + ("Syntax Highlighting", "構文のハイライト"), + ("Line Numbers", "行番号を表示"), + ("Auto-complete", "オートコンプリート"), + ("Bracket Matching", "対応する括弧をハイライト"), + ("Word Wrap", "折り返し"), + ("Show Expression Error Banner", "式のエラーがある場合に警告バナーを表示"), + ( + "Expressions use the JavaScript engine. Script errors report their file and line in the Script Console; there is no step debugger.", + "エクスプレッションはJavaScriptエンジンを使用します。スクリプトのエラーはファイルと行をスクリプトコンソールに報告します。ステップ実行デバッガーはありません。", + ), + ("Roto Brush", "ロトブラシ"), + ("Segmentation Model", "セグメンテーションモデル"), + ( + "Roto Brush 2.0 and 3.0 use the model chosen here (Version 1.0 always uses the classic engine). Models are open source, downloaded only when you ask, and checked against their published SHA-256.", + "ロトブラシ2.0と3.0はここで選んだモデルを使用します(バージョン1.0は常に従来のエンジンを使用します)。モデルはオープンソースで、要求したときだけダウンロードされ、公開されているSHA-256と照合されます。", + ), + ("Face Tracking", "フェイストラッキング"), + ("Face Model", "フェイスモデル"), + ( + "Face tracking in the Tracker panel (Outline Only and Detailed Features) uses the model chosen here. Models are open source, downloaded only when you ask, and checked against their published SHA-256.", + "トラッカーパネルのフェイストラッキング(輪郭のみと詳細機能)はここで選んだモデルを使用します。モデルはオープンソースで、要求したときだけダウンロードされ、公開されているSHA-256と照合されます。", + ), + ("Match System", "システムに合わせる"), ("Total Offset", "合計オフセット"), ("Per Layer", "レイヤーごと"), ("Track", "トラック"), From eb95f732e5ae2aabda0443ec941173dc2960d9bc Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 14 Oct 2026 11:33:49 -0400 Subject: [PATCH 028/279] Media: millisecond-rounded MP4 frame times read at the rate they average to (#1442) Phones and messaging apps store frame times rounded to whole milliseconds (33/34 ms at 30 fps, 16/17 ms at 60 fps). FilmCraft's reader takes the median duration, so such clips imported at 30.303 or 58.824 fps, and New Comp from Selection and renders followed that rate. When the median is no standard rate, the durations near it are averaged and snapped to the standard rate within 0.5 %; exact and variable rates keep the reader's estimate. --- crates/media/src/probe.rs | 54 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index bae714c81..dad34fd13 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -19,8 +19,10 @@ pub fn probe(path: impl AsRef) -> Result { // A movie is read in place (its index), not loaded whole. if let Some(src) = crate::stream::probe_source(path) { let mut f = footage_from_info(&path.to_string_lossy(), src?.info()); - let turn = crate::stream::movie_header(path).map_or(Orientation::NoTransforms, |h| video_turn(&h)); + let header = crate::stream::movie_header(path); + 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)); return Ok(f); } let bytes = std::fs::read(path).map_err(|e| MediaError::Io(format!("{}: {e}", path.display())))?; @@ -95,12 +97,24 @@ pub fn probe_bytes(path: &str, bytes: Arc<[u8]>) -> Result { } let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()); let turn = video_turn(&bytes); + let rate = video_rate(&bytes); let src = filmcraft_codecs::open_bytes(&name, bytes)?; let mut f = footage_from_info(path, src.info()); turn_footage(&mut f, turn); + correct_rate(&mut f, rate); Ok(f) } +/// 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) { + if f.kind == FootageKind::Video + && let Some(rate) = rate + { + f.frame_rate = rate; + } +} + /// The size as displayed: a movie shot with the camera on its side trades width and height. fn turn_footage(f: &mut Footage, turn: Orientation) { if swaps_sides(turn) { @@ -254,3 +268,41 @@ pub(crate) fn video_turn(bytes: &[u8]) -> Orientation { (false, _, false) => Orientation::Rotate270, } } + +/// The frame rate of an MP4/MOV movie's video track when the reader's own estimate, the median +/// frame duration, misreads it. Phones and messaging apps store frame times rounded to whole +/// milliseconds: a 30 fps clip's frames last 33 or 34 ms and a 60 fps clip's 16 or 17 ms, whose +/// median reads as 30.303 or 58.824 fps. `None` for other containers and for movies the median +/// reads right. +fn video_rate(bytes: &[u8]) -> Option { + let file = filmcraft_isobmff::open(bytes).ok()?; + let t = file.tracks.iter().find(|t| t.kind == filmcraft_isobmff::TrackKind::Video && !t.samples.is_empty())?; + averaged_rate(t.timescale, t.samples.iter().take(240).map(|s| s.duration)) +} + +/// The standard rate ([`FrameRate::COMMON`]) within 0.5 % of what the frame durations near their +/// median average to (`durations` in `1 / timescale` s); `None` when the median duration is a +/// standard rate already, or when the average is no standard rate (variable or unusual rates keep +/// the reader's estimate). +fn averaged_rate(timescale: u32, durations: impl Iterator) -> Option { + let ts = i128::from(timescale); + let mut durs: Vec = durations.filter(|&d| d > 0).map(u64::from).collect(); + durs.sort_unstable(); + let median = *durs.get(durs.len() / 2)?; + if FrameRate::COMMON.iter().any(|r| ts * i128::from(r.den) == i128::from(median) * i128::from(r.num)) { + return None; + } + // Durations within a quarter of the median (widened, so extreme durations can't overflow). + let (n, sum) = durs.iter().filter(|&&d| d.abs_diff(median).saturating_mul(4) <= median).fold((0u64, 0u64), |(n, s), &d| (n + 1, s.saturating_add(d))); + if sum == 0 { + return None; + } + let fps = timescale as f64 * n as f64 / sum as f64; + FrameRate::COMMON + .into_iter() + .map(|r| (r, (fps / r.as_f64() - 1.0).abs())) + .filter(|&(_, off)| off <= 0.005) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(r, _)| r) +} + From 36a905293b65d4487528456cf6d308e89fd7283d Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 14 Oct 2026 11:33:49 -0400 Subject: [PATCH 029/279] Media: tests for the averaged MP4 frame rate (#1442) --- crates/media/src/probe.rs | 45 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index dad34fd13..2793be251 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -306,3 +306,48 @@ fn averaged_rate(timescale: u32, durations: impl Iterator) -> Option .map(|(r, _)| r) } +#[cfg(test)] +mod tests { + use super::*; + + /// Durations (in 1/`timescale` s) of `n` frames at `rate` whose times are rounded to the timescale. + fn rounded(rate: FrameRate, timescale: i64, n: i64) -> Vec { + let at = |k: i64| (2 * k * timescale * rate.den + rate.num) / (2 * rate.num); + (0..n).map(|k| (at(k + 1) - at(k)) as u32).collect() + } + + #[test] + fn millisecond_rounded_frame_times_average_to_their_rate() { + assert_eq!(rounded(FrameRate::FPS_30, 1000, 6), [33, 34, 33, 33, 34, 33]); + assert_eq!(averaged_rate(1000, rounded(FrameRate::FPS_30, 1000, 120).into_iter()), Some(FrameRate::FPS_30)); + assert_eq!(averaged_rate(1000, rounded(FrameRate::FPS_60, 1000, 240).into_iter()), Some(FrameRate::FPS_60)); + // NTSC rates stay NTSC, not the round rate next to them. + for r in [FrameRate::FPS_23_976, FrameRate::FPS_29_97, FrameRate::FPS_59_94] { + assert_eq!(averaged_rate(1000, rounded(r, 1000, 240).into_iter()), Some(r), "{r:?}"); + } + // A short clip: its few frames average to the rate as well. + assert_eq!(averaged_rate(1000, [33, 34, 33].into_iter()), Some(FrameRate::FPS_30)); + } + + #[test] + fn exact_and_variable_rates_keep_the_readers_estimate() { + // A median that is a standard rate already reads right. + assert_eq!(averaged_rate(30000, rounded(FrameRate::FPS_29_97, 30000, 240).into_iter()), None); + assert_eq!(averaged_rate(90000, [3000; 10].into_iter()), None); + assert_eq!(averaged_rate(600, [20, 20, 19, 21, 20].into_iter()), None); + // Durations averaging to no standard rate (22.2 fps). + assert_eq!(averaged_rate(1000, [40, 45, 50].repeat(20).into_iter()), None); + // A truly 30.3 fps clip is not pulled to 30 (1 % off). + assert_eq!(averaged_rate(1000, [33; 30].into_iter()), None); + } + + #[test] + fn degenerate_durations_have_no_rate() { + assert_eq!(averaged_rate(1000, std::iter::empty()), None); + assert_eq!(averaged_rate(1000, [0, 0, 0].into_iter()), None); + assert_eq!(averaged_rate(0, [33, 34, 33].into_iter()), None); + assert_eq!(averaged_rate(u32::MAX, [u32::MAX, u32::MAX - 1, 1, 0].into_iter()), None); + assert_eq!(averaged_rate(u32::MAX, [u32::MAX; 300].into_iter()), None); + assert_eq!(video_rate(b"not a movie"), None); + } +} From 8b536e597bab07116fb2776f23473549e868e329 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 14 Oct 2026 11:37:36 -0400 Subject: [PATCH 030/279] Export: millisecond-rounded footage renders at its rate, each frame once (#1442) --- crates/export/tests/ms_rounded_footage.rs | 148 ++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 crates/export/tests/ms_rounded_footage.rs diff --git a/crates/export/tests/ms_rounded_footage.rs b/crates/export/tests/ms_rounded_footage.rs new file mode 100644 index 000000000..3d6880232 --- /dev/null +++ b/crates/export/tests/ms_rounded_footage.rs @@ -0,0 +1,148 @@ +//! Footage whose frame times are rounded to whole milliseconds (#1442), as phones and messaging +//! apps write MP4s: a 30 fps clip's frames last 33 or 34 ms, a 60 fps clip's 16 or 17 ms. It +//! imports at its true rate, a comp made from it (New Comp from Selection takes the footage's +//! rate) renders every source frame once, and the H.264 output has that rate and frame count. +//! +//! The fixtures are written here with FilmCraft's own encoder and muxer: 128×32 frames showing +//! their index in binary (eight bars, white for a set bit). + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use effectcraft_color::Label; +use effectcraft_export::render_queue::{OutputFormat, OutputModule, RenderSettings, TimeSpan}; +use effectcraft_export::{Job, export}; +use effectcraft_media::MediaPool; +use effectcraft_project::{Comp, Footage, ItemKind, LayerSource, Project, build}; +use effectcraft_raster::Image; +use effectcraft_time::{FrameRate, Tick}; +use filmcraft_isobmff::{AvcConfig, Brand, Mp4Writer, SampleEntry, TrackConfig, WriteSample, WriterOptions}; + +const W: u32 = 128; +const H: u32 = 32; +const BARS: u32 = 8; + +fn out_dir(name: &str) -> PathBuf { + let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/export").join(name); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).expect("mkdir"); + d +} + +/// Durations of `n` frames at `rate` with their times rounded to whole milliseconds. +fn ms_rounded(rate: FrameRate, n: i64) -> Vec { + let at = |k: i64| (2 * k * 1000 * rate.den + rate.num) / (2 * rate.num); + (0..n).map(|k| u32::try_from(at(k + 1) - at(k)).expect("duration")).collect() +} + +/// An H.264 MP4 (video track timescale 1000) with one frame per entry of `durations` (ms); +/// frame k shows k in binary. +fn write_movie(path: &Path, durations: &[u32]) { + let mut cfg = filmcraft_h264enc::EncoderConfig::new(W, H, 30, 1); + cfg.format = filmcraft_h264enc::PacketFormat::LengthPrefixed; + cfg.aud = false; + cfg.bframes = 0; + cfg.keyint = 30; + cfg.scenecut = false; + let mut enc = filmcraft_h264enc::Encoder::new(cfg).expect("encoder"); + let (u, v) = (vec![128u8; (W * H / 4) as usize], vec![128u8; (W * H / 4) as usize]); + let mut packets = Vec::new(); + for k in 0..durations.len() { + let y: Vec = (0..W * H).map(|i| if (k >> (i % W * BARS / W)) & 1 == 1 { 235 } else { 16 }).collect(); + let frame = filmcraft_h264enc::YuvFrame { y: &y, u: &u, v: &v, y_stride: W as usize, uv_stride: W as usize / 2 }; + packets.extend(enc.try_encode(&frame, k as i64).expect("encode")); + } + packets.extend(enc.flush()); + assert_eq!(packets.len(), durations.len(), "one access unit per frame"); + let avcc = AvcConfig::parse(&enc.avcc()).expect("avcC"); + let file = std::fs::File::create(path).expect("create"); + let mut mux = Mp4Writer::new(file, WriterOptions::new(Brand::Mp4)).expect("mux"); + let t = mux.add_track(TrackConfig::new(SampleEntry::avc(avcc, W as u16, H as u16), 1000)).expect("track"); + for (p, &duration) in packets.iter().zip(durations) { + mux.write_sample(t, WriteSample { data: &p.data, duration, composition_offset: 0, is_sync: p.keyframe }).expect("sample"); + } + mux.finish().expect("finish"); +} + +/// The frame index a decoded frame shows. +fn shown(img: &Image) -> u32 { + (0..BARS).filter(|b| img.data[img.idx(b * W / BARS + W / BARS / 2, H / 2)][1] > 0.5).map(|b| 1 << b).sum() +} + +fn frame_count(f: &Footage) -> i64 { + f.frame_rate.frame_at(f.duration - Tick(1)) + 1 +} + +/// Every frame of a movie in order, as indices. +fn frames_of(f: &Footage) -> Vec { + let pool = MediaPool::new(); + (0..frame_count(f)).map(|k| shown(&pool.frame_at(f, f.frame_rate.tick_of(k)).expect("decode"))).collect() +} + +/// Import `path`, make a comp from it as New Comp from Selection does (the footage's size, rate +/// and duration), render it to H.264 and return the output's footage. +fn comp_from_footage(path: &Path, out: &Path) -> Footage { + let footage = effectcraft_media::probe(path).expect("probe"); + let mut p = Project::default(); + let fid = p.add_item("clip.mp4", Label::Aqua, None, ItemKind::Footage(footage.clone())); + let rate = p.item(fid).and_then(|it| it.frame_rate()).expect("a movie has a rate"); + let mut comp = Comp::new(W, H, rate, footage.duration); + comp.layers.push(build::layer(&mut p, &comp, "clip.mp4", LayerSource::Footage { item: fid }, (W, H), None)); + let cid = p.add_item("clip", Label::Sandstone, None, ItemKind::Comp(comp.into())); + let pool = MediaPool::new(); + let settings = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() }; + let om = OutputModule { bitrate_kbps: 4000, ..OutputModule::for_format(OutputFormat::H264) }; + let out_path = out.to_string_lossy().to_string(); + let job = Job { + project: &p, + footage: &pool, + expr: None, + accel: None, + comp: cid, + settings: &settings, + output: &om, + path: &out_path, + sink: None, + options: Default::default(), + nested_switches: true, + }; + let r = export(&job, &mut |_| true).expect("export"); + assert_eq!(r.frames as i64, frame_count(&footage), "the render has the footage's frames"); + effectcraft_media::probe(out).expect("probe the render") +} + +#[test] +fn millisecond_rounded_footage_keeps_its_rate_through_comp_and_render() { + let d = out_dir("ms_rounded"); + for (rate, frames) in [(FrameRate::FPS_30, 120), (FrameRate::FPS_60, 240)] { + let path = d.join(format!("ms-{}.mp4", rate.num)); + write_movie(&path, &ms_rounded(rate, frames)); + let f = effectcraft_media::probe(&path).expect("probe"); + assert_eq!((f.frame_rate, frame_count(&f)), (rate, frames), "{rate:?} footage"); + let bytes: Arc<[u8]> = std::fs::read(&path).expect("read").into(); + let from_bytes = effectcraft_media::probe_bytes(&path.to_string_lossy(), bytes).expect("probe bytes"); + assert_eq!(from_bytes.frame_rate, rate, "{rate:?} footage read from memory"); + let all: Vec = (0..frames as u32).collect(); + assert_eq!(frames_of(&f), all, "{rate:?} footage: each source frame once"); + + let out = comp_from_footage(&path, &d.join(format!("render-{}.mp4", rate.num))); + assert_eq!((out.frame_rate, frame_count(&out)), (rate, frames), "{rate:?} render"); + assert_eq!(out.duration, Tick::from_seconds_f64(4.0), "{rate:?} render duration"); + assert_eq!(frames_of(&out), all, "{rate:?} render: no frame skipped or repeated"); + } +} + +#[test] +fn ntsc_and_variable_rate_footage_is_not_pulled_to_a_round_rate() { + let d = out_dir("ms_rounded_controls"); + for rate in [FrameRate::FPS_23_976, FrameRate::FPS_29_97, FrameRate::FPS_59_94] { + let path = d.join(format!("ms-{}.mp4", rate.num)); + write_movie(&path, &ms_rounded(rate, 60)); + assert_eq!(effectcraft_media::probe(&path).expect("probe").frame_rate, rate); + } + // Frames of 40, 45 and 50 ms: the reader's median (45 ms, 22.2 fps) stands. + let path = d.join("variable.mp4"); + write_movie(&path, &[40, 45, 50].repeat(20)); + let f = effectcraft_media::probe(&path).expect("probe"); + assert!(!FrameRate::COMMON.contains(&f.frame_rate) && (f.frame_rate.as_f64() - 1000.0 / 45.0).abs() < 0.01, "{:?}", f.frame_rate); +} From 2f437ea551de3b8f512d28f10a46fe58a509ed78 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 14 Oct 2026 09:38:47 -0700 Subject: [PATCH 031/279] EPS and PDF: huge arcs, long paths and loop passes count against the import's limits The EPS interpreter counted one operation against its budget for each name it ran, whatever the operator did, and paths had no length limit. Parsing one arc at a radius of 10^300 passed 3 GiB of resident memory within 43 seconds. A loop of arc at a radius of 2.2 x 10^18 was still running after 60 seconds, and a loop of pathbbox on a path of 100,001 elements and { {} loop } loop did not finish within 20 seconds. PDF content had no path limit either: parsing a page that repeats 0 0 1 1 re 4,000,000 times peaked at 2.3 GiB of resident memory. A path now holds at most 1,048,576 elements (MAX_PATH, the limit of VectorCraft's EPS interpreter). In EPS, moveto, lineto, curveto, closepath, arc, charpath and the rect operators check it, and an operator that would take a path past it stops the program with "PostScript (path limit)". In PDF, m, l, c, v, y, h and re (each adds at most five elements) stop the content stream with "content (path limit)" when the path already holds more than 1,048,571. Each path element an operator makes, reads or copies (arc, rectangle arrays, charpath, pathbbox, clip, eoclip, gsave, save) now counts against the EPS budget. Each pass of repeat, for, loop and forall counts too, and so does each procedure item that is not a name. A moveto right after a moveto replaces it, as the PostScript Language Reference describes, so text placed with moveto and show leaves one element. Five tests in tests_import.rs cover the loops, the path limit in both interpreters and runs of moveto and show. --- crates/pdf/src/build.rs | 4 + crates/pdf/src/page.rs | 9 +- crates/pdf/src/ps.rs | 174 +++++++++++++++++++++++---------- crates/pdf/src/tests_import.rs | 94 ++++++++++++++++++ 4 files changed, 228 insertions(+), 53 deletions(-) diff --git a/crates/pdf/src/build.rs b/crates/pdf/src/build.rs index 7023e931c..0b1586f89 100644 --- a/crates/pdf/src/build.rs +++ b/crates/pdf/src/build.rs @@ -3,6 +3,10 @@ use effectcraft_svg::{Affine, BezPath, FillRule, Geom, Group, Node, Paint, Shape, Stroke}; +/// The most elements a path being built holds: an operator that would add more stops the +/// PostScript program, or the PDF content stream. +pub(crate) const MAX_PATH: usize = 1 << 20; + #[derive(Clone, Copy, PartialEq, Eq)] enum Kind { Plain, diff --git a/crates/pdf/src/page.rs b/crates/pdf/src/page.rs index 13e98b3c9..1cf620f14 100644 --- a/crates/pdf/src/page.rs +++ b/crates/pdf/src/page.rs @@ -12,7 +12,7 @@ use effectcraft_svg::{ }; use kurbo::{Point, Shape as _}; -use crate::build::Builder; +use crate::build::{Builder, MAX_PATH}; use crate::color::{Cs, Func, color_space, text_string}; use crate::font::Font; use crate::object::{Dict, File, Lexer, Obj, decode_stream}; @@ -281,7 +281,12 @@ impl<'a> Interp<'a> { self.ext_gstate(&g, res, depth); } } - // Path construction. + // Path construction. Each operator adds at most five elements (`re`), so when fewer + // than five more fit within `MAX_PATH`, the operator stops the stream. + "m" | "l" | "c" | "v" | "y" | "h" | "re" if self.path.elements().len() > MAX_PATH - 5 => { + self.b.skip("content (path limit)"); + return; + } "m" => { let p = Point::new(n(0), n(1)); self.path.move_to(p); diff --git a/crates/pdf/src/ps.rs b/crates/pdf/src/ps.rs index 65e352cb2..93ed55e74 100644 --- a/crates/pdf/src/ps.rs +++ b/crates/pdf/src/ps.rs @@ -14,7 +14,7 @@ use std::rc::Rc; use effectcraft_svg::{Affine, BezPath, Cap, FillRule, Join, Paint, Stroke}; use kurbo::{PathEl, Point}; -use crate::build::Builder; +use crate::build::{Builder, MAX_PATH}; use crate::font::Font; use crate::object::{ascii85, is_white, parse_num}; @@ -441,7 +441,14 @@ impl Interp { break; } } - other => self.push(other.clone()), + // Other items count against the budget as names do. + other => { + r = self.spend(1); + if r.is_err() { + break; + } + self.push(other.clone()); + } } } self.calls -= 1; @@ -456,11 +463,7 @@ impl Interp { } fn exec_name(&mut self, n: &str, src: &mut Src) -> Result<(), Flow> { - if self.budget == 0 { - self.b.skip("PostScript (operation limit)"); - return Err(Flow::Quit); - } - self.budget -= 1; + self.spend(1)?; if let Some(v) = self.lookup(n) { return match v { V::Proc(_) => self.exec_obj(v, src), @@ -473,6 +476,21 @@ impl Interp { self.builtin(n, src) } + /// Count `n` operations against the budget, and stop the program when fewer are left. An + /// operator that makes, reads or copies path elements counts each one as an operation. + fn spend(&mut self, n: usize) -> Result<(), Flow> { + match self.budget.checked_sub(n) { + Some(left) => { + self.budget = left; + Ok(()) + } + None => { + self.b.skip("PostScript (operation limit)"); + Err(Flow::Quit) + } + } + } + fn device_scale(&self) -> f64 { self.gs.ctm.determinant().abs().sqrt() } @@ -483,13 +501,34 @@ impl Interp { Point::new(x, y) } - fn move_to(&mut self, p: Point) { + /// Stop the program when a path of `len` elements would be longer than [`MAX_PATH`]. + fn path_fits(&mut self, len: usize) -> Result<(), Flow> { + if len <= MAX_PATH { + return Ok(()); + } + self.b.skip("PostScript (path limit)"); + Err(Flow::Quit) + } + + /// [`Self::path_fits`] for the current path with `n` more elements. + fn room(&mut self, n: usize) -> Result<(), Flow> { + self.path_fits(self.gs.path.elements().len().saturating_add(n)) + } + + fn move_to(&mut self, p: Point) -> Result<(), Flow> { + // A moveto right after a moveto replaces it (PostScript Language Reference §8.2, `moveto`). + if matches!(self.gs.path.elements().last(), Some(PathEl::MoveTo(_))) { + self.gs.path.pop(); + } + self.room(1)?; let q = self.gs.ctm * p; self.gs.path.move_to(q); self.gs.cur = Some(q); self.gs.start = Some(q); + Ok(()) } - fn line_to(&mut self, p: Point) { + fn line_to(&mut self, p: Point) -> Result<(), Flow> { + self.room(1 + usize::from(self.gs.cur.is_none()))?; let q = self.gs.ctm * p; if self.gs.cur.is_none() { self.gs.path.move_to(q); @@ -497,20 +536,23 @@ impl Interp { } self.gs.path.line_to(q); self.gs.cur = Some(q); + Ok(()) } - fn curve_to(&mut self, a: Point, b: Point, c: Point) { + fn curve_to(&mut self, a: Point, b: Point, c: Point) -> Result<(), Flow> { + self.room(1 + usize::from(self.gs.cur.is_none()))?; let m = self.gs.ctm; if self.gs.cur.is_none() { self.gs.path.move_to(m * a); } self.gs.path.curve_to(m * a, m * b, m * c); self.gs.cur = Some(m * c); + Ok(()) } fn current_user(&self) -> Point { self.gs.ctm.inverse() * self.gs.cur.unwrap_or_default() } - fn arc(&mut self, neg: bool) { + fn arc(&mut self, neg: bool) -> Result<(), Flow> { let a2 = self.popn(); let a1 = self.popn(); let r = self.popn(); @@ -540,16 +582,23 @@ impl Interp { let (a1, sweep) = ((a1 % 360.0).to_radians(), sweep.to_radians()); let start = Point::new(c.x + r * a1.cos(), c.y + r * a1.sin()); if self.gs.cur.is_some() { - self.line_to(start); + self.line_to(start)?; } else { - self.move_to(start); + self.move_to(start)?; } let arc = kurbo::Arc::new(c, (r, r), a1, sweep, 0.0); let m = self.gs.ctm; - arc.to_cubic_beziers(0.1, |p1, p2, p3| { + // kurbo divides an arc into more curves the larger the radius (about 3.7 × 10^49 for a + // quarter turn at a radius of 10^300, more than a usize can count), so each one counts + // against the budget and the path limit. + let mut curves = arc.append_iter(0.1); + while let Some(PathEl::CurveTo(p1, p2, p3)) = curves.next() { + self.room(1)?; + self.spend(1)?; self.gs.path.curve_to(m * p1, m * p2, m * p3); self.gs.cur = Some(m * p3); - }); + } + Ok(()) } fn paint(&mut self, fill: Option, stroke: bool) { @@ -569,11 +618,16 @@ impl Interp { self.b.fill_stroke(path, Affine::IDENTITY, fill.map(|r| (Paint::Color(self.gs.color), 1.0, r)), st); } - fn rect_path(&mut self) -> Vec { + fn rect_path(&mut self) -> Result, Flow> { // x y w h, or an array / string of numbers. let top = self.pop(); let nums: Vec = match &top { - V::Array(_) | V::Proc(_) => top.items().unwrap_or_default().iter().filter_map(V::num).collect(), + V::Array(_) | V::Proc(_) => { + let items = top.items().unwrap_or_default(); + // An array's rectangles count against the budget, five path elements each. + self.spend(items.len() / 4 * 5)?; + items.iter().filter_map(V::num).collect() + } V::Num(h) => { let w = self.popn(); let y = self.popn(); @@ -582,8 +636,10 @@ impl Interp { } _ => vec![], }; + self.path_fits(nums.len() / 4 * 5)?; let m = self.gs.ctm; - nums.as_chunks::<4>() + Ok(nums + .as_chunks::<4>() .0 .iter() .map(|r| { @@ -595,7 +651,7 @@ impl Interp { p.close_path(); p }) - .collect() + .collect()) } fn grestore(&mut self) { @@ -809,10 +865,12 @@ impl Interp { let c = matches!(self.pop(), V::Bool(true)); self.exec_obj(if c { t } else { f }, src)?; } + // Each pass of a loop counts against the budget. "repeat" => { let p = self.pop(); let k = self.popn().clamp(0.0, 1e6) as usize; for _ in 0..k { + self.spend(1)?; match self.exec_obj(p.clone(), src) { Err(Flow::Exit) => break, r => r?, @@ -826,6 +884,7 @@ impl Interp { let mut i = self.popn(); let mut guard = 0; while inc != 0.0 && ((inc > 0.0 && i <= lim) || (inc < 0.0 && i >= lim)) && guard < 1_000_000 { + self.spend(1)?; self.push(V::Num(i)); match self.exec_obj(p.clone(), src) { Err(Flow::Exit) => break, @@ -838,6 +897,7 @@ impl Interp { "loop" => { let p = self.pop(); for _ in 0..1_000_000 { + self.spend(1)?; match self.exec_obj(p.clone(), src) { Err(Flow::Exit) => break, r => r?, @@ -850,6 +910,7 @@ impl Interp { V::Dict(d) => { let items: Vec<(String, V)> = d.borrow().iter().map(|(k, v)| (k.clone(), v.clone())).collect(); for (k, v) in items { + self.spend(1)?; self.push(V::Lit(k.into())); self.push(v); match self.exec_obj(p.clone(), src) { @@ -861,6 +922,7 @@ impl Interp { V::Str(s) => { let bytes = s.borrow().clone(); for c in bytes { + self.spend(1)?; self.push(V::Num(c as f64)); match self.exec_obj(p.clone(), src) { Err(Flow::Exit) => break, @@ -870,6 +932,7 @@ impl Interp { } a => { for v in a.items().unwrap_or_default() { + self.spend(1)?; self.push(v); match self.exec_obj(p.clone(), src) { Err(Flow::Exit) => break, @@ -1127,6 +1190,7 @@ impl Interp { "languagelevel" => self.push(V::Num(3.0)), "version" | "product" => self.push(V::Str(Rc::new(RefCell::new(b"3010".to_vec())))), "save" => { + self.spend(self.gs.path.elements().len())?; self.saved.push(self.gs.clone()); self.gs.depth = self.b.depth(); self.push(V::Null); @@ -1156,6 +1220,7 @@ impl Interp { } // Graphics state. "gsave" => { + self.spend(self.gs.path.elements().len())?; let mut g = self.gs.clone(); g.depth = self.b.depth(); self.saved.push(g); @@ -1316,21 +1381,21 @@ impl Interp { } "moveto" => { let p = self.user_point(); - self.move_to(p); + self.move_to(p)?; } "rmoveto" => { let d = self.user_point(); let c = self.current_user(); - self.move_to(Point::new(c.x + d.x, c.y + d.y)); + self.move_to(Point::new(c.x + d.x, c.y + d.y))?; } "lineto" => { let p = self.user_point(); - self.line_to(p); + self.line_to(p)?; } "rlineto" => { let d = self.user_point(); let c = self.current_user(); - self.line_to(Point::new(c.x + d.x, c.y + d.y)); + self.line_to(Point::new(c.x + d.x, c.y + d.y))?; } "curveto" | "rcurveto" => { let p3 = self.user_point(); @@ -1339,25 +1404,27 @@ impl Interp { if n == "rcurveto" { let c = self.current_user(); let o = |p: Point| Point::new(c.x + p.x, c.y + p.y); - self.curve_to(o(p1), o(p2), o(p3)); + self.curve_to(o(p1), o(p2), o(p3))?; } else { - self.curve_to(p1, p2, p3); + self.curve_to(p1, p2, p3)?; } } "closepath" => { if self.gs.cur.is_some() { + self.room(1)?; self.gs.path.close_path(); self.gs.cur = self.gs.start; } } - "arc" => self.arc(false), - "arcn" => self.arc(true), + "arc" => self.arc(false)?, + "arcn" => self.arc(true)?, "currentpoint" => { let c = self.current_user(); self.push(V::Num(c.x)); self.push(V::Num(c.y)); } "pathbbox" => { + self.spend(self.gs.path.elements().len())?; let r = kurbo::Shape::bounding_box(&self.gs.path); let inv = self.gs.ctm.inverse(); let (a, b) = (inv * Point::new(r.x0, r.y0), inv * Point::new(r.x1, r.y1)); @@ -1376,13 +1443,14 @@ impl Interp { "stroke" => self.paint(None, true), "clip" | "eoclip" => { let rule = if n == "clip" { FillRule::NonZero } else { FillRule::EvenOdd }; + self.spend(self.gs.path.elements().len())?; let p = self.gs.path.clone(); if !p.elements().iter().all(|e| matches!(e, PathEl::MoveTo(_))) { self.b.clip(p, rule); } } "rectfill" | "rectstroke" | "rectclip" => { - let paths = self.rect_path(); + let paths = self.rect_path()?; if n == "rectstroke" && matches!(self.st.last(), Some(V::Array(_))) { self.pop(); } @@ -1617,9 +1685,9 @@ impl Interp { /// A string's glyph outlines (device space) from the current point, with an extra /// displacement `extra(index, code)` (user space) after each glyph; moves the current /// point. - fn text_path(&mut self, bytes: &[u8], mut extra: impl FnMut(usize, u32) -> (f64, f64)) -> Option<(BezPath, String)> { - let d = self.gs.font.clone()?; - let (font, m) = self.font_of(&d)?; + fn text_path(&mut self, bytes: &[u8], mut extra: impl FnMut(usize, u32) -> (f64, f64)) -> Result, Flow> { + let Some(d) = self.gs.font.clone() else { return Ok(None) }; + let Some((font, m)) = self.font_of(&d) else { return Ok(None) }; let mut p = self.current_user(); let mut path = BezPath::new(); let mut text = String::new(); @@ -1634,8 +1702,8 @@ impl Interp { let (ex, ey) = extra(i, code); p += adv + kurbo::Vec2::new(ex, ey); } - self.move_to(p); - Some((path, text)) + self.move_to(p)?; + Ok(Some((path, text))) } fn fill_text(&mut self, path: BezPath, text: &str) { @@ -1651,11 +1719,12 @@ impl Interp { } } - fn show_with(&mut self, s: &[u8], extra: impl FnMut(usize, u32) -> (f64, f64)) { - match self.text_path(s, extra) { + fn show_with(&mut self, s: &[u8], extra: impl FnMut(usize, u32) -> (f64, f64)) -> Result<(), Flow> { + match self.text_path(s, extra)? { Some((path, text)) => self.fill_text(path, &text), None => self.b.skip("text (no font)"), } + Ok(()) } /// Text and font operators (PostScript Language Reference §5.1, §8.2). @@ -1713,20 +1782,20 @@ impl Interp { "StandardEncoding" | "ISOLatin1Encoding" => self.push(encoding_array(n)), "show" => { let s = self.pop_string(); - self.show_with(&s, |_, _| (0.0, 0.0)); + self.show_with(&s, |_, _| (0.0, 0.0))?; } "ashow" => { let s = self.pop_string(); let ay = self.popn(); let ax = self.popn(); - self.show_with(&s, |_, _| (ax, ay)); + self.show_with(&s, |_, _| (ax, ay))?; } "widthshow" => { let s = self.pop_string(); let ch = self.popn() as u32; let cy = self.popn(); let cx = self.popn(); - self.show_with(&s, |_, c| if c == ch { (cx, cy) } else { (0.0, 0.0) }); + self.show_with(&s, |_, c| if c == ch { (cx, cy) } else { (0.0, 0.0) })?; } "awidthshow" => { let s = self.pop_string(); @@ -1735,18 +1804,18 @@ impl Interp { let ch = self.popn() as u32; let cy = self.popn(); let cx = self.popn(); - self.show_with(&s, |_, c| if c == ch { (cx + ax, cy + ay) } else { (ax, ay) }); + self.show_with(&s, |_, c| if c == ch { (cx + ax, cy + ay) } else { (ax, ay) })?; } "kshow" | "cshow" => { // The procedure between characters is not run. let s = self.pop_string(); self.pop(); - self.show_with(&s, |_, _| (0.0, 0.0)); + self.show_with(&s, |_, _| (0.0, 0.0))?; } "xshow" | "yshow" | "xyshow" => { let disp: Vec = self.pop().items().unwrap_or_default().iter().filter_map(V::num).collect(); let s = self.pop_string(); - self.explicit_show(&s, &disp, n); + self.explicit_show(&s, &disp, n)?; } "glyphshow" => { let name = self.pop().key(); @@ -1757,7 +1826,7 @@ impl Interp { let tmp = transformed(&d, Affine::IDENTITY); tmp.borrow_mut().insert("Encoding".into(), array(e)); self.gs.font = Some(tmp); - self.show_with(b"A", |_, _| (0.0, 0.0)); + self.show_with(b"A", |_, _| (0.0, 0.0))?; self.gs.font = Some(d); } "charpath" => { @@ -1765,14 +1834,17 @@ impl Interp { let s = self.pop_string(); let saved = std::mem::take(&mut self.gs.path); let start = self.gs.start; - match self.text_path(&s, |_, _| (0.0, 0.0)) { + match self.text_path(&s, |_, _| (0.0, 0.0))? { Some((glyphs, _)) => { - let mut p = saved; - p.extend(glyphs); + // The outlines join the path: each element counts against the budget and + // the path limit. + self.gs.path = saved; + self.room(glyphs.elements().len() + 1)?; + self.spend(glyphs.elements().len())?; + self.gs.path.extend(glyphs); if let Some(c) = self.gs.cur { - p.move_to(c); + self.gs.path.move_to(c); } - self.gs.path = p; self.gs.start = start; } None => self.gs.path = saved, @@ -1822,7 +1894,7 @@ impl Interp { /// `xshow` / `yshow` / `xyshow`: glyph displacements from a number array instead of the /// advances. - fn explicit_show(&mut self, s: &[u8], disp: &[f64], n: &str) { + fn explicit_show(&mut self, s: &[u8], disp: &[f64], n: &str) -> Result<(), Flow> { let step = if n == "xyshow" { 2 } else { 1 }; let d: Vec<(f64, f64)> = (0..s.len()) .map(|k| { @@ -1837,13 +1909,13 @@ impl Interp { .collect(); let Some(fd) = self.gs.font.clone() else { self.b.skip("text (no font)"); - return; + return Ok(()); }; - let Some((font, m)) = self.font_of(&fd) else { return }; + let Some((font, m)) = self.font_of(&fd) else { return Ok(()) }; self.show_with(s, move |k, c| { let adv = m * Point::new(font.width(c), 0.0) - m * Point::ZERO; let (dx, dy) = d.get(k).copied().unwrap_or((0.0, 0.0)); (dx - adv.x, dy - adv.y) - }); + }) } } diff --git a/crates/pdf/src/tests_import.rs b/crates/pdf/src/tests_import.rs index 532e32cb9..de49ec408 100644 --- a/crates/pdf/src/tests_import.rs +++ b/crates/pdf/src/tests_import.rs @@ -359,6 +359,100 @@ fn arcs_with_huge_angles_end_at_their_second_angle() { assert!(img.get(50, 50)[3] > 0.99, "{:?}", img.get(50, 50)); } +/// Parses `body` after a square through [`eps_soon`]: the square stays, and the program stops at +/// the operation limit. +fn runs_out_of_budget(body: &str) { + let doc = eps_soon(&format!("0 0 10 10 rectfill {body}")); + assert_eq!(doc.flatten().len(), 1, "{body}: {:?}", doc.skipped); + assert_eq!(doc.skipped, ["PostScript (operation limit)"], "{body}"); +} + +#[test] +fn paths_made_in_a_loop_run_out_of_the_budget() { + // Each curve, rectangle element and glyph outline element an operator makes counts against the + // operation budget: `arc` at a radius of 2.2 × 10^18 makes about 6,800 curves a call, + // `rectclip` with an array of 2,000 rectangles makes 10,000 elements, and `charpath` makes the + // outlines of 1,000 H's. + for body in [ + "{ newpath 0 0 2200000000000000000 0 1440 arc } loop", + "/a 8000 array def 0 1 7999 { a exch 0 put } for { gsave a rectclip grestore } loop", + "/Helvetica findfont 10 scalefont setfont /s 1000 string def 0 1 999 { s exch 72 put } for \ + { newpath 0 0 moveto s false charpath } loop", + ] { + runs_out_of_budget(body); + } +} + +#[test] +fn paths_read_or_copied_in_a_loop_run_out_of_the_budget() { + // `pathbbox` reads each element of the current path, and `gsave`, `save` and `clip` copy it: + // each element counts against the operation budget. The path has 100,001 elements. + let path = "newpath 0 0 moveto 0 1 99999 { pop 1 0 rlineto } for"; + for body in ["{ pathbbox pop pop pop pop } loop", "{ gsave grestore } loop", "{ save restore } loop", "{ gsave clip grestore } loop"] { + runs_out_of_budget(&format!("{path} {body}")); + } +} + +#[test] +fn nested_loops_run_out_of_the_budget() { + // Each pass of `loop`, `repeat`, `for` and `forall` counts against the operation budget, and + // so does each item of a procedure that is not a name. `clear` empties the operand stack that + // the passes fill. + let literals = format!("{{ {} clear }} loop", "0 ".repeat(100_000)); + for body in [ + "{ {} loop } loop", + "{ 1000000 {} repeat } loop", + "{ 0 1 999999 {} for clear } loop", + "/s 1000000 string def { s {} forall clear } loop", + "/a 1000000 array def { a {} forall clear } loop", + "/d 1 dict def 0 1 99999 { d exch 0 put } for { d {} forall clear } loop", + literals.as_str(), + ] { + runs_out_of_budget(body); + } +} + +#[test] +fn paths_stop_at_the_path_limit() { + // A path holds at most 1,048,576 elements. An operator that would add more stops the program: + // the square drawn before it stays, and the square after it is left out. + for body in [ + "newpath 0 0 moveto 1100 { 1000 { 1 0 rlineto } repeat } repeat", + "newpath 0 0 moveto 0 0 0 0 0 0 1100 { 1000 { 6 copy rcurveto } repeat } repeat", + "newpath 0 0 moveto 1100 { 1000 { closepath } repeat } repeat", + // 1,024 turns at a radius of 2 × 10^17: about 1,171,000 curves. + "newpath 0 0 200000000000000000 0 368640 arc", + // At a radius of 10^300, kurbo divides the arc into more curves than a usize can count. + "newpath 0 0 10 300 exp 0 90 arc", + // The outlines of 100,000 H's, 13 elements each. + "/Helvetica findfont 10 scalefont setfont /s 100000 string def 0 1 99999 { s exch 72 put } for \ + 0 0 moveto s false charpath", + // 210,000 rectangles of five elements each. + "/a 840000 array def 0 1 839999 { a exch 0 put } for a rectfill", + ] { + let doc = eps_soon(&format!("0 0 10 10 rectfill {body} 20 20 10 10 rectfill")); + assert_eq!(doc.flatten().len(), 1, "{body}: {:?}", doc.skipped); + assert_eq!(doc.skipped, ["PostScript (path limit)"], "{body}"); + } + // A `moveto` replaces a `moveto` just before it (PostScript Language Reference §8.2, `moveto`), + // and so does the current point that `show` sets, so 550,000 pieces of text placed with + // `moveto` and `show` leave a path of one element. + let doc = eps_soon("/Helvetica findfont 10 scalefont setfont 550 { 1000 { 0 0 moveto ( ) show } repeat } repeat 0 0 10 10 rectfill"); + assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); + assert_eq!(doc.flatten().len(), 1); +} + +#[test] +fn pdf_paths_stop_at_the_path_limit() { + // A content stream's current path holds at most 1,048,576 elements: `re` (five elements each) + // and `h` stop the stream at the limit, and the square painted before it stays. + for (op, n) in [("0 0 1 1 re ", 210_000), ("h ", 1_100_000)] { + let doc = parse(&page_pdf(&format!("0 0 10 10 re f 0 0 m {} f 20 20 10 10 re f", op.repeat(n)), "", vec![])).unwrap(); + assert_eq!(doc.flatten().len(), 1, "{op}: {:?}", doc.skipped); + assert_eq!(doc.skipped, ["content (path limit)"], "{op}"); + } +} + #[test] fn closing_an_empty_path_is_a_no_op() { // "s", "b" and "b*" closed the path without a current point (kurbo asserts on that). From a1a1a36a417d7a5ee6298780be0692f241f79eef Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Wed, 14 Oct 2026 02:38:38 +0400 Subject: [PATCH 032/279] i18n: context menus, tab titles, workspaces and column headers reach the catalogs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Text drawn outside the menu bar stayed English in every language: - i18n::command_label: the Timeline / Composition layer and property context menus now take the menu bar's row for the same command (… or ...), else the panel catalog's row; the layer menu's Effects submenu draws effect and category names through the effect catalog. - The dock's tab titles (and the dragged tab), the Composition, Effect Controls and Properties tabs, and the workspace bar and menus use the window.panel and window.workspace rows every menu catalog already has. - The Project panel's search hint, details hint and column headers, and the Timeline's column headers and Columns menu go through tr(). The Simplified and Traditional Chinese rows for "Type" read 文字 (text, the Type tool); the string is the Camera Settings field and now the Project column, so they read 类型 / 類型. dock_ui.rs joins the converted files. Languages without a row for a string keep the English, as before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF (cherry picked from commit b335f1e2764929b78d7b678ec026f680b1a8129f) --- crates/ui-egui/src/dock_ui.rs | 10 +++-- crates/ui-egui/src/header.rs | 6 +-- crates/ui-egui/src/i18n.rs | 58 +++++++++++++++++++-------- crates/ui-egui/src/i18n/ui.rs | 5 ++- crates/ui-egui/src/panels/project.rs | 14 +++++-- crates/ui-egui/src/panels/timeline.rs | 12 +++--- crates/ui-egui/src/panels/viewers.rs | 2 +- 7 files changed, 71 insertions(+), 36 deletions(-) diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index a92d376e9..ba9e9e6b9 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -41,8 +41,8 @@ impl EffectcraftApp { } let Some(comp) = self.session.active_comp() else { return out }; if let Some(l) = self.session.state.selected_layers.first().and_then(|id| comp.layer(*id)) { - out.push((PanelKind::EffectControls, format!("Effect Controls {}", l.name))); - out.push((PanelKind::Properties, format!("Properties: {}", l.name))); + out.push((PanelKind::EffectControls, format!("{} {}", crate::i18n::command_label("window.panel", "Effect Controls"), l.name))); + out.push((PanelKind::Properties, format!("{}: {}", crate::i18n::command_label("window.panel", "Properties"), l.name))); } out } @@ -206,7 +206,9 @@ impl EffectcraftApp { let titles = self.tab_titles(); let decos = self.tab_decos(); let docs = self.tab_docs(); - let title = |p: PanelKind| titles.iter().find(|(k, _)| *k == p).map(|(_, s)| s.clone()).unwrap_or_else(|| p.title().to_string()); + let title = |p: PanelKind| { + titles.iter().find(|(k, _)| *k == p).map(|(_, s)| s.clone()).unwrap_or_else(|| crate::i18n::command_label("window.panel", p.title()).to_string()) + }; let info = dock::TabInfo { title: &title, decos: &decos, docs: &docs }; // Where each group's tabs are (tab drags show where a tab will land between them). let mut tab_rects: Vec<(String, Vec<(PanelKind, Rect)>)> = vec![]; @@ -570,7 +572,7 @@ impl EffectcraftApp { } } // Ghost of the dragged tab. - let g = painter.layout_no_wrap(p.title().to_string(), crate::theme::Tokens::ui(12.0), Color32::WHITE); + let g = painter.layout_no_wrap(crate::i18n::command_label("window.panel", p.title()).to_string(), crate::theme::Tokens::ui(12.0), Color32::WHITE); let gr = Rect::from_min_size(pos + vec2(12.0, 10.0), g.size() + vec2(16.0, 8.0)); painter.rect_filled(gr, 4.0, t.focus.gamma_multiply(0.85)); painter.galley(gr.min + vec2(8.0, 4.0), g, Color32::WHITE); diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index fb381cfd3..acbd2fdcb 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -259,7 +259,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if mresp.clicked() { widgets::open_popup(ui, egui::Id::new("ws-pop")); } - let names: Vec = crate::dock::WORKSPACES.iter().map(|s| s.to_string()).collect(); + let names: Vec = crate::dock::WORKSPACES.iter().map(|s| crate::i18n::command_label("window.workspace", s).to_string()).collect(); if let Some(i) = widgets::popup_menu( ui, egui::Id::new("ws-pop"), @@ -274,7 +274,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Small Screen, Standard. let shown = ["Standard", "Small Screen", "Learn", "Review", "Default"]; for name in shown { - let g = p.layout_no_wrap(name.to_string(), Tokens::ui(12.0), t.text); + let g = p.layout_no_wrap(crate::i18n::command_label("window.workspace", name).to_string(), Tokens::ui(12.0), t.text); let w = g.size().x + 16.0; let r = Rect::from_min_max(pos2(rx - w, cy - 13.0), pos2(rx, cy + 13.0)); if r.min.x < x + 120.0 { @@ -641,7 +641,7 @@ fn puppet_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter fn ws_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui) { for w in app.workspace_names() { - if ui.selectable_label(app.ui.workspace == w, &w).clicked() { + if ui.selectable_label(app.ui.workspace == w, crate::i18n::command_label("window.workspace", &w)).clicked() { app.set_workspace(&w); ui.close(); } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 4caae8dde..8bc348df7 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -118,25 +118,37 @@ pub(crate) fn label<'a>(app: &EffectcraftApp, command: &str, source: &'a str) -> _ => {} } } - match language { - "ja" => lookup(JAPANESE, command, source), - "zh-hans" => lookup(SIMPLIFIED_CHINESE, command, source), - "zh-hant" => lookup(TRADITIONAL_CHINESE, command, source), - "uk" => lookup(UKRAINIAN, command, source), - "es" => lookup(SPANISH, command, source), - "pt-br" => lookup(PORTUGUESE, command, source), - "ru" => lookup(RUSSIAN, command, source), - "fr" => lookup(FRENCH, command, source), - "de" => lookup(GERMAN, command, source), - "cs" => lookup(CZECH, command, source), - _ => source, - } + lookup(language, command, source).unwrap_or(source) } -/// `source` looked up in `catalog` by (command, English label); the source itself when the pair is +/// A command's label drawn outside the menu bar (the Timeline's and Composition panel's layer and +/// property context menus, the dock's tab titles, the workspace bar), in the language the panels +/// draw in: the menu bar's row for (`command`, `source`), else the panel catalog's row for +/// `source`, else `source`. Those menus write `…` where the menu bar's sources have `...`, so +/// either spelling finds the menu bar's row. +pub(crate) fn command_label<'a>(command: &str, source: &'a str) -> &'a str { + let language = ui::current(); + let dotted = source.strip_suffix('…').map(|stem| format!("{stem}...")); + lookup(language, command, source).or_else(|| lookup(language, command, dotted.as_deref()?)).unwrap_or_else(|| tr(source)) +} + +/// The row for (command, English label) in `language`'s menu catalog; `None` when the pair is /// absent, so computed labels and user names stay verbatim. -fn lookup<'a>(catalog: &[(&str, &str, &'a str)], command: &str, source: &'a str) -> &'a str { - catalog.iter().find(|(id, en, _)| *id == command && *en == source).map(|(_, _, translated)| *translated).unwrap_or(source) +fn lookup(language: &str, command: &str, source: &str) -> Option<&'static str> { + let catalog = match language { + "ja" => JAPANESE, + "zh-hans" => SIMPLIFIED_CHINESE, + "zh-hant" => TRADITIONAL_CHINESE, + "uk" => UKRAINIAN, + "es" => SPANISH, + "pt-br" => PORTUGUESE, + "ru" => RUSSIAN, + "fr" => FRENCH, + "de" => GERMAN, + "cs" => CZECH, + _ => return None, + }; + catalog.iter().find(|(id, en, _)| *id == command && *en == source).map(|(_, _, translated)| *translated) } pub(crate) fn entry(app: &EffectcraftApp, e: &MenuEntry, shown: String) -> String { @@ -6617,6 +6629,20 @@ mod tests { use serde_json::json; use std::collections::BTreeSet; + /// Labels drawn outside the menu bar take the menu bar's row for their command (with `…` for + /// `...`), else the panel catalog's row, else stay as they are. + #[test] + fn command_label_reads_the_menu_row_then_the_panel_row() { + set_current("zh-hans"); + assert_eq!(command_label("window.panel", "Project"), "项目"); + let (command, en, translated) = SIMPLIFIED_CHINESE.iter().find(|(c, en, _)| !c.is_empty() && en.ends_with("...")).expect("a menu row ending in ..."); + assert_eq!(command_label(command, &format!("{}…", en.trim_end_matches("..."))), *translated); + assert_eq!(command_label("no.such.command", "Cancel"), "取消"); + assert_eq!(command_label("window.panel", "Comp 1"), "Comp 1"); + set_current("en"); + assert_eq!(command_label("window.panel", "Project"), "Project"); + } + #[test] fn czech_translates_menus_without_changing_bindings_or_user_names() { let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 3a4dd4aff..3a271aeb1 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -1055,7 +1055,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Transparency grid", "透明网格"), ("Tutorial complete", "教程完成"), ("Two-Node Camera", "双节点摄像机"), - ("Type", "文字"), + ("Type", "类型"), ("Type a command or effect…", "输入命令或效果…"), ("URL:", "URL:"), ("Undock Panel", "取消停靠面板"), @@ -1787,7 +1787,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Transparency grid", "透明格點"), ("Tutorial complete", "教學完成"), ("Two-Node Camera", "雙節點攝影機"), - ("Type", "文字"), + ("Type", "類型"), ("Type a command or effect…", "輸入命令或效果…"), ("URL:", "URL:"), ("Undock Panel", "取消停靠面板"), @@ -4209,6 +4209,7 @@ mod tests { const CONVERTED: &[(&str, &str)] = &[ ("color_picker.rs", include_str!("../color_picker.rs")), ("credits.rs", include_str!("../credits.rs")), + ("dock_ui.rs", include_str!("../dock_ui.rs")), ("header.rs", include_str!("../header.rs")), ("menus.rs", include_str!("../menus.rs")), ("widgets.rs", include_str!("../widgets.rs")), diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 46187fa8c..f2d9c303a 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -425,13 +425,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } else { let hint = "Select an item to see its details"; - let r = widgets::text_fit(&p, head.center(), Align2::CENTER_CENTER, hint, Tokens::ui(11.0), head.width() - 20.0, t.text_faint); + let r = widgets::text_fit(&p, head.center(), Align2::CENTER_CENTER, crate::i18n::tr(hint), Tokens::ui(11.0), head.width() - 20.0, t.text_faint); app.auto.add("project.details.hint", r, hint); } // Search. let sr = Rect::from_min_size(pos2(rect.min.x + 8.0, head.max.y + 2.0), vec2(rect.width() - 16.0, 22.0)); let mut q = app.ui.project_search.clone(); - widgets::search_field(ui, sr, &mut q, "Search project", &t); + widgets::search_field(ui, sr, &mut q, crate::i18n::tr("Search project"), &t); app.ui.project_search = q; app.auto.add("project.search", sr, "Search"); // Column header: Name, Label, then the visible optional columns. @@ -463,7 +463,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { continue; } let resp = ui.interact(hr.intersect(hdr), egui::Id::new(("proj-sort", *key)), Sense::click()); - hp.with_clip_rect(hr).text(pos2(*x, hdr.center().y), Align2::LEFT_CENTER, *label, Tokens::ui(11.0), if resp.hovered() { t.text } else { t.text_dim }); + hp.with_clip_rect(hr).text( + pos2(*x, hdr.center().y), + Align2::LEFT_CENTER, + crate::i18n::tr(label), + Tokens::ui(11.0), + if resp.hovered() { t.text } else { t.text_dim }, + ); if *key == "label" { icons::paint(&hp, Rect::from_center_size(pos2(label_x, hdr.center().y), vec2(10.0, 10.0)), Icon::Keyframe, t.text_dim); } @@ -1094,7 +1100,7 @@ fn column_menu(app: &mut EffectcraftApp, resp: &egui::Response) { ui.label(egui::RichText::new(crate::i18n::tr("Columns")).weak()); for (key, label, _) in COLUMNS { let mut on = app.ui.project_columns.iter().any(|c| c == key); - if ui.checkbox(&mut on, label).changed() { + if ui.checkbox(&mut on, crate::i18n::tr(label)).changed() { set_column(&mut app.ui.project_columns, key, on); } } diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 163e6507d..065517d2a 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1786,7 +1786,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Column headers scroll with the outline and never spill into the time ruler. let hp = p.with_clip_rect(Rect::from_min_max(ch.min, pos2(graph_x0 - 1.0, ch.max.y))); let hic = |icon: Icon, x: f32| icons::paint(&hp, Rect::from_center_size(pos2(x, hy), vec2(12.0, 12.0)), icon, t.text_dim); - let htext = |x: f32, s: &str| hp.text(pos2(x, hy), Align2::LEFT_CENTER, s, Tokens::ui(11.0), t.text_dim); + let htext = |x: f32, s: &str| hp.text(pos2(x, hy), Align2::LEFT_CENTER, crate::i18n::tr(s), Tokens::ui(11.0), t.text_dim); if vis.av { hic(Icon::Eye, cw.av + 10.0); hic(Icon::Speaker, cw.av + 28.0); @@ -1870,7 +1870,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { for (id, label) in COLUMNS { let label = if id == "name" { if app.ui.timeline.source_name { "Source Name" } else { "Layer Name" } } else { label }; let on = column_visible(&app.ui.timeline, id); - if ui.add_enabled(id != "name", egui::Button::new(label).selected(on)).clicked() { + if ui.add_enabled(id != "name", egui::Button::new(crate::i18n::tr(label)).selected(on)).clicked() { actions.push(("timeline.column".into(), json!({"column": id, "visible": !on}))); ui.close(); } @@ -3796,7 +3796,7 @@ fn prop_menu( ) { let (l, uid) = (layer.id.0, prop.uid); let mut item = |ui: &mut egui::Ui, key: &str, label: &str, selected: bool, cmd: &str, params: serde_json::Value| { - let resp = ui.add(egui::Button::new(label).selected(selected)); + let resp = ui.add(egui::Button::new(crate::i18n::command_label(cmd, label)).selected(selected)); auto.add(&format!("timeline.prop.{uid}.menu.{key}"), resp.rect, label); if resp.clicked() { actions.push((cmd.into(), params)); @@ -3877,7 +3877,7 @@ fn layer_context_menu( pub(crate) fn layer_menu(ui: &mut egui::Ui, layers: &[u64], caps: LayerMenuCaps, actions: &mut Vec<(String, serde_json::Value)>) { let Some(&id) = layers.first() else { return }; let mut item = |ui: &mut egui::Ui, label: &str, cmd: &str, params: serde_json::Value| { - if ui.button(label).clicked() { + if ui.button(crate::i18n::command_label(cmd, label)).clicked() { actions.push(("layer.select".into(), json!({"layers": layers}))); actions.push((cmd.into(), params)); ui.close(); @@ -3959,10 +3959,10 @@ fn effects_submenu(ui: &mut egui::Ui, layers: &[u64], item: &mut dyn FnMut(&mut widgets::menu_scroll(ui, |ui| { for (cat, mut fx) in cats { fx.sort_by_key(|e| e.name.to_ascii_lowercase()); - ui.menu_button(cat, |ui| { + ui.menu_button(crate::i18n::effects::category(cat), |ui| { widgets::menu_scroll(ui, |ui| { for e in fx { - item(ui, e.name, "effect.apply", json!({"effect": e.id, "layers": layers})); + item(ui, &crate::i18n::effects::name(e.id, e.name), "effect.apply", json!({"effect": e.id, "layers": layers})); } }); }); diff --git a/crates/ui-egui/src/panels/viewers.rs b/crates/ui-egui/src/panels/viewers.rs index 8d2351330..140dd6f8a 100644 --- a/crates/ui-egui/src/panels/viewers.rs +++ b/crates/ui-egui/src/panels/viewers.rs @@ -297,5 +297,5 @@ pub fn on_close(app: &mut EffectcraftApp, p: PanelKind) { /// The tab label of viewer `id` ("Composition Intro"). pub fn title(app: &EffectcraftApp, id: u32) -> Option { let c = comp_of(app, id)?; - Some(format!("Composition {}", app.session.project.item(c)?.name)) + Some(format!("{} {}", crate::i18n::command_label("window.panel", "Composition"), app.session.project.item(c)?.name)) } From 5539bb4a95c0e879c46a4f4263a81f59d30d399a Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Wed, 14 Oct 2026 02:41:12 +0400 Subject: [PATCH 033/279] web: Match System follows ?lang= and the browser's languages Match System (the default) stayed English in the browser build, which has no system-locale probe (#229). The page now passes ?lang= from the address, then navigator.languages, and Match System takes the first of them with a catalog (an English tag counts), else English. A language chosen in Settings still wins; native builds are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF (cherry picked from commit 73c064e3858068ed19fbfafbdd0b3c8cc229a42e) --- apps/effectcraft-web/Cargo.toml | 1 + apps/effectcraft-web/src/app.rs | 5 +++++ crates/ui-egui/src/i18n.rs | 35 ++++++++++++++++++++++++++++++--- docs/preferences.md | 2 +- 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/apps/effectcraft-web/Cargo.toml b/apps/effectcraft-web/Cargo.toml index 6c30f9a56..28ea2f369 100644 --- a/apps/effectcraft-web/Cargo.toml +++ b/apps/effectcraft-web/Cargo.toml @@ -50,6 +50,7 @@ web-sys = { version = "0.3", features = [ "HtmlElement", "HtmlInputElement", "Location", + "Navigator", "Url", "UrlSearchParams", "Window", diff --git a/apps/effectcraft-web/src/app.rs b/apps/effectcraft-web/src/app.rs index 1a6952408..a5fad30b1 100644 --- a/apps/effectcraft-web/src/app.rs +++ b/apps/effectcraft-web/src/app.rs @@ -184,6 +184,11 @@ fn install_hooks(app: &mut EffectcraftApp) { #[wasm_bindgen] pub async fn start(canvas_id: String) -> Result<(), JsValue> { eframe::WebLogger::init(log::LevelFilter::Info).ok(); + // Match System follows `?lang=` in the address, then the browser's languages; a language + // chosen in Settings still wins. + let navigator = web_sys::window().map(|w| w.navigator()); + let browser = navigator.iter().flat_map(|n| n.languages().iter().filter_map(|v| v.as_string()).collect::>()); + effectcraft_ui_egui::i18n::set_system_locales(query_value("lang").into_iter().chain(browser).collect()); let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?; let canvas: web_sys::HtmlCanvasElement = doc.get_element_by_id(&canvas_id).ok_or("no canvas")?.dyn_into()?; let empty = query_flag("empty"); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 8bc348df7..315e2ba34 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -66,16 +66,35 @@ pub(crate) fn czech(app: &EffectcraftApp) -> bool { /// Settings ▸ General ▸ Language ▸ Match System: the operating system's interface language /// where EffectCraft has it, else English (#229), as After Effects installs in the system's -/// language. The browser build stays in English because it has no system-locale probe. +/// language. The browser build follows the locales its page passes in ([`set_system_locales`]). fn system_language() -> &'static str { #[cfg(not(target_arch = "wasm32"))] { static LANGUAGE: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new(); LANGUAGE.get_or_init(|| supported(sys_locale::get_locale().as_deref())) } - // No system locale in the browser build: English. #[cfg(target_arch = "wasm32")] - supported(None) + first_supported(SYSTEM_LOCALES.get().map(Vec::as_slice).unwrap_or_default()) +} + +/// The browser's preferred languages, most preferred first (`?lang=` in the address, then +/// `navigator.languages`), for Match System in the web build. +static SYSTEM_LOCALES: std::sync::OnceLock> = std::sync::OnceLock::new(); + +/// Tell Match System the system's preferred languages (the web build has no locale probe of its +/// own). Set before the first frame; only the first call counts. +pub fn set_system_locales(tags: Vec) { + let _ = SYSTEM_LOCALES.set(tags); +} + +/// The first of `tags` EffectCraft has a catalog for (English counts), else English: someone who +/// prefers Dutch, then Czech, gets Czech. +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] +fn first_supported(tags: &[String]) -> &'static str { + tags.iter() + .map(|t| (t, supported(Some(t)))) + .find(|(t, code)| *code != "en" || t.split(['-', '_']).next().is_some_and(|p| p.eq_ignore_ascii_case("en"))) + .map_or("en", |(_, code)| code) } /// The language EffectCraft shows for a BCP 47 locale (`ja-JP` → `ja`, `zh-Hans-CN` → `zh-hans`, @@ -6643,6 +6662,16 @@ mod tests { assert_eq!(command_label("window.panel", "Project"), "Project"); } + #[test] + fn match_system_takes_the_first_browser_language_with_a_catalog() { + let tags = |t: &[&str]| t.iter().map(|s| s.to_string()).collect::>(); + assert_eq!(super::first_supported(&tags(&["cs-CZ", "cs", "en"])), "cs"); + assert_eq!(super::first_supported(&tags(&["nl-NL", "cs"])), "cs"); + assert_eq!(super::first_supported(&tags(&["en-US", "cs"])), "en"); + assert_eq!(super::first_supported(&tags(&["nl-NL"])), "en"); + assert_eq!(super::first_supported(&tags(&[])), "en"); + } + #[test] fn czech_translates_menus_without_changing_bindings_or_user_names() { let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); diff --git a/docs/preferences.md b/docs/preferences.md index 8b86528a1..59b0fc8a9 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -30,7 +30,7 @@ Commands (CLI, MCP, control channel): ### Settings that change behaviour -- `general.language`: interface language (`system` / `en` / `ja` / `zh-hans` / `zh-hant` / `uk` / `es` / `pt-br`), Settings ▸ General ▸ Language; menu labels change immediately. `system` (Match System, the default) follows the operating system's interface language where EffectCraft has a translation and is English otherwise: a Simplified Chinese locale (`zh`, `zh-CN`, `zh_CN.UTF-8`, `zh-Hans-CN`, `zh-SG`) selects `zh-hans`, a Traditional one (`zh-TW`, `zh-HK`, `zh-Hant`, `zh_MO`) selects `zh-hant`, a Ukrainian one (`uk`, `uk-UA`, `uk_UA.UTF-8`) selects `uk`, a Spanish one (`es`, `es-ES`, `es_MX.UTF-8`) selects `es`, a Portuguese one (`pt`, `pt-BR`, `pt_PT.UTF-8`) selects `pt-br`; the browser build stays in English with it. Choose **Español** or **Português (Brasil)** for Spanish or Brazilian Portuguese menus (panels stay in English for now); choose **Українська** for Ukrainian menus and cataloged panel, dialog, button and tooltip text; Ukrainian uses the bundled UI fonts. Native Japanese, Simplified and Traditional Chinese UI uses installed system fonts; the web host must supply a Japanese font. The same setting picks the CJK fallback for the interface *and* the Han preference of the text engine, so the composition's own text is drawn with the same language's glyph forms (the text engine's preference is process-global); swapping the language reinstalls the fonts, so it applies without a restart. Two catalogs back the text: the menus are keyed by `(command, English label)` (`crates/ui-egui/src/i18n.rs`), and the rest of the interface - panels, dialogs, buttons, tooltips - is keyed by its English source string (`crates/ui-egui/src/i18n/ui.rs`, with rows for Japanese, Simplified and Traditional Chinese and Ukrainian), where a panel writes `tr("Rename")`, or `tr_args("Proxy Use: {}", &[&quality])` when the row carries a value. A row that is missing falls back to English, so a new string shows up untranslated rather than empty; the tests in that module fail on an unused row and on a hard-coded string in a converted panel. +- `general.language`: interface language (`system` / `en` / `ja` / `zh-hans` / `zh-hant` / `uk` / `es` / `pt-br`), Settings ▸ General ▸ Language; menu labels change immediately. `system` (Match System, the default) follows the operating system's interface language where EffectCraft has a translation and is English otherwise: a Simplified Chinese locale (`zh`, `zh-CN`, `zh_CN.UTF-8`, `zh-Hans-CN`, `zh-SG`) selects `zh-hans`, a Traditional one (`zh-TW`, `zh-HK`, `zh-Hant`, `zh_MO`) selects `zh-hant`, a Ukrainian one (`uk`, `uk-UA`, `uk_UA.UTF-8`) selects `uk`, a Spanish one (`es`, `es-ES`, `es_MX.UTF-8`) selects `es`, a Portuguese one (`pt`, `pt-BR`, `pt_PT.UTF-8`) selects `pt-br`; the browser build follows `?lang=` in the address, then the browser's languages (`navigator.languages`). Choose **Español** or **Português (Brasil)** for Spanish or Brazilian Portuguese menus (panels stay in English for now); choose **Українська** for Ukrainian menus and cataloged panel, dialog, button and tooltip text; Ukrainian uses the bundled UI fonts. Native Japanese, Simplified and Traditional Chinese UI uses installed system fonts; the web host must supply a Japanese font. The same setting picks the CJK fallback for the interface *and* the Han preference of the text engine, so the composition's own text is drawn with the same language's glyph forms (the text engine's preference is process-global); swapping the language reinstalls the fonts, so it applies without a restart. Two catalogs back the text: the menus are keyed by `(command, English label)` (`crates/ui-egui/src/i18n.rs`), and the rest of the interface - panels, dialogs, buttons, tooltips - is keyed by its English source string (`crates/ui-egui/src/i18n/ui.rs`, with rows for Japanese, Simplified and Traditional Chinese and Ukrainian), where a panel writes `tr("Rename")`, or `tr_args("Proxy Use: {}", &[&quality])` when the row carries a value. A row that is missing falls back to English, so a new string shows up untranslated rather than empty; the tests in that module fail on an unused row and on a hard-coded string in a converted panel. - `general.undoLevels`: Levels of Undo - `general.pathPointSize`: Path Point and Handle Size - `general.recentItems`: Recent Projects Shown From fb51427ec0080226d8282e9c799277a750990060 Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Wed, 14 Oct 2026 02:40:33 +0400 Subject: [PATCH 034/279] i18n: Georgian (ka) interface language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Georgian (ქართული, code ka) to Settings > General > Language, with Match System for ka / ka-GE / ka_GE.UTF-8 locales: - i18n.rs: the menu catalog, the same 628 (command, English label) keys as the other languages, plus the Undo/Redo entries, the workspace and 3D view shortcut submenus, the Reveal labels on Windows and Linux and the macOS Services items. - i18n/ui.rs: GEORGIAN, every row of the Chinese panel catalogs (687), and GEORGIAN_PANELS, 195 rows for text the panels send through tr() that no catalog has yet (form dialogs, drop messages, screen-reader names, context menus, Project and Timeline column headers). The tests hold them to the same rules (unique, same placeholders, used by a converted panel) and keep them from shadowing a Chinese row; prefs_live.rs joins the converted files. - i18n/effects.rs: Georgian names for all 307 effects and 21 categories; the CC series and brand names stay in English. - Properties > Text Animation: the Add Animator and Presets buttons grow with their translated labels (English keeps 116 / 84 px). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- ROADMAP.md | 3 +- crates/engine/src/prefs.rs | 1 + crates/engine/src/tests_settings.rs | 6 + crates/ui-egui/src/i18n.rs | 739 +++++++++++++++++- crates/ui-egui/src/i18n/effects.rs | 366 ++++++++- crates/ui-egui/src/i18n/ui.rs | 986 +++++++++++++++++++++++- crates/ui-egui/src/native_menu.rs | 23 + crates/ui-egui/src/panels/properties.rs | 6 +- docs/gaps.md | 6 +- docs/localization-parity.md | 10 +- docs/preferences.md | 2 +- 11 files changed, 2130 insertions(+), 18 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a579491a8..26786ee9a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -97,7 +97,7 @@ UI strings translated out of ≈ 3,900 (detail: [localization-parity.md](docs/lo | Korean | none | 0% | | Vietnamese | none | 0% | -Also shipped: Traditional Chinese (partial, 29%), Ukrainian (partial, 29%), Czech (partial, 29%) and Russian (menus only, 16%). No language has had +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 native-speaker review. ## Upcoming @@ -131,6 +131,7 @@ 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. diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 3abf336b0..9bcea9444 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -36,6 +36,7 @@ pub const LANGUAGES: &[(&str, &str)] = &[ ("Français", "fr"), ("Deutsch", "de"), ("Čeština", "cs"), + ("ქართული", "ka"), ]; /// Settings ▸ Startup & Repair ▸ Window Graphics: (label, `startup.windowGraphics` value). diff --git a/crates/engine/src/tests_settings.rs b/crates/engine/src/tests_settings.rs index 458c886fc..baf914619 100644 --- a/crates/engine/src/tests_settings.rs +++ b/crates/engine/src/tests_settings.rs @@ -766,6 +766,12 @@ fn interface_language_is_validated_persisted_and_backward_compatible() { cs.load_settings(); assert_eq!(cs.prefs.general.language, "cs"); assert_eq!(Prefs::from_json(r#"{"general":{"language":"cs"}}"#).general.language, "cs"); + // Georgian too. + assert!(crate::prefs::LANGUAGES.contains(&("ქართული", "ka"))); + s.execute("prefs.set", json!({"key": "general.language", "value": "ka"})).unwrap(); + let mut ka = Session { config: Some(store.clone()), ..Default::default() }; + ka.load_settings(); + assert_eq!(ka.prefs.general.language, "ka"); s.execute("prefs.set", json!({"key": "general.language", "value": "ja"})).unwrap(); let mut reloaded = Session { config: Some(store), ..Default::default() }; reloaded.load_settings(); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 315e2ba34..ed5de67cf 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -13,7 +13,7 @@ use effectcraft_engine::menus::{MenuEntry, MenuNode}; pub(crate) use ui::{set_current, tr, tr_args}; /// Every language with a catalog, in the order Settings ▸ General ▸ Language lists them. -const CATALOG_CODES: [&str; 10] = ["ja", "zh-hans", "zh-hant", "uk", "es", "pt-br", "ru", "fr", "de", "cs"]; +const CATALOG_CODES: [&str; 11] = ["ja", "zh-hans", "zh-hant", "uk", "es", "pt-br", "ru", "fr", "de", "cs", "ka"]; /// The active interface language: the stored preference when it names a catalog, otherwise `en` /// (English is the source of every label and needs no lookup). @@ -64,6 +64,11 @@ pub(crate) fn czech(app: &EffectcraftApp) -> bool { language(app) == "cs" } +/// The UI is in Georgian. +pub(crate) fn georgian(app: &EffectcraftApp) -> bool { + language(app) == "ka" +} + /// Settings ▸ General ▸ Language ▸ Match System: the operating system's interface language /// where EffectCraft has it, else English (#229), as After Effects installs in the system's /// language. The browser build follows the locales its page passes in ([`set_system_locales`]). @@ -98,7 +103,8 @@ fn first_supported(tags: &[String]) -> &'static str { } /// The language EffectCraft shows for a BCP 47 locale (`ja-JP` → `ja`, `zh-Hans-CN` → `zh-hans`, -/// `zh-TW` → `zh-hant`, `uk-UA` → `uk`, `es-ES` → `es`, `pt-BR` → `pt-br`, `cs-CZ` → `cs`). +/// `zh-TW` → `zh-hant`, `uk-UA` → `uk`, `es-ES` → `es`, `pt-BR` → `pt-br`, `cs-CZ` → `cs`, +/// `ka-GE` → `ka`). fn supported(locale: Option<&str>) -> &'static str { let Some(locale) = locale else { return "en" }; let mut parts = locale.split(['-', '_']); @@ -109,6 +115,7 @@ fn supported(locale: Option<&str>) -> &'static str { "uk" => "uk", "es" => "es", "cs" => "cs", + "ka" => "ka", // The only Portuguese catalog: Brazil's, also for Portugal rather than English. "pt" => "pt-br", "zh" => { @@ -137,6 +144,13 @@ pub(crate) fn label<'a>(app: &EffectcraftApp, command: &str, source: &'a str) -> _ => {} } } + if language == "ka" && matches!(command, "file.revealInFinder" | "layer.revealInFinder") { + match source { + "Reveal in Explorer" => return "Explorer-ში ჩვენება", + "Reveal in File Manager" => return "ფაილების მენეჯერში ჩვენება", + _ => {} + } + } lookup(language, command, source).unwrap_or(source) } @@ -165,6 +179,7 @@ fn lookup(language: &str, command: &str, source: &str) -> Option<&'static str> { "fr" => FRENCH, "de" => GERMAN, "cs" => CZECH, + "ka" => GEORGIAN, _ => return None, }; catalog.iter().find(|(id, en, _)| *id == command && *en == source).map(|(_, _, translated)| *translated) @@ -190,6 +205,8 @@ pub(crate) fn entry(app: &EffectcraftApp, e: &MenuEntry, shown: String) -> Strin "es" => ("Deshacer", "No se puede deshacer", "Rehacer", "No se puede rehacer"), "pt-br" => ("Desfazer", "Não há nada para desfazer", "Refazer", "Não há nada para refazer"), "cs" => ("Zpět", "Není co vrátit", "Znovu", "Není co zopakovat"), + // A colon before the history's (untranslated) action name: "დაბრუნება: Comp1". + "ka" => ("დაბრუნება:", "დაბრუნება შეუძლებელია", "ხელახლა შესრულება:", "ხელახლა შესრულება შეუძლებელია"), _ => return shown, }; match e.command.as_str() { @@ -299,6 +316,17 @@ pub(crate) fn submenu(app: &EffectcraftApp, source: &str, shown: String) -> Stri return format!("Atribuir atalho à visualização 3D “{name}”"); } } + "ka" => { + if source == "Assign Shortcut to Workspace" { + if let Some(name) = shown.strip_prefix("Assign Shortcut to “").and_then(|s| s.strip_suffix("” Workspace")) { + return format!("მალსახმობის მინიჭება სამუშაო სივრცისთვის „{name}“"); + } + } else if source == "Assign Shortcut to 3D View" + && let Some(name) = shown.strip_prefix("Assign Shortcut to “").and_then(|s| s.strip_suffix('”')) + { + return format!("მალსახმობის მინიჭება 3D ხედისთვის „{name}“"); + } + } _ => {} } label(app, "", &shown).into() @@ -6641,6 +6669,641 @@ const CZECH: &[(&str, &str, &str)] = &[ ("file.openDemoProject", "Open Demo Project", "Otevřít ukázkový projekt"), ]; +/// Georgian (ka): the same (command, English label) keys as `JAPANESE`, one entry per fixed menu row. +/// Original translations written from the meaning of the English labels, sharing the terminology of +/// the other Craft apps' Georgian catalogs (შრე, საკვანძო კადრი, კომპოზიცია, ნიღაბი); commands are +/// verbal nouns (შენახვა, გახსნა) and Georgian has no capital letters. +const GEORGIAN: &[(&str, &str, &str)] = &[ + ("", "EffectCraft", "EffectCraft"), + ("app.about", "About EffectCraft...", "EffectCraft-ის შესახებ..."), + ("", "Settings...", "პარამეტრები..."), + ("app.settings", "General...", "ზოგადი..."), + ("app.settings", "Startup & Repair...", "გაშვება და აღდგენა..."), + ("app.settings", "Project...", "პროექტი..."), + ("app.settings", "Composition...", "კომპოზიცია..."), + ("app.settings", "Previews...", "წინასწარი ნახვა..."), + ("app.settings", "Appearance...", "იერსახე..."), + ("app.settings", "Grids & Guides...", "ბადე და დამხმარე ხაზები..."), + ("app.settings", "Labels...", "იარლიყები..."), + ("app.settings", "Type...", "ტექსტი..."), + ("app.settings", "Import...", "იმპორტი..."), + ("app.settings", "Export...", "ექსპორტი..."), + ("app.settings", "Audio...", "აუდიო..."), + ("app.settings", "Disk...", "დისკი..."), + ("app.settings", "Memory & CPU...", "მეხსიერება და CPU..."), + ("app.settings", "Video...", "ვიდეო..."), + ("app.settings", "3D...", "3D..."), + ("app.settings", "Scripting & Expressions...", "სკრიპტები და ექსპრესიები..."), + ("app.hide", "Hide EffectCraft", "EffectCraft-ის დამალვა"), + ("app.hideOthers", "Hide Others", "სხვების დამალვა"), + ("app.showAll", "Show All", "ყველას ჩვენება"), + ("app.quit", "Quit EffectCraft", "EffectCraft-იდან გასვლა"), + ("", "File", "ფაილი"), + ("", "New", "ახალი"), + ("file.newProject", "New Project", "ახალი პროექტი"), + ("file.newFromTemplate", "New Project from Template...", "ახალი პროექტი შაბლონიდან..."), + ("project.newFolder", "New Folder", "ახალი საქაღალდე"), + ("file.open", "Open Project...", "პროექტის გახსნა..."), + ("", "Open Recent", "ბოლოს გახსნილი ფაილების გახსნა"), + ("file.clearRecent", "Clear Recent Projects", "ბოლო პროექტების სიის გასუფთავება"), + ("file.close", "Close", "დახურვა"), + ("file.closeProject", "Close Project", "პროექტის დახურვა"), + ("file.save", "Save", "შენახვა"), + ("", "Save As", "შენახვა როგორც"), + ("file.saveAs", "Save As...", "შენახვა როგორც..."), + ("file.saveCopy", "Save a Copy...", "ასლის შენახვა..."), + ("file.saveCopyAsXml", "Save a Copy As XML...", "ასლის შენახვა XML-ად..."), + ("templates.saveAs", "Save as Template...", "შაბლონად შენახვა..."), + ("file.incrementAndSave", "Increment and Save", "ვერსიის გაზრდით შენახვა"), + ("file.revert", "Revert", "წინა ვერსიაზე დაბრუნება"), + ("", "Import", "იმპორტი"), + ("file.import", "File...", "ფაილი..."), + ("file.importMultiple", "Multiple Files...", "რამდენიმე ფაილი..."), + ("file.importTimeline", "Adobe Premiere Pro Project...", "Adobe Premiere Pro-ს პროექტი..."), + ("file.importProject", "EffectCraft Project...", "EffectCraft-ის პროექტი..."), + ("file.importPlaceholder", "Placeholder...", "ჩანაცვლების ობიექტი..."), + ("file.importSolid", "Solid...", "სოლიდი..."), + ("file.importLottie", "Lottie...", "Lottie..."), + ("file.importVanishingPoint", "Vanishing Point (.vpe)...", "Vanishing Point (.vpe)..."), + ("essential.importTemplate", "Essential Graphics Template...", "ძირითადი გრაფიკის შაბლონი..."), + ("", "Import Recent Footage", "ბოლო ვიდეომასალის იმპორტი"), + ("file.clearRecentFootage", "Clear Recent Footage", "ბოლო ვიდეომასალის სიის გასუფთავება"), + ("", "Export", "ექსპორტი"), + ("renderQueue.add", "Add to Render Queue", "რენდერის რიგში დამატება"), + ("file.exportTimeline", "Adobe Premiere Pro Project...", "Adobe Premiere Pro-ს პროექტი..."), + ("file.exportLottie", "Lottie JSON...", "Lottie JSON..."), + ("essential.exportTemplate", "Essential Graphics Template...", "ძირითადი გრაფიკის შაბლონი..."), + ("app.find", "Find", "ძებნა"), + ("layer.addItem", "Add Footage to Comp", "ვიდეომასალის კომპოზიციაში დამატება"), + ("file.newCompFromSelection", "New Comp from Selection...", "ახალი კომპოზიცია მონიშნულიდან..."), + ("", "Dependencies", "დამოკიდებულებები"), + ("file.collectFiles", "Collect Files...", "ფაილების შეგროვება..."), + ("file.consolidateFootage", "Consolidate All Footage", "მთელი ვიდეომასალის კონსოლიდაცია"), + ("file.removeUnusedFootage", "Remove Unused Footage", "გამოუყენებელი ვიდეომასალის წაშლა"), + ("file.reduceProject", "Reduce Project", "პროექტის შემცირება"), + ("file.findMissing", "Find Missing Effects", "დაკარგული ეფექტების ძებნა"), + ("file.findMissing", "Find Missing Fonts", "დაკარგული შრიფტების ძებნა"), + ("file.findMissing", "Find Missing Footage", "დაკარგული ვიდეომასალის ძებნა"), + ("file.relinkFootage", "Relink Missing Footage...", "დაკარგული ვიდეომასალის ხელახლა დაკავშირება..."), + ("file.watchFolder", "Watch Folder...", "საქაღალდეზე თვალყურის დევნება..."), + ("", "Scripts", "სკრიპტები"), + ("file.installScript", "Install Script File...", "სკრიპტის ფაილის დაყენება..."), + ("file.installScriptUIPanel", "Install ScriptUI Panel...", "ScriptUI პანელის დაყენება..."), + ("file.runScript", "Run Script File...", "სკრიპტის ფაილის გაშვება..."), + ("", "Create Proxy", "პროქსის შექმნა"), + ("file.createProxy", "Still...", "სტატიკური კადრი..."), + ("file.createProxy", "Movie...", "ვიდეო..."), + ("", "Set Proxy", "პროქსის მინიჭება"), + ("file.setProxy", "File...", "ფაილი..."), + ("file.setProxyNone", "None", "არცერთი"), + ("", "Interpret Footage", "ვიდეომასალის ინტერპრეტაცია"), + ("file.interpretFootage", "Main...", "ძირითადი..."), + ("file.interpretProxy", "Proxy...", "პროქსი..."), + ("file.rememberInterpretation", "Remember Interpretation", "ინტერპრეტაციის დამახსოვრება"), + ("file.applyInterpretation", "Apply Interpretation", "ინტერპრეტაციის გამოყენება"), + ("", "Replace Footage", "ვიდეომასალის ჩანაცვლება"), + ("file.replaceFootage", "File...", "ფაილი..."), + ("file.replaceWithPlaceholder", "Placeholder...", "ჩანაცვლების ობიექტი..."), + ("file.replaceWithSolid", "Solid...", "სოლიდი..."), + ("file.replaceWithLayeredComp", "With Layered Comp", "შრეებიანი კომპოზიციით"), + ("file.reloadFootage", "Reload Footage", "ვიდეომასალის ხელახლა ჩატვირთვა"), + ("file.revealInFinder", "Reveal in Finder", "Finder-ში ჩვენება"), + ("file.projectSettings", "Project Settings...", "პროექტის პარამეტრები..."), + ("app.quit", "Exit", "გასვლა"), + ("", "Edit", "რედაქტირება"), + ("edit.undo", "Undo", "დაბრუნება"), + ("edit.redo", "Redo", "ხელახლა შესრულება"), + ("", "History", "ისტორია"), + ("app.commandPalette", "Quick Apply...", "სწრაფი გამოყენება..."), + ("edit.cut", "Cut", "ამოჭრა"), + ("edit.copy", "Copy", "კოპირება"), + ("edit.copyWithPropertyLinks", "Copy with Property Links", "კოპირება თვისებების ბმულებით"), + ("edit.copyWithRelativePropertyLinks", "Copy with Relative Property Links", "კოპირება თვისებების ფარდობითი ბმულებით"), + ("edit.copyExpressionOnly", "Copy Expression Only", "მხოლოდ ექსპრესიის კოპირება"), + ("edit.paste", "Paste", "ჩასმა"), + ("edit.pasteReversedKeyframes", "Paste Reversed Keyframes", "საკვანძო კადრების შებრუნებული ჩასმა"), + ("edit.pasteTextMatchFormatting", "Paste Text and Match Formatting", "ტექსტის ჩასმა ფორმატის შეთანხმებით"), + ("edit.pasteTextFormattingOnly", "Paste Text Formatting Only", "მხოლოდ ტექსტის ფორმატის ჩასმა"), + ("edit.clear", "Clear", "გასუფთავება"), + ("edit.duplicate", "Duplicate", "დუბლირება"), + ("edit.splitLayer", "Split Layer", "შრის დაყოფა"), + ("edit.liftWorkArea", "Lift Work Area", "სამუშაო არის აწევა"), + ("edit.extractWorkArea", "Extract Work Area", "სამუშაო არის ამოღება"), + ("edit.selectAll", "Select All", "ყველაფრის მონიშვნა"), + ("edit.deselectAll", "Deselect All", "ყველა მონიშვნის მოხსნა"), + ("", "Label", "იარლიყი"), + ("edit.selectLabelGroup", "Select Label Group", "იარლიყის ჯგუფის მონიშვნა"), + ("edit.label", "None", "არცერთი"), + ("edit.label", "Red", "წითელი"), + ("edit.label", "Yellow", "ყვითელი"), + ("edit.label", "Aqua", "ზღვისფერი"), + ("edit.label", "Pink", "ვარდისფერი"), + ("edit.label", "Lavender", "ლავანდისფერი"), + ("edit.label", "Peach", "ატმისფერი"), + ("edit.label", "Sea Foam", "ზღვის ქაფი"), + ("edit.label", "Blue", "ლურჯი"), + ("edit.label", "Green", "მწვანე"), + ("edit.label", "Purple", "იისფერი"), + ("edit.label", "Orange", "ნარინჯისფერი"), + ("edit.label", "Brown", "ყავისფერი"), + ("edit.label", "Fuchsia", "ფუქსია"), + ("edit.label", "Cyan", "ცისფერი"), + ("edit.label", "Sandstone", "ქვიშაქვა"), + ("edit.label", "Dark Green", "მუქი მწვანე"), + ("app.settings", "Edit Label Colors...", "იარლიყის ფერების რედაქტირება..."), + ("", "Select Keyframe Label Group", "საკვანძო კადრების იარლიყის ჯგუფის მონიშვნა"), + ("keys.selectLabelGroup", "On Selected Layers", "მონიშნულ შრეებზე"), + ("keys.selectLabelGroup", "On All Layers", "ყველა შრეზე"), + ("keys.selectLabelGroup", "Visible Keyframes on Selected Layers", "ხილული საკვანძო კადრები მონიშნულ შრეებზე"), + ("keys.selectLabelGroup", "Visible Keyframes on All Layers", "ხილული საკვანძო კადრები ყველა შრეზე"), + ("", "Purge", "გაწმენდა"), + ("edit.purge", "All Cache...", "მთელი ქეში..."), + ("edit.purge", "All Memory & Disk Cache...", "მთელი მეხსიერება და დისკის ქეში..."), + ("edit.purge", "All Memory", "მთელი მეხსიერება"), + ("edit.purge", "All Disk Cache...", "მთელი დისკის ქეში..."), + ("edit.purge", "All 3D Cache...", "მთელი 3D ქეში..."), + ("edit.purgeUndo", "Undo", "დაბრუნების ისტორია"), + ("edit.purge", "Image Cache Memory", "გამოსახულების ქეშის მეხსიერება"), + ("edit.purge", "Snapshot", "მომენტალური ასლი"), + ("edit.editOriginal", "Edit Original...", "ორიგინალის რედაქტირება..."), + ("", "Templates", "შაბლონები"), + ("app.templates", "Render Settings...", "რენდერის პარამეტრები..."), + ("app.templates", "Output Module...", "გამოტანის მოდული..."), + ("app.keyboardShortcuts", "Keyboard Shortcuts", "კლავიატურის მალსახმობები"), + ("", "Preferences", "პარამეტრები"), + ("", "Composition", "კომპოზიცია"), + ("comp.new", "New Composition...", "ახალი კომპოზიცია..."), + ("comp.settings", "Composition Settings...", "კომპოზიციის პარამეტრები..."), + ("comp.setPosterTime", "Set Poster Time", "პოსტერის დროის დაყენება"), + ("comp.trimToWorkArea", "Trim Comp to Work Area", "კომპოზიციის მოკვეთა სამუშაო არემდე"), + ("comp.cropToRegionOfInterest", "Crop Comp to Region of Interest", "კომპოზიციის ჩამოჭრა ინტერესის არემდე"), + ("comp.cropToLayerBounds", "Crop Comp to Selected Layer(s) Bounds", "კომპოზიციის ჩამოჭრა მონიშნული შრ(ეებ)ის საზღვრებამდე"), + ("render.addOutputModule", "Add Output Module", "გამოტანის მოდულის დამატება"), + ("", "Preview", "წინასწარი ნახვა"), + ("playback.toggle", "Play Current Preview", "მიმდინარე წინასწარი ნახვის დაკვრა"), + ("playback.cacheWhenIdle", "Cache Frames When Idle", "კადრების ქეშირება უქმობისას"), + ("playback.audio", "Audio", "აუდიო"), + ("", "Save Frame As", "კადრის შენახვა როგორც"), + ("comp.saveFrameAs", "File...", "ფაილი..."), + ("comp.saveFrameAsPsd", "Photoshop Layers...", "Photoshop-ის შრეები..."), + ("comp.saveFrameAsExr", "ProEXR...", "ProEXR..."), + ("comp.copyFrame", "Copy Frame to Clipboard", "კადრის კოპირება გაცვლის ბუფერში"), + ("render.preRender", "Pre-render...", "წინასწარი რენდერი..."), + ("render.saveCurrentPreview", "Save Current Preview...", "მიმდინარე წინასწარი ნახვის შენახვა..."), + ("comp.openInEssentialGraphics", "Open in Essential Graphics", "ძირითად გრაფიკაში გახსნა"), + ("", "Responsive Design — Time", "რესპონსიული დიზაინი — დრო"), + ("comp.responsiveTime", "Create Intro", "შესავლის შექმნა"), + ("comp.responsiveTime", "Create Outro", "დასასრულის შექმნა"), + ("comp.responsiveTime", "Create Protected Region from Work Area", "დაცული არის შექმნა სამუშაო არიდან"), + ("comp.flowchart", "Composition Flowchart", "კომპოზიციის ბლოკ-სქემა"), + ("comp.miniFlowchart", "Composition Mini-Flowchart", "კომპოზიციის მინი-ბლოკ-სქემა"), + ("", "VR", "VR"), + ("comp.vr.createEnvironment", "Create VR Environment...", "VR გარემოს შექმნა..."), + ("comp.vr.extractCubemap", "Extract Cubemap...", "კუბური რუკის ამოღება..."), + ("", "Layer", "შრე"), + ("layer.newText", "Text", "ტექსტი"), + ("layer.newSolid", "Solid...", "სოლიდი..."), + ("layer.newLight", "Light...", "სინათლე..."), + ("layer.newCamera", "Camera...", "კამერა..."), + ("layer.newNull", "Null Object", "ნულოვანი ობიექტი"), + ("layer.newShape", "Shape Layer", "ფიგურის შრე"), + ("layer.newAdjustment", "Adjustment Layer", "კორექციის შრე"), + ("layer.newContentAwareFill", "Content-Aware Fill Layer...", "შიგთავსის გათვალისწინებით შევსების შრე..."), + ("layer.new3dPrimitive", "Cube", "კუბი"), + ("layer.new3dPrimitive", "Sphere", "სფერო"), + ("layer.new3dPrimitive", "Plane", "სიბრტყე"), + ("layer.new3dPrimitive", "Torus", "ტორი"), + ("layer.new3dPrimitive", "Cone", "კონუსი"), + ("layer.new3dPrimitive", "Cylinder", "ცილინდრი"), + ("layer.settings", "Layer Settings...", "შრის პარამეტრები..."), + ("layer.openLayer", "Open Layer", "შრის გახსნა"), + ("layer.openSource", "Open Layer Source", "შრის წყაროს გახსნა"), + ("layer.revealInFinder", "Reveal in Finder", "Finder-ში ჩვენება"), + ("", "Mask", "ნიღაბი"), + ("layer.addMask", "New Mask", "ახალი ნიღაბი"), + ("layer.mask.shape", "Mask Shape...", "ნიღბის ფორმა..."), + ("layer.mask.set", "Mask Feather...", "ნიღბის კიდეების შერბილება..."), + ("layer.mask.set", "Mask Opacity...", "ნიღბის გაუმჭვირვალობა..."), + ("layer.mask.set", "Mask Expansion...", "ნიღბის გაფართოება..."), + ("layer.mask.reset", "Reset Mask", "ნიღბის ჩამოყრა"), + ("layer.mask.remove", "Remove Mask", "ნიღბის წაშლა"), + ("layer.mask.removeAll", "Remove All Masks", "ყველა ნიღბის წაშლა"), + ("track.mask", "Track Mask", "ნიღბის ტრეკინგი"), + ("", "Mode", "რეჟიმი"), + ("layer.mask.mode", "None", "არცერთი"), + ("layer.mask.mode", "Add", "დამატება"), + ("layer.mask.mode", "Subtract", "გამოკლება"), + ("layer.mask.mode", "Intersect", "გადაკვეთა"), + ("layer.mask.mode", "Lighten", "გაღიავება"), + ("layer.mask.mode", "Darken", "ჩამუქება"), + ("layer.mask.mode", "Difference", "სხვაობა"), + ("layer.mask.invert", "Inverted", "შებრუნებული"), + ("layer.mask.lock", "Locked", "ჩაკეტილი"), + ("", "Motion Blur", "მოძრაობის გაბუნდოვნება"), + ("layer.mask.motionBlur", "Same As Layer", "შრის მსგავსად"), + ("layer.mask.motionBlur", "On", "ჩართული"), + ("layer.mask.motionBlur", "Off", "გამორთული"), + ("", "Feather Falloff", "კიდეების შერბილების მილევა"), + ("layer.mask.featherFalloff", "Smooth", "გლუვი"), + ("layer.mask.featherFalloff", "Linear", "წრფივი"), + ("layer.mask.unlockAll", "Unlock All Masks", "ყველა ნიღბის განბლოკვა"), + ("layer.mask.lockOthers", "Lock Other Masks", "სხვა ნიღბების ჩაკეტვა"), + ("layer.mask.hideLocked", "Hide Locked Masks", "ჩაკეტილი ნიღბების დამალვა"), + ("", "Mask and Shape Path", "ნიღაბი და ფიგურის ტრაექტორია"), + ("path.rotoBezier", "RotoBezier", "RotoBezier"), + ("mask.setClosed", "Closed", "შეკრული"), + ("path.convertToBezier", "Convert To Bezier Path", "ბეზიეს ტრაექტორიად გარდაქმნა"), + ("path.setFirstVertex", "Set First Vertex", "პირველი წვეროს დაყენება"), + ("path.freeTransform", "Free Transform Points", "წერტილების თავისუფალი გარდაქმნა"), + ("", "Quality", "ხარისხი"), + ("layer.quality", "Best", "საუკეთესო"), + ("layer.quality", "Draft", "მონახაზი"), + ("layer.quality", "Wireframe", "კარკასი"), + ("layer.sampling", "Bilinear", "ბიწრფივი"), + ("layer.sampling", "Bicubic", "ბიკუბური"), + ("", "Switches", "გადამრთველები"), + ("layer.hideOtherVideo", "Hide Other Video", "სხვა ვიდეოს დამალვა"), + ("layer.showAllVideo", "Show All Video", "მთელი ვიდეოს ჩვენება"), + ("layer.unlockAll", "Unlock All Layers", "ყველა შრის განბლოკვა"), + ("layer.expressions", "Enable Expressions", "ექსპრესიების ჩართვა"), + ("layer.expressions", "Disable Expressions", "ექსპრესიების გამორთვა"), + ("layer.setSwitch", "Shy", "მორცხვი"), + ("layer.setSwitch", "Lock", "ჩაკეტვა"), + ("layer.setSwitch", "Audio", "აუდიო"), + ("layer.setSwitch", "Video", "ვიდეო"), + ("layer.setSwitch", "Solo", "სოლო"), + ("layer.setSwitch", "Effect", "ეფექტი"), + ("layer.setSwitch", "Collapse", "ჩაკეცვა"), + ("layer.setSwitch", "Motion Blur", "მოძრაობის გაბუნდოვნება"), + ("layer.setSwitch", "Adjustment Layer", "კორექციის შრე"), + ("", "Transform", "გარდაქმნა"), + ("layer.transform", "Reset", "ჩამოყრა"), + ("layer.setTransform", "Anchor Point...", "საყრდენი წერტილი..."), + ("layer.setTransform", "Position...", "პოზიცია..."), + ("layer.setTransform", "Scale...", "მასშტაბი..."), + ("layer.setTransform", "Orientation...", "ორიენტაცია..."), + ("layer.setTransform", "Rotation...", "შემობრუნება..."), + ("layer.setTransform", "Opacity...", "გაუმჭვირვალობა..."), + ("layer.transform", "Flip Horizontal", "ჰორიზონტალურად არეკვლა"), + ("layer.transform", "Flip Vertical", "ვერტიკალურად არეკვლა"), + ("layer.transform", "Center In View", "ხედის ცენტრში მოთავსება"), + ("layer.centerAnchor", "Center Anchor Point in Layer Content", "საყრდენი წერტილის შრის შიგთავსის ცენტრში მოთავსება"), + ("layer.transform", "Fit to Comp", "კომპოზიციაში ჩატევა"), + ("layer.transform", "Fit to Comp Width", "კომპოზიციის სიგანეში ჩატევა"), + ("layer.transform", "Fit to Comp Height", "კომპოზიციის სიმაღლეში ჩატევა"), + ("layer.autoOrient", "Auto-Orient...", "ავტოორიენტაცია..."), + ("", "Time", "დრო"), + ("layer.enableTimeRemap", "Enable Time Remapping", "დროის გადანაწილების ჩართვა"), + ("layer.timeReverse", "Time-Reverse Layer", "შრის დროში შებრუნება"), + ("layer.timeStretch", "Time Stretch...", "დროში გაწელვა..."), + ("layer.freezeFrame", "Freeze Frame", "კადრის გაყინვა"), + ("layer.freezeOnLastFrame", "Freeze On Last Frame", "ბოლო კადრზე გაყინვა"), + ("layer.alignVideoToData", "Align Video to Data", "ვიდეოს მონაცემებთან სწორება"), + ("", "Frame Blending", "კადრების შერევა"), + ("layer.frameBlending", "Off", "გამორთული"), + ("layer.frameBlending", "Frame Mix", "კადრების ნაზავი"), + ("layer.frameBlending", "Pixel Motion", "პიქსელების მოძრაობა"), + ("layer.setSwitch", "3D Layer", "3D შრე"), + ("layer.setSwitch", "Guide Layer", "დამხმარე შრე"), + ("layer.environment", "Environment Layer", "გარემოს შრე"), + ("", "Markers", "მარკერები"), + ("layer.addMarker", "Add Marker", "მარკერის დამატება"), + ("layer.updateMarkersFromSource", "Update Markers From Source", "მარკერების განახლება წყაროდან"), + ("layer.markersLock", "Lock Markers", "მარკერების ჩაკეტვა"), + ("layer.deleteAllMarkers", "Delete All Markers", "ყველა მარკერის წაშლა"), + ("layer.setSwitch", "Preserve Transparency", "გამჭვირვალობის შენარჩუნება"), + ("", "Blending Mode", "შერევის რეჟიმი"), + ("layer.setBlendMode", "Normal", "ჩვეულებრივი"), + ("layer.setBlendMode", "Dissolve", "გაფანტვა"), + ("layer.setBlendMode", "Dancing Dissolve", "მოცეკვავე გაფანტვა"), + ("layer.setBlendMode", "Darken", "ჩამუქება"), + ("layer.setBlendMode", "Multiply", "გამრავლება"), + ("layer.setBlendMode", "Color Burn", "ფერით ჩამუქება"), + ("layer.setBlendMode", "Classic Color Burn", "კლასიკური ფერით ჩამუქება"), + ("layer.setBlendMode", "Linear Burn", "წრფივი ჩამუქება"), + ("layer.setBlendMode", "Darker Color", "უფრო მუქი ფერი"), + ("layer.setBlendMode", "Add", "დამატება"), + ("layer.setBlendMode", "Lighten", "გაღიავება"), + ("layer.setBlendMode", "Screen", "ეკრანი"), + ("layer.setBlendMode", "Color Dodge", "ფერით გაღიავება"), + ("layer.setBlendMode", "Classic Color Dodge", "კლასიკური ფერით გაღიავება"), + ("layer.setBlendMode", "Linear Dodge", "წრფივი გაღიავება"), + ("layer.setBlendMode", "Lighter Color", "უფრო ღია ფერი"), + ("layer.setBlendMode", "Overlay", "გადაფარვა"), + ("layer.setBlendMode", "Soft Light", "რბილი სინათლე"), + ("layer.setBlendMode", "Hard Light", "მკვეთრი სინათლე"), + ("layer.setBlendMode", "Linear Light", "წრფივი სინათლე"), + ("layer.setBlendMode", "Vivid Light", "კაშკაშა სინათლე"), + ("layer.setBlendMode", "Pin Light", "წერტილოვანი სინათლე"), + ("layer.setBlendMode", "Hard Mix", "მკვეთრი შერევა"), + ("layer.setBlendMode", "Difference", "სხვაობა"), + ("layer.setBlendMode", "Classic Difference", "კლასიკური სხვაობა"), + ("layer.setBlendMode", "Exclusion", "გამორიცხვა"), + ("layer.setBlendMode", "Subtract", "გამოკლება"), + ("layer.setBlendMode", "Divide", "გაყოფა"), + ("layer.setBlendMode", "Hue", "ფერის ტონი"), + ("layer.setBlendMode", "Saturation", "გაჯერება"), + ("layer.setBlendMode", "Color", "ფერი"), + ("layer.setBlendMode", "Luminosity", "ნათება"), + ("layer.setBlendMode", "Stencil Alpha", "ალფა-ტრაფარეტი"), + ("layer.setBlendMode", "Stencil Luma", "ლუმა-ტრაფარეტი"), + ("layer.setBlendMode", "Silhouette Alpha", "ალფა-სილუეტი"), + ("layer.setBlendMode", "Silhouette Luma", "ლუმა-სილუეტი"), + ("layer.setBlendMode", "Alpha Add", "ალფას დამატება"), + ("layer.setBlendMode", "Luminescent Premul", "მანათობელი წინასწარ გამრავლებული"), + ("layer.setBlendMode", "Next Blending Mode", "შემდეგი შერევის რეჟიმი"), + ("layer.setBlendMode", "Previous Blending Mode", "წინა შერევის რეჟიმი"), + ("", "Track Matte", "ტრეკ-მატი"), + ("layer.trackMatte", "No Track Matte", "ტრეკ-მატის გარეშე"), + ("layer.trackMatte", "Alpha Matte", "ალფა-მატი"), + ("layer.trackMatte", "Alpha Inverted Matte", "შებრუნებული ალფა-მატი"), + ("layer.trackMatte", "Luma Matte", "ლუმა-მატი"), + ("layer.trackMatte", "Luma Inverted Matte", "შებრუნებული ლუმა-მატი"), + ("layer.trackMatte", "Matte with Layer Above", "მატი ზედა შრით"), + ("layer.trackMatte", "Matte with Layer Below", "მატი ქვედა შრით"), + ("", "Layer Styles", "შრის სტილები"), + ("layer.style.options", "Layer Style Options...", "შრის სტილის პარამეტრები..."), + ("layer.style.convertToEditable", "Convert to Editable Styles", "რედაქტირებად სტილებად გარდაქმნა"), + ("layer.style.showAll", "Show All", "ყველას ჩვენება"), + ("layer.style.removeAll", "Remove All", "ყველას წაშლა"), + ("layer.style.dropShadow", "Drop Shadow", "ჩრდილი"), + ("layer.style.innerShadow", "Inner Shadow", "შიდა ჩრდილი"), + ("layer.style.outerGlow", "Outer Glow", "გარე ნათება"), + ("layer.style.innerGlow", "Inner Glow", "შიდა ნათება"), + ("layer.style.bevelEmboss", "Bevel and Emboss", "ფასკა და რელიეფი"), + ("layer.style.satin", "Satin", "ატლასი"), + ("layer.style.colorOverlay", "Color Overlay", "ფერის გადაფარვა"), + ("layer.style.gradientOverlay", "Gradient Overlay", "გრადიენტის გადაფარვა"), + ("layer.style.stroke", "Stroke", "კონტური"), + ("path.groupShapes", "Group Shapes", "ფიგურების დაჯგუფება"), + ("path.ungroupShapes", "Ungroup Shapes", "ფიგურების დაჯგუფების მოხსნა"), + ("", "Arrange", "დალაგება"), + ("layer.arrange", "Bring Layer to Front", "შრის წინა პლანზე გადმოტანა"), + ("layer.arrange", "Bring Layer Forward", "შრის წინ გადმოტანა"), + ("layer.arrange", "Send Layer Backward", "შრის უკან გადატანა"), + ("layer.arrange", "Send Layer to Back", "შრის უკანა პლანზე გადატანა"), + ("", "Reveal", "ჩვენება"), + ("layer.revealSource", "Reveal Layer Source in Project", "შრის წყაროს ჩვენება პროექტში"), + ("comp.flowchart", "Reveal Layer in Project Flowchart", "შრის ჩვენება პროექტის ბლოკ-სქემაში"), + ("comp.revealInProject", "Reveal Composition in Project", "კომპოზიციის ჩვენება პროექტში"), + ("layer.revealExpressionErrors", "Reveal Expression Errors", "ექსპრესიის შეცდომების ჩვენება"), + ("", "Create", "შექმნა"), + ("layer.create", "Convert to Editable Text", "რედაქტირებად ტექსტად გარდაქმნა"), + ("layer.create", "Create Shapes from Text", "ფიგურების შექმნა ტექსტიდან"), + ("layer.create", "Create Masks from Text", "ნიღბების შექმნა ტექსტიდან"), + ("layer.create", "Create Shapes from Vector Layer", "ფიგურების შექმნა ვექტორული შრიდან"), + ("layer.create", "Create Keyframes from Data", "საკვანძო კადრების შექმნა მონაცემებიდან"), + ("layer.create", "Null Controllers for Positional Points", "ნულოვანი მართვის ობიექტები პოზიციის წერტილებისთვის"), + ("layer.create", "Null Controllers for Path Points", "ნულოვანი მართვის ობიექტები ტრაექტორიის წერტილებისთვის"), + ("layer.create", "Nulls Following Path Points", "ტრაექტორიის წერტილებს მიმყოლი ნულოვანი ობიექტები"), + ("layer.create", "Null Tracing Along Path", "ტრაექტორიის გასწვრივ მოძრავი ნულოვანი ობიექტი"), + ("layer.create", "Create 3D Layer Instance", "3D შრის ეგზემპლარის შექმნა"), + ("", "Camera", "კამერა"), + ("camera.fromView", "Create Camera from 3D View", "კამერის შექმნა 3D ხედიდან"), + ("camera.stereoRig", "Create Stereo 3D Rig", "სტერეო 3D სისტემის შექმნა"), + ("camera.orbitNull", "Create Orbit Null", "ორბიტის ნულოვანი ობიექტის შექმნა"), + ("camera.fromModel", "Create Cameras from 3D Model", "კამერების შექმნა 3D მოდელიდან"), + ("camera.linkFocusToPoi", "Link Focus Distance to Point of Interest", "ფოკუსური მანძილის მიბმა ინტერესის წერტილზე"), + ("camera.linkFocusToLayer", "Link Focus Distance to Layer", "ფოკუსური მანძილის მიბმა შრეზე"), + ("camera.setFocusToLayer", "Set Focus Distance to Layer", "ფოკუსური მანძილის დაყენება შრეზე"), + ("layer.cameraSettings", "Camera Settings...", "კამერის პარამეტრები..."), + ("view.reset3DView", "Reset 3D View", "3D ხედის ჩამოყრა"), + ("", "Light", "სინათლე"), + ("light.fromModel", "Create Lights from 3D Model", "სინათლის წყაროების შექმნა 3D მოდელიდან"), + ("light.controlWithCamera", "Control Light with Camera", "სინათლის მართვა კამერით"), + ("light.environmentBackground", "Create Environment Light Background Layer", "გარემოს სინათლის ფონური შრის შექმნა"), + ("", "Material", "მასალა"), + ("material.revealSource", "Reveal Material Source in Project", "მასალის წყაროს ჩვენება პროექტში"), + ("material.reset", "Reset Material", "მასალის ჩამოყრა"), + ("material.duplicateAssign", "Duplicate and Assign Material", "მასალის დუბლირება და მინიჭება"), + ("layer.autoTrace", "Auto-trace...", "ავტომატური ვექტორიზაცია..."), + ("layer.precompose", "Pre-compose...", "კომპოზიციაში მოთავსება..."), + ("layer.sceneEditDetection", "Scene Edit Detection...", "სცენების ჭრილების ამოცნობა..."), + ("", "Effect", "ეფექტი"), + ("window.panel", "Effect Controls", "ეფექტის მართვა"), + ("effect.applyLast", "Last Effect", "ბოლო ეფექტი"), + ("effect.removeAll", "Remove All", "ყველას წაშლა"), + ("effect.manage", "Manage Effects...", "ეფექტების მართვა..."), + ("effect.plugins.load", "Load Effect Plug-in...", "ეფექტის პლაგინის ჩატვირთვა..."), + ("", "Animation", "ანიმაცია"), + ("anim.savePreset", "Save Animation Preset...", "ანიმაციის პრესეტის შენახვა..."), + ("anim.applyPreset", "Apply Animation Preset...", "ანიმაციის პრესეტის გამოყენება..."), + ("", "Recent Animation Presets", "ბოლო ანიმაციის პრესეტები"), + ("anim.clearRecentPresets", "Clear Recent Presets", "ბოლო პრესეტების სიის გასუფთავება"), + ("anim.browsePresets", "Browse Presets...", "პრესეტების დათვალიერება..."), + ("", "Text Animation Presets", "ტექსტის ანიმაციის პრესეტები"), + ("layer.applyTextPreset", "Typewriter", "საბეჭდი მანქანა"), + ("layer.applyTextPreset", "Fade Up Characters", "ასოების ამოსვლა გამოჩენით"), + ("layer.applyTextPreset", "Bounce In Words", "სიტყვების ხტუნვით შემოსვლა"), + ("layer.applyTextPreset", "Tracking In", "ასოების შეკუმშვით შემოსვლა"), + ("layer.applyTextPreset", "Scramble", "არევა"), + ("layer.applyTextPreset", "Blur In", "გამოჩენა გაბუნდოვნებიდან"), + ("layer.applyTextPreset", "Jitter", "კანკალი"), + ("layer.applyTextPreset", "Drop In Lines", "სტრიქონების ჩამოვარდნა"), + ("anim.addKeyframe", "Add Keyframe", "საკვანძო კადრის დამატება"), + ("keys.toggleHold", "Toggle Hold Keyframe", "ფიქსირებული საკვანძო კადრის გადართვა"), + ("keys.interpolation", "Keyframe Interpolation...", "საკვანძო კადრის ინტერპოლაცია..."), + ("keys.velocity", "Keyframe Velocity...", "საკვანძო კადრის სიჩქარე..."), + ("", "Keyframe Assistant", "საკვანძო კადრების ასისტენტი"), + ("keys.audioToKeyframes", "Convert Audio to Keyframes", "აუდიოს საკვანძო კადრებად გარდაქმნა"), + ("prop.convertExpressionToKeyframes", "Convert Expression to Keyframes", "ექსპრესიის საკვანძო კადრებად გარდაქმნა"), + ("keys.easyEase", "Easy Ease", "რბილი შენელება"), + ("keys.easyEaseIn", "Easy Ease In", "რბილი შენელება შესვლისას"), + ("keys.easyEaseOut", "Easy Ease Out", "რბილი შენელება გამოსვლისას"), + ("keys.exponentialScale", "Exponential Scale", "ექსპონენციალური მასშტაბი"), + ("keys.rpfCameraImport", "RPF Camera Import", "RPF კამერის იმპორტი"), + ("layer.sequence", "Sequence Layers...", "შრეების თანმიმდევრობით განლაგება..."), + ("keys.timeReverse", "Time-Reverse Keyframes", "საკვანძო კადრების დროში შებრუნება"), + ("", "Animate Text", "ტექსტის ანიმირება"), + ("layer.enablePerChar3D", "Enable Per-character 3D", "თითოეული ასოს 3D-ს ჩართვა"), + ("layer.addTextAnimator", "Anchor Point", "საყრდენი წერტილი"), + ("layer.addTextAnimator", "Position", "პოზიცია"), + ("layer.addTextAnimator", "Scale", "მასშტაბი"), + ("layer.addTextAnimator", "Skew", "დახრა"), + ("layer.addTextAnimator", "Rotation", "შემობრუნება"), + ("layer.addTextAnimator", "Opacity", "გაუმჭვირვალობა"), + ("layer.addTextAnimator", "All Transform Properties", "გარდაქმნის ყველა თვისება"), + ("", "Fill Color", "შევსების ფერი"), + ("layer.addTextAnimator", "RGB", "RGB"), + ("layer.addTextAnimator", "Hue", "ფერის ტონი"), + ("layer.addTextAnimator", "Saturation", "გაჯერება"), + ("layer.addTextAnimator", "Brightness", "სიკაშკაშე"), + ("", "Stroke Color", "კონტურის ფერი"), + ("layer.addTextAnimator", "Stroke Width", "კონტურის სისქე"), + ("", "Front", "წინა მხარე"), + ("", "Bevel", "წაკვეთა"), + ("", "Side", "გვერდითი მხარე"), + ("", "Back", "უკანა მხარე"), + ("layer.addTextAnimator", "Color", "ფერი"), + ("layer.addTextAnimator", "Tracking", "ასოთაშორისი ინტერვალი"), + ("layer.addTextAnimator", "Line Anchor", "სტრიქონის საყრდენი"), + ("layer.addTextAnimator", "Line Spacing", "სტრიქონთა დაშორება"), + ("layer.addTextAnimator", "Character Offset", "ასოს წანაცვლება"), + ("layer.addTextAnimator", "Character Value", "ასოს მნიშვნელობა"), + ("layer.addTextAnimator", "Blur", "გაბუნდოვნება"), + ("text.animatorFontAxes", "Variable Font Axes", "ცვლადი შრიფტის ღერძები"), + ("", "Add Text Selector", "ტექსტის სელექტორის დამატება"), + ("text.addSelector", "Range", "დიაპაზონი"), + ("text.addSelector", "Wiggly", "რხევითი"), + ("text.addSelector", "Expression", "ექსპრესია"), + ("text.removeAllAnimators", "Remove All Text Animators", "ტექსტის ყველა ანიმატორის წაშლა"), + ("prop.setExpression", "Add Expression", "ექსპრესიის დამატება"), + ("essential.addProperty", "Add Property to Essential Graphics", "თვისების დამატება ძირითად გრაფიკაში"), + ("prop.separateDimensions", "Separate Dimensions", "განზომილებების გაყოფა"), + ("track.camera", "Track Camera", "კამერის ტრეკინგი"), + ("track.warpStabilizer", "Warp Stabilizer VFX", "Warp Stabilizer VFX"), + ("track.motion", "Track Motion", "მოძრაობის ტრეკინგი"), + ("track.stabilize", "Stabilize Motion", "მოძრაობის სტაბილიზაცია"), + ("track.property", "Track this Property", "ამ თვისების ტრეკინგი"), + ("anim.reveal", "Reveal Properties with Keyframes", "საკვანძო კადრების მქონე თვისებების ჩვენება"), + ("anim.reveal", "Reveal Properties with Animation", "ანიმაციის მქონე თვისებების ჩვენება"), + ("anim.reveal", "Reveal All Modified Properties", "ყველა შეცვლილი თვისების ჩვენება"), + ("", "View", "ხედი"), + ("view.newViewer", "New Viewer", "ახალი დამთვალიერებელი"), + ("view.splitLockedViewer", "Split with New Locked Viewer", "დაყოფა ახალი ჩაკეტილი დამთვალიერებელით"), + ("view.zoomIn", "Zoom In", "გადიდება"), + ("view.zoomOut", "Zoom Out", "დაპატარავება"), + ("", "Resolution", "გარჩევადობა"), + ("view.res.full", "Full", "სრული"), + ("view.res.half", "Half", "ნახევარი"), + ("view.res.third", "Third", "მესამედი"), + ("view.res.quarter", "Quarter", "მეოთხედი"), + ("view.res.custom", "Custom...", "მორგებული..."), + ("view.displayColorManagement", "Use Display Color Management", "ეკრანის ფერთა მართვის გამოყენება"), + ("", "Simulate Output", "გამოტანის იმიტაცია"), + ("view.simulateOutput", "No Output Simulation", "გამოტანის იმიტაციის გარეშე"), + ("view.simulateOutput", "HDTV (Rec. 709)", "HDTV (Rec. 709)"), + ("view.simulateOutput", "SDTV NTSC", "SDTV NTSC"), + ("view.simulateOutput", "SDTV PAL", "SDTV PAL"), + ("view.simulateOutput", "Legacy Macintosh RGB (Gamma 1.8)", "ძველი Macintosh RGB (გამა 1.8)"), + ("view.simulateOutput", "Internet Standard RGB (sRGB)", "ინტერნეტის სტანდარტული RGB (sRGB)"), + ("view.simulateOutput", "UHDTV (Rec. 2020)", "UHDTV (Rec. 2020)"), + ("view.simulateOutput", "Display P3", "Display P3"), + ("view.simulateOutput", "Linear (1.0 Gamma)", "წრფივი (გამა 1.0)"), + ("view.customRgb", "My Custom RGB...", "ჩემი მორგებული RGB..."), + ("view.simulateOutput", "Custom...", "მორგებული..."), + ("view.rulers", "Show Rulers", "სახაზავების ჩვენება"), + ("", "Panel Background Color", "პანელის ფონის ფერი"), + ("view.panelBackground", "Black", "შავი"), + ("view.panelBackground", "Dark Gray", "მუქი ნაცრისფერი"), + ("view.panelBackground", "Medium Gray (Default)", "საშუალო ნაცრისფერი (ნაგულისხმევი)"), + ("view.panelBackground", "Light Gray", "ღია ნაცრისფერი"), + ("view.panelBackground", "White", "თეთრი"), + ("view.panelBackground", "Custom", "მორგებული"), + ("view.panelBackground", "Select Custom Background Color...", "მორგებული ფონის ფერის არჩევა..."), + ("view.guides", "Show Guides", "დამხმარე ხაზების ჩვენება"), + ("view.snapToGuides", "Snap to Guides", "დამხმარე ხაზებზე მიბმა"), + ("view.lockGuides", "Lock Guides", "დამხმარე ხაზების ჩაკეტვა"), + ("view.addGuide", "Add Guide...", "დამხმარე ხაზის დამატება..."), + ("view.addPercentGuide", "Add Percentage Guide...", "პროცენტული დამხმარე ხაზის დამატება..."), + ("view.convertGuides", "Convert Guides to Percentage", "დამხმარე ხაზების პროცენტულად გარდაქმნა"), + ("view.convertGuides", "Convert Guides to Pixels", "დამხმარე ხაზების პიქსელებად გარდაქმნა"), + ("view.clearGuides", "Clear Guides", "დამხმარე ხაზების გასუფთავება"), + ("view.importGuides", "Import Guides...", "დამხმარე ხაზების იმპორტი..."), + ("view.exportGuides", "Export Guides...", "დამხმარე ხაზების ექსპორტი..."), + ("view.grid", "Show Grid", "ბადის ჩვენება"), + ("view.snapToGrid", "Snap to Grid", "ბადეზე მიბმა"), + ("view.snapping", "Snapping", "მიბმა"), + ("view.options", "View Options...", "ხედის პარამეტრები..."), + ("view.layerControls", "Show Layer Controls", "შრის მართვის ელემენტების ჩვენება"), + ("", "Switch View Layout", "ხედების განლაგების გადართვა"), + ("view.layout", "1 View", "1 ხედი"), + ("view.layout", "2 Views", "2 ხედი"), + ("view.layout", "4 Views", "4 ხედი"), + ("view.shareViewOptions", "Share View Options", "ხედის პარამეტრების გაზიარება"), + ("", "Switch 3D View", "3D ხედის გადართვა"), + ("view.3d.activeCamera", "Active Camera", "აქტიური კამერა"), + ("view.3d.default", "Default", "ნაგულისხმევი"), + ("view.3d.front", "Front", "წინიდან"), + ("view.3d.left", "Left", "მარცხნიდან"), + ("view.3d.top", "Top", "ზემოდან"), + ("view.3d.back", "Back", "უკნიდან"), + ("view.3d.right", "Right", "მარჯვნიდან"), + ("view.3d.bottom", "Bottom", "ქვემოდან"), + ("view.3d.custom1", "Custom View 1", "მორგებული ხედი 1"), + ("view.3d.custom2", "Custom View 2", "მორგებული ხედი 2"), + ("view.3d.custom3", "Custom View 3", "მორგებული ხედი 3"), + ("", "Assign Shortcut to 3D View", "მალსახმობის მინიჭება 3D ხედისთვის"), + ("view.3d.last", "Switch to Last 3D View", "ბოლო 3D ხედზე გადართვა"), + ("view.lookAtSelected", "Look at Selected Layers", "მონიშნული შრეების დათვალიერება"), + ("view.lookAtAll", "Look at All Layers", "ყველა შრის დათვალიერება"), + ("time.set", "Go to Time...", "დროზე გადასვლა..."), + ("view.fullScreen", "Enter Full Screen", "სრულეკრანიან რეჟიმში გადასვლა"), + ("", "Window", "ფანჯარა"), + ("", "Workspace", "სამუშაო სივრცე"), + ("window.workspace", "Default", "ნაგულისხმევი"), + ("window.workspace", "Review", "განხილვა"), + ("window.workspace", "Learn", "სწავლა"), + ("window.workspace", "Small Screen", "პატარა ეკრანი"), + ("window.workspace", "Standard", "სტანდარტული"), + ("window.workspace", "All Panels", "ყველა პანელი"), + ("window.workspace", "Animation", "ანიმაცია"), + ("window.workspace", "Color", "ფერი"), + ("window.workspace", "Effects", "ეფექტები"), + ("window.workspace", "Essential Graphics", "ძირითადი გრაფიკა"), + ("window.workspace", "Minimal", "მინიმალური"), + ("window.workspace", "Motion Tracking", "მოძრაობის ტრეკინგი"), + ("window.workspace", "Paint", "ხატვა"), + ("window.workspace", "Text", "ტექსტი"), + ("window.workspace", "Undocked Panels", "მოხსნილი პანელები"), + ("window.resetWorkspace", "Reset to Saved Layout", "შენახულ განლაგებაზე დაბრუნება"), + ("window.saveWorkspace", "Save Changes to this Workspace", "ამ სამუშაო სივრცეში ცვლილებების შენახვა"), + ("window.saveWorkspaceAs", "Save as New Workspace...", "ახალ სამუშაო სივრცედ შენახვა..."), + ("window.editWorkspaces", "Edit Workspaces...", "სამუშაო სივრცეების რედაქტირება..."), + ("", "Assign Shortcut to Workspace", "მალსახმობის მინიჭება სამუშაო სივრცისთვის"), + ("window.panel", "Align", "სწორება"), + ("window.panel", "Audio", "აუდიო"), + ("window.panel", "Brushes", "ფუნჯები"), + ("window.panel", "Character", "სიმბოლო"), + ("window.panel", "Content-Aware Fill", "შიგთავსის გათვალისწინებით შევსება"), + ("window.panel", "Effects & Presets", "ეფექტები და პრესეტები"), + ("window.panel", "Essential Graphics", "ძირითადი გრაფიკა"), + ("window.panel", "Info", "ინფორმაცია"), + ("help.inAppTutorials", "Learn", "სწავლა"), + ("window.panel", "Lumetri Scopes", "Lumetri Scopes"), + ("window.panel", "Mask Interpolation", "ნიღბის ინტერპოლაცია"), + ("window.panel", "Media Browser", "მედიის ბრაუზერი"), + ("window.panel", "Metadata", "მეტამონაცემები"), + ("window.panel", "Motion Sketch", "მოძრაობის ესკიზი"), + ("window.panel", "Paint", "ხატვა"), + ("window.panel", "Paragraph", "აბზაცი"), + ("window.panel", "Preview", "წინასწარი ნახვა"), + ("window.panel", "Progress", "პროგრესი"), + ("window.panel", "Properties", "თვისებები"), + ("window.panel", "Script Console", "სკრიპტის კონსოლი"), + ("window.panel", "Smoother", "დამგლუვებელი"), + ("window.panel", "Tools", "ხელსაწყოები"), + ("window.panel", "Tracker", "ტრეკერი"), + ("window.panel", "Wiggler", "რხევის გენერატორი"), + ("window.panel", "Composition", "კომპოზიცია"), + ("window.panel", "Flowchart", "ბლოკ-სქემა"), + ("window.panel", "Footage", "ვიდეომასალა"), + ("window.panel", "Layer", "შრე"), + ("window.panel", "Project", "პროექტი"), + ("window.panel", "Render Queue", "რენდერის რიგი"), + ("window.panel", "Timeline", "დროის ხაზი"), + ("window.panel", "Create Nulls From Paths", "ნულოვანი ობიექტების შექმნა ტრაექტორიებიდან"), + ("window.panel", "VR Comp Editor", "VR კომპოზიციის რედაქტორი"), + ("", "Help", "დახმარება"), + ("help.docs", "EffectCraft Help...", "EffectCraft-ის დახმარება..."), + ("help.docs", "Scripting Help...", "სკრიპტების დახმარება..."), + ("help.docs", "Expression Reference...", "ექსპრესიების ცნობარი..."), + ("help.docs", "Effect Reference...", "ეფექტების ცნობარი..."), + ("anim.browsePresets", "Animation Presets...", "ანიმაციის პრესეტები..."), + ("app.keyboardShortcuts", "Keyboard Shortcuts...", "კლავიატურის მალსახმობები..."), + ("help.inAppTutorials", "In-App Tutorials...", "აპის შიგნით გაკვეთილები..."), + ("help.onlineTutorials", "Online Tutorials...", "ონლაინ გაკვეთილები..."), + ("help.systemReport", "System Compatibility Report...", "სისტემის თავსებადობის ანგარიში..."), + ("help.enableLogging", "Enable Logging", "ჟურნალის ჩართვა"), + ("help.revealLogFile", "Reveal Logging File", "ჟურნალის ფაილის ჩვენება"), + ("help.discord", "Join the ArtCraft Discord...", "შემოუერთდით ArtCraft-ის Discord-ს..."), + ("help.reportIssue", "Provide Feedback...", "გამოხმაურების გაგზავნა..."), + ("help.website", "ArtCraft Website", "ArtCraft-ის ვებსაიტი"), + ("help.appPage", "EffectCraft Home Page", "EffectCraft-ის საწყისი გვერდი"), + ("help.github", "EffectCraft on GitHub", "EffectCraft GitHub-ზე"), + ("file.openDemoProject", "Open Demo Project", "სადემონსტრაციო პროექტის გახსნა"), +]; + #[cfg(test)] mod tests { use super::*; @@ -6753,6 +7416,22 @@ mod tests { } } + #[test] + fn georgian_reveal_labels_follow_the_platform_file_manager() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.session.execute("prefs.set", json!({"key": "general.language", "value": "ka"})).unwrap(); + for command in ["file.revealInFinder", "layer.revealInFinder"] { + for (source, translated) in [ + ("Reveal in Finder", "Finder-ში ჩვენება"), + ("Reveal in Explorer", "Explorer-ში ჩვენება"), + ("Reveal in File Manager", "ფაილების მენეჯერში ჩვენება"), + ] { + assert_eq!(label(&app, command, source), translated); + assert_eq!(label(&app, "file.openRecent", source), source); + } + } + } + fn keys(nodes: &[MenuNode], out: &mut BTreeSet<(String, String)>) { for node in nodes { match node { @@ -6784,6 +7463,7 @@ mod tests { let mut french_keys = BTreeSet::new(); let mut german_keys = BTreeSet::new(); let mut czech_keys = BTreeSet::new(); + let mut georgian_keys = BTreeSet::new(); for (catalog, unique) in [ (JAPANESE, &mut japanese_keys), (SIMPLIFIED_CHINESE, &mut chinese_keys), @@ -6795,6 +7475,7 @@ mod tests { (FRENCH, &mut french_keys), (GERMAN, &mut german_keys), (CZECH, &mut czech_keys), + (GEORGIAN, &mut georgian_keys), ] { for (command, en, translated) in catalog { let key = (command.to_string(), en.to_string()); @@ -6825,6 +7506,7 @@ mod tests { assert_eq!(russian_keys, french_keys, "the languages must translate the same entries"); assert_eq!(french_keys, german_keys, "the languages must translate the same entries"); assert_eq!(japanese_keys, czech_keys, "the languages must translate the same entries"); + assert_eq!(japanese_keys, georgian_keys, "the languages must translate the same entries"); } /// Exercise each engine spelling on every host: a macOS-only run must still catch a @@ -6911,6 +7593,9 @@ mod tests { (Some("cs-CZ"), "cs"), (Some("cs_CZ.UTF-8"), "cs"), (Some("CS_cz.UTF-8"), "cs"), + (Some("ka"), "ka"), + (Some("ka-GE"), "ka"), + (Some("ka_GE.UTF-8"), "ka"), (Some("en-US"), "en"), (Some("jv-ID"), "en"), (Some("de-DE"), "en"), @@ -6944,9 +7629,15 @@ mod tests { #[test] fn every_translated_entry_keeps_the_english_shape() { - for (code, catalog) in - [("zh-hans", SIMPLIFIED_CHINESE), ("zh-hant", TRADITIONAL_CHINESE), ("uk", UKRAINIAN), ("es", SPANISH), ("pt-br", PORTUGUESE), ("cs", CZECH)] - { + for (code, catalog) in [ + ("zh-hans", SIMPLIFIED_CHINESE), + ("zh-hant", TRADITIONAL_CHINESE), + ("uk", UKRAINIAN), + ("es", SPANISH), + ("pt-br", PORTUGUESE), + ("cs", CZECH), + ("ka", GEORGIAN), + ] { for (command, en, translated) in catalog { assert_eq!(en.contains("..."), translated.contains("..."), "{code} {command} / {en}: {translated}"); let brackets = |s: &str| s.chars().filter(|c| *c == '(' || *c == ')').count(); @@ -7190,6 +7881,44 @@ mod tests { assert_eq!(submenu(&app, "Assign Shortcut to 3D View", "Assign Shortcut to “Top”".into()), "Atribuir atalho à visualização 3D “Top”"); } + #[test] + fn ka_translates_menus_without_changing_bindings_or_user_names() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.session.execute("prefs.set", json!({"key":"general.language", "value":"en"})).unwrap(); + let en = crate::native_menu::build(&app); + app.session.execute("prefs.set", json!({"key":"general.language", "value":"ka"})).unwrap(); + assert_eq!(language(&app), "ka"); + assert!(georgian(&app)); + assert!(!japanese(&app)); + let ka = crate::native_menu::build(&app); + let bindings = |menu: &crate::native_menu::NativeMenu| { + menu.items().into_iter().map(|i| (i.id.clone(), i.command.clone(), i.params.clone(), i.shortcut.clone(), i.enabled, i.checked)).collect::>() + }; + assert_eq!(bindings(&en), bindings(&ka)); + assert_eq!(label(&app, "", "Composition"), "კომპოზიცია"); + assert_eq!(label(&app, "", "Layer"), "შრე"); + assert_eq!(label(&app, "comp.new", "New Composition..."), "ახალი კომპოზიცია..."); + assert_eq!(label(&app, "anim.addKeyframe", "Add Keyframe"), "საკვანძო კადრის დამატება"); + assert_eq!(label(&app, "unknown.command", "Text"), "Text"); + assert!(ka.items().iter().any(|i| i.command == "keys.easyEase" && i.label == "რბილი შენელება")); + let undo = MenuEntry { label: "Undo".into(), command: "edit.undo".into(), params: json!({}), shortcut: None, alt_shortcuts: vec![] }; + assert_eq!(entry(&app, &undo, "Can't Undo".into()), "დაბრუნება შეუძლებელია"); + app.session.history.undo.push(("Comp 1".into(), app.session.project.clone())); + assert_eq!(entry(&app, &undo, "Undo Comp 1".into()), "დაბრუნება: Comp 1"); + let panel = + MenuEntry { label: "Layer".into(), command: "window.panel".into(), params: json!({"panel":"layer"}), shortcut: None, alt_shortcuts: vec![] }; + assert_eq!(entry(&app, &panel, "Layer: File".into()), "შრე: File"); + assert_eq!( + submenu(&app, "Assign Shortcut to Workspace", "Assign Shortcut to “Default” Workspace".into()), + "მალსახმობის მინიჭება სამუშაო სივრცისთვის „Default“" + ); + assert_eq!(submenu(&app, "Assign Shortcut to 3D View", "Assign Shortcut to “Top”".into()), "მალსახმობის მინიჭება 3D ხედისთვის „Top“"); + // A dynamic filename is never translated. + app.session.prefs.push_recent("/tmp/File.ecproj"); + assert!(crate::native_menu::build(&app).items().iter().any(|i| i.command == "file.openRecent" && i.label == "File.ecproj")); + assert!(effectcraft_engine::prefs::LANGUAGES.contains(&("ქართული", "ka"))); + } + #[test] fn general_settings_exposes_the_language_automation_id() { let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); diff --git a/crates/ui-egui/src/i18n/effects.rs b/crates/ui-egui/src/i18n/effects.rs index 3ab115582..f670e63a8 100644 --- a/crates/ui-egui/src/i18n/effects.rs +++ b/crates/ui-egui/src/i18n/effects.rs @@ -70,12 +70,14 @@ fn catalog(language: &str) -> Option<&'static Catalog> { static SIMPLIFIED: Catalog = Catalog { rows: SIMPLIFIED_CHINESE, categories: SIMPLIFIED_CHINESE_CATEGORIES, index: OnceLock::new() }; static TRADITIONAL: Catalog = Catalog { rows: TRADITIONAL_CHINESE, categories: TRADITIONAL_CHINESE_CATEGORIES, index: OnceLock::new() }; static JAPANESE: Catalog = Catalog { rows: JAPANESE_EFFECTS, categories: JAPANESE_CATEGORIES, index: OnceLock::new() }; + static GEORGIAN_CATALOG: Catalog = Catalog { rows: GEORGIAN, categories: GEORGIAN_CATEGORIES, index: OnceLock::new() }; match language { "zh-hans" => Some(&SIMPLIFIED), "zh-hant" => Some(&TRADITIONAL), // Japanese: the slot the Japanese data point on #821 asked for, so both languages share // this one catalog instead of inventing a second mechanism. "ja" => Some(&JAPANESE), + "ka" => Some(&GEORGIAN_CATALOG), _ => None, } } @@ -447,6 +449,346 @@ const TRADITIONAL_CHINESE_CATEGORIES: &[(&str, &str)] = &[]; /// Japanese (`ja`) category labels, keyed by the registry's English category. const JAPANESE_CATEGORIES: &[(&str, &str)] = &[]; +/// Georgian (`ka`): one row per effect, ids from [`SIMPLIFIED_CHINESE`]. Written from the meaning +/// of the English names, with the terms the Georgian menus and panels use (FilmCraft's Georgian +/// for the effects the two apps share). The `CC` series and the brand names (`Cryptomatte`, +/// `EXtractoR`, `IDentifier`, `Key Light`, `Unmult`, `Lumetri Color`) stay in English, as +/// Adobe's Japanese interface keeps the `CC` series; `VR` and `OCIO` keep their prefix. +const GEORGIAN: &[Row] = &[ + ("ec.3d.channelextract", "3D Channel Extract", "3D არხის ამოღება"), + ("ec.3d.cryptomatte", "Cryptomatte", "Cryptomatte"), + ("ec.3d.depthmatte", "Depth Matte", "სიღრმის მატი"), + ("ec.3d.depthoffield", "Depth of Field", "ველის სიღრმე"), + ("ec.3d.extractor", "EXtractoR", "EXtractoR"), + ("ec.3d.fog3d", "Fog 3D", "3D ნისლი"), + ("ec.3d.identifier", "IDentifier", "IDentifier"), + ("ec.3d.idmatte", "ID Matte", "ID მატი"), + ("ec.audio.backwards", "Backwards", "უკუღმა"), + ("ec.audio.basstreble", "Bass & Treble", "ბასი და ტრებლი"), + ("ec.audio.compressor", "Compressor", "კომპრესორი"), + ("ec.audio.delay", "Delay", "დაყოვნება"), + ("ec.audio.distortion", "Distortion", "დამახინჯება"), + ("ec.audio.flangechorus", "Flange & Chorus", "ფლენჯერი და ქორუსი"), + ("ec.audio.gate", "Gate", "გეიტი"), + ("ec.audio.highlowpass", "High-Low Pass", "მაღალი/დაბალი სიხშირეების ფილტრი"), + ("ec.audio.modulator", "Modulator", "მოდულატორი"), + ("ec.audio.parametriceq", "Parametric EQ", "პარამეტრული ეკვალაიზერი"), + ("ec.audio.reverb", "Reverb", "რევერბი"), + ("ec.audio.stereomixer", "Stereo Mixer", "სტერეომიქშერი"), + ("ec.audio.tone", "Tone", "ტონი"), + ("ec.blur.bilateral", "Bilateral Blur", "ბილატერალური გაბუნდოვნება"), + ("ec.blur.cameralens", "Camera Lens Blur", "კამერის ობიექტივის გაბუნდოვნება"), + ("ec.blur.camerashakedeblur", "Camera-Shake Deblur", "კამერის რყევით გაბუნდოვნების მოცილება"), + ("ec.blur.cccross", "CC Cross Blur", "CC Cross Blur"), + ("ec.blur.ccradial", "CC Radial Blur", "CC Radial Blur"), + ("ec.blur.ccradialfast", "CC Radial Fast Blur", "CC Radial Fast Blur"), + ("ec.blur.ccvector", "CC Vector Blur", "CC Vector Blur"), + ("ec.blur.channel", "Channel Blur", "არხის გაბუნდოვნება"), + ("ec.blur.compound", "Compound Blur", "შედგენილი გაბუნდოვნება"), + ("ec.blur.directional", "Directional Blur", "მიმართული გაბუნდოვნება"), + ("ec.blur.fastbox", "Fast Box Blur", "სწრაფი ბლოკური გაბუნდოვნება"), + ("ec.blur.gaussian", "Gaussian Blur", "გაუსის გაბუნდოვნება"), + ("ec.blur.radial", "Radial Blur", "რადიალური გაბუნდოვნება"), + ("ec.blur.reduceflicker", "Reduce Interlace Flicker", "ნახევარკადრული ციმციმის შემცირება"), + ("ec.blur.sharpen", "Sharpen", "სიმკვეთრე"), + ("ec.blur.smart", "Smart Blur", "ჭკვიანი გაბუნდოვნება"), + ("ec.blur.unsharp", "Unsharp Mask", "არამკვეთრი ნიღაბი"), + ("ec.channel.arithmetic", "Arithmetic", "არითმეტიკა"), + ("ec.channel.blend", "Blend", "შერევა"), + ("ec.channel.calculations", "Calculations", "გამოთვლები"), + ("ec.channel.cccomposite", "CC Composite", "CC Composite"), + ("ec.channel.combiner", "Channel Combiner", "არხების გამაერთიანებელი"), + ("ec.channel.compoundarithmetic", "Compound Arithmetic", "შედგენილი არითმეტიკა"), + ("ec.channel.invert", "Invert", "ინვერსია"), + ("ec.channel.minimax", "Minimax", "მინიმაქსი"), + ("ec.channel.removecolormatting", "Remove Color Matting", "ფერადი მატინგის მოცილება"), + ("ec.channel.setchannels", "Set Channels", "არხების დაყენება"), + ("ec.channel.setmatte", "Set Matte", "მატის დაყენება"), + ("ec.channel.shiftchannels", "Shift Channels", "არხების გადანაცვლება"), + ("ec.channel.solidcomposite", "Solid Composite", "სოლიდთან კომპოზიტი"), + ("ec.color.autocolor", "Auto Color", "ავტოფერი"), + ("ec.color.autocontrast", "Auto Contrast", "ავტოკონტრასტი"), + ("ec.color.autolevels", "Auto Levels", "ავტოდონეები"), + ("ec.color.blackwhite", "Black & White", "შავ-თეთრი"), + ("ec.color.brightnesscontrast", "Brightness & Contrast", "სიკაშკაშე და კონტრასტი"), + ("ec.color.broadcast", "Broadcast Colors", "სამაუწყებლო ფერები"), + ("ec.color.cccolorneutralizer", "CC Color Neutralizer", "CC Color Neutralizer"), + ("ec.color.cccoloroffset", "CC Color Offset", "CC Color Offset"), + ("ec.color.cckernel", "CC Kernel", "CC Kernel"), + ("ec.color.cctoner", "CC Toner", "CC Toner"), + ("ec.color.changecolor", "Change Color", "ფერის შეცვლა"), + ("ec.color.changetocolor", "Change to Color", "ფერად შეცვლა"), + ("ec.color.channelmixer", "Channel Mixer", "არხების მიქშერი"), + ("ec.color.colorama", "Colorama", "კოლორამა"), + ("ec.color.colorbalance", "Color Balance", "ფერთა ბალანსი"), + ("ec.color.colorbalancehls", "Color Balance (HLS)", "ფერთა ბალანსი (HLS)"), + ("ec.color.colorlink", "Color Link", "ფერის ბმა"), + ("ec.color.colorstabilizer", "Color Stabilizer", "ფერის სტაბილიზატორი"), + ("ec.color.curves", "Curves", "მრუდები"), + ("ec.color.equalize", "Equalize", "გათანაბრება"), + ("ec.color.exposure", "Exposure", "ექსპოზიცია"), + ("ec.color.gammapedestalgain", "Gamma/Pedestal/Gain", "გამა/საფეხური/გაძლიერება"), + ("ec.color.huesaturation", "Hue/Saturation", "ფერის ტონი/გაჯერება"), + ("ec.color.leavecolor", "Leave Color", "ფერის დატოვება"), + ("ec.color.levels", "Levels", "დონეები"), + ("ec.color.levelsic", "Levels (Individual Controls)", "დონეები (ცალკეული მართვა)"), + ("ec.color.lumetri", "Lumetri Color", "Lumetri Color"), + ("ec.color.ociocdl", "OCIO CDL Transform", "OCIO CDL გარდაქმნა"), + ("ec.color.ociocolorspace", "OCIO Color Space Transform", "OCIO ფერთა სივრცის გარდაქმნა"), + ("ec.color.ociodisplay", "OCIO Display Transform", "OCIO ეკრანის გარდაქმნა"), + ("ec.color.ociofile", "OCIO File Transform", "OCIO ფაილის გარდაქმნა"), + ("ec.color.ociolook", "OCIO Look Transform", "OCIO ვიზუალური სტილის გარდაქმნა"), + ("ec.color.photofilter", "Photo Filter", "ფოტოფილტრი"), + ("ec.color.psarbitrarymap", "PS Arbitrary Map", "PS ნებისმიერი რუკა"), + ("ec.color.selectivecolor", "Selective Color", "შერჩევითი ფერი"), + ("ec.color.shadowhighlight", "Shadow/Highlight", "ჩრდილები/ღია ტონები"), + ("ec.color.tint", "Tint", "ტონირება"), + ("ec.color.tritone", "Tritone", "სამტონიანი"), + ("ec.color.vibrance", "Vibrance", "სიხალასე"), + ("ec.color.videolimiter", "Video Limiter", "ვიდეოლიმიტერი"), + ("ec.control.angle", "Angle Control", "კუთხის მართვა"), + ("ec.control.checkbox", "Checkbox Control", "მოსანიშნი ველის მართვა"), + ("ec.control.color", "Color Control", "ფერის მართვა"), + ("ec.control.dropdown", "Dropdown Menu Control", "ჩამოსაშლელი მენიუს მართვა"), + ("ec.control.layer", "Layer Control", "შრის მართვა"), + ("ec.control.point", "Point Control", "წერტილის მართვა"), + ("ec.control.point3d", "3D Point Control", "3D წერტილის მართვა"), + ("ec.control.slider", "Slider Control", "სლაიდერის მართვა"), + ("ec.control.stereo3d", "Stereo 3D Controls", "სტერეო 3D მართვა"), + ("ec.distort.bezierwarp", "Bezier Warp", "ბეზიეს დეფორმაცია"), + ("ec.distort.bulge", "Bulge", "გამობერილობა"), + ("ec.distort.ccbender", "CC Bender", "CC Bender"), + ("ec.distort.ccbendit", "CC Bend It", "CC Bend It"), + ("ec.distort.ccblobbylize", "CC Blobbylize", "CC Blobbylize"), + ("ec.distort.ccflomotion", "CC Flo Motion", "CC Flo Motion"), + ("ec.distort.ccgriddler", "CC Griddler", "CC Griddler"), + ("ec.distort.cclens", "CC Lens", "CC Lens"), + ("ec.distort.ccpageturn", "CC Page Turn", "CC Page Turn"), + ("ec.distort.ccpowerpin", "CC Power Pin", "CC Power Pin"), + ("ec.distort.ccripplepulse", "CC Ripple Pulse", "CC Ripple Pulse"), + ("ec.distort.ccslant", "CC Slant", "CC Slant"), + ("ec.distort.ccsmear", "CC Smear", "CC Smear"), + ("ec.distort.ccsplit", "CC Split", "CC Split"), + ("ec.distort.ccsplit2", "CC Split 2", "CC Split 2"), + ("ec.distort.cctiler", "CC Tiler", "CC Tiler"), + ("ec.distort.cornerpin", "Corner Pin", "კუთხეების მიმაგრება"), + ("ec.distort.displacementmap", "Displacement Map", "წანაცვლების რუკა"), + ("ec.distort.liquify", "Liquify", "გათხევადება"), + ("ec.distort.magnify", "Magnify", "გადიდება"), + ("ec.distort.meshwarp", "Mesh Warp", "ბადისებრი დეფორმაცია"), + ("ec.distort.mirror", "Mirror", "სარკე"), + ("ec.distort.offset", "Offset", "წანაცვლება"), + ("ec.distort.opticscompensation", "Optics Compensation", "ოპტიკის კომპენსაცია"), + ("ec.distort.polar", "Polar Coordinates", "პოლარული კოორდინატები"), + ("ec.distort.puppet", "Puppet", "მარიონეტი"), + ("ec.distort.reshape", "Reshape", "ფორმის შეცვლა"), + ("ec.distort.ripple", "Ripple", "ტალღა"), + ("ec.distort.rollingshutterrepair", "Rolling Shutter Repair", "მცოცავი ჩამკეტის გასწორება"), + ("ec.distort.smear", "Smear", "გადღაბნა"), + ("ec.distort.spherize", "Spherize", "სფერიზება"), + ("ec.distort.transform", "Transform", "გარდაქმნა"), + ("ec.distort.turbulentdisplace", "Turbulent Displace", "ტურბულენტური წანაცვლება"), + ("ec.distort.twirl", "Twirl", "გრიგალი"), + ("ec.distort.twirllegacy", "Twirl (Legacy)", "გრიგალი (მოძველებული)"), + ("ec.distort.upscale", "Detail-preserving Upscale", "გადიდება დეტალების შენარჩუნებით"), + ("ec.distort.warp", "Warp", "დეფორმაცია"), + ("ec.distort.warpstabilizer", "Warp Stabilizer", "დეფორმაციის სტაბილიზატორი"), + ("ec.distort.wavewarp", "Wave Warp", "ტალღური დეფორმაცია"), + ("ec.generate.advancedlightning", "Advanced Lightning", "გაფართოებული ელვა"), + ("ec.generate.audiospectrum", "Audio Spectrum", "აუდიოს სპექტრი"), + ("ec.generate.audiowaveform", "Audio Waveform", "აუდიოს ტალღის ფორმა"), + ("ec.generate.beam", "Beam", "სხივი"), + ("ec.generate.ccgluegun", "CC Glue Gun", "CC Glue Gun"), + ("ec.generate.cclightburst", "CC Light Burst 2.5", "CC Light Burst 2.5"), + ("ec.generate.cclightrays", "CC Light Rays", "CC Light Rays"), + ("ec.generate.cclightsweep", "CC Light Sweep", "CC Light Sweep"), + ("ec.generate.ccthreads", "CC Threads", "CC Threads"), + ("ec.generate.cellpattern", "Cell Pattern", "უჯრედების პატერნი"), + ("ec.generate.checkerboard", "Checkerboard", "ჭადრაკის დაფა"), + ("ec.generate.circle", "Circle", "წრე"), + ("ec.generate.ellipse", "Ellipse", "ელიფსი"), + ("ec.generate.eyedropperfill", "Eyedropper Fill", "პიპეტით შევსება"), + ("ec.generate.fill", "Fill", "შევსება"), + ("ec.generate.fourcolor", "4-Color Gradient", "4-ფერიანი გრადიენტი"), + ("ec.generate.fractal", "Fractal", "ფრაქტალი"), + ("ec.generate.gradientramp", "Gradient Ramp", "გრადიენტული გადასვლა"), + ("ec.generate.grid", "Grid", "ბადე"), + ("ec.generate.lensflare", "Lens Flare", "ობიექტივის ანარეკლი"), + ("ec.generate.paintbucket", "Paint Bucket", "საღებავის ვედრო"), + ("ec.generate.radiowaves", "Radio Waves", "რადიოტალღები"), + ("ec.generate.scribble", "Scribble", "ჯღაბნა"), + ("ec.generate.stroke", "Stroke", "კონტური"), + ("ec.generate.vegas", "Vegas", "ვეგასი"), + ("ec.generate.writeon", "Write-on", "თანდათანობითი წერა"), + ("ec.key.advancedspill", "Advanced Spill Suppressor", "ფერის გადაღვრის გაფართოებული ჩამხშობი"), + ("ec.key.ccsimplewireremoval", "CC Simple Wire Removal", "CC Simple Wire Removal"), + ("ec.key.colordifference", "Color Difference Key", "ფერთა სხვაობის გასაღები"), + ("ec.key.colorkey", "Color Key", "ფერის გასაღები"), + ("ec.key.colorrange", "Color Range", "ფერების დიაპაზონი"), + ("ec.key.differencematte", "Difference Matte", "სხვაობის მატი"), + ("ec.key.extract", "Extract", "ამოღება"), + ("ec.key.innerouter", "Inner/Outer Key", "შიდა/გარე გასაღები"), + ("ec.key.keycleaner", "Key Cleaner", "გასაღების გამწმენდი"), + ("ec.key.linearcolor", "Linear Color Key", "წრფივი ფერის გასაღები"), + ("ec.key.luma", "Luma Key", "სიკაშკაშის გასაღები"), + ("ec.key.screen", "Screen Key", "ეკრანის გასაღები"), + ("ec.key.spill", "Spill Suppressor", "ფერის გადაღვრის ჩამხშობი"), + ("ec.key.unmult", "Unmult", "Unmult"), + ("ec.keying.keylight", "Key Light", "Key Light"), + ("ec.matte.mattechoker", "Matte Choker", "მატის შემკუმშველი"), + ("ec.matte.objectmatte", "Object Matte", "ობიექტის მატი"), + ("ec.matte.refinehard", "Refine Hard Matte", "მკვეთრი მატის დახვეწა"), + ("ec.matte.refinesoft", "Refine Soft Matte", "რბილი მატის დახვეწა"), + ("ec.matte.rotobrush", "Roto Brush & Refine Edge", "როტო-ფუნჯი და კიდის დახვეწა"), + ("ec.matte.simplechoker", "Simple Choker", "მარტივი შემკუმშველი"), + ("ec.noise.addgrain", "Add Grain", "მარცვლოვნების დამატება"), + ("ec.noise.curlnoise", "Curl Noise", "გრიგალური ხმაური"), + ("ec.noise.dustscratches", "Dust & Scratches", "მტვერი და ნაკაწრები"), + ("ec.noise.fractal", "Fractal Noise", "ფრაქტალური ხმაური"), + ("ec.noise.matchgrain", "Match Grain", "მარცვლოვნების მისადაგება"), + ("ec.noise.median", "Median", "მედიანა"), + ("ec.noise.medianlegacy", "Median (Legacy)", "მედიანა (მოძველებული)"), + ("ec.noise.noise", "Noise", "ხმაური"), + ("ec.noise.noisealpha", "Noise Alpha", "ალფა-ხმაური"), + ("ec.noise.noisehls", "Noise HLS", "HLS ხმაური"), + ("ec.noise.noisehlsauto", "Noise HLS Auto", "HLS ხმაური (ავტომატური)"), + ("ec.noise.removegrain", "Remove Grain", "მარცვლოვნების მოცილება"), + ("ec.noise.turbulent", "Turbulent Noise", "ტურბულენტური ხმაური"), + ("ec.obsolete.basic3d", "Basic 3D", "საბაზო 3D"), + ("ec.obsolete.basictext", "Basic Text", "საბაზო ტექსტი"), + ("ec.obsolete.gaussianlegacy", "Gaussian Blur (Legacy)", "გაუსის გაბუნდოვნება (მოძველებული)"), + ("ec.obsolete.lightning", "Lightning", "ელვა"), + ("ec.obsolete.mochashape", "Mocha shape", "Mocha-ს ფიგურა"), + ("ec.obsolete.pathtext", "Path Text", "ტექსტი ტრაექტორიაზე"), + ("ec.paint.paint", "Paint", "ხატვა"), + ("ec.perspective.3dglasses", "3D Glasses", "3D სათვალე"), + ("ec.perspective.bevelalpha", "Bevel Alpha", "ალფას ფასკა"), + ("ec.perspective.beveledges", "Bevel Edges", "კიდეების ფასკა"), + ("ec.perspective.cameratracker", "3D Camera Tracker", "3D კამერის ტრეკერი"), + ("ec.perspective.cccylinder", "CC Cylinder", "CC Cylinder"), + ("ec.perspective.ccenvironment", "CC Environment", "CC Environment"), + ("ec.perspective.ccsphere", "CC Sphere", "CC Sphere"), + ("ec.perspective.ccspotlight", "CC Spotlight", "CC Spotlight"), + ("ec.perspective.dropshadow", "Drop Shadow", "ჩრდილი"), + ("ec.perspective.radialshadow", "Radial Shadow", "რადიალური ჩრდილი"), + ("ec.sim.carddance", "Card Dance", "მოცეკვავე ბარათები"), + ("ec.sim.caustics", "Caustics", "კაუსტიკა"), + ("ec.sim.ccballaction", "CC Ball Action", "CC Ball Action"), + ("ec.sim.ccbubbles", "CC Bubbles", "CC Bubbles"), + ("ec.sim.ccdrizzle", "CC Drizzle", "CC Drizzle"), + ("ec.sim.cchair", "CC Hair", "CC Hair"), + ("ec.sim.ccmrmercury", "CC Mr. Mercury", "CC Mr. Mercury"), + ("ec.sim.ccparticlesystems2", "CC Particle Systems II", "CC Particle Systems II"), + ("ec.sim.ccparticleworld", "CC Particle World", "CC Particle World"), + ("ec.sim.ccpixelpolly", "CC Pixel Polly", "CC Pixel Polly"), + ("ec.sim.ccrainfall", "CC Rainfall", "CC Rainfall"), + ("ec.sim.ccscatterize", "CC Scatterize", "CC Scatterize"), + ("ec.sim.ccsnowfall", "CC Snowfall", "CC Snowfall"), + ("ec.sim.ccstarburst", "CC Star Burst", "CC Star Burst"), + ("ec.sim.foam", "Foam", "ქაფი"), + ("ec.sim.particleplayground", "Particle Playground", "ნაწილაკების სიმულაცია"), + ("ec.sim.shatter", "Shatter", "დამსხვრევა"), + ("ec.sim.waveworld", "Wave World", "ტალღების სამყარო"), + ("ec.stylize.brushstrokes", "Brush Strokes", "ფუნჯის შტრიხები"), + ("ec.stylize.cartoon", "Cartoon", "მულტფილმის სტილი"), + ("ec.stylize.ccblockload", "CC Block Load", "CC Block Load"), + ("ec.stylize.ccburnfilm", "CC Burn Film", "CC Burn Film"), + ("ec.stylize.ccglass", "CC Glass", "CC Glass"), + ("ec.stylize.cchextile", "CC HexTile", "CC HexTile"), + ("ec.stylize.cckaleida", "CC Kaleida", "CC Kaleida"), + ("ec.stylize.ccmrsmoothie", "CC Mr. Smoothie", "CC Mr. Smoothie"), + ("ec.stylize.ccplastic", "CC Plastic", "CC Plastic"), + ("ec.stylize.ccrepetile", "CC RepeTile", "CC RepeTile"), + ("ec.stylize.ccthreshold", "CC Threshold", "CC Threshold"), + ("ec.stylize.ccthresholdrgb", "CC Threshold RGB", "CC Threshold RGB"), + ("ec.stylize.ccvignette", "CC Vignette", "CC Vignette"), + ("ec.stylize.coloremboss", "Color Emboss", "ფერადი რელიეფი"), + ("ec.stylize.emboss", "Emboss", "რელიეფი"), + ("ec.stylize.findedges", "Find Edges", "კიდეების პოვნა"), + ("ec.stylize.glow", "Glow", "ნათება"), + ("ec.stylize.mosaic", "Mosaic", "მოზაიკა"), + ("ec.stylize.motiontile", "Motion Tile", "მოძრავი ფილები"), + ("ec.stylize.posterize", "Posterize", "პოსტერიზაცია"), + ("ec.stylize.roughenedges", "Roughen Edges", "კიდეების დაუხეშება"), + ("ec.stylize.scatter", "Scatter", "გაბნევა"), + ("ec.stylize.strobe", "Strobe Light", "სტრობოსკოპი"), + ("ec.stylize.texturize", "Texturize", "ტექსტურირება"), + ("ec.stylize.threshold", "Threshold", "ზღვარი"), + ("ec.text.numbers", "Numbers", "რიცხვები"), + ("ec.text.timecode", "Timecode", "თაიმკოდი"), + ("ec.time.ccforcemotionblur", "CC Force Motion Blur", "CC Force Motion Blur"), + ("ec.time.ccwidetime", "CC Wide Time", "CC Wide Time"), + ("ec.time.echo", "Echo", "ექო"), + ("ec.time.pixelmotionblur", "Pixel Motion Blur", "პიქსელების მოძრაობის გაბუნდოვნება"), + ("ec.time.posterizetime", "Posterize Time", "დროის პოსტერიზაცია"), + ("ec.time.timedifference", "Time Difference", "დროითი სხვაობა"), + ("ec.time.timedisplacement", "Time Displacement", "დროითი წანაცვლება"), + ("ec.time.timewarp", "Timewarp", "Timewarp"), + ("ec.transition.blockdissolve", "Block Dissolve", "ბლოკური გადადნობა"), + ("ec.transition.cardwipe", "Card Wipe", "ბარათებით წმენდა"), + ("ec.transition.ccglasswipe", "CC Glass Wipe", "CC Glass Wipe"), + ("ec.transition.ccgridwipe", "CC Grid Wipe", "CC Grid Wipe"), + ("ec.transition.ccimagewipe", "CC Image Wipe", "CC Image Wipe"), + ("ec.transition.ccjaws", "CC Jaws", "CC Jaws"), + ("ec.transition.cclightwipe", "CC Light Wipe", "CC Light Wipe"), + ("ec.transition.cclinesweep", "CC Line Sweep", "CC Line Sweep"), + ("ec.transition.ccradialscalewipe", "CC Radial ScaleWipe", "CC Radial ScaleWipe"), + ("ec.transition.ccscalewipe", "CC Scale Wipe", "CC Scale Wipe"), + ("ec.transition.cctwister", "CC Twister", "CC Twister"), + ("ec.transition.ccwarpomatic", "CC WarpoMatic", "CC WarpoMatic"), + ("ec.transition.gradientwipe", "Gradient Wipe", "გრადიენტული წმენდა"), + ("ec.transition.iriswipe", "Iris Wipe", "დიაფრაგმული წმენდა"), + ("ec.transition.linearwipe", "Linear Wipe", "წრფივი წმენდა"), + ("ec.transition.radialwipe", "Radial Wipe", "რადიალური წმენდა"), + ("ec.transition.venetian", "Venetian Blinds", "ჟალუზი"), + ("ec.utility.applylut", "Apply Color LUT", "ფერის LUT-ის გამოყენება"), + ("ec.utility.ccoverbrights", "CC Overbrights", "CC Overbrights"), + ("ec.utility.cineon", "Cineon Converter", "Cineon-ის გარდამქმნელი"), + ("ec.utility.colorprofileconverter", "Color Profile Converter", "ფერის პროფილის გარდამქმნელი"), + ("ec.utility.facemeasurements", "Face Measurements", "სახის გაზომვები"), + ("ec.utility.facetrackpoints", "Face Track Points", "სახის ტრეკინგის წერტილები"), + ("ec.utility.growbounds", "Grow Bounds", "საზღვრების გაზრდა"), + ("ec.utility.hdrcompander", "HDR Compander", "HDR კომპანდერი"), + ("ec.utility.hdrcompression", "HDR Highlight Compression", "HDR ღია ტონების შეკუმშვა"), + ("ec.vr.blur", "VR Blur", "VR გაბუნდოვნება"), + ("ec.vr.chromaticaberrations", "VR Chromatic Aberrations", "VR ქრომატული აბერაციები"), + ("ec.vr.colorgradients", "VR Color Gradients", "VR ფერადი გრადიენტები"), + ("ec.vr.converter", "VR Converter", "VR გარდამქმნელი"), + ("ec.vr.denoise", "VR De-Noise", "VR ხმაურის მოცილება"), + ("ec.vr.digitalglitch", "VR Digital Glitch", "VR ციფრული ხარვეზი"), + ("ec.vr.fractalnoise", "VR Fractal Noise", "VR ფრაქტალური ხმაური"), + ("ec.vr.glow", "VR Glow", "VR ნათება"), + ("ec.vr.planetosphere", "VR Plane to Sphere", "VR სიბრტყიდან სფეროზე"), + ("ec.vr.rotatesphere", "VR Rotate Sphere", "VR სფეროს შემობრუნება"), + ("ec.vr.sharpen", "VR Sharpen", "VR სიმკვეთრე"), + ("ec.vr.spheretoplane", "VR Sphere to Plane", "VR სფეროდან სიბრტყეზე"), +]; + +/// Georgian (`ka`) category labels, keyed by the registry's English category. +const GEORGIAN_CATEGORIES: &[(&str, &str)] = &[ + ("3D Channel", "3D არხი"), + ("Audio", "აუდიო"), + ("Blur & Sharpen", "გაბუნდოვნება და სიმკვეთრე"), + ("Channel", "არხი"), + ("Color Correction", "ფერის კორექცია"), + ("Distort", "დამახინჯება"), + ("Expression Controls", "ექსპრესიის მართვა"), + ("Generate", "გენერაცია"), + ("Immersive Video", "იმერსიული ვიდეო"), + ("Keying", "კეიინგი"), + ("Matte", "მატი"), + ("Noise & Grain", "ხმაური და მარცვლოვნება"), + ("Obsolete", "მოძველებული"), + ("Paint", "ხატვა"), + ("Perspective", "პერსპექტივა"), + ("Simulation", "სიმულაცია"), + ("Stylize", "სტილიზაცია"), + ("Text", "ტექსტი"), + ("Time", "დრო"), + ("Transition", "გადასვლა"), + ("Utility", "დამხმარე"), +]; + /// The 21 category folder names the generated Effect menu and Effects & Presets draw, in /// Simplified Chinese. Read from the installed After Effects 2026 (26.5.0.89) Simplified Chinese /// interface (the checklist's `07-类别` sheet); two of them are the interface's own wording where @@ -554,7 +896,9 @@ mod tests { for category in &categories { assert!(SIMPLIFIED_CHINESE_CATEGORIES.iter().any(|(english, _)| english == category), "no catalog row for category {category}"); } - for (language, rows) in [("zh-hans", SIMPLIFIED_CHINESE_CATEGORIES), ("zh-hant", TRADITIONAL_CHINESE_CATEGORIES), ("ja", JAPANESE_CATEGORIES)] { + for (language, rows) in + [("zh-hans", SIMPLIFIED_CHINESE_CATEGORIES), ("zh-hant", TRADITIONAL_CHINESE_CATEGORIES), ("ja", JAPANESE_CATEGORIES), ("ka", GEORGIAN_CATEGORIES)] + { for (english, translated) in rows { assert!(categories.contains(english), "{language} lists {english}, which is not a category"); assert!(!translated.is_empty(), "empty {language} translation for {english}"); @@ -562,6 +906,26 @@ mod tests { } } + /// Georgian has a row for every effect the zh-hans catalog has, in the same order and naming + /// the same effect, and a label for every category; repeats and renames keep their index. + #[test] + fn georgian_names_every_effect_and_category() { + let hans: Vec<(&str, &str)> = SIMPLIFIED_CHINESE.iter().map(|(id, en, _)| (*id, *en)).collect(); + let ka: Vec<(&str, &str)> = GEORGIAN.iter().map(|(id, en, _)| (*id, *en)).collect(); + assert_eq!(hans, ka, "the Georgian effect rows differ from the zh-hans ones"); + assert!(GEORGIAN.iter().all(|(_, _, translated)| !translated.is_empty()), "an empty Georgian effect name"); + for category in effects::categories() { + assert!(GEORGIAN_CATEGORIES.iter().any(|(english, _)| *english == category), "no Georgian row for category {category}"); + } + set_current("ka"); + assert_eq!(name("ec.blur.gaussian", "Gaussian Blur"), "გაუსის გაბუნდოვნება"); + assert_eq!(group("ec.blur.gaussian", "Gaussian Blur 2"), "გაუსის გაბუნდოვნება 2"); + assert_eq!(group("ec.blur.gaussian", "My Blur"), "My Blur"); + assert_eq!(category("Blur & Sharpen"), "გაბუნდოვნება და სიმკვეთრე"); + assert!(matches("ec.blur.gaussian", "Gaussian Blur", "გაუსის")); + set_current("en"); + } + /// A repeated effect carries the index the engine appends to the group name; a name the user /// typed in Effect Controls stays verbatim. #[test] diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 3a271aeb1..4ac96fd18 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -19,7 +19,7 @@ use std::fmt::Display; use std::sync::OnceLock; thread_local! { - /// The resolved interface language ("en", "ja", "zh-hans", "zh-hant", "uk", "pt-br", "cs", "es"); "en" until the first + /// The resolved interface language ("en", "ja", "zh-hans", "zh-hant", "uk", "pt-br", "cs", "es", "ka"); "en" until the first /// frame sets it. static CURRENT: Cell<&'static str> = const { Cell::new("en") }; } @@ -47,6 +47,7 @@ fn catalog(language: &str) -> Option<&'static Index> { static PT_BR: OnceLock = OnceLock::new(); static CS: OnceLock = OnceLock::new(); static ES: OnceLock = OnceLock::new(); + static KA: OnceLock = OnceLock::new(); let (index, rows) = match language { "ja" => (&JA, JAPANESE), "zh-hans" => (&HANS, SIMPLIFIED_CHINESE), @@ -55,6 +56,8 @@ fn catalog(language: &str) -> Option<&'static Index> { "pt-br" => (&PT_BR, PORTUGUESE), "cs" => (&CS, CZECH), "es" => (&ES, SPANISH), + // Georgian also reads its own rows for text the other catalogs don't have yet. + "ka" => return Some(KA.get_or_init(|| GEORGIAN.iter().chain(GEORGIAN_PANELS).copied().collect())), _ => return None, }; Some(index.get_or_init(|| rows.iter().copied().collect())) @@ -4200,6 +4203,959 @@ const SPANISH: &[(&str, &str)] = &[ ("Project", "Proyecto"), ]; +/// Georgian (ka): every row of the Chinese catalogs, in the same order (the tests keep them in step). +const GEORGIAN: &[(&str, &str)] = &[ + ("Appearance Settings…", "იერსახის პარამეტრები…"), + ("Add to favorites", "რჩეულებში დამატება"), + ("Remove from favorites", "რჩეულებიდან ამოშლა"), + (" OK ", " OK "), + (" Delete ", " წაშლა "), + (" Open Auto-Save ", " ავტოშენახვის გახსნა "), + ("(missing)", "(აკლია)"), + ("360 View Orientation", "360° ხედის ორიენტაცია"), + ("3:2 Pulldown: {}", "3:2 Pulldown: {}"), + ( + "A clean-room, open-source compositor for motion graphics and visual effects: native on macOS, Windows and Linux, and in the browser. Part of the ArtCraft family of creative apps.", + "სუფთა ოთახის მეთოდით შექმნილი, ღია კოდის კომპოზიტორი მოძრავი გრაფიკისა და ვიზუალური ეფექტებისთვის: ნატიურად მუშაობს macOS-ზე, Windows-სა და Linux-ზე, ასევე ბრაუზერში. ArtCraft-ის შემოქმედებითი აპების ოჯახის ნაწილი.", + ), + ("About EffectCraft", "EffectCraft-ის შესახებ"), + ("About...", "პროგრამის შესახებ..."), + ("Active", "აქტიური"), + ("Active Camera", "აქტიური კამერა"), + ("Alpha", "ალფა"), + ("Adapt Feature On Every Frame", "მახასიათებლის განახლება ყოველ კადრზე"), + ("Adaptive Sample Limit:", "ადაპტური ნიმუშების ზღვარი:"), + ("Add Animator", "ანიმატორის დამატება"), + ("Add Comment", "კომენტარის დამატება"), + ("Add Formatting", "ფორმატირების დამატება"), + ("Add Group", "ჯგუფის დამატება"), + ("Add Mirror", "სარკის დამატება"), + ("Add Selected As", "მონიშნულის დამატება როგორც"), + ("Add a comment", "კომენტარის დამატება"), + ("Add a shape, fill, stroke or path operation", "ფიგურის, შევსების, კონტურის ან ტრაექტორიის ოპერაციის დამატება"), + ("Add to Composition", "კომპოზიციაში დამატება"), + ("Add to Favorites", "რჩეულებში დამატება"), + ("Add:", "დამატება:"), + ("Adjust Exposure (stops)", "ექსპოზიციის კორექცია (საფეხურები)"), + ("Adjust composition duration to the time span of the selected layers", "კომპოზიციის ხანგრძლივობის მორგება მონიშნული შრეების დროის შუალედზე"), + ("Advanced 3D", "გაფართოებული 3D"), + ("Align Layers to:", "შრეების სწორება:"), + ("All six face cameras turn together; the scene is not changed.", "სახეების ექვსივე კამერა ერთად ბრუნავს; სცენა არ იცვლება."), + ("Anchor:", "მიმაგრება:"), + ("Aperture", "დიაფრაგმა"), + ("Appearance Mode", "იერსახის რეჟიმი"), + ("Appearance Mode: {}", "იერსახის რეჟიმი: {}"), + ("Apply Dimensions:", "განზომილებების გამოყენება:"), + ("Apply Motion To:", "მოძრაობის გამოყენება:"), + ("Arrange", "დალაგება"), + ("Assign", "მინიჭება"), + ("Auto", "ავტომატური"), + ("Auto-saves", "ავტოშენახვები"), + ("Back", "უკან"), + ("Background Color:", "ფონის ფერი:"), + ("Blend Mode:", "შერევის რეჟიმი:"), + ("Blending Mode", "შერევის რეჟიმი"), + ("Blur", "გაბუნდოვნება"), + ("Blur Level", "გაბუნდოვნების დონე"), + ("Boundary", "საზღვარი"), + ("Browser Storage", "ბრაუზერის მეხსიერება"), + ("Browse…", "არჩევა…"), + ("Brush Dynamics", "ფუნჯის დინამიკა"), + ("Caching frames…", "კადრების ქეშირება…"), + ("Cancel", "გაუქმება"), + ("Casts Shadows", "ჩრდილს აგდებს"), + ("Change Renderer…", "რენდერერის შეცვლა…"), + ("Changing a shortcut creates a custom preset; the default stays as it is.", "მალსახმობის შეცვლა ქმნის მორგებულ პრესეტს; ნაგულისხმევი უცვლელი რჩება."), + ("Channel", "არხი"), + ("Channel:", "არხი:"), + ("Chapter:", "თავი:"), + ("Choose...", "არჩევა..."), + ("Classic 3D", "კლასიკური 3D"), + ("Clear", "გასუფთავება"), + ("Clear list", "სიის გასუფთავება"), + ("Click a key to see its commands.", "დაწკაპეთ კლავიშზე მისი ბრძანებების სანახავად."), + ( + "Click above the bar to add an opacity stop, below it to add a color stop.", + "დაწკაპეთ ზოლის ზემოთ გაუმჭვირვალობის გადასვლის წერტილის დასამატებლად, ქვემოთ — ფერის გადასვლის წერტილის დასამატებლად.", + ), + ("Click adds a point (½ height = no change)", "დაწკაპუნება ამატებს წერტილს (½ სიმაღლე = ცვლილების გარეშე)"), + ("Click adds a point; drag one off to remove", "დაწკაპუნება ამატებს წერტილს; მის მოსაშორებლად გადაათრიეთ გარეთ"), + ("Click to make this viewer active", "დაწკაპეთ ამ დამთვალიერებელის გასააქტიურებლად"), + ("Click to reveal the expression in the timeline", "დაწკაპეთ ექსპრესიის დროის ხაზზე საჩვენებლად"), + ("Click to show the active composition here", "დაწკაპეთ აქტიური კომპოზიციის აქ საჩვენებლად"), + ("Click, then click in the Composition panel", "დაწკაპეთ, შემდეგ კი დაწკაპეთ კომპოზიციის პანელში"), + ("Clone Options", "კლონირების პარამეტრები"), + ("Clone Source Overlay:", "კლონირების წყაროს გადაფარვა:"), + ("Close", "დახურვა"), + ("Close Panel", "პანელის დახურვა"), + ("Close tutorial", "გაკვეთილის დახურვა"), + ("Color", "ფერი"), + ("Color Depth: {}", "ფერის სიღრმე: {}"), + ("Color:", "ფერი:"), + ("Colour map", "ფერების რუკა"), + ("Columns", "სვეტები"), + ("Comment:", "კომენტარი:"), + ("Community", "საზოგადოება"), + ("Composition", "კომპოზიცია"), + ("Composition Mini-Flowchart", "კომპოზიციის მინი-ბლოკ-სქემა"), + ("Composition Name:", "კომპოზიციის სახელი:"), + ("Composition Settings ▸ 3D Renderer", "კომპოზიციის პარამეტრები ▸ 3D რენდერერი"), + ("Composition Settings…", "კომპოზიციის პარამეტრები…"), + ("Composition marker bin: drag a marker to the time ruler", "კომპოზიციის მარკერების ბინი: გადაათრიეთ მარკერი დროის სახაზავზე"), + ("Cone Angle", "კონუსის კუთხე"), + ("Cone Feather", "კონუსის კიდეების შერბილება"), + ("Constrain Proportions", "პროპორციების შენარჩუნება"), + ("Continuous (Lock Outgoing to Incoming)", "უწყვეტი (გამავალის მიბმა შემავალზე)"), + ("Cue Point", "მინიშნების წერტილი"), + ("Current Render", "მიმდინარე რენდერი"), + ("Dark", "ბნელი"), + ("Dark Theme", "ბნელი თემა"), + ("Darker", "უფრო მუქი"), + ("Default Output:", "ნაგულისხმევი გამოტანა:"), + ("Default: {}", "ნაგულისხმევი: {}"), + ("Defaults", "ნაგულისხმევი"), + ("Delete", "წაშლა"), + ("Delete Selected Points", "მონიშნული წერტილების წაშლა"), + ("Delete selected project items", "პროექტის მონიშნული ელემენტების წაშლა"), + ("Delete this template", "ამ შაბლონის წაშლა"), + ("Depth of Field", "ველის სიღრმე"), + ("Discord", "Discord"), + ("Distribute Layers:", "შრეების განაწილება:"), + ("Don't Recover", "არ აღადგინო"), + ("Don't Save", "არ შეინახო"), + ("Double-click a layer to open it in the Layer panel", "ორჯერ დაწკაპეთ შრეზე მის შრის პანელში გასახსნელად"), + ("Double-click footage in the Project panel to open it here", "ორჯერ დაწკაპეთ ვიდეომასალაზე პროექტის პანელში მის აქ გასახსნელად"), + ("Double-click to edit the comment", "ორჯერ დაწკაპეთ კომენტარის რედაქტირებისთვის"), + ("Download", "ჩამოტვირთვა"), + ( + "Drag properties here from the timeline,\nor use Animation > Add Property to Essential Graphics.", + "გადმოათრიეთ თვისებები დროის ხაზიდან,\nან გამოიყენეთ ანიმაცია > თვისების დამატება ძირითად გრაფიკაში.", + ), + ("Drag to change", "გადაათრიეთ შესაცვლელად"), + ("Drag to create a guide", "გადაათრიეთ დამხმარე ხაზის შესაქმნელად"), + ("Drag to slip the source", "გადაათრიეთ წყაროს გადასაწევად"), + ("Drop Frame", "კადრების გამოტოვებით"), + ("Duplicate", "დუბლირება"), + ("Duration:", "ხანგრძლივობა:"), + ("Edit Gradient…", "გრადიენტის რედაქტირება…"), + ("Edit Target: {}", "რედაქტირების სამიზნე: {}"), + ("Edit in the Timeline", "რედაქტირება დროის ხაზზე"), + ("EffectCraft didn't quit normally last time.", "EffectCraft ბოლოს ნორმალურად არ დახურულა."), + ("Effects & Presets options", "ეფექტებისა და პრესეტების პარამეტრები"), + ("Copy Presets Folder Path", "პრესეტების საქაღალდის მისამართის კოპირება"), + ("Effects: {}", "ეფექტები: {}"), + ("Empty folder", "ცარიელი საქაღალდე"), + ("Enable Depth of Field", "ველის სიღრმის ჩართვა"), + ("Enable Expression", "ექსპრესიის ჩართვა"), + ("Enhance", "გაუმჯობესება"), + ("Essential Properties — {}", "ძირითადი თვისებები — {}"), + ("Event", "მოვლენა"), + ("Export Template…", "შაბლონის ექსპორტი…"), + ("Export...", "ექსპორტი..."), + ("Expression Language Menu", "ექსპრესიების ენის მენიუ"), + ("Expression pick whip: drag onto a property", "ექსპრესიის ბმის სპირალი: გადაათრიეთ თვისებაზე"), + ("Expression:", "ექსპრესია:"), + ("Eyedropper: click, then click in the Composition panel", "პიპეტი: დაწკაპეთ, შემდეგ კი დაწკაპეთ კომპოზიციის პანელში"), + ("Falloff", "მილევა"), + ("Falloff Distance", "მილევის მანძილი"), + ("Favorites", "რჩეულები"), + ("Field Render: {}", "ნახევარკადრების რენდერი: {}"), + ("Figures", "ციფრები"), + ("Fill Target", "შევსების სამიზნე"), + ("Finished", "დასრულდა"), + ("Flowchart: {}", "ბლოკ-სქემა: {}"), + ("Focus Distance", "ფოკუსური მანძილი"), + ("Follow Feature Rotation and Scale", "მახასიათებლის შემობრუნებისა და მასშტაბის მიდევნება"), + ("Footage: {}", "ვიდეომასალა: {}"), + ("Format: {}", "ფორმატი: {}"), + ("Frame Aspect Ratio: {}", "კადრის თანაფარდობა: {}"), + ("Frame Blending: {}", "კადრების შერევა: {}"), + ("Frame Rate:", "კადრების სიხშირე:"), + ("Frame Render Time {}ms", "კადრის რენდერის დრო {}ms"), + ("Frame Target:", "კადრის სამიზნე:"), + ("Freeze", "გაყინვა"), + ("Grab bag", "სხვადასხვა"), + ("Gradient Editor", "გრადიენტის რედაქტორი"), + ("Grid", "ბადე"), + ("Guide Layers: {}", "დამხმარე შრეები: {}"), + ("Guides", "დამხმარე ხაზები"), + ("Height:", "სიმაღლე:"), + ("Hide This", "ამის დამალვა"), + ("Histogram", "ჰისტოგრამა"), + ("Hold In Place", "ადგილზე დაფიქსირება"), + ("Home", "საწყისი ეკრანი"), + ("If Confidence is Below", "თუ სანდოობა ნაკლებია"), + ("Import", "იმპორტი"), + ("Import and Add to Composition", "იმპორტი და კომპოზიციაში დამატება"), + ("Import...", "იმპორტი..."), + ("Imported media", "იმპორტირებული მედია"), + ("Influence:", "გავლენა:"), + ("Install from File…", "ფაილიდან დაყენება…"), + ("Intensity", "ინტენსივობა"), + ("Inverted", "შებრუნებული"), + ("Join the ArtCraft community on Discord", "შემოუერთდით ArtCraft-ის საზოგადოებას Discord-ზე"), + ("Key: {}", "გასაღები: {}"), + ("LEARN", "სწავლა"), + ("Label", "იარლიყი"), + ("Label Colors and Names:", "იარლიყების ფერები და სახელები:"), + ("Label:", "იარლიყი:"), + ("Latest auto-save: {}", "ბოლო ავტოშენახვა: {}"), + ("Layer", "შრე"), + ("Layer controls", "შრის მართვის ელემენტები"), + ("Layer {}", "შრე {}"), + ("Layer:", "შრე:"), + ("Layer: {}", "შრე: {}"), + ("Learn", "სწავლა"), + ("Leave all attributes in \"{}\"", "ყველა ატრიბუტის დატოვება „{}“-ში"), + ("Licence: {}", "ლიცენზია: {}"), + ("Ligatures", "ლიგატურები"), + ("Light", "ღია"), + ("Light Theme", "ღია თემა"), + ("Light Type", "სინათლის ტიპი"), + ("Lighter", "უფრო ღია"), + ("Link Selected Property", "მონიშნული თვისების დაკავშირება"), + ("Location:", "პოზიცია:"), + ("Lock Aspect Ratio to {}", "თანაფარდობის ჩაკეტვა: {}"), + ("Lock to Zoom", "მასშტაბზე მიბმა"), + ("Locked: {} — {}", "ჩაკეტილი: {} — {}"), + ("Log:", "ჟურნალი:"), + ("Loop (Trace Path)", "მარყუჟი (ტრაექტორიის გავლა)"), + ("MIT OR Apache-2.0 • Fonts: Inter, JetBrains Mono (OFL)", "MIT OR Apache-2.0 • შრიფტები: Inter, JetBrains Mono (OFL)"), + ("Magnification ratio popup", "გადიდების კოეფიციენტის მენიუ"), + ("Make Comp Size", "კომპოზიციის ზომაზე მორგება"), + ("Markers", "მარკერები"), + ("Masks", "ნიღბები"), + ("Maximize Panel", "პანელის გაშლა"), + ("Media Replacement", "მედიის ჩანაცვლება"), + ("Mesh:", "ბადე:"), + ("Diameter:", "დიამეტრი:"), + ("Method Used: {} Average Error: {} pixels", "გამოყენებული მეთოდი: {} საშუალო ცდომილება: {} პიქსელი"), + ("Models folder: {}", "მოდელების საქაღალდე: {}"), + ("Modifiers:", "მოდიფიკატორები:"), + ("More", "მეტი"), + ("More ArtCraft apps", "ArtCraft-ის სხვა აპლიკაციები"), + ("More tutorials", "მეტი გაკვეთილი"), + ("Motion Blur", "მოძრაობის გაბუნდოვნება"), + ("Motion Blur: {}", "მოძრაობის გაბუნდოვნება: {}"), + ("Motion Sketch: drag to record", "მოძრაობის ესკიზი: გადაათრიეთ ჩასაწერად"), + ("Move Down", "ჩამოწევა"), + ("Move Up", "აწევა"), + ("Move to Project Root", "პროექტის ძირში გადატანა"), + ("Name", "სახელი"), + ("Name:", "სახელი:"), + ("Navigation", "ნავიგაცია"), + ("New", "ახალი"), + ("New Comp from Selection", "ახალი კომპოზიცია მონიშნულიდან"), + ("Replace with Precomposition", "ჩადგმული კომპოზიციით ჩანაცვლება"), + ("New Duration:", "ახალი ხანგრძლივობა:"), + ("New composition name:", "ახალი კომპოზიციის სახელი:"), + ("New from Render Queue item:", "ახალი რენდერის რიგის ელემენტიდან:"), + ("New from Template", "შაბლონიდან შექმნა"), + ("Next", "შემდეგი"), + ("Nice work. Pick another tutorial on the Home screen's Learn tab.", "ყოჩაღ. აირჩიეთ სხვა გაკვეთილი საწყისი ეკრანის „სწავლის“ ჩანართზე."), + ( + "No VR environments yet. Create one with Composition ▸ VR ▸ Create VR Environment…", + "VR გარემო ჯერ არ არის. შექმენით: კომპოზიცია ▸ VR ▸ VR გარემოს შექმნა…", + ), + ("No audio layer selected", "აუდიოშრე არ არის მონიშნული"), + ("No background jobs running", "ფონური ამოცანები არ მიმდინარეობს"), + ("No composition markers", "კომპოზიციის მარკერები არ არის"), + ("No contributor data was built into this copy.", "ამ ასლში მონაწილეების მონაცემები არ არის ჩაშენებული."), + ( + "No device list from this host; the system default output is used.", + "ამ ჰოსტიდან მოწყობილობების სია მიუწვდომელია; გამოიყენება სისტემის ნაგულისხმევი გამოტანა.", + ), + ("No layer selected", "შრე არ არის მონიშნული"), + ("No matching effects or presets", "შესაბამისი ეფექტები ან პრესეტები არ მოიძებნა"), + ("No matching fonts", "შესაბამისი შრიფტები არ მოიძებნა"), + ("Variable fonts only", "მხოლოდ ცვლადი შრიფტები"), + ("No model credits were built into this copy.", "ამ ასლში მოდელების მონაცემები არ არის ჩაშენებული."), + ("No nested compositions", "ჩადგმული კომპოზიციები არ არის"), + ("No shortcut", "მალსახმობის გარეშე"), + ("No unlocked layer selected", "განბლოკილი შრე არ არის მონიშნული"), + ("No video", "ვიდეოს გარეშე"), + ("None", "არცერთი"), + ("Not applied: tick it in the list to add it.", "არ არის გამოყენებული: დასამატებლად მონიშნეთ სიაში."), + ("Not assigned", "არ არის მინიჭებული"), + ("Note: Shadows are only cast from layers with Cast Shadows enabled.", "შენიშვნა: ჩრდილს აგდებს მხოლოდ ის შრე, რომელსაც ჩართული აქვს „ჩრდილს აგდებს“."), + ("Notify", "შეტყობინება"), + ("OK", "OK"), + ("Offset:", "წანაცვლება:"), + ("On ({}) stop:", "ჩართვისას ({}) გაჩერება:"), + ("One-Node Camera", "ერთკვანძიანი კამერა"), + ("Opacity:", "გაუმჭვირვალობა:"), + ("Open Composition", "კომპოზიციის გახსნა"), + ("used once", "გამოყენებულია ერთხელ"), + ("used {} times", "გამოყენებულია {}-ჯერ"), + ("Compositions that use this item", "კომპოზიციები, რომლებიც ამ ელემენტს იყენებს"), + ("Open Last Saved Project", "ბოლოს შენახული პროექტის გახსნა"), + ("Open New Composition", "ახალი კომპოზიციის გახსნა"), + ("Open Output", "გამოტანის გახსნა"), + ("Open a composition to build Essential Graphics", "გახსენით კომპოზიცია ძირითადი გრაფიკის ასაგებად"), + ("Open a composition to see its scopes", "გახსენით კომპოზიცია მისი სკოპების სანახავად"), + ("Open a composition to track motion.", "გახსენით კომპოზიცია მოძრაობის ტრეკინგისთვის."), + ("Opus Tuning: {}", "Opus-ის მორგება: {}"), + ("Original Duration:", "საწყისი ხანგრძლივობა:"), + ("Output Cycle: {}", "გამოტანის ციკლი: {}"), + ("Output To:", "გამოტანის ადგილი:"), + ("Overlay", "გადაფარვა"), + ("Parameter Name:", "პარამეტრის სახელი:"), + ("Parent pick whip: drag onto a layer", "მშობლის ბმის სპირალი: გადაათრიეთ შრეზე"), + ("Pixel Aspect Ratio:", "პიქსელის გვერდების თანაფარდობა:"), + ("Playing on Video Preview", "იკვრება ვიდეოს წინასწარ ნახვაზე"), + ("Post-Render Action: {}", "რენდერის შემდგომი მოქმედება: {}"), + ("Preserve frame rate when nested or in render queue", "კადრების სიხშირის შენარჩუნება ჩადგმისას ან რენდერის რიგში"), + ("Preserve resolution when nested", "გარჩევადობის შენარჩუნება ჩადგმისას"), + ("Preset", "პრესეტი"), + ("Preset:", "პრესეტი:"), + ("Presets", "პრესეტები"), + ("Previous", "წინა"), + ("Primary:", "ძირითადი:"), + ("Process Before Match", "დამუშავება შეთანხმებამდე"), + ("Profile: {}", "პროფილი: {}"), + ( + "Project color depth: click to cycle 8/16/32 bpc, Alt-click for Project Settings", + "პროექტის ფერის სიღრმე: დაწკაპეთ 8/16/32 bpc-ის გადასართავად, Alt-დაწკაპუნება — პროექტის პარამეტრები", + ), + ("Project: {}", "პროექტი: {}"), + ("Projects", "პროექტები"), + ("Property pick whip: drag onto a property", "თვისების ბმის სპირალი: გადაათრიეთ თვისებაზე"), + ("Protected Region", "დაცული არე"), + ("Proxy Use: {}", "პროქსის გამოყენება: {}"), + ("Push All to Comp", "ყველაფრის კომპოზიციაში გადატანა"), + ("RGB", "RGB"), + ("Radius", "რადიუსი"), + ("Rate Control: {}", "სიჩქარის კონტროლი: {}"), + ("Recent", "ბოლოდროინდელი"), + ("Recording", "ჩაწერა"), + ("Remove", "წაშლა"), + ("Rename", "სახელის შეცვლა"), + ("Render: {} ms • UI {} fps{}", "რენდერი: {} ms • UI {} fps{}"), + ("Renderer:", "რენდერერი:"), + ("Request Persistent Storage", "მუდმივი მეხსიერების მოთხოვნა"), + ("Reset", "ჩამოყრა"), + ("Reset Labels", "იარლიყების ჩამოყრა"), + ("Reset Preset", "პრესეტის ჩამოყრა"), + ("Reset to Default", "ნაგულისხმევზე დაბრუნება"), + ("Reset to Saved Layout", "შენახულ განლაგებაზე დაბრუნება"), + ("Resolution/Down Sample Factor Popup", "გარჩევადობის/დისკრეტიზაციის კოეფიციენტის მენიუ"), + ("Restore Panel Size", "პანელის ზომის აღდგენა"), + ("Reverse the order", "რიგის შებრუნება"), + ("Revert All", "ყველაფრის დაბრუნება"), + ("Roman Hanging Punctuation", "ლათინური გამოწეული პუნქტუაცია"), + ("Roving:", "მცურავი:"), + ("Ruler zero point (drag to move, double-click to reset)", "სახაზავის ნულოვანი წერტილი (გადაათრიეთ გადასაადგილებლად, ორჯერ დაწკაპეთ ჩამოსაყრელად)"), + ("Rulers", "სახაზავები"), + ("Run", "გაშვება"), + ("Sample Format: {}", "ნიმუშის ფორმატი: {}"), + ("Samples Per Frame:", "ნიმუშები კადრზე:"), + ("Save from #{}", "შენახვა #{}-დან"), + ("Saved {}", "შენახულია {}"), + ("Script… (⌘/Ctrl+Enter runs)", "სკრიპტი… (⌘/Ctrl+Enter უშვებს)"), + ("Search commands or shortcuts", "ბრძანებების ან მალსახმობების ძიება"), + ("Select", "მონიშვნა"), + ("Select a command to change its shortcut.", "მონიშნეთ ბრძანება მისი მალსახმობის შესაცვლელად."), + ("Select a layer to see its effects", "მონიშნეთ შრე მისი ეფექტების სანახავად"), + ("Select a layer to see its properties", "მონიშნეთ შრე მისი თვისებების სანახავად"), + ("({}) Selected Objects", "({}) მონიშნული ობიექტი"), + ( + "Select a layer with a mask or shape path (or the path property), or Puppet pins.", + "მონიშნეთ ნიღბიანი ან ფიგურის ტრაექტორიის მქონე შრე (ან ტრაექტორიის თვისება), ან მარიონეტის სამაგრები.", + ), + ("Select a text layer", "მონიშნეთ ტექსტური შრე"), + ("Select an item in the Project panel", "მონიშნეთ ელემენტი პროექტის პანელში"), + ("Select animated properties to show them in the Graph Editor", "მონიშნეთ ანიმირებული თვისებები გრაფიკების რედაქტორში საჩვენებლად"), + ( + "Select the leader pin, then Shift-click the pins that should trail it (hair, cloth, tails)", + "მონიშნეთ წამყვანი სამაგრი, შემდეგ Shift-დაწკაპუნებით მონიშნეთ მიმყოლი სამაგრები (თმა, ქსოვილი, კუდები)", + ), + ("Set Ground Plane and Origin", "მიწის სიბრტყისა და სათავის დაყენება"), + ("Set Paint Colors to Black and White (D)", "ხატვის ფერების შავ-თეთრზე დაყენება (D)"), + ("Settings", "პარამეტრები"), + ("Settings Name:", "პარამეტრების სახელი:"), + ("Shadow Darkness", "ჩრდილის სიმუქე"), + ("Shadow Diffusion", "ჩრდილის გაბნევა"), + ("Shadows", "ჩრდილები"), + ("Shape…", "ფორმა…"), + ("Show", "ჩვენება"), + ("Show Layer Name", "შრის სახელის ჩვენება"), + ("Show Source Name", "წყაროს სახელის ჩვენება"), + ("Show me", "მაჩვენე"), + ("Show:", "ჩვენება:"), + ("Shutter Angle:", "ობტურატორის კუთხე:"), + ("Shutter Phase:", "ობტურატორის ფაზა:"), + ("Size", "ზომა"), + ("Skip", "გამოტოვება"), + ("Snapping", "მიბმა"), + ("Snapping options", "მიბმის პარამეტრები"), + ("Snapshot", "მომენტალური ასლი"), + ("Solo Supported Properties", "მხარდაჭერილი თვისებების სოლო"), + ("Solo Switches: {}", "სოლოს გადამრთველები: {}"), + ("Sort", "სორტირება"), + ("Spacing:", "ინტერვალი:"), + ("Spatial Interpolation:", "სივრცითი ინტერპოლაცია:"), + ("Speed:", "სიჩქარე:"), + ("Start Timecode:", "საწყისი თაიმკოდი:"), + ("Start: {} Duration: {}", "დასაწყისი: {} ხანგრძლივობა: {}"), + ("Step {} of {}: {}", "ნაბიჯი {} / {}: {}"), + ("Stop", "შეწყვეტა"), + ("Stop {} at {}°", "გადასვლის წერტილი {} — {}°"), + ("Stored in: {}", "შენახულია: {}"), + ("Stretch", "გაწელვა"), + ("Stretch Factor:", "გაწელვის კოეფიციენტი:"), + ("Stylistic Sets", "სტილისტური ნაკრებები"), + ("Subpixel Positioning", "სუბპიქსელური პოზიციონირება"), + ("Swap Colors", "ფერების გაცვლა"), + ("Swap Paint Colors (X)", "ხატვის ფერების გაცვლა (X)"), + ("Sync with System", "სისტემასთან სინქრონიზაცია"), + ("System Default", "სისტემის ნაგულისხმევი"), + ("Table", "ცხრილი"), + ("Template name", "შაბლონის სახელი"), + ("Template: {}\n{}", "შაბლონი: {}\n{}"), + ("Temporal Interpolation:", "დროითი ინტერპოლაცია:"), + ("The Media Browser is not available here (use File ▸ Import)", "მედიის ბრაუზერი აქ მიუწვდომელია (გამოიყენეთ ფაილი ▸ იმპორტი)"), + ("The OpenEXR layer shown in red, green, blue and alpha", "OpenEXR შრე წითლად, მწვანედ, ლურჯად და ალფად"), + ("The render queue is empty.", "რენდერის რიგი ცარიელია."), + ( + "These adjacent keyframes cannot interpolate because their stop counts differ.", + "ამ მეზობელ საკვანძო კადრებს შორის ინტერპოლაცია შეუძლებელია, რადგან მათ გრადიენტის გადასვლის წერტილების განსხვავებული რაოდენობა აქვთ.", + ), + ("This ScriptUI panel has closed", "ეს ScriptUI პანელი დაიხურა"), + ("This change will create a new solid for this layer.", "ეს ცვლილება ამ შრისთვის ახალ სოლიდს შექმნის."), + ("Time", "დრო"), + ("Time Stretch…", "დროში გაწელვა…"), + ("Time:", "დრო:"), + ("Time: {}", "დრო: {}"), + ("Title/Action Safe", "სათაურისა და მოქმედების უსაფრთხო არე"), + ("Title/action safe", "სათაურისა და მოქმედების უსაფრთხო არე"), + ("Toggle Switches / Modes", "გადამრთველების / რეჟიმების გადართვა"), + ("Track Name:", "ტრეკის სახელი:"), + ( + "Trained models are available in the desktop app; the browser uses the classic engine.", + "გაწვრთნილი მოდელები ხელმისაწვდომია დესკტოპის აპში; ბრაუზერი კლასიკურ ძრავს იყენებს.", + ), + ("Transform", "გარდაქმნა"), + ("Transparency grid", "გამჭვირვალობის ბადე"), + ("Tutorial complete", "გაკვეთილი დასრულდა"), + ("Two-Node Camera", "ორკვანძიანი კამერა"), + ("Type", "ტიპი"), + ("Type a command or effect…", "აკრიფეთ ბრძანება ან ეფექტი…"), + ("URL:", "URL:"), + ("Undock Panel", "პანელის მოხსნა"), + ("Move Panel to New Window", "პანელის ახალ ფანჯარაში გადატანა"), + ("Move Panel to Main Window", "პანელის მთავარ ფანჯარაში გადატანა"), + ("Unfreeze", "გაყინვის მოხსნა"), + ("Unlink {}", "{}-ის კავშირის გაწყვეტა"), + ( + "Use this option to place the currently selected layers together into a new intermediate composition.", + "გამოიყენეთ ეს ვარიანტი მონიშნული შრეების ერთად ახალ შუალედურ კომპოზიციაში მოსათავსებლად.", + ), + ("VR Environments", "VR გარემოები"), + ("Value:", "მნიშვნელობა:"), + ("Variable Font Axes", "ცვლადი შრიფტის ღერძები"), + ("Video Codec: {}", "ვიდეო კოდეკი: {}"), + ("View:", "ხედი:"), + ("Welcome to EffectCraft", "მოგესალმებათ EffectCraft"), + ("Width:", "სიგანე:"), + ("You can undo this.", "ამის დაბრუნება შეგიძლიათ."), + ("You can't undo this.", "ამის დაბრუნება შეუძლებელია."), + ("Your changes will be lost if you don't save them.", "თუ ცვლილებებს არ შეინახავთ, ისინი დაიკარგება."), + ("Zoom", "მასშტაბი"), + ("built-in", "ჩაშენებული"), + ("frames per second", "კადრი წამში"), + ("template name", "შაბლონის სახელი"), + ("{} contributors · {} commits", "მონაწილე: {} · კომიტი: {}"), + ("{} x {}, {}MB per 8bpc frame", "{} x {}, {}MB 8bpc კადრზე"), + ("{} — coming soon", "{} — მალე"), + ("{} — double-click to open, {}-click to reveal", "{} — ორჯერ დაწკაპეთ გასახსნელად, {}-დაწკაპუნება საჩვენებლად"), + ("⌘/Ctrl-drag a pin to record its motion", "⌘/Ctrl-ით გადაათრიეთ სამაგრი მისი მოძრაობის ჩასაწერად"), + ("▣ {}", "▣ {}"), + ("⚠ {} is used by:", "⚠ {} გამოიყენება:"), + ("3D Reference Axes", "3D საორიენტაციო ღერძები"), + ("Affect all layers that use this solid", "ზემოქმედება ყველა შრეზე, რომელიც ამ სოლიდს იყენებს"), + ("Choose grid and guide options", "ბადისა და დამხმარე ხაზების პარამეტრების არჩევა"), + ("Colorize", "გაფერადება"), + ("Custom", "მორგებული"), + ("Custom...", "მორგებული..."), + ("Fit", "ჩატევა"), + ("Fit up to 100%", "ჩატევა 100%-მდე"), + ("Full", "სრული"), + ("Half", "ნახევარი"), + ("Only this layer uses this solid.", "ამ სოლიდს მხოლოდ ეს შრე იყენებს."), + ("Proportional Grid", "პროპორციული ბადე"), + ("Quarter", "მეოთხედი"), + ("Region of Interest", "ინტერესის არე"), + ("Reset Exposure", "ექსპოზიციის ჩამოყრა"), + ("Show Channel and Color Management Settings", "არხისა და ფერთა მართვის პარამეტრების ჩვენება"), + ("Show Snapshot (hold)", "მომენტალური ასლის ჩვენება (დაჭერით)"), + ("Take Snapshot", "მომენტალური ასლის გადაღება"), + ("Third", "მესამედი"), + ("Toggle Mask and Shape Path Visibility", "ნიღბისა და ფიგურის ტრაექტორიის ხილვადობის გადართვა"), + ("Toggle Transparency Grid", "გამჭვირვალობის ბადის გადართვა"), + ("16 bits per channel", "16 ბიტი არხზე"), + ("32 bits per channel (float)", "32 ბიტი არხზე (მცურავი)"), + ("8 bits per channel", "8 ბიტი არხზე"), + ("Adaptive Resolution", "ადაპტური გარჩევადობა"), + ("Adobe-style built-in", "ჩაშენებული, Adobe-ს სტილის"), + ("Blue", "ლურჯი"), + ("Current Settings", "მიმდინარე პარამეტრები"), + ("Disable Per-character 3D", "თითოეული ასოს 3D-ს გამორთვა"), + ("Draft", "მონახაზი"), + ("Enable Per-character 3D", "თითოეული ასოს 3D-ს ჩართვა"), + ("Fast Draft", "სწრაფი მონახაზი"), + ("Fast Previews (Draft 3D, Faster Effects)", "სწრაფი წინასწარი ნახვა (3D მონახაზი, უფრო სწრაფი ეფექტები)"), + ("Green", "მწვანე"), + ("Import Image Sequence", "გამოსახულებების თანმიმდევრობის იმპორტი"), + ("Import PDF / Illustrator File", "PDF / Illustrator ფაილის იმპორტი"), + ("Import Photoshop File", "Photoshop ფაილის იმპორტი"), + ("Interpret Unlabeled Alpha As", "არამონიშნული ალფას ინტერპრეტაცია როგორც"), + ("Join the ArtCraft Discord", "შემოუერთდით ArtCraft-ის Discord-ს"), + ("Mercury GPU Acceleration ({})", "Mercury GPU აჩქარება ({})"), + ("Mercury Software Only", "მხოლოდ Mercury Software"), + ("OCIO (built-in ACES config)", "OCIO (ჩაშენებული ACES კონფიგურაცია)"), + ("Off (Final Quality)", "გამორთული (საბოლოო ხარისხი)"), + ("OpenGL (compatibility)", "OpenGL (თავსებადობა)"), + ("Opus Bitrate: {} kbps", "Opus-ის ბიტრეიტი: {} kbps"), + ("RGB Straight", "RGB Straight"), + ("Red", "წითელი"), + ("Tone map", "ტონური რუკა"), + ("Use In-Window Menu Bar on macOS", "ფანჯრის შიდა მენიუს ზოლის გამოყენება macOS-ზე"), + ("Wireframe", "კარკასი"), + ("sRGB IEC61966-2.1 (default)", "sRGB IEC61966-2.1 (ნაგულისხმევი)"), + ("New Project", "ახალი პროექტი"), + ("Open Project…", "პროექტის გახსნა…"), + ("Templates", "შაბლონები"), + ("EffectCraft home page", "EffectCraft-ის საწყისი გვერდი"), + ("EffectCraft on GitHub", "EffectCraft GitHub-ზე"), + ("Motion graphics and visual effects · version {}", "მოძრავი გრაფიკა და ვიზუალური ეფექტები · ვერსია {}"), + ("New Composition", "ახალი კომპოზიცია"), + ("Open Demo Project", "სადემონსტრაციო პროექტის გახსნა"), + ("Import Footage…", "ვიდეომასალის იმპორტი…"), + ("Filter recent files", "ბოლო ფაილების გაფილტვრა"), + ("NAME", "სახელი"), + ("OPENED", "გახსნილია"), + ("SIZE", "ზომა"), + ("KIND", "სახეობა"), + ("Your recent files will show here", "ბოლოდროინდელი ფაილები აქ გამოჩნდება"), + ("Create a new project, or open the demo project to look around.", "შექმენით ახალი პროექტი, ან გახსენით სადემონსტრაციო პროექტი გასაცნობად."), + ("No recent files match the filter", "ფილტრს ბოლოდროინდელი ფაილები არ შეესაბამება"), + ("Try another name or folder.", "სცადეთ სხვა სახელი ან საქაღალდე."), + ("Just now", "ახლახან"), + ("Yesterday", "გუშინ"), + ("{} minute ago", "{} წუთის წინ"), + ("{} minutes ago", "{} წუთის წინ"), + ("{} hour ago", "{} საათის წინ"), + ("{} hours ago", "{} საათის წინ"), + ("{} day ago", "{} დღის წინ"), + ("{} days ago", "{} დღის წინ"), + ("EffectCraft project", "EffectCraft-ის პროექტი"), + ("After Effects project", "After Effects-ის პროექტი"), + ("Lottie animation", "Lottie ანიმაცია"), + ("Project", "პროექტი"), + ("General", "ზოგადი"), + ("Language", "ენა"), + ("Levels of Undo", "დაბრუნების დონეები"), + ("Path Point and Handle Size", "ტრაექტორიის წერტილისა და სახელურის ზომა"), + ("Recent Projects Shown", "ნაჩვენები ბოლო პროექტები"), + ("Options", "პარამეტრები"), + ("Show Tool Tips", "მინიშნებების ჩვენება"), + ("Create Layers at Composition Start Time", "შრეების შექმნა კომპოზიციის დაწყების დროზე"), + ("Switches Affect Nested Comps", "გადამრთველები მოქმედებს ჩადგმულ კომპოზიციებზე"), + ("Default Spatial Interpolation to Linear", "ნაგულისხმევი სივრცითი ინტერპოლაცია: წრფივი"), + ( + "Preserve Constant Vertex and Feather Point Count when Editing Masks", + "ნიღბების რედაქტირებისას წვეროებისა და შერბილების წერტილების რაოდენობის შენარჩუნება", + ), + ("Synchronize Time of All Related Items", "ყველა დაკავშირებული ელემენტის დროის სინქრონიზაცია"), + ("Expression Pick Whip Writes Compact English", "ექსპრესიის ბმის სპირალი წერს კომპაქტურ ინგლისურს"), + ("Create Split Layers Above Original Layer", "დაყოფილი შრეების შექმნა საწყისი შრის ზემოთ"), + ("Use System Color Picker", "სისტემის ფერის ამომრჩევის გამოყენება"), + ( + "EffectCraft always uses its own colour picker (the same on every platform and the web).", + "EffectCraft ყოველთვის საკუთარ ფერის ამომრჩევს იყენებს (ერთნაირს ყველა პლატფორმაზე და ვებში).", + ), + ("Startup & Repair", "გაშვება და აღდგენა"), + ("Show Home Screen When Launching", "საწყისი ეკრანის ჩვენება გაშვებისას"), + ("Show Home Screen When Opening a Project", "საწყისი ეკრანის ჩვენება პროექტის გახსნისას"), + ("Offer to Open the Latest Auto-Save After a Crash", "ავარიის შემდეგ ბოლო ავტოშენახვის გახსნის შეთავაზება"), + ("Window Graphics", "ფანჯრის გრაფიკა"), + ("Automatic", "ავტომატური"), + ( + "Window Graphics applies from the next launch. When the graphics driver stops EffectCraft as its window opens, the next launch switches to OpenGL.", + "ფანჯრის გრაფიკა მოქმედებს შემდეგი გაშვებიდან. თუ გრაფიკის დრაივერი EffectCraft-ს ფანჯრის გახსნისას აჩერებს, შემდეგი გაშვება OpenGL-ზე გადაერთვება.", + ), + ("Repair", "აღდგენა"), + ("Reset Settings", "პარამეტრების ჩამოყრა"), + ("Reset Keyboard Shortcuts", "კლავიატურის მალსახმობების ჩამოყრა"), + ("Empty Disk Cache", "დისკის ქეშის გასუფთავება"), + ("New Project Loads Template", "ახალი პროექტი შაბლონს ტვირთავს"), + ("Template Project", "შაბლონი პროექტი"), + ("Auto-Save", "ავტოშენახვა"), + ("Automatically Save Projects", "პროექტების ავტომატური შენახვა"), + ("Save Every", "შენახვა ყოველ"), + ("Minutes", "წუთში"), + ("Maximum Project Versions", "პროექტის ვერსიების მაქსიმალური რაოდენობა"), + ("Auto-Save Location", "ავტოშენახვის მდებარეობა"), + ("Next to Project", "პროექტის გვერდით"), + ("Custom Location", "სხვა მდებარეობა"), + ("Save When Starting Render Queue", "შენახვა რენდერის რიგის დაწყებისას"), + ("Save Now", "ახლავე შენახვა"), + ("Motion Path", "მოძრაობის ტრაექტორია"), + ("All Keyframes", "ყველა საკვანძო კადრი"), + ("No Motion Path", "მოძრაობის ტრაექტორიის გარეშე"), + ("No More Than (Seconds)", "არაუმეტეს (წამი)"), + ("No More Than (Keyframes)", "არაუმეტეს (საკვანძო კადრი)"), + ("Seconds", "წამი"), + ("Keyframes", "საკვანძო კადრები"), + ("Display", "ეკრანი"), + ("Show Rendering Progress in Info Panel and Flowchart", "რენდერის პროგრესის ჩვენება ინფორმაციის პანელსა და ბლოკ-სქემაში"), + ("Hardware Accelerate Composition, Layer and Footage Panels", "კომპოზიციის, შრისა და ვიდეომასალის პანელების აპარატული აჩქარება"), + ("Panels are always drawn by the GPU (wgpu).", "პანელებს ყოველთვის GPU ხატავს (wgpu)."), + ("Previews", "წინასწარი ნახვა"), + ("Adaptive Resolution Limit", "ადაპტური გარჩევადობის ზღვარი"), + ("Show Internal Wireframes", "შიდა კარკასების ჩვენება"), + ("Cache Frames When Idle", "კადრების ქეშირება უქმობისას"), + ("Viewer Zoom Quality", "დამთვალიერებლის მასშტაბირების ხარისხი"), + ("Faster", "უფრო სწრაფი"), + ("More Accurate", "უფრო ზუსტი"), + ("Display Color Space", "ეკრანის ფერთა სივრცე"), + ("GPU Information...", "ინფორმაცია GPU-ს შესახებ..."), + ("Appearance", "იერსახე"), + ("Brightness", "სიკაშკაშე"), + ("UI Scale", "ინტერფეისის მასშტაბი"), + ("Labels and Colors", "იარლიყები და ფერები"), + ("Use Label Color for Layer Handles and Paths", "იარლიყის ფერის გამოყენება შრის სახელურებისა და ტრაექტორიებისთვის"), + ("Use Label Color for Related Tabs", "იარლიყის ფერის გამოყენება დაკავშირებული ჩანართებისთვის"), + ("Cycle Mask Colors", "ნიღბის ფერების მონაცვლეობა"), + ("Use Gradients", "გრადიენტების გამოყენება"), + ("Menu Bar", "მენიუს ზოლი"), + ("Grids & Guides", "ბადე და დამხმარე ხაზები"), + ("Gridline Every", "ბადის ხაზი ყოველ"), + ("Subdivisions", "ქვედანაყოფები"), + ("Style", "სტილი"), + ("Lines", "ხაზები"), + ("Dashed Lines", "წყვეტილი ხაზები"), + ("Dots", "წერტილები"), + ("Horizontal", "ჰორიზონტალური"), + ("Vertical", "ვერტიკალური"), + ("Safe Margins", "უსაფრთხო ზღვრები"), + ("Action-safe", "მოქმედების უსაფრთხო არე"), + ("Title-safe", "ტიტრების უსაფრთხო არე"), + ("Labels", "იარლიყები"), + ("Text Engine", "ტექსტის ძრავა"), + ("Latin", "ლათინური"), + ("South Asian and Middle Eastern", "სამხრეთაზიური და ახლო აღმოსავლური"), + ("Show Font Preview", "შრიფტის წინასწარი ნახვის ჩვენება"), + ("Number of Recent Fonts to Display", "საჩვენებელი ბოლო შრიფტების რაოდენობა"), + ("Show Font Names in English", "შრიფტების სახელების ინგლისურად ჩვენება"), + ("Still Footage", "უძრავი ვიდეომასალა"), + ("Length of Composition", "კომპოზიციის სიგრძე"), + ("Duration (Seconds)", "ხანგრძლივობა (წამი)"), + ("Still Duration", "უძრავი კადრის ხანგრძლივობა"), + ("Sequence Footage", "სეკვენციური ვიდეომასალა"), + ("Report Missing Frames", "გამოტოვებული კადრების შეტყობინება"), + ("Automatically Reload Footage", "ვიდეომასალის ავტომატური ხელახლა ჩატვირთვა"), + ("Off", "გამორთული"), + ("Non-Sequence Footage", "არასეკვენციური ვიდეომასალა"), + ("All Footage", "მთელი ვიდეომასალა"), + ("Ask User", "მომხმარებლისთვის კითხვა"), + ("Guess", "ვარაუდი"), + ("Ignore", "უგულებელყოფა"), + ("Straight", "პირდაპირი"), + ("Premultiplied", "წინასწარ გამრავლებული"), + ("Default Drag Import As", "გადათრევით იმპორტი ნაგულისხმევად როგორც"), + ("Footage", "ვიდეომასალა"), + ("Composition - Retain Layer Sizes", "კომპოზიცია - შრეების ზომების შენარჩუნებით"), + ("Export", "ექსპორტი"), + ("Default Output Folder", "ნაგულისხმევი გამოტანის საქაღალდე"), + ("Segment Sequences", "სეკვენციების სეგმენტებად დაყოფა"), + ("Files per Segment", "ფაილი სეგმენტში"), + ("files", "ფაილი"), + ("Segment Movie Files", "ვიდეოფაილების სეგმენტებად დაყოფა"), + ("Segment Size", "სეგმენტის ზომა"), + ("Append Bit Depth to File Name", "ბიტური სიღრმის ფაილის სახელზე დამატება"), + ("Audio", "აუდიო"), + ("Audio Hardware", "აუდიოაპარატურა"), + ("Preview Sample Rate", "წინასწარი ნახვის დისკრეტიზაციის სიხშირე"), + ("Audio Output Mapping", "აუდიოს გამოტანის ასახვა"), + ("Left", "მარცხენა"), + ("Right", "მარჯვენა"), + ("channel", "არხი"), + ("Disk", "დისკი"), + ("Disk Cache", "დისკის ქეში"), + ("Enable Disk Cache", "დისკის ქეშის ჩართვა"), + ("Maximum Disk Cache Size", "დისკის ქეშის მაქსიმალური ზომა"), + ("Disk Cache Folder", "დისკის ქეშის საქაღალდე"), + ("Media Cache", "მედიაქეში"), + ("Database and Cache Folder", "მონაცემთა ბაზისა და ქეშის საქაღალდე"), + ("Conformed Audio Folder", "მისადაგებული აუდიოს საქაღალდე"), + ("Memory & CPU", "მეხსიერება და CPU"), + ("RAM Reserved for Other Applications", "სხვა აპლიკაციებისთვის დაჯავშნული RAM"), + ("Cache Budgets", "ქეშის ლიმიტები"), + ("Layer Cache", "შრეების ქეში"), + ("Footage Frame Cache", "ვიდეომასალის კადრების ქეში"), + ("Preview (RAM) Cache", "წინასწარი ნახვის (RAM) ქეში"), + ("Reduce Cache Size When System Is Low on Memory", "ქეშის ზომის შემცირება, როცა სისტემას მეხსიერება აკლია"), + ("Video", "ვიდეო"), + ("Enable Video Preview Output", "ვიდეოს წინასწარი ნახვის გამოტანის ჩართვა"), + ("Video Device", "ვიდეომოწყობილობა"), + ("Floating Window", "მცურავი ფანჯარა"), + ("Full Screen", "მთელ ეკრანზე"), + ("Video Output During Playback", "ვიდეოს გამოტანა დაკვრისას"), + ("Mirror on Computer Monitor", "დუბლირება კომპიუტერის მონიტორზე"), + ("Disable Video Output When in Background", "ვიდეოს გამოტანის გამორთვა ფონურ რეჟიმში"), + ("Default 3D Renderer", "ნაგულისხმევი 3D რენდერერი"), + ("Show 3D Reference Axes", "3D საორიენტაციო ღერძების ჩვენება"), + ("Extended Viewer", "გაფართოებული დამთვალიერებელი"), + ("Realtime Shadows in Draft", "რეალურ დროში ჩრდილები მონახაზში"), + ("Scripting & Expressions", "სკრიპტები და ექსპრესიები"), + ("Application Scripting", "აპლიკაციის სკრიპტები"), + ("Allow Scripts to Write Files and Access Network", "სკრიპტებისთვის ფაილების ჩაწერისა და ქსელთან წვდომის დაშვება"), + ("Warn User When Executing Files", "მომხმარებლის გაფრთხილება ფაილების გაშვებისას"), + ("Enable JavaScript Debugger", "JavaScript-ის გამმართველის ჩართვა"), + ("Expressions Editor", "ექსპრესიების რედაქტორი"), + ("Font Size", "შრიფტის ზომა"), + ("Syntax Highlighting", "სინტაქსის გამოკვეთა"), + ("Line Numbers", "სტრიქონების ნომრები"), + ("Auto-complete", "ავტოდასრულება"), + ("Bracket Matching", "ფრჩხილების დაწყვილება"), + ("Word Wrap", "სიტყვების გადატანა"), + ("Show Expression Error Banner", "ექსპრესიის შეცდომის ზოლის ჩვენება"), + ( + "Expressions use the JavaScript engine. Script errors report their file and line in the Script Console; there is no step debugger.", + "ექსპრესიები JavaScript-ის ძრავას იყენებს. სკრიპტის შეცდომები ფაილსა და სტრიქონს სკრიპტის კონსოლში აჩვენებს; ნაბიჯ-ნაბიჯ გამმართველი არ არის.", + ), + ("Roto Brush", "როტო-ფუნჯი"), + ("Segmentation Model", "სეგმენტაციის მოდელი"), + ( + "Roto Brush 2.0 and 3.0 use the model chosen here (Version 1.0 always uses the classic engine). Models are open source, downloaded only when you ask, and checked against their published SHA-256.", + "როტო-ფუნჯი 2.0 და 3.0 აქ არჩეულ მოდელს იყენებს (ვერსია 1.0 ყოველთვის კლასიკურ ძრავას იყენებს). მოდელები ღია კოდისაა, ჩამოიტვირთება მხოლოდ თქვენი მოთხოვნით და მოწმდება გამოქვეყნებული SHA-256-ით.", + ), + ("Face Tracking", "სახის ტრეკინგი"), + ("Face Model", "სახის მოდელი"), + ( + "Face tracking in the Tracker panel (Outline Only and Detailed Features) uses the model chosen here. Models are open source, downloaded only when you ask, and checked against their published SHA-256.", + "ტრეკერის პანელში სახის ტრეკინგი (მხოლოდ კონტური და დეტალური ნიშნები) აქ არჩეულ მოდელს იყენებს. მოდელები ღია კოდისაა, ჩამოიტვირთება მხოლოდ თქვენი მოთხოვნით და მოწმდება გამოქვეყნებული SHA-256-ით.", + ), + ("Match System", "სისტემის მიხედვით"), + ("Default", "ნაგულისხმევი"), + ("Stored, but not used by EffectCraft yet", "შენახულია, მაგრამ EffectCraft ჯერ არ იყენებს"), + ("Total Offset", "ჯამური წანაცვლება"), + ("Per Layer", "შრიდან შრემდე"), + ("Track", "ტრეკინგი"), +]; + +/// Georgian rows for interface text no other catalog has yet: strings the panels already send +/// through [`tr`] (form fields and titles, drop messages, screen-reader names) and the labels of +/// the Timeline's context menus. Kept apart from [`GEORGIAN`] so that table can keep listing +/// exactly the Chinese catalogs' sources; the tests hold these to the same rules. +const GEORGIAN_PANELS: &[(&str, &str)] = &[ + ("Search project", "პროექტში ძიება"), + ("Select an item to see its details", "მონიშნეთ ელემენტი მისი დეტალების სანახავად"), + ("Media Duration", "მედიის ხანგრძლივობა"), + ("Frame Rate", "კადრების სიხშირე"), + ("File Path", "ფაილის მისამართი"), + ("Comment", "კომენტარი"), + ("A/V Features", "A/V ფუნქციები"), + ("Keys", "საკვანძო კადრები"), + ("Layer Name", "შრის სახელი"), + ("Source Name", "წყაროს სახელი"), + ("Switches", "გადამრთველები"), + ("Modes", "რეჟიმები"), + ("Track Matte", "ტრეკ-მატი"), + ("Parent & Link", "მშობელი და ბმა"), + ("In", "შესვლა"), + ("Out", "გამოსვლა"), + ("Duration", "ხანგრძლივობა"), + ("Original color: click to go back to it", "საწყისი ფერი: დაწკაპეთ მასზე დასაბრუნებლად"), + ("Resize floating panel", "მცურავი პანელის ზომის შეცვლა"), + ("Render every n-th pixel", "ყოველი n-ური პიქსელის რენდერი"), + ("Color (#rrggbb)", "ფერი (#rrggbb)"), + ("Workspace", "სამუშაო სივრცე"), + ("Rename to", "ახალი სახელი"), + ("Open", "გახსნა"), + ("Custom Resolution", "მორგებული გარჩევადობა"), + ("Panel Background Color", "პანელის ფონის ფერი"), + ("New Workspace", "ახალი სამუშაო სივრცე"), + ("Edit Workspaces", "სამუშაო სივრცეების რედაქტირება"), + ("A script wants to open a file", "სკრიპტს ფაილის გახსნა სურს"), + ("Drop file to import", "ჩააგდეთ ფაილი იმპორტისთვის"), + ("Drop {} files to import", "ჩააგდეთ ფაილები იმპორტისთვის: {}"), + ( + "Skipped {} dropped file(s) without a local path. Use File ▸ Import ▸ File…", + "გამოტოვებულია ჩაგდებული ფაილი ლოკალური მისამართის გარეშე: {}. გამოიყენეთ ფაილი ▸ იმპორტი ▸ ფაილი…", + ), + ("Only one project can be opened per drop; skipped {} more", "ერთ ჩაგდებაზე მხოლოდ ერთი პროექტი იხსნება; გამოტოვებულია კიდევ: {}"), + ("Cannot open dropped project {}: {}", "ჩაგდებული პროექტის გახსნა შეუძლებელია, {}: {}"), + ("Cannot import dropped files: {}", "ჩაგდებული ფაილების იმპორტი შეუძლებელია: {}"), + ("A built-in preset has this name", "ამ სახელით ჩაშენებული პრესეტი არსებობს"), + ("Replaces your preset with this name", "ჩაანაცვლებს თქვენს პრესეტს ამ სახელით"), + ("Save Preset…", "პრესეტის შენახვა…"), + ("Delete Preset", "პრესეტის წაშლა"), + ("Null Settings", "ნულოვანი ობიექტის პარამეტრები"), + ("Click to sample text {}", "დაწკაპეთ ტექსტის ნიმუშის ასაღებად {}"), + ("Force alphabetical order", "ანბანური რიგის იძულება"), + ("Frame rate (fps)", "კადრების სიხშირე (fps)"), + ( + "Change the frame rate, alpha and start frame later with File ▸ Interpret Footage.", + "კადრების სიხშირე, ალფა და საწყისი კადრი მოგვიანებით შეცვალეთ: ფაილი ▸ ვიდეომასალის ინტერპრეტაცია.", + ), + ("Import Kind", "იმპორტის სახე"), + ("Page", "გვერდი"), + ("Delay (s)", "დაყოვნება (წმ)"), + ("Amount (%)", "რაოდენობა (%)"), + ("Cascade (farther pins trail longer)", "კასკადი (შორეული სამაგრები უფრო დიდხანს ჩამორჩება)"), + ("Speed (%)", "სიჩქარე (%)"), + ("Smoothing", "დაგლუვება"), + ("Use Draft Deformation", "მონახაზის დეფორმაციის გამოყენება"), + ("Show Mesh", "ბადის ჩვენება"), + ("Scale X (%)", "მასშტაბი X (%)"), + ("Scale Y (%)", "მასშტაბი Y (%)"), + ("Rotation (degrees)", "შემობრუნება (გრადუსი)"), + ("Move X", "გადაადგილება X"), + ("Move Y", "გადაადგილება Y"), + ("Top", "ზემოდან"), + ("Width", "სიგანე"), + ("Height", "სიმაღლე"), + ("Shape", "ფიგურა"), + ("Mode", "რეჟიმი"), + ("Frame", "კადრი"), + ("Orientation", "ორიენტაცია"), + ("Position (pixels)", "პოზიცია (პიქსელი)"), + ("Position (percent of the composition)", "პოზიცია (კომპოზიციის პროცენტი)"), + ("Create", "შექმნა"), + ("Use Dimensions From", "ზომების აღება"), + ("Still Duration (seconds)", "უძრავი კადრის ხანგრძლივობა (წამი)"), + ("Add to Render Queue", "რენდერის რიგში დამატება"), + ("Sequence Layers", "შრეების თანმიმდევრობით განლაგება"), + ("Overlap", "გადაფარვა"), + ("Duration (seconds)", "ხანგრძლივობა (წამი)"), + ("Transition", "გადასვლა"), + ("Matte color (premultiplied)", "მატის ფერი (წინასწარ გამრავლებული)"), + ("Invert Alpha", "ალფას ინვერსია"), + ("Assume this frame rate", "ამ კადრების სიხშირის გამოყენება"), + ("Separate Fields", "ნახევარკადრების გაყოფა"), + ("Pixel Aspect Ratio", "პიქსელის გვერდების თანაფარდობა"), + ("Loop (times)", "ციკლი (ჯერ)"), + ("Assign Profile", "პროფილის მინიჭება"), + ("Preserve RGB", "RGB-ის შენარჩუნება"), + ("Interpret As Linear Light", "წრფივ სინათლედ ინტერპრეტაცია"), + ("Start Timecode", "საწყისი თაიმკოდი"), + ("Override Start", "დასაწყისის ხელით მითითება"), + ("Depth", "სიღრმე"), + ("Time display style", "დროის ჩვენების სტილი"), + ("Color engine", "ფერთა ძრავა"), + ("Working space", "სამუშაო სივრცე"), + ("Working gamma", "სამუშაო გამა"), + ("Linearize working space", "სამუშაო სივრცის წრფივად გარდაქმნა"), + ("Blend colors using 1.0 gamma", "ფერების შერევა 1.0 გამით"), + ("HDR on SDR displays and outputs", "HDR SDR ეკრანებსა და გამოტანებზე"), + ("Output color space", "გამოტანის ფერთა სივრცე"), + ("Video rendering and effects", "ვიდეოს რენდერი და ეფექტები"), + ("Time Span", "დროის მონაკვეთი"), + ("Invert", "ინვერსია"), + ("Blur (pixels before auto-trace)", "გაბუნდოვნება (პიქსელი, ვექტორიზაციამდე)"), + ("Tolerance (pixels)", "დაშვება (პიქსელი)"), + ("Threshold (%)", "ზღვარი (%)"), + ("Minimum Area (pixels)", "მინიმალური ფართობი (პიქსელი)"), + ("Corner Roundness (%)", "კუთხეების მომრგვალება (%)"), + ("Apply To New Layer", "ახალ შრეზე გამოყენება"), + ("Action", "მოქმედება"), + ("Sensitivity threshold (0–1, lower finds more)", "მგრძნობელობის ზღვარი (0–1, ნაკლები მეტს პოულობს)"), + ("Data", "მონაცემები"), + ("Time field (empty: automatic)", "დროის ველი (ცარიელი: ავტომატური)"), + ("Video start (ISO date-time, hh:mm:ss, timecode; empty: file date)", "ვიდეოს დასაწყისი (ISO თარიღი-დრო, hh:mm:ss, თაიმკოდი; ცარიელი: ფაილის თარიღი)"), + ("First sample at (comp seconds)", "პირველი ნიმუში (კომპოზიციის წამი)"), + ( + "Writes Final Cut Pro XML (.xml), which Premiere Pro opens with File ▸ Import. Native .prproj files are not written.", + "იწერს Final Cut Pro XML-ს (.xml), რომელსაც Premiere Pro ხსნის ბრძანებით File ▸ Import. მშობლიური .prproj ფაილები არ იწერება.", + ), + ("File", "ფაილი"), + ("Format", "ფორმატი"), + ("Pre-render (ProRes 4444)", "წინასწარი რენდერი (ProRes 4444)"), + ("Precomps", "ჩადგმული კომპოზიციები"), + ("Frame rate", "კადრების სიხშირე"), + ("Description", "აღწერა"), + ("Category", "კატეგორია"), + ("Embed footage files", "ვიდეომასალის ფაილების ჩაშენება"), + ("Embed up to (MB)", "ჩაშენება მაქსიმუმ (MB)"), + ("Use the sRGB curve (ignore Gamma)", "sRGB მრუდის გამოყენება (გამის უგულებელყოფა)"), + ("ICC profile (replaces the numbers)", "ICC პროფილი (ცვლის რიცხვებს)"), + ("Rectangle", "მართკუთხედი"), + ("Ellipse", "ელიფსი"), + ("Orient Along Path", "ორიენტაცია ტრაექტორიის გასწვრივ"), + ("Orient Towards Camera", "ორიენტაცია კამერისკენ"), + ("Orient Towards Point of Interest", "ორიენტაცია ინტერესის წერტილისკენ"), + ("Single Composition", "ერთი კომპოზიცია"), + ("Multiple Compositions", "რამდენიმე კომპოზიცია"), + ("Dissolve Front Layer", "წინა შრის გადადნობა"), + ("Cross Dissolve Front and Back Layers", "წინა და უკანა შრეების ჯვარედინი გადადნობა"), + ("Interpret Straight - Unmatted", "პირდაპირად ინტერპრეტაცია - მატის გარეშე"), + ("Interpret Premultiplied - Matted With Color", "წინასწარ გამრავლებულად ინტერპრეტაცია - ფერით მატირებული"), + ("Upper Field First", "ჯერ ზედა ნახევარკადრი"), + ("Lower Field First", "ჯერ ქვედა ნახევარკადრი"), + ("Embedded / sRGB", "ჩაშენებული / sRGB"), + ("Use Source File Timecode", "წყარო ფაილის თაიმკოდის გამოყენება"), + ("Timecode", "თაიმკოდი"), + ("Frames", "კადრები"), + ("Feet + Frames (35mm)", "ფუტი + კადრი (35 მმ)"), + ("Feet + Frames (16mm)", "ფუტი + კადრი (16 მმ)"), + ("Clip", "მოჭრა"), + ("Compand", "კომპანდირება"), + ("Current Frame", "მიმდინარე კადრი"), + ("Work Area", "სამუშაო არე"), + ("Luminance", "სიკაშკაშე"), + ("Create Markers", "მარკერების შექმნა"), + ("Split Layers", "შრეების დაყოფა"), + ("Split and Precompose", "დაყოფა და კომპოზიციაში მოთავსება"), + ("CMX 3600 EDL (.edl, V1 only)", "CMX 3600 EDL (.edl, მხოლოდ V1)"), + ("OMF (.omf, audio)", "OMF (.omf, აუდიო)"), + ("Layers Premiere can't show (text, shapes, effects…)", "შრეები, რომლებსაც Premiere ვერ აჩვენებს (ტექსტი, ფიგურები, ეფექტები…)"), + ("All layers", "ყველა შრე"), + ("None (leave them out)", "არცერთი (გამოტოვება)"), + ("Nested sequences", "ჩადგმული სეკვენციები"), + ("Pre-render", "წინასწარი რენდერი"), + ("Puppet Follow-Through", "მარიონეტის ინერციული მოძრაობა"), + ("Puppet Record Options", "მარიონეტის ჩაწერის პარამეტრები"), + ("Free Transform Points", "წერტილების თავისუფალი გარდაქმნა"), + ("Mask Shape", "ნიღბის ფორმა"), + ("Auto-Orientation", "ავტოორიენტაცია"), + ("Go to Time", "დროზე გადასვლა"), + ("Add Guide", "დამხმარე ხაზის დამატება"), + ("Add Percentage Guide", "პროცენტული დამხმარე ხაზის დამატება"), + ("New Composition from Selection", "ახალი კომპოზიცია მონიშნულიდან"), + ("Project Settings", "პროექტის პარამეტრები"), + ("Auto-trace", "ავტომატური ვექტორიზაცია"), + ("Scene Edit Detection", "სცენების ჭრილების ამოცნობა"), + ("Align Video to Data", "ვიდეოს მონაცემებთან სწორება"), + ("Export Adobe Premiere Pro Project", "Adobe Premiere Pro-ს პროექტის ექსპორტი"), + ("New Placeholder", "ახალი ჩანაცვლების ობიექტი"), + ("Solid Settings", "სოლიდის პარამეტრები"), + ("Save as Template", "შაბლონად შენახვა"), + ("My Custom RGB", "ჩემი მორგებული RGB"), + ("Mercury GPU Acceleration (no GPU: software)", "Mercury GPU აჩქარება (GPU არ არის: პროგრამული)"), + ("Sort by {}, {}", "დალაგება: {}, {}"), + ("Sort by {}", "დალაგება: {}"), + ("Label of {}: {}", "{}-ის იარლიყი: {}"), + ("Comment of {}: {}", "{}-ის კომენტარი: {}"), + ("Project color depth: {}", "პროექტის ფერის სიღრმე: {}"), + ("Start (s)", "დასაწყისი (წმ)"), + ("Duration (s)", "ხანგრძლივობა (წმ)"), + ("Frames per second", "კადრი წამში"), + ("Bottom", "ქვემოდან"), + ("Bitrate (kbps)", "ბიტრეიტი (kbps)"), + ("Frames between key frames (0 = every 2 s)", "კადრები საკვანძო კადრებს შორის (0 = ყოველ 2 წმ-ში)"), + ("Click in the Composition panel to sample text {}", "დაწკაპეთ კომპოზიციის პანელში ტექსტის ნიმუშის ასაღებად {}"), + ("Video preview off: audio only", "ვიდეოს წინასწარი ნახვა გამორთულია: მხოლოდ აუდიო"), + ("Open in Essential Graphics", "ძირითად გრაფიკაში გახსნა"), + ("Effects", "ეფექტები"), + ("Video preview is off (Preview panel): audio only", "ვიდეოს წინასწარი ნახვა გამორთულია (წინასწარი ნახვის პანელი): მხოლოდ აუდიო"), + ("Solid…", "სოლიდი…"), + ("Freeze on Last Frame", "ბოლო კადრზე გაყინვა"), + ("Edit Value…", "მნიშვნელობის რედაქტირება…"), + ("Remove Expression", "ექსპრესიის მოცილება"), +]; + #[cfg(test)] mod tests { use super::*; @@ -4212,6 +5168,7 @@ mod tests { ("dock_ui.rs", include_str!("../dock_ui.rs")), ("header.rs", include_str!("../header.rs")), ("menus.rs", include_str!("../menus.rs")), + ("prefs_live.rs", include_str!("../prefs_live.rs")), ("widgets.rs", include_str!("../widgets.rs")), ("panels/anim_tools.rs", include_str!("../panels/anim_tools.rs")), ("panels/camera_tracker_ui.rs", include_str!("../panels/camera_tracker_ui.rs")), @@ -4303,6 +5260,8 @@ mod tests { .chain(PORTUGUESE.iter().copied()) .chain(CZECH.iter().copied()) .chain(SPANISH.iter().copied()) + .chain(GEORGIAN.iter().copied()) + .chain(GEORGIAN_PANELS.iter().copied()) .collect() } @@ -4332,6 +5291,15 @@ mod tests { let hans: Vec<&str> = SIMPLIFIED_CHINESE.iter().map(|(en, _)| *en).collect(); let hant: Vec<&str> = TRADITIONAL_CHINESE.iter().map(|(en, _)| *en).collect(); assert_eq!(hans, hant, "the two Chinese catalogs list different source strings"); + let ka: Vec<&str> = GEORGIAN.iter().map(|(en, _)| *en).collect(); + assert_eq!(hans, ka, "the Georgian catalog lists different source strings from the Chinese ones"); + let ka_panels: Vec<&str> = GEORGIAN_PANELS.iter().map(|(en, _)| *en).collect(); + let shared: Vec<&str> = ka_panels.iter().copied().filter(|en| hans.contains(en)).collect(); + assert!(shared.is_empty(), "Georgian-only rows the Chinese catalogs have too (move them to GEORGIAN): {shared:?}"); + let mut own = ka_panels.clone(); + own.sort_unstable(); + own.dedup(); + assert_eq!(own.len(), ka_panels.len(), "a Georgian-only source string is listed twice"); // Japanese, Ukrainian and Czech may lag the Chinese catalogs: a missing row falls back to English. for (name, cat) in [("Japanese", JAPANESE), ("Ukrainian", UKRAINIAN), ("Brazilian Portuguese", PORTUGUESE), ("Czech", CZECH), ("Spanish", SPANISH)] { let extra: Vec<&str> = cat.iter().map(|(en, _)| *en).filter(|en| !hans.contains(en)).collect(); @@ -4403,6 +5371,22 @@ mod tests { assert_eq!(tr("Cancel"), "Cancel"); } + /// Georgian reads its own rows; counts stay count-neutral and values keep their order. + #[test] + fn georgian_panels_translate_and_keep_project_content() { + set_current("ka"); + assert_eq!(tr("Composition Name:"), "კომპოზიციის სახელი:"); + assert_eq!(tr("Cancel"), "გაუქმება"); + assert_eq!(tr("my layer.mp4"), "my layer.mp4"); + assert_eq!(tr_args("Step {} of {}: {}", &[&1, &3, &"Colour"]), "ნაბიჯი 1 / 3: Colour"); + assert_eq!(tr_args("{} minutes ago", &[&5]), "5 წუთის წინ"); + // A Georgian-only row (GEORGIAN_PANELS), which other languages still draw in English. + assert_eq!(tr("Solid Settings"), "სოლიდის პარამეტრები"); + set_current("cs"); + assert_eq!(tr("Solid Settings"), "Solid Settings"); + set_current("en"); + } + /// Values fill the placeholders in order, and a missing value is dropped instead of panicking. #[test] fn tr_args_fills_values_in_order() { diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs index 3e0984494..13986ac84 100644 --- a/crates/ui-egui/src/native_menu.rs +++ b/crates/ui-egui/src/native_menu.rs @@ -206,6 +206,8 @@ pub fn build(app: &EffectcraftApp) -> NativeMenu { "Serviços" } else if crate::i18n::czech(app) { "Služby" + } else if crate::i18n::georgian(app) { + "სერვისები" } else { "Services" } @@ -489,6 +491,27 @@ mod tests { assert_eq!(translated, ["Služby", "Skrýt EffectCraft", "Skrýt ostatní", "Zobrazit vše"]); } + #[test] + fn georgian_translates_macos_predefined_items() { + if !cfg!(target_os = "macos") { + return; + } + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.session.execute("prefs.set", json!({"key": "general.language", "value": "ka"})).unwrap(); + fn labels(nodes: &[NativeNode], out: &mut Vec) { + for node in nodes { + match node { + NativeNode::Predefined { label, .. } => out.push(label.clone()), + NativeNode::Submenu { children, .. } => labels(children, out), + _ => {} + } + } + } + let mut translated = vec![]; + labels(&build(&app).menus, &mut translated); + assert_eq!(translated, ["სერვისები", "EffectCraft-ის დამალვა", "სხვების დამალვა", "ყველას ჩვენება"]); + } + #[test] fn hide_items_are_predefined_on_macos() { let spec = build(&app()); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 2649a38ef..e56275cc2 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -120,7 +120,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { divider(&p, x0, w, y - 4.0, &t); section_header(app, ui, &p, x0, w, y + 4.0, "Text Animation", "properties.textAnimation"); y += ROW_H + 10.0; - let br = Rect::from_min_size(pos2(x0, y), vec2(116.0, 24.0)); + // Both buttons grow with their (translated) labels; English keeps its 116 / 84 px. + let text_w = |s: &str| p.layout_no_wrap(s.to_string(), Tokens::ui(12.0), t.text).size().x; + let br = Rect::from_min_size(pos2(x0, y), vec2(116f32.max(text_w(crate::i18n::tr("Add Animator")) + 36.0), 24.0)); let resp = ui.interact(br, egui::Id::new("props-add-animator"), Sense::click()); p.rect_stroke(br, 4.0, Stroke::new(1.0, if resp.hovered() { t.text_dim } else { t.field_border }), egui::StrokeKind::Inside); icons::paint(&p, Rect::from_center_size(pos2(br.min.x + 14.0, br.center().y), vec2(10.0, 10.0)), Icon::Plus, t.text); @@ -142,7 +144,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } // Our own presets (original work, see presets/text_animators.json). - let pr = Rect::from_min_size(pos2(br.max.x + 8.0, y), vec2(84.0, 24.0)); + let pr = Rect::from_min_size(pos2(br.max.x + 8.0, y), vec2(84f32.max(text_w(crate::i18n::tr("Presets")) + 16.0), 24.0)); let presp = ui.interact(pr, egui::Id::new("props-text-presets"), Sense::click()); p.rect_stroke(pr, 4.0, Stroke::new(1.0, if presp.hovered() { t.text_dim } else { t.field_border }), egui::StrokeKind::Inside); p.text(pr.center(), Align2::CENTER_CENTER, crate::i18n::tr("Presets"), Tokens::ui(12.0), t.text); diff --git a/docs/gaps.md b/docs/gaps.md index c22a97fbd..2d44bb2fc 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -72,7 +72,7 @@ are grouped by area; the issue tracker has the rest. | 13 | **3D model formats and materials** | After Effects 26 imports FBX, USD/USDZ, STL besides glTF and OBJ (`usd_plugins`), and Substance 3D materials with mesh displacement (26.0–26.2); we read glTF, OBJ, STL (binary and ASCII, with facet colours) and FBX 7.x (binary and ASCII: meshes, hierarchy, Lambert/Phong materials and textures; no FBX animation or skinning yet) | Product shots and 3D scenes come as FBX/USDZ | 25–45 h | — · [file-format-parity.md](file-format-parity.md) | | 14 | **Object Matte** (26.2, disk cache in 26.5) | First slice (#783): the Object Matte effect (Effect ▸ Matte) and `objectMatte.apply` turn a point (and optional box) on a reference frame into a matte and follow the object up to 600 frames each way, or segment every frame on its own; Roto Brush's engine, cache and model (MobileSAM from Settings ▸ Roto Brush, classical graph cut without it). Since then: the Object Matte tool in the viewer (click / drag a box, hover crosshair), the box as the model's box prompt, background tracking with progress and cancel, Freeze, and segmentations kept in the disk cache (26.5). Missing: a comparison with After Effects ([plan](fidelity/object-matte.md)) | The new one-click roto | 10–20 h | G7 · [target-app-parity.md](target-app-parity.md) | | 15 | **Hardware video decode and encode** | No VideoToolbox, NVENC, Quick Sync or VA-API; #498 asks for it (answered: CUDA / NVENC need Nvidia's proprietary SDK through FFI, which the no-`unsafe` rule forbids); open PR #819 adds hardware decoding via `filmcraft-platform`; FilmCraft has VideoToolbox / NVENC / VA-API paths to borrow | 4K playback and export speed | 25–45 h | G5/G6 · [hardware-parity.md](hardware-parity.md) | -| 16 | **Localization depth** | Menus in 10 languages (ja, zh-hans, zh-hant, uk, es, pt-br, ru, fr, de, cs: 624 rows each); panels in 6 (ja, zh-hans, zh-hant, uk, pt-br, cs); effect names translated in Simplified Chinese (306, plus the generated Effect menu), their ~1,650 parameter names untranslated; Korean and Italian (After Effects ships them) absent; Hangul probe added (#599 closed) | After Effects is used heavily in Japan, China, Korea, Brazil, Europe | 60–110 h + native review | G9 · [localization-parity.md](localization-parity.md) | +| 16 | **Localization depth** | Menus in 11 languages (ja, zh-hans, zh-hant, uk, es, pt-br, ru, fr, de, cs, ka: 624 rows each); panels in 7 (ja, zh-hans, zh-hant, uk, pt-br, cs, ka); effect names translated in Simplified Chinese and Georgian (306, plus the generated Effect menu), their ~1,650 parameter names untranslated; Korean and Italian (After Effects ships them) absent; Hangul probe added (#599 closed) | After Effects is used heavily in Japan, China, Korea, Brazil, Europe | 60–110 h + native review | G9 · [localization-parity.md](localization-parity.md) | | 17 | **Media Browser lists files import refuses** | `media_browser.rs` lists AVI; `media::is_importable` has none of them (Targa and Radiance HDR import since #745; DPX and Cineon since #756, log shown as stored: no Cineon Settings conversion yet; MXF OP1a / OP-Atom since #767; FilmCraft has no AVI demuxer); #431 and #324 closed | Confusing failures on common VFX formats | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) | | 18 | **Relative paths and relinking** | Projects store absolute footage paths; no relink when a project moves | Moving or sharing a project breaks it | 6–12 h | G4 · [file-format-parity.md](file-format-parity.md) | | 19 | **Plug-in ecosystem** | No After Effects SDK hosting (#600, #327 OpenFX, #334, #376 plug-in manager); our WebAssembly API has no third-party plug-ins | Many pro projects depend on Trapcode, Element, Saber… | 40–80 h for API growth; SDK hosting needs an owner decision | G8 · [scripting-parity.md](scripting-parity.md) | @@ -300,8 +300,8 @@ Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the vie - Localisation of the interface ([localization-parity.md](localization-parity.md)): since 6–12 October the menus are translated into Japanese, Simplified and Traditional Chinese, Ukrainian, - Spanish, Brazilian Portuguese, Russian, French, German and Czech, and the panel catalog covers - Japanese, both Chinese, Ukrainian, Brazilian Portuguese and Czech. Next: effect and parameter + Spanish, Brazilian Portuguese, Russian, French, German, Czech and Georgian, and the panel catalog covers + Japanese, both Chinese, Ukrainian, Brazilian Portuguese, Czech and Georgian. Next: effect and parameter names, the remaining panel strings (Spanish, Russian, French, German), Korean and Italian, and native-speaker review of all of them. - Accessibility (keyboard navigation, screen readers, contrast), user documentation and diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 5606a857d..701850477 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (catalogs recounted at `upstream/main` 470ea7a3: menus in 10 languages, panels in 6, Hangul probe (#599)) · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (catalogs recounted at `upstream/main` 470ea7a3: menus in 10 languages, panels in 6, Hangul probe (#599); Georgian: menus, panels and effect names) · **Target:** Adobe After Effects 2026 How much of EffectCraft's interface is translated, language by language, compared with After Effects. [gaps.md](gaps.md) gap 16 and workstream G9 hold the work; @@ -24,9 +24,9 @@ interface; Arabic and Hebrew text *in layers* is supported through its Middle Ea - **Panels, dialogs, buttons, tooltips:** `crates/ui-egui/src/i18n/ui.rs`, keyed by the English source string, with English fallback. Catalogs exist for Japanese (410 rows), Simplified and Traditional Chinese (506 each), Ukrainian (505), Brazilian Portuguese (400) and Czech (501); - counted at 470ea7a3. + counted at 470ea7a3; Georgian has every row the Chinese catalogs have. - **Effect names:** `crates/ui-egui/src/i18n/effects.rs`, keyed by **effect id**, in Simplified - Chinese (306 rows, one per effect in the registry, plus its 21 categories). The registry's + Chinese and Georgian (306 rows, one per effect in the registry, plus its 21 categories). The registry's English name stays the effect's identity: `effect.list`, `effect.apply {"effect": …}`, scripts and expressions match on it, and the property tree stores it as the effect group's name. Only the drawing — the Effect menu, Effect Controls, the Timeline and Effects & Presets — goes @@ -76,13 +76,14 @@ the true percentages are slightly lower. | Korean | `ko` | 0 | — | Hangul fallback probe added (#599, closed); not checked on macOS here | no | none | 9–13 h + review | | Vietnamese | `vi` | 0 | — | Latin with stacked diacritics; untested | no | none (After Effects: none) | 8–12 h + review | -Other languages EffectCraft ships (3): +Other languages EffectCraft ships (4): | Language | Code | UI strings translated | Status | To `full` | |---|---|---|---|---| | Traditional Chinese | `zh-hant` | 1,130 (≈ 29%): menus 624 rows, panels 506 | partial | 6–9 h + review | | Ukrainian | `uk` | 1,129 (≈ 29%): menus 624 rows, panels 505 | partial | 6–9 h + review | | Czech | `cs` | 1,125 (≈ 29%): menus 624 rows, panels 501 | Existing catalog entries covered; unwrapped UI, effects and engine text still in English; AI-assisted, not yet native-speaker proofread | Remaining strings + independent native review | +| Georgian | `ka` | 1,510 (≈ 38%): menus 628 rows, panels 882 (the Chinese catalogs' 687 plus 195 Georgian-only rows: form dialogs, context menus, column headers), effect names 307 + 21 categories; native-speaker reviewed | partial | 5–8 h | Russian (`ru`) has a menu catalog (624 rows, ≈ 16%); After Effects also ships Italian, which EffectCraft does not (8–12 h). @@ -104,6 +105,7 @@ can give; none has had it. | Date | Change | Summary | |---|---|---| | 2026-10-12 | minor | Recounted: Russian, French, German and Czech menus (#873, #987), Brazilian Portuguese and Czech panels (#830, #987), Hangul probe (#599); overall ≈ 12% to ≈ 18%; Korean and Italian still absent | +| 2026-10-14 | minor | Georgian (`ka`): menus 628, panels 882 (195 Georgian-only, in `GEORGIAN_PANELS`), effect names and categories in `effects.rs` | | 2026-10-10 | major | First localization parity document: catalog rows counted for all six languages, the twelve-language table, After Effects' 11 shipped languages from its bundle | | 2026-10-11 | minor | Ukrainian panel catalog brought level with the Chinese ones (495 of 496 rows, counted at this commit); its share rises to ≈ 29%. The other languages' panel counts are the 10 October measurement | | 2026-10-13 | minor | Effect names and the generated Effect menu leave English: `i18n/effects.rs` adds 306 rows keyed by effect id (Simplified Chinese, from After Effects 2026's interface and Adobe's Chinese documentation for the `CC` series) and the 21 category names. Parameter names are still English; no other language has rows | diff --git a/docs/preferences.md b/docs/preferences.md index 59b0fc8a9..52fe92279 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -30,7 +30,7 @@ Commands (CLI, MCP, control channel): ### Settings that change behaviour -- `general.language`: interface language (`system` / `en` / `ja` / `zh-hans` / `zh-hant` / `uk` / `es` / `pt-br`), Settings ▸ General ▸ Language; menu labels change immediately. `system` (Match System, the default) follows the operating system's interface language where EffectCraft has a translation and is English otherwise: a Simplified Chinese locale (`zh`, `zh-CN`, `zh_CN.UTF-8`, `zh-Hans-CN`, `zh-SG`) selects `zh-hans`, a Traditional one (`zh-TW`, `zh-HK`, `zh-Hant`, `zh_MO`) selects `zh-hant`, a Ukrainian one (`uk`, `uk-UA`, `uk_UA.UTF-8`) selects `uk`, a Spanish one (`es`, `es-ES`, `es_MX.UTF-8`) selects `es`, a Portuguese one (`pt`, `pt-BR`, `pt_PT.UTF-8`) selects `pt-br`; the browser build follows `?lang=` in the address, then the browser's languages (`navigator.languages`). Choose **Español** or **Português (Brasil)** for Spanish or Brazilian Portuguese menus (panels stay in English for now); choose **Українська** for Ukrainian menus and cataloged panel, dialog, button and tooltip text; Ukrainian uses the bundled UI fonts. Native Japanese, Simplified and Traditional Chinese UI uses installed system fonts; the web host must supply a Japanese font. The same setting picks the CJK fallback for the interface *and* the Han preference of the text engine, so the composition's own text is drawn with the same language's glyph forms (the text engine's preference is process-global); swapping the language reinstalls the fonts, so it applies without a restart. Two catalogs back the text: the menus are keyed by `(command, English label)` (`crates/ui-egui/src/i18n.rs`), and the rest of the interface - panels, dialogs, buttons, tooltips - is keyed by its English source string (`crates/ui-egui/src/i18n/ui.rs`, with rows for Japanese, Simplified and Traditional Chinese and Ukrainian), where a panel writes `tr("Rename")`, or `tr_args("Proxy Use: {}", &[&quality])` when the row carries a value. A row that is missing falls back to English, so a new string shows up untranslated rather than empty; the tests in that module fail on an unused row and on a hard-coded string in a converted panel. +- `general.language`: interface language (`system` / `en` / `ja` / `zh-hans` / `zh-hant` / `uk` / `es` / `pt-br` / `ka`), Settings ▸ General ▸ Language; menu labels change immediately. `system` (Match System, the default) follows the operating system's interface language where EffectCraft has a translation and is English otherwise: a Simplified Chinese locale (`zh`, `zh-CN`, `zh_CN.UTF-8`, `zh-Hans-CN`, `zh-SG`) selects `zh-hans`, a Traditional one (`zh-TW`, `zh-HK`, `zh-Hant`, `zh_MO`) selects `zh-hant`, a Ukrainian one (`uk`, `uk-UA`, `uk_UA.UTF-8`) selects `uk`, a Spanish one (`es`, `es-ES`, `es_MX.UTF-8`) selects `es`, a Portuguese one (`pt`, `pt-BR`, `pt_PT.UTF-8`) selects `pt-br`, a Georgian one (`ka`, `ka-GE`, `ka_GE.UTF-8`) selects `ka`; the browser build follows `?lang=` in the address, then the browser's languages (`navigator.languages`). Choose **Español** or **Português (Brasil)** for Spanish or Brazilian Portuguese menus (panels stay in English for now); choose **Українська** for Ukrainian menus and cataloged panel, dialog, button and tooltip text; Ukrainian uses the bundled UI fonts. Native Japanese, Simplified and Traditional Chinese UI uses installed system fonts; the web host must supply a Japanese font. The same setting picks the CJK fallback for the interface *and* the Han preference of the text engine, so the composition's own text is drawn with the same language's glyph forms (the text engine's preference is process-global); swapping the language reinstalls the fonts, so it applies without a restart. Two catalogs back the text: the menus are keyed by `(command, English label)` (`crates/ui-egui/src/i18n.rs`), and the rest of the interface - panels, dialogs, buttons, tooltips - is keyed by its English source string (`crates/ui-egui/src/i18n/ui.rs`, with rows for Japanese, Simplified and Traditional Chinese and Ukrainian), where a panel writes `tr("Rename")`, or `tr_args("Proxy Use: {}", &[&quality])` when the row carries a value. A row that is missing falls back to English, so a new string shows up untranslated rather than empty; the tests in that module fail on an unused row and on a hard-coded string in a converted panel. - `general.undoLevels`: Levels of Undo - `general.pathPointSize`: Path Point and Handle Size - `general.recentItems`: Recent Projects Shown From 642eed0802d6516a691c4e589a6983d91adbc665 Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Wed, 14 Oct 2026 14:39:26 -0600 Subject: [PATCH 035/279] Properties/Timeline/Effect Controls: a typed component edits only that component of the other selected layers (#1434) With several layers selected, typing a value into one dimension (say Z Position) sent the edited layer's whole value with selected: true, and prop.set gave every selected layer that same value: they all took the first layer's X and Y and stacked on top of it. Scrubbing was fine (it sends an offset). prop.set takes dims: with selected and a typed value, the other selected layers get only those components and keep the rest of their own value (Position with Separate Dimensions included). The three panels send the components the edit changed (both for linked Scale). Without dims, agents still set the whole value on every selected layer. Fixes #1434 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01N9kwMcw2pYQ5fSjKa3ZPnh --- crates/engine/src/commands/prop.rs | 24 ++++++++++- crates/engine/src/tests_timeline.rs | 42 ++++++++++++++++++ crates/ui-egui/src/panels/effect_controls.rs | 5 +++ crates/ui-egui/src/panels/properties.rs | 5 +++ crates/ui-egui/src/panels/timeline.rs | 5 +++ crates/ui-egui/tests/ui_inline_edit.rs | 45 ++++++++++++++++++++ 6 files changed, 124 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index 29da9c1e0..8f2638182 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -241,7 +241,9 @@ pub(crate) fn proportional_share(index: usize, count: usize) -> f64 { } /// `prop.set`; with `selected`, also on the other selected layers: the same value, or with -/// `offset` (a scrub) the same change from each layer's own value. With `offset` and +/// `offset` (a scrub) the same change from each layer's own value. A typed value with `dims` +/// (the components edited, e.g. `[2]` for Z) sets only those components on the other layers, +/// which keep the rest of their own value (#1434). With `offset` and /// `proportional` (Proportional Scrubbing) each layer takes [`proportional_share`] of the change /// by its place in the selection order, the scrubbed layer too. One undo step (with `merge`, /// one that a continuing scrub keeps folding into). @@ -261,6 +263,23 @@ fn set_selected(s: &mut Session, p: &Value) -> Result { .then(|| s.state.selected_layers.iter().copied().filter(|o| *o == lid || twins.iter().any(|(t, _)| t == o)).collect()); let share = |l: LayerId| order.as_ref().map_or(1.0, |o| o.iter().position(|x| *x == l).map_or(1.0, |i| proportional_share(i, o.len()))); let scalar = p.get("value").is_some_and(Value::is_number); + // Typed into some components only: the others stay each layer's own. + let dims: Option> = match (&delta, p.get("dims").and_then(Value::as_array), &new) { + (None, Some(a), Some(_)) if !scalar => Some(a.iter().filter_map(Value::as_u64).filter_map(|d| usize::try_from(d).ok()).collect()), + _ => None, + }; + let typed = |s: &Session, q: &mut Value, olid: LayerId, ouid: Uid| { + if let (Some(dims), Some(n)) = (&dims, &new) + && let Some(mut c) = current_components(s, cid, olid, ouid) + { + for &d in dims { + if let (Some(slot), Some(v)) = (c.get_mut(d), n.get(d)) { + *slot = *v; + } + } + q["value"] = json!(c); + } + }; // `p` aimed at (layer, prop) with its value moved by `k` × the change. let shifted = |s: &Session, q: &mut Value, olid: LayerId, ouid: Uid, k: f64| { if let Some(d) = &delta @@ -291,6 +310,7 @@ fn set_selected(s: &mut Session, p: &Value) -> Result { } let mut q = twin_params(p, olid, ouid); shifted(s, &mut q, olid, ouid, k); + typed(s, &mut q, olid, ouid); // (a layer that can't take it, e.g. a clamped or static property, keeps its value) let _ = set(s, &q); } @@ -1229,7 +1249,7 @@ pub fn specs() -> Vec { "Set Property Value", [], None, - "{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?, selected? (also the other selected layers), offset? (them by the same change), proportional? (with offset: Proportional Scrubbing, each layer by its share in selection order, the first none, the last all)}", + "{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?, selected? (also the other selected layers), offset? (them by the same change), proportional? (with offset: Proportional Scrubbing, each layer by its share in selection order, the first none, the last all), dims? (with selected, a typed value: the components edited, e.g. [2] for Z; the other layers keep the rest of their own value)}", has_layers, set_selected ), diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index 71a9943c0..a7aa8bace 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -1193,3 +1193,45 @@ fn relative_trim_moves_every_selected_layer_by_the_same_delta() { s.execute("layer.timing", json!({"layers": [a], "inDelta": 0.1})).unwrap(); assert!(near(io(&s, a).0, -0.9), "{:?}", io(&s, a)); } + +/// #1434: with several 3D layers selected, typing a value into one component (Z Position) sets +/// that component on each of them; their other components stay their own. Before, every +/// selected layer took the edited layer's whole value and they piled up on top of it. +#[test] +fn a_typed_component_applies_only_that_component_to_the_selected_layers() { + let (mut s, a) = setup(); + let b = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap(); + let c = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap(); + for (l, xy) in [(a, [10.0, 20.0]), (b, [110.0, 120.0]), (c, [210.0, 220.0])] { + s.execute("layer.setSwitch", json!({"layers": [l], "switch": "threeD", "value": true})).unwrap(); + s.execute("prop.set", json!({"layer": l, "path": "transform/position", "value": [xy[0], xy[1], 5.0]})).unwrap(); + } + s.execute("layer.select", json!({"layers": [a, b, c]})).unwrap(); + let pos = |s: &Session, l: u64| prop(s, l, "transform/position").value.components(); + let uid = prop(&s, a, "transform/position").uid; + // The panels send the edited layer's value with the typed component, and which one it was. + let undo = s.history.undo.len(); + s.execute_checked("prop.set", json!({"layer": a, "prop": uid, "value": [10.0, 20.0, -300.0], "selected": true, "offset": false, "dims": [2]})).unwrap(); + assert_eq!(pos(&s, a), vec![10.0, 20.0, -300.0]); + assert_eq!(pos(&s, b), vec![110.0, 120.0, -300.0], "B keeps its X and Y"); + assert_eq!(pos(&s, c), vec![210.0, 220.0, -300.0], "C keeps its X and Y"); + assert_eq!(s.history.undo.len(), undo + 1, "one undo step"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(pos(&s, b), vec![110.0, 120.0, 5.0]); + + // Linked Scale: both edited components go to every layer, Z stays each layer's own. + s.execute("prop.set", json!({"layer": c, "path": "transform/scale", "value": [50.0, 50.0, 30.0]})).unwrap(); + let scale_uid = prop(&s, a, "transform/scale").uid; + s.execute("prop.set", json!({"layer": a, "prop": scale_uid, "value": [200.0, 200.0, 100.0], "selected": true, "dims": [0, 1]})).unwrap(); + assert_eq!(prop(&s, c, "transform/scale").value.components(), vec![200.0, 200.0, 30.0]); + + // Position with Separate Dimensions on one of the other layers. + s.execute("prop.separateDimensions", json!({"layer": b, "value": true})).unwrap(); + s.execute("prop.set", json!({"layer": a, "prop": uid, "value": [10.0, 20.0, 40.0], "selected": true, "dims": [2]})).unwrap(); + let axis = |m: &str| prop(&s, b, &format!("transform/{m}")).value.as_f64(); + assert_eq!((axis("positionX"), axis("positionY"), axis("positionZ")), (110.0, 120.0, 40.0)); + + // Without `dims` (agents setting a whole value) every selected layer takes the value as is. + s.execute("prop.set", json!({"layer": a, "prop": uid, "value": [1.0, 2.0, 3.0], "selected": true})).unwrap(); + assert_eq!(pos(&s, c), vec![1.0, 2.0, 3.0]); +} diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index bad1db39f..ecfed5a23 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -377,6 +377,11 @@ fn prop_row( let mut nc = c.clone(); nc[d] = nv; set(actions, json!(nc)); + // Typed into this component only: other selected layers keep the rest of + // their own value (#1434). + if let Some((_, a)) = actions.last_mut() { + a["dims"] = json!([d]); + } } x = vr.max.x + 8.0; } diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 2649a38ef..5cb365137 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -306,6 +306,11 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, } } set(actions, json!(nc)); + // Typed into these components only: other selected layers keep the rest of + // their own value (#1434). + if let Some((_, a)) = actions.last_mut() { + a["dims"] = json!((from..from + shown).collect::>()); + } } x = vr.max.x + 10.0; } diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 163e6507d..f54b1cd40 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -4064,6 +4064,11 @@ fn value_editor( } } set(actions, json!(nc)); + // Typed into these components only: other selected layers keep the rest of + // their own value (#1434). + if let Some((_, a)) = actions.last_mut() { + a["dims"] = json!((from..from + shown).collect::>()); + } } x = r.max.x + if d + 1 < n { 2.0 } else { 0.0 }; if d + 1 < n { diff --git a/crates/ui-egui/tests/ui_inline_edit.rs b/crates/ui-egui/tests/ui_inline_edit.rs index dd20e55b0..d7e532d76 100644 --- a/crates/ui-egui/tests/ui_inline_edit.rs +++ b/crates/ui-egui/tests/ui_inline_edit.rs @@ -539,3 +539,48 @@ fn arrow_keys_move_the_caret_in_rename_fields() { let layer_name = h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(layer)).unwrap().name.clone(); assert_eq!(layer_name, "Pla-te"); } + +/// #1434: with several 3D layers selected, typing a Z Position sets Z on each of them; their X +/// and Y stay their own. Every selected layer used to take the typed layer's whole Position, so +/// they stacked on top of it and seemed to disappear. +#[test] +fn typing_one_component_with_several_layers_selected_keeps_their_other_components() { + let (mut h, _) = harness(); + let (a, b, transform) = { + let s = &mut h.state_mut().session; + let mut ids = vec![]; + for (name, xy) in [("A", [40.0, 50.0]), ("B", [200.0, 120.0])] { + let l = s.execute("layer.newSolid", json!({"name": name, "color": "#406080", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [l], "switch": "threeD", "value": true})).unwrap(); + s.execute("prop.set", json!({"layer": l, "path": "transform/position", "value": [xy[0], xy[1], 0.0]})).unwrap(); + ids.push(l); + } + s.execute("layer.select", json!({"layers": ids})).unwrap(); + let tr = s.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(ids[0])).unwrap().props.sub("transform").unwrap().uid; + (ids[0], ids[1], tr) + }; + h.state_mut().ui.timeline.open_layers.insert(a); + h.state_mut().ui.timeline.open_groups.insert(transform); + h.run_steps(3); + let pos = |h: &Harness<'_, EffectcraftApp>, l: u64| { + h.state() + .session + .active_comp() + .unwrap() + .layer(effectcraft_engine::project::LayerId(l)) + .unwrap() + .props + .prop("transform/position") + .unwrap() + .value + .components() + }; + let position = + h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(a)).unwrap().props.prop("transform/position").unwrap().uid; + let p = center(&h, &format!("timeline.prop.{position}.value.2")); + click(&mut h, p, 1); + type_text(&mut h, "-300"); + key(&mut h, Key::Enter); + assert_eq!(pos(&h, a), [40.0, 50.0, -300.0]); + assert_eq!(pos(&h, b), [200.0, 120.0, -300.0], "B keeps its own X and Y"); +} From e0f25c77bb56d763d3b5f1312f76d0120f06769b Mon Sep 17 00:00:00 2001 From: caldiag Date: Wed, 14 Oct 2026 18:27:25 -0300 Subject: [PATCH 036/279] when double-clicking an effect property, it reveals that effect in the timeline --- crates/ui-egui/src/panels/effect_controls.rs | 5 ++- crates/ui-egui/src/panels/timeline.rs | 41 ++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index bad1db39f..737988802 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -272,7 +272,10 @@ fn prop_row( let name_clip = Rect::from_min_max(pos2(swr.max.x + 6.0, r.min.y), pos2(vx - 6.0, r.max.y)); app.auto.add(&format!("effectControls.prop.{uid}.name"), name_clip, &prop.name); p.with_clip_rect(name_clip.intersect(p.clip_rect())).text(pos2(swr.max.x + 6.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(12.0), t.text); - ui.interact(name_clip, egui::Id::new(("ec-prop-name", uid)), Sense::click()); + let name_response = ui.interact(name_clip, egui::Id::new(("ec-prop-name", uid)), Sense::click()); + if name_response.double_clicked() && super::timeline::reveal_effect_property(app, layer.id.0, uid) { + app.raise_panel(crate::dock::PanelKind::Timeline); + } app.auto.add(&format!("effectControls.prop.{uid}.name"), name_clip, &prop.name); let merge = format!("ec-{uid}"); // With several layers selected the edit applies to the same effect property of each (#491). diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 163e6507d..c7114a28e 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1134,6 +1134,21 @@ pub(crate) fn open_effect_paths(app: &mut EffectcraftApp, layer: u64, props: &[u app.ui.timeline.open_groups.extend(open); } +/// Show an Effect Controls property in its layer's Timeline Effects view. +pub(crate) fn reveal_effect_property(app: &mut EffectcraftApp, layer: u64, prop: u64) -> bool { + let Some(l) = app.session.active_comp().and_then(|c| c.layer(LayerId(layer))) else { return false }; + let Some(fx) = l.effects().map(|g| g.uid) else { return false }; + let path = enclosing_groups(l, prop); + if path.first() != Some(&fx) { + return false; + } + let tl = &mut app.ui.timeline; + tl.open_layers.insert(layer); + tl.layer_reveal.insert(layer, vec!["effects".into()]); + tl.open_groups.extend(path); + true +} + /// After a reveal shortcut that picks properties by state (EE expressions): those inside effects /// on `layers` show under their effect, twirled open. pub(crate) fn open_revealed(app: &mut EffectcraftApp, layers: &[u64], kinds: &[String]) { @@ -4317,6 +4332,32 @@ mod tests { assert!(labels(&app).iter().any(|r| r.trim() == "Scale")); } + #[test] + fn reveal_effect_property_opens_layer_effect_path_in_timeline() { + let mut app = app(); + let comp = app.session.active_comp().unwrap().clone(); + let bx = comp.layers.iter().find(|l| l.name == "Box").unwrap().id.0; + let prop = comp + .layer(LayerId(bx)) + .and_then(|layer| layer.effects()) + .and_then(|effects| effects.groups().next()) + .and_then(|effect| effect.get("blurriness")) + .unwrap() + .uid; + + assert!(reveal_effect_property(&mut app, bx, prop)); + assert!(app.ui.timeline.open_layers.contains(&bx)); + assert_eq!(app.ui.timeline.layer_reveal.get(&bx), Some(&vec!["effects".to_string()])); + + let rows = labels(&app); + let effect = rows.iter().position(|row| row.trim() == ">Gaussian Blur").unwrap(); + assert_eq!(rows.get(effect + 1).map(String::as_str).map(str::trim), Some("Blurriness"), "{rows:?}"); + assert!(rows.iter().any(|row| row.trim() == "Blur Dimensions"), "Effects view shows the effect's other properties too: {rows:?}"); + + let transform_prop = comp.layer(LayerId(bx)).and_then(|layer| layer.transform()).and_then(|group| group.get("position")).unwrap().uid; + assert!(!reveal_effect_property(&mut app, bx, transform_prop), "non-effect properties do not change the Effects view"); + } + #[test] fn columns_toggle_and_layout() { let mut app = app(); From 7a9c2a8c401271aa3917318400fda80fe5416b00 Mon Sep 17 00:00:00 2001 From: IanHollow <72767437+IanHollow@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:44:19 -0700 Subject: [PATCH 037/279] Fonts: honor Fontconfig configuration search paths Assisted-by: GPT-6 Signed-off-by: IanHollow <72767437+IanHollow@users.noreply.github.com> --- crates/text/src/font_dirs.rs | 147 ++++++++++++++++++++++++++++++++++- 1 file changed, 143 insertions(+), 4 deletions(-) diff --git a/crates/text/src/font_dirs.rs b/crates/text/src/font_dirs.rs index 39714cad7..8004a62c0 100644 --- a/crates/text/src/font_dirs.rs +++ b/crates/text/src/font_dirs.rs @@ -19,6 +19,8 @@ pub(crate) struct FontEnv { pub xdg_config_home: Option, /// `FONTCONFIG_FILE`: the main configuration file instead of the standard ones. pub fontconfig_file: Option, + /// `FONTCONFIG_PATH`: ordered search folders, before the standard configuration folders. + pub fontconfig_path: Option, /// The main configuration files tried when `FONTCONFIG_FILE` is unset (the first that exists). pub fontconfig_roots: Vec, /// Folders scanned if they exist (the usual system folders and the Flatpak host folders). @@ -34,6 +36,7 @@ impl FontEnv { xdg_data_dirs: var("XDG_DATA_DIRS"), xdg_config_home: var("XDG_CONFIG_HOME").map(PathBuf::from), fontconfig_file: var("FONTCONFIG_FILE").map(PathBuf::from), + fontconfig_path: var("FONTCONFIG_PATH"), fontconfig_roots: ["/etc/fonts/fonts.conf", "/usr/local/etc/fonts/fonts.conf"].map(PathBuf::from).to_vec(), fixed: [ "/usr/share/fonts", @@ -87,10 +90,7 @@ const MAX_FILE_BYTES: u64 = 1 << 20; /// The font folders named by the fontconfig configuration. pub(crate) fn fontconfig_dirs(env: &FontEnv) -> Vec { let mut st = Reader { env, out: Vec::new(), seen: HashSet::new() }; - let root = match &env.fontconfig_file { - Some(f) => Some(f.clone()), - None => env.fontconfig_roots.iter().find(|p| p.is_file()).cloned(), - }; + let root = fontconfig_root(env); if let Some(root) = root { st.file(&root, 0); } @@ -102,6 +102,40 @@ pub(crate) fn fontconfig_dirs(env: &FontEnv) -> Vec { st.out } +/// Select the main file as fontconfig does: absolute names bypass the search path; +/// relative names (or `fonts.conf`) use FONTCONFIG_PATH, then the standard folders. +fn fontconfig_root(env: &FontEnv) -> Option { + if let Some(file) = &env.fontconfig_file + && file.is_absolute() + { + return Some(file.clone()); + } + if env.fontconfig_file.is_none() && env.fontconfig_path.is_none() { + return env.fontconfig_roots.iter().find(|p| readable_config(p)).cloned(); + } + let file = env.fontconfig_file.as_deref().unwrap_or_else(|| Path::new("fonts.conf")); + let mut dirs = Vec::new(); + if let Some(path) = &env.fontconfig_path { + let mut paths = std::env::split_paths(path).peekable(); + while let Some(mut dir) = paths.next() { + // Fontconfig ignores a final empty item; an interior empty item names `/`. + if dir.as_os_str().is_empty() { + if paths.peek().is_none() { + continue; + } + dir = PathBuf::from("/"); + } + dirs.push(dir); + } + } + dirs.extend(env.fontconfig_roots.iter().filter_map(|p| p.parent().map(Path::to_path_buf))); + dirs.into_iter().map(|dir| dir.join(file)).find(|p| readable_config(p)) +} + +fn readable_config(path: &Path) -> bool { + path.is_file() && std::fs::File::open(path).is_ok() +} + struct Reader<'a> { env: &'a FontEnv, out: Vec, @@ -371,4 +405,109 @@ mod tests { assert!(faces.iter().any(|f| f.family == "Noto Serif" && f.origin == "system"), "{:?}", faces.iter().map(|f| &f.family).collect::>()); let _ = std::fs::remove_dir_all(&root); } + + #[test] + fn fontconfig_path_selects_the_first_existing_config_in_search_order() { + let root = temp("config-search"); + let first = root.join("first"); + let second = root.join("second"); + write(&first.join("fonts.conf"), "/first/fonts"); + write(&second.join("fonts.conf"), "/second/fonts"); + let mut env = env_in(&root); + env.fontconfig_path = Some(std::env::join_paths([root.join("missing"), first.clone(), second.clone()]).unwrap()); + assert_eq!(fontconfig_dirs(&env), [PathBuf::from("/first/fonts")]); + env.fontconfig_path = Some(std::env::join_paths([second, first]).unwrap()); + assert_eq!(fontconfig_dirs(&env), [PathBuf::from("/second/fonts")]); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn a_relative_selected_config_uses_the_search_path_and_keeps_relative_includes() { + let root = temp("selected-config"); + let config = root.join("config"); + write(&config.join("chosen.conf"), "conf.d"); + write(&config.join("conf.d/10-fonts.conf"), "../../fonts"); + let mut env = env_in(&root); + env.fontconfig_path = Some(config.clone().into_os_string()); + env.fontconfig_file = Some(PathBuf::from("chosen.conf")); + assert_eq!(fontconfig_dirs(&env), [config.join("conf.d/../../fonts")]); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn an_absolute_selected_config_bypasses_the_search_path() { + let root = temp("absolute-config"); + write(&root.join("search/fonts.conf"), "/wrong/fonts"); + write(&root.join("selected.conf"), "/selected/fonts"); + let mut env = env_in(&root); + env.fontconfig_path = Some(root.join("search").into_os_string()); + env.fontconfig_file = Some(root.join("selected.conf")); + assert_eq!(fontconfig_dirs(&env), [PathBuf::from("/selected/fonts")]); + env.fontconfig_file = Some(root.join("missing.conf")); + assert!(fontconfig_dirs(&env).is_empty(), "an explicit missing file does not pick another config"); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn configuration_search_falls_back_to_standard_folders() { + let root = temp("default-config"); + let config = root.join("etc/fonts"); + write(&config.join("fonts.conf"), "/default/fonts"); + write(&config.join("selected.conf"), "/selected/fonts"); + let mut env = env_in(&root); + env.fontconfig_path = Some(root.join("missing").into_os_string()); + assert_eq!(fontconfig_dirs(&env), [PathBuf::from("/default/fonts")]); + env.fontconfig_file = Some(PathBuf::from("selected.conf")); + assert_eq!(fontconfig_dirs(&env), [PathBuf::from("/selected/fonts")]); + env.fontconfig_path = None; + assert_eq!(fontconfig_dirs(&env), [PathBuf::from("/selected/fonts")]); + let _ = std::fs::remove_dir_all(&root); + } + + #[cfg(unix)] + #[test] + fn configuration_search_preserves_non_unicode_folder_names() { + use std::os::unix::ffi::OsStrExt; + let root = temp("non-unicode-config"); + let config = root.join(std::ffi::OsStr::from_bytes(b"config-\xff")); + write(&config.join("fonts.conf"), "/configured/fonts"); + let mut env = env_in(&root); + env.fontconfig_path = Some(config.into_os_string()); + assert_eq!(fontconfig_dirs(&env), [PathBuf::from("/configured/fonts")]); + let _ = std::fs::remove_dir_all(&root); + } + + #[cfg(unix)] + #[test] + fn configuration_search_skips_unreadable_files() { + use std::os::unix::fs::PermissionsExt; + struct Cleanup { + root: PathBuf, + file: PathBuf, + permissions: std::fs::Permissions, + } + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = std::fs::set_permissions(&self.file, self.permissions.clone()); + let _ = std::fs::remove_dir_all(&self.root); + } + } + let root = temp("unreadable-config"); + let first = root.join("first/fonts.conf"); + let second = root.join("second/fonts.conf"); + write(&first, "/unreadable/fonts"); + write(&second, "/readable/fonts"); + let _cleanup = Cleanup { root: root.clone(), file: first.clone(), permissions: std::fs::metadata(&first).unwrap().permissions() }; + std::fs::set_permissions(&first, std::fs::Permissions::from_mode(0)).unwrap(); + if std::fs::File::open(&first).is_ok() { + eprintln!("skipped: this process can read mode-000 files"); + return; + } + let mut env = env_in(&root); + env.fontconfig_roots = vec![first.clone(), second.clone()]; + env.fontconfig_path = Some(std::env::join_paths([first.parent().unwrap(), second.parent().unwrap()]).unwrap()); + assert_eq!(fontconfig_dirs(&env), [PathBuf::from("/readable/fonts")]); + env.fontconfig_path = None; + assert_eq!(fontconfig_dirs(&env), [PathBuf::from("/readable/fonts")]); + } } From a3c3b8530c927b198749e5468c669bc873d11f05 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 15 Oct 2026 15:30:59 +0200 Subject: [PATCH 038/279] Text: a variable font's named instances are styles of its family (#1563) The font scan read only whether a face has an fvar table, so a variable family offered one style (its default instance), fontStyle = "Black" picked that face with synthetic bold, and an instance's PostScript name (Figtree-Black, what After Effects scripts use) found no face at all. The scan now reads the named instances (style, axis values, PostScript name, or the one Adobe Technical Note #5902 derives). They are styles of the family in text.fonts and the font menus, a style, PostScript name or full name selects the instance, and layout uses its axis values as the base variations; axes set on the text still win. --- crates/engine/src/tests_text_edit.rs | 60 +++++++ crates/text/src/fonts.rs | 139 ++++++++++++++-- crates/text/src/layout.rs | 44 ++++++ crates/text/src/sfnt.rs | 193 ++++++++++++++++++++++- crates/text/src/variable.rs | 10 +- crates/ui-egui/src/panels/text_panels.rs | 7 +- 6 files changed, 435 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/tests_text_edit.rs b/crates/engine/src/tests_text_edit.rs index 3e41688d2..243a9c676 100644 --- a/crates/engine/src/tests_text_edit.rs +++ b/crates/engine/src/tests_text_edit.rs @@ -317,6 +317,66 @@ fn fonts_filter_variable_families() { assert!(menu.iter().any(|r| r.family == "Inter" && !r.variable)); } +/// A variable font of `name` and `fvar` only: one `wght` axis 100..900 (default 300, style +/// `Light`) and the named `instances` (style, `wght`), each with a PostScript name +/// `-{rects}"#).into_bytes() +} + +#[test] +fn comma_selector_lists_share_one_declaration_block() { + // Before #2140 every selector of a comma list got its own copy of the declaration block, so + // `a,a,a…{…}` stored selectors × declarations strings; one selector past the cap is refused. + let sels = vec!["rect"; MAX_CSS_SELECTORS + 1].join(","); + let decls: String = (0..10).map(|i| format!("p{i}:v;")).collect(); + let svg = styled(&format!("{sels}{{fill:red;{decls}}}"), &[""]); + let e = parse(&svg).unwrap_err(); + assert!(e.to_string().contains("selectors"), "{e}"); +} + +#[test] +fn a_style_sheet_with_too_many_declarations_errors() { + let decls: String = (0..=MAX_CSS_DECLARATIONS).map(|i| format!("p{i}:v;")).collect(); + let svg = styled(&format!("rect{{{decls}}}"), &[""]); + let e = parse(&svg).unwrap_err(); + assert!(e.to_string().contains("declarations"), "{e}"); +} + +#[test] +fn selector_lists_keep_the_css_cascade() { + let green = Some(Paint::Color([0.0, 1.0, 0.0])); + let blue = Some(Paint::Color([0.0, 0.0, 1.0])); + let red = Some(Paint::Color([1.0, 0.0, 0.0])); + let css = "rect, .b, #c { fill: #00ff00 } .b, .d { fill: #0000ff; stroke: #000000; stroke-width: 3 } rect.e, #c { fill: #ff0000 }"; + let svg = styled(css, &["", r#"class="b""#, r#"id="c" class="b""#, r#"class="b" style="fill:#00ff00""#, r##"class="e" fill="#0000ff""##, r#"class="d""#]); + let doc = parse(&svg).unwrap(); + let shapes = doc.flatten(); + let fills: Vec<_> = shapes.iter().map(|s| s.2.fill.clone()).collect(); + // Type selector; class beats type; id beats class; inline style beats every rule; a later, + // more specific rule beats the presentation attribute. + assert_eq!(fills, vec![green.clone(), blue.clone(), red.clone(), green, red, blue]); + assert!(shapes[0].2.stroke.is_none()); + // The second list's block reaches both of its selectors. + for i in [1, 5] { + assert_eq!(shapes[i].2.stroke.as_ref().map(|s| s.width), Some(3.0), "shape {i}"); + } +} + +#[test] +fn a_comma_list_stores_its_declarations_once() { + let sels = vec!["rect"; 1000].join(","); + let decls: String = (0..50).map(|i| format!("p{i}:v;")).collect(); + let mut sheet = StyleSheet::default(); + parse_css(&format!("{sels}{{{decls}}}"), &mut sheet).unwrap(); + assert_eq!(sheet.rules.len(), 1000); + assert_eq!(sheet.blocks.len(), 1); + assert_eq!(sheet.decls, 50); +} + +#[test] +fn matching_work_past_the_budget_errors_without_doing_it() { + // Within both sheet caps, yet one rectangle would apply 10 000 x 5 001 declarations. + let sels = vec!["rect"; 10_000].join(","); + let decls: String = (0..5_001).map(|i| format!("p{i}:v;")).collect(); + let svg = styled(&format!("{sels}{{{decls}}}"), &[""]); + let e = parse(&svg).unwrap_err(); + assert!(e.to_string().contains("{uses}"## + ) + }; + let e = parse(svg(&uses).as_bytes()).unwrap_err(); + assert!(e.to_string().contains("{uses}"## + ) + }; + let e = parse(svg(&uses).as_bytes()).unwrap_err(); + assert!(e.to_string().contains("