From a1a1a36a417d7a5ee6298780be0692f241f79eef Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Wed, 14 Oct 2026 02:38:38 +0400 Subject: [PATCH 01/44] 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 02/44] 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 03/44] 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 2fbcc6b59a4f5db5d9b5f4110b932893c691a26f Mon Sep 17 00:00:00 2001 From: FeiLiuEM Date: Sun, 18 Oct 2026 22:28:56 +0800 Subject: [PATCH 04/44] ui: the interface prefers the platform's UI faces, rendered text keeps the print ones Windows ships a UI variant of its Japanese and Chinese families (`Meiryo`/`Meiryo UI`, `Yu Gothic`/`Yu Gothic UI`, `MS Gothic`/`MS UI Gothic`, `Microsoft YaHei`/`Microsoft YaHei UI`, `Microsoft JhengHei`/`Microsoft JhengHei UI`) whose kana and kanji are drawn for the screen, and its own link table for `Segoe UI` picks the UI one for everything it draws. The interface should ask for it - but the same lists feed `fonts::fallback_for`, which shapes composed frames, so reordering them would change how existing projects render. The two orders are therefore separate: this leaves `crates/text/src/fonts.rs` exactly as it is and gives the interface its own candidate list in `install_script_fallbacks`, which falls back to the shared choice when the platform has no UI variant, none is installed, or it does not cover the probe. `PMingLiU` is in the interface list for its proportional spacing, not as a sibling of `MingLiU`: it is MingLiU's proportional relative. The rendered order is pinned by a test now: the Japanese list keeps `Yu Gothic` ahead of `Yu Gothic UI`, and no UI name appears in the lists a document renders with. `the_interface_asks_for_the_ platforms_ui_faces` pins the interface list and checks that every other script the install probes has none, so a script that draws correctly in any face keeps the shared order. Review fixes: the locale is mapped once (the call passes the language, the helper maps it), the new test sits after the #394 test it had split from its doc comment, and the fonts.rs test asserts instead of panicking inside a closure. Rebased on current main; #1395 has not landed yet, so there is nothing to rebase after it. --- crates/text/src/fonts.rs | 24 +++++++++++++++++ crates/ui-egui/src/theme.rs | 52 ++++++++++++++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index d555954c9..a2dd1fbdc 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -1144,6 +1144,30 @@ mod tests { assert_eq!(han_order_for("ja_JP.UTF-8")[0], FallbackScript::Japanese); } + #[test] + fn the_rendered_order_keeps_the_print_face_first() { + // A composed frame renders with these lists, so they keep the platform's print order: the + // interface prefers the platform's UI faces on its own (see `install_script_fallbacks` in + // the ui crate), and an existing project must not render differently because a window's + // chrome changed. + let pos = |list: &[&str], name: &str| list.iter().position(|f| *f == name).unwrap_or(usize::MAX); + assert!( + pos(JAPANESE_FAMILIES, "Yu Gothic") < pos(JAPANESE_FAMILIES, "Yu Gothic UI"), + "the print face must come before its UI sibling: {JAPANESE_FAMILIES:?}" + ); + for name in ["Meiryo UI", "MS UI Gothic"] { + assert!(!JAPANESE_FAMILIES.contains(&name), "{name} belongs to the interface order, not this list"); + } + assert_eq!(SIMPLIFIED_CHINESE_FAMILIES[0], "Microsoft YaHei"); + assert_eq!(TRADITIONAL_CHINESE_FAMILIES[0], "Microsoft JhengHei"); + for name in ["Microsoft YaHei UI", "Microsoft JhengHei UI", "PMingLiU"] { + assert!( + !SIMPLIFIED_CHINESE_FAMILIES.contains(&name) && !TRADITIONAL_CHINESE_FAMILIES.contains(&name), + "{name} belongs to the interface order, not these lists" + ); + } + } + #[test] fn bundled_ui_fonts_cover_the_ukrainian_alphabet() { const ALPHABET: &str = "АБВГҐДЕЄЖЗИІЇЙКЛМНОПРСТУФХЦЧШЩЬЮЯабвгґдеєжзиіїйклмнопрстуфхцчшщьюя’«»"; diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index f5a0664c2..76d699ae9 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -409,7 +409,15 @@ fn install_script_fallbacks(family_fonts: &mut FontDefinitions, language: &str) let mut names = Vec::new(); let mut seen = Vec::new(); for &(probe, name) in cjk.iter().chain(SCRIPT_PROBES).chain(std::iter::once(&HANGUL)) { - let id = fonts::fallback_for(probe, base); + // The interface asks for the platform's UI face first; the order a composed frame + // renders with is `fonts::fallback_for`'s and is not touched here. + let id = interface_fallback_candidates(name, language) + .iter() + .find_map(|family| { + let r = fonts::resolve(family, "Regular"); + (!r.missing && fonts::face(r.face).has_char(probe)).then_some(r.face) + }) + .unwrap_or_else(|| fonts::fallback_for(probe, base)); if seen.contains(&id) { continue; } @@ -432,6 +440,31 @@ fn install_script_fallbacks(family_fonts: &mut FontDefinitions, language: &str) } } +/// The faces the interface asks for before it settles on the one a composed frame renders with. +/// +/// Windows ships a UI variant of its Japanese and Chinese families - `Meiryo`/`Meiryo UI`, `Yu +/// Gothic`/`Yu Gothic UI`, `MS Gothic`/`MS UI Gothic`, `Microsoft YaHei`/`Microsoft YaHei UI`, +/// `Microsoft JhengHei`/`Microsoft JhengHei UI` - and its own link table for `Segoe UI` +/// (`FontLink\SystemLink`) picks the UI one for what it draws on screen. `PMingLiU` is the one face +/// here that is not a sibling: it is `MingLiU`'s proportional relative, and proportional spacing is +/// what makes the chrome read better in it. +/// +/// Only the interface takes this order. `fonts::fallback_for` keeps the order a document renders +/// with, so an existing project's output does not move when the chrome changes. +#[cfg(not(target_arch = "wasm32"))] +fn interface_fallback_candidates(name: &str, language: &str) -> &'static [&'static str] { + match (name, cjk_locale(language)) { + // Kana, and the Han ideographs of a Japanese interface: the language asks for the Japanese + // face, so its UI sibling is the one the platform would draw chrome with. + ("japanese-system", _) | ("chinese-system", "ja") => &["Meiryo UI", "Yu Gothic UI", "MS UI Gothic"], + ("chinese-system", "zh-hant") => &["Microsoft JhengHei UI", "PMingLiU"], + ("chinese-system", "zh-hans") => &["Microsoft YaHei UI"], + // The other scripts have no UI variant, and their shared order already picks the face made + // for the script - the one that draws it correctly at all. + _ => &[], + } +} + /// The CJK locale the text engine should prefer for Han text: the interface language when it names /// one, otherwise the empty string, which leaves the operating system's locale in charge. #[cfg(not(target_arch = "wasm32"))] @@ -530,6 +563,7 @@ pub fn apply_visuals(ctx: &egui::Context, t: &Tokens) { #[cfg(all(test, not(target_arch = "wasm32")))] mod script_font_tests { + use super::{SCRIPT_PROBES, interface_fallback_candidates}; use effectcraft_text::fonts; /// Whether an installed face covers `probe` (else the glyph check is skipped). @@ -581,6 +615,7 @@ mod script_font_tests { /// #394: Arabic, Hebrew, Indic and Thai names are drawn by an installed face in an English /// interface: the UI fonts were Inter and the CJK fallbacks only, so a layer named in Arabic /// showed as missing-glyph boxes. + #[test] fn installed_script_fallbacks_draw_arabic_hebrew_indic_and_thai_names() { // Letters only: a vowel sign or virama on its own is drawn on a dotted circle. @@ -595,6 +630,21 @@ mod script_font_tests { } } + #[test] + fn the_interface_asks_for_the_platforms_ui_faces() { + // The face a window draws its own chrome with, which is not the one a composed frame + // renders with: the two orders are deliberately separate. + let ja = &["Meiryo UI", "Yu Gothic UI", "MS UI Gothic"][..]; + assert_eq!(interface_fallback_candidates("japanese-system", "ja"), ja); + assert_eq!(interface_fallback_candidates("chinese-system", "ja"), ja); + assert_eq!(interface_fallback_candidates("chinese-system", "zh-hans"), &["Microsoft YaHei UI"][..]); + assert_eq!(interface_fallback_candidates("chinese-system", "zh-hant"), &["Microsoft JhengHei UI", "PMingLiU"][..]); + // Every other script the install probes has no UI variant, so it keeps the shared order. + for &(_, name) in SCRIPT_PROBES.iter() { + assert!(interface_fallback_candidates(name, "en").is_empty(), "{name} asked for a UI face"); + } + } + /// #1141: switching the interface to Simplified Chinese drew most labels as boxes. The English /// interface had already loaded a Japanese face for kana, and that face covers the Han probe, /// so it became the Chinese fallback too, without the simplified-only hanzi. From 6c6b54a182d5cbf3737981ec4fc117a5afe0c695 Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Sun, 18 Oct 2026 19:54:25 +0400 Subject: [PATCH 05/44] Georgian interface text draws from an installed font Georgian joins the installed-font fallback like Thai, Hebrew and the Indic scripts: Sylfaen on Windows, SF Georgian on macOS, Noto Sans Georgian or DejaVu Sans on Linux. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- crates/text/src/fonts.rs | 23 +++++++++++++++++++++++ crates/ui-egui/src/theme.rs | 12 ++++++++++++ 2 files changed, 35 insertions(+) diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index d555954c9..11a436f22 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -542,6 +542,8 @@ enum FallbackScript { /// Sinhala (one family covers them all on Windows; elsewhere each has its own). Indic, Thai, + /// Georgian: Mkhedruli, Asomtavruli and Nuskhuri, and Mtavruli capitals. + Georgian, Other, } @@ -570,6 +572,7 @@ fn script_of(c: char) -> FallbackScript { 0x0590..=0x05FF | 0xFB1D..=0xFB4F => FallbackScript::Hebrew, 0x0900..=0x0DFF | 0xA8E0..=0xA8FF => FallbackScript::Indic, 0x0E00..=0x0E7F => FallbackScript::Thai, + 0x10A0..=0x10FF | 0x1C90..=0x1CBF | 0x2D00..=0x2D2F => FallbackScript::Georgian, _ => FallbackScript::Other, } } @@ -635,6 +638,7 @@ const INDIC_FAMILIES: &[&str] = &[ "Noto Sans Sinhala", ]; const THAI_FAMILIES: &[&str] = &["Leelawadee UI", "Tahoma", "Thonburi", "Noto Sans Thai", "Noto Sans Thai UI", "Loma", "Garuda"]; +const GEORGIAN_FAMILIES: &[&str] = &["Sylfaen", ".SF Georgian", "SF Georgian", "Noto Sans Georgian", "DejaVu Sans"]; /// The CJK locale the host asked fallbacks to prefer, when it has one (see [`set_cjk_locale`]). static REQUESTED_CJK_LOCALE: RwLock> = RwLock::new(None); @@ -719,6 +723,7 @@ fn families_for(s: FallbackScript) -> &'static [&'static str] { FallbackScript::Hebrew => HEBREW_FAMILIES, FallbackScript::Indic => INDIC_FAMILIES, FallbackScript::Thai => THAI_FAMILIES, + FallbackScript::Georgian => GEORGIAN_FAMILIES, FallbackScript::Other => &[], } } @@ -981,6 +986,8 @@ mod tests { assert_eq!(script_of('क'), FallbackScript::Indic); assert_eq!(script_of('த'), FallbackScript::Indic); assert_eq!(script_of('ก'), FallbackScript::Thai); + assert_eq!(script_of('ა'), FallbackScript::Georgian); + assert_eq!(script_of('\u{1c90}'), FallbackScript::Georgian, "Mtavruli"); assert_eq!(script_of('\u{feff}'), FallbackScript::Other, "the byte order mark is no Arabic letter"); } @@ -1004,6 +1011,22 @@ mod tests { } } + /// Native: the script search picks a Georgian family for Mkhedruli (a face already loaded + /// that happens to cover it, such as macOS Helvetica, still wins in `fallback_for`). Skipped + /// where no family from the list is installed. + #[test] + fn georgian_falls_back_to_a_georgian_system_family() { + scan_system(); + let c = 'ა'; + if !GEORGIAN_FAMILIES.iter().any(|n| resolve_in(n, "Regular").is_some_and(|r| face(r.face).has_char(c))) { + eprintln!("no Georgian family from the list is installed; skipping"); + return; + } + let f = face(system_fallback(c, "Regular").unwrap()); + assert!(f.has_char(c), "{} lacks it", f.info.family); + assert!(GEORGIAN_FAMILIES.iter().any(|n| f.info.family.eq_ignore_ascii_case(n)), "{c} → {}", f.info.family); + } + /// Native: Japanese text in a Latin-only family falls back to an installed Japanese family /// (whichever the platform ships) instead of the missing-glyph box, without any layer having /// loaded a CJK font first. Skipped where no family from the list is installed. diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index f5a0664c2..c8aaafed7 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -370,6 +370,7 @@ const SCRIPT_PROBES: &[(char, &str)] = &[ ('ಕ', "kannada-system"), ('ക', "malayalam-system"), ('ก', "thai-system"), + ('ა', "georgian-system"), ]; /// Register the system fallbacks for scripts the bundled fonts lack in every font family, and @@ -595,6 +596,17 @@ mod script_font_tests { } } + /// The Georgian interface draws its Mkhedruli text with an installed Georgian face (Sylfaen, + /// SF Georgian, Noto Sans Georgian…): Inter has no Georgian, so the labels showed as boxes. + #[test] + fn installed_georgian_fallback_draws_the_georgian_interface() { + if !installed('ა') { + eprintln!("no Georgian system font installed; skipping glyph coverage"); + return; + } + assert_drawn("ka", "კომპოზიციაფენასაკვანძოკადრიეფექტიპარამეტრები"); + } + /// #1141: switching the interface to Simplified Chinese drew most labels as boxes. The English /// interface had already loaded a Japanese face for kana, and that face covers the Han probe, /// so it became the Chinese fallback too, without the simplified-only hanzi. From 7999754a139dfdcfb575dab9e9a92d0a09960638 Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Mon, 19 Oct 2026 03:20:50 +0400 Subject: [PATCH 06/44] i18n(ka): Georgian rows for the panel strings main added; drop one it no longer uses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copy, Paste, Copy Source Path, Guide Layer and Layer Styles join GEORGIAN in the Chinese order; Edit Value…, Remove Expression and Effects move there from GEORGIAN_PANELS now that the Chinese catalogs carry them; Freeze on Last Frame is only a menu command now. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- crates/ui-egui/src/i18n/ui.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 935973125..c42a17ff9 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -4574,13 +4574,19 @@ const GEORGIAN: &[(&str, &str)] = &[ ("Drag to slip the source", "გადაათრიეთ წყაროს გადასაწევად"), ("Drop Frame", "კადრების გამოტოვებით"), ("Duplicate", "დუბლირება"), + ("Copy", "კოპირება"), + ("Paste", "ჩასმა"), + ("Edit Value…", "მნიშვნელობის რედაქტირება…"), + ("Remove Expression", "ექსპრესიის მოცილება"), ("Duration:", "ხანგრძლივობა:"), ("Edit Gradient…", "გრადიენტის რედაქტირება…"), ("Edit Target: {}", "რედაქტირების სამიზნე: {}"), ("Edit in the Timeline", "რედაქტირება დროის ხაზზე"), ("EffectCraft didn't quit normally last time.", "EffectCraft ბოლოს ნორმალურად არ დახურულა."), + ("Effects", "ეფექტები"), ("Effects & Presets options", "ეფექტებისა და პრესეტების პარამეტრები"), ("Copy Presets Folder Path", "პრესეტების საქაღალდის მისამართის კოპირება"), + ("Copy Source Path", "წყაროს მისამართის კოპირება"), ("Effects: {}", "ეფექტები: {}"), ("Empty folder", "ცარიელი საქაღალდე"), ("Enable Depth of Field", "ველის სიღრმის ჩართვა"), @@ -4615,6 +4621,7 @@ const GEORGIAN: &[(&str, &str)] = &[ ("Grab bag", "სხვადასხვა"), ("Gradient Editor", "გრადიენტის რედაქტორი"), ("Grid", "ბადე"), + ("Guide Layer", "დამხმარე შრე"), ("Guide Layers: {}", "დამხმარე შრეები: {}"), ("Guides", "დამხმარე ხაზები"), ("Height:", "სიმაღლე:"), @@ -4643,6 +4650,7 @@ const GEORGIAN: &[(&str, &str)] = &[ ("Layer {}", "შრე {}"), ("Layer:", "შრე:"), ("Layer: {}", "შრე: {}"), + ("Layer Styles", "შრის სტილები"), ("Learn", "სწავლა"), ("Leave all attributes in \"{}\"", "ყველა ატრიბუტის დატოვება „{}“-ში"), ("Licence: {}", "ლიცენზია: {}"), @@ -5386,12 +5394,8 @@ const GEORGIAN_PANELS: &[(&str, &str)] = &[ ("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)] From a5f096324db28ae9ee617616f9528db517c10c92 Mon Sep 17 00:00:00 2001 From: heojunfo Date: Mon, 19 Oct 2026 10:36:16 +0900 Subject: [PATCH 07/44] Text: draw Faux Bold and synthetic bold (#1676) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The layout marks a glyph synth_bold when Faux Bold is on, or when a bold style is asked of a family without a bold face, but the renderer never read it, so both drew as regular. PlacedGlyph now carries the bold radius (0.03 em, which brings Inter Regular's stems to Inter Bold's). A bold fill is the outline's coverage merged with a round stroke twice that wide, and a bold glyph's stroke is the band between the outline grown by radius ± half the stroke width, so it follows the bolder shape. Runs of solid fills only merge glyphs of the same bold radius, and the patch and layer bounds grow by it. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/text.rs | 68 ++++++++++++++++++++++++++------- crates/render/src/text/tests.rs | 31 +++++++++++++++ 2 files changed, 85 insertions(+), 14 deletions(-) diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index fbb0feb24..856b2f578 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -12,7 +12,7 @@ use effectcraft_geom::{Mat3, Mat4, Vec3, vec2, vec3}; use effectcraft_keyframe::{Justify, TextDoc, Value}; use effectcraft_path::{BezPath, FillRule, StrokeStyle}; use effectcraft_project::{Layer, PropGroup}; -use effectcraft_raster::Image; +use effectcraft_raster::{Image, Mask}; use effectcraft_text::path_text::{self, PathMeasure}; use effectcraft_text::selectors::{self, BasedOn, Mode, Range, Shape, Wiggly}; use effectcraft_text::{CharGlyph, TextLayout, char_glyph_style, layout_doc}; @@ -479,6 +479,9 @@ pub struct PlacedGlyph { pub fill: [f32; 4], pub stroke: [f32; 4], pub stroke_width: f64, + /// Faux Bold (or synthetic bold for a family without a bold face): how far the outline grows + /// on every side, in character space (0 when not bold). + pub bold: f64, /// The character's style fills it (Character panel fill on). pub apply_fill: bool, /// Index of the character in the text. @@ -498,8 +501,17 @@ impl PlacedGlyph { pub fn path(&self) -> BezPath { effectcraft_path::transform(std::slice::from_ref(&self.local), &self.m2()).remove(0) } + /// [`Self::bold`] in layer space. + fn bold_in_layer(&self) -> f64 { + self.bold * self.m2().mean_scale() + } } +/// How far Faux Bold grows a glyph's outline on every side, as a fraction of its size: Inter +/// Regular grown by this has about the stems of Inter Bold, so synthetic bold stands in for a +/// missing bold face. +const FAUX_BOLD_RADIUS: f64 = 0.03; + /// Laid-out, animated text of a layer at the context time. pub struct TextGeom { pub doc: TextDoc, @@ -777,6 +789,7 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { fill, stroke, stroke_width: (base_stroke_w + x.stroke_width).max(0.0), + bold: if g.synth_bold { FAUX_BOLD_RADIUS * g.size } else { 0.0 }, apply_fill: st.apply_fill, char_index: g.char_index, rest, @@ -826,17 +839,41 @@ fn passes(geom: &TextGeom) -> Vec { v } +/// Coverage of the outline grown by `d` on every side (shrunk when `d` is negative): the fill +/// merged with, or cut by, a round stroke `2|d|` wide, which keeps the edges anti-aliased. +fn grown_coverage(paths: &[BezPath], mm: &Mat3, w: u32, h: u32, d: f64) -> Mask { + let mut cov = effectcraft_path::fill_coverage(paths, mm, w, h, FillRule::NonZero); + if d.abs() < 1e-9 { + return cov; + } + let st = StrokeStyle { width: 2.0 * d.abs(), join: effectcraft_path::Join::Round, ..Default::default() }; + let ring = effectcraft_path::stroke_coverage(paths, &st, mm, w, h); + for (c, r) in cov.data.iter_mut().zip(&ring.data) { + *c = if d > 0.0 { c.max(*r) } else { c.min(1.0 - r) }; + } + cov +} + /// Rasterize one pass of a glyph — or of a run of glyphs painted alike, as one outline — (paths /// mapped into target pixels by `m`) into a premultiplied patch over the target rect, blurred -/// when the character has Blur. -fn raster_pass(g: &PlacedGlyph, stroke: bool, paths: &[BezPath], m: &Mat3, rect: [i64; 4], blur_px: [f64; 2]) -> Image { +/// when the character has Blur. `bold` is the glyph's Faux Bold in the paths' space. +fn raster_pass(g: &PlacedGlyph, stroke: bool, paths: &[BezPath], m: &Mat3, rect: [i64; 4], blur_px: [f64; 2], bold: f64) -> Image { let (w, h) = ((rect[2] - rect[0]) as u32, (rect[3] - rect[1]) as u32); let mm = Mat3::translate(vec2(-rect[0] as f64, -rect[1] as f64)) * *m; - let cov = if stroke { + let cov = if stroke && bold > 0.0 { + // The stroke of the bold outline: the band between it grown and shrunk by half the width. + let half = g.stroke_width / 2.0; + let mut outer = grown_coverage(paths, &mm, w, h, bold + half); + let inner = grown_coverage(paths, &mm, w, h, bold - half); + for (o, i) in outer.data.iter_mut().zip(&inner.data) { + *o = o.min(1.0 - i); + } + outer + } else if stroke { let st = StrokeStyle { width: g.stroke_width, join: effectcraft_path::Join::Round, ..Default::default() }; effectcraft_path::stroke_coverage(paths, &st, &mm, w, h) } else { - effectcraft_path::fill_coverage(paths, &mm, w, h, FillRule::NonZero) + grown_coverage(paths, &mm, w, h, bold) }; let col = if stroke { g.stroke } else { g.fill }; let op = (g.xf.opacity / 100.0).clamp(0.0, 1.0) as f32; @@ -895,7 +932,10 @@ fn runs(geom: &TextGeom) -> Vec<(Vec, bool)> { if geom.blend == BlendMode::Normal && solid_fill(g, stroke) && let Some((run, false)) = out.last_mut() - && run.last().is_some_and(|&p| solid_fill(&geom.glyphs[p], false) && geom.glyphs[p].fill == g.fill) + && run.last().is_some_and(|&p| { + let q = &geom.glyphs[p]; + solid_fill(q, false) && q.fill == g.fill && (q.bold_in_layer() - g.bold_in_layer()).abs() < 1e-6 + }) { run.push(gi); continue; @@ -917,17 +957,17 @@ fn draw_glyphs(geom: &TextGeom, img: &mut Image, to_px: &Mat3, s: f64, only: Opt // One glyph is drawn from its own outline; a run from its glyphs' outlines in layer // space, all wound the same way so overlaps add up instead of cancelling (NonZero). let local = Cow::Borrowed(std::slice::from_ref(&g.local)); - let (paths, m): (Cow<[BezPath]>, Mat3) = match (only, run.as_slice()) { - (Some(_), _) => (local, *to_px), - (None, [_]) => (local, *to_px * g.m2()), + let (paths, m, bold): (Cow<[BezPath]>, Mat3, f64) = match (only, run.as_slice()) { + (Some(_), _) => (local, *to_px, g.bold), + (None, [_]) => (local, *to_px * g.m2(), g.bold), (None, _) => { let wound = |p: BezPath| if kurbo::Shape::area(&p) < 0.0 { p.reverse_subpaths() } else { p }; - (Cow::Owned(run.iter().map(|&gi| wound(geom.glyphs[gi].path())).collect()), *to_px) + (Cow::Owned(run.iter().map(|&gi| wound(geom.glyphs[gi].path())).collect()), *to_px, g.bold_in_layer()) } }; let Some(b) = effectcraft_path::bounds(&paths) else { continue }; let blur = [g.xf.blur[0] * s, g.xf.blur[1] * s]; - let pad = g.stroke_width * m.mean_scale() + 2.0 + blur[0].max(blur[1]) * 1.5; + let pad = (g.stroke_width + 2.0 * bold) * m.mean_scale() + 2.0 + blur[0].max(blur[1]) * 1.5; let r = m.map_rect(&effectcraft_geom::Rect::new(b.x0, b.y0, b.x1, b.y1)); let rect = [ ((r.x0 - pad).floor() as i64).max(0), @@ -938,7 +978,7 @@ fn draw_glyphs(geom: &TextGeom, img: &mut Image, to_px: &Mat3, s: f64, only: Opt if rect[2] <= rect[0] || rect[3] <= rect[1] { continue; } - let patch = raster_pass(g, stroke, &paths, &m, rect, blur); + let patch = raster_pass(g, stroke, &paths, &m, rect, blur, bold); blit(img, &patch, [rect[0], rect[1]], if only.is_some() { BlendMode::Normal } else { geom.blend }); } } @@ -950,7 +990,7 @@ pub fn render(ctx: &EvalCtx, layer: &Layer, s: f64) -> Buf { let mut bounds: Option = None; for g in &geom.glyphs { if let Some(b) = effectcraft_path::bounds(&[g.path()]) { - let pad = g.stroke_width + 2.0 + g.xf.blur[0].max(g.xf.blur[1]) * 1.5; + let pad = g.stroke_width + 2.0 * g.bold_in_layer() + 2.0 + g.xf.blur[0].max(g.xf.blur[1]) * 1.5; let b = b.inflate(pad, pad); bounds = Some(bounds.map_or(b, |a| a.union(b))); } @@ -976,7 +1016,7 @@ pub fn per_char_planes(ctx: &EvalCtx, layer: &Layer, s: f64) -> Vec<(Buf, Mat4)> continue; } let Some(b) = effectcraft_path::bounds(std::slice::from_ref(&g.local)) else { continue }; - let pad = g.stroke_width + 2.0 + g.xf.blur[0].max(g.xf.blur[1]) * 1.5; + let pad = g.stroke_width + 2.0 * g.bold + 2.0 + g.xf.blur[0].max(g.xf.blur[1]) * 1.5; let b = b.inflate(pad, pad); let w = ((b.width() * s).ceil() as u32 + 4).clamp(1, 4096); let h = ((b.height() * s).ceil() as u32 + 4).clamp(1, 4096); diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index 020e08517..3e6800aba 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -524,6 +524,7 @@ fn touching_glyphs_join_without_a_seam() { fill, stroke: [0.0; 4], stroke_width: 0.0, + bold: 0.0, apply_fill: true, char_index: 0, rest: [0.0; 2], @@ -551,6 +552,36 @@ fn touching_glyphs_join_without_a_seam() { assert!(multiply[3] < 0.9, "{multiply:?}"); } +/// #1676: Faux Bold, and the synthetic bold of a style the family has no face for, are drawn. +#[test] +fn faux_bold_and_synthetic_bold_thicken_the_glyphs() { + // Total alpha and the alpha bounding box of a rendered word. + let ink = |d: TextDoc| { + let (mut p, cid, comp) = setup(400, 120); + let mut l = text_layer(&mut p, &comp, TextDoc { text: "Hamburg".into(), size: 60.0, ..d }); + set(&mut l, "transform/position", Value::Vec3([40.0, 85.0, 0.0])); + p.comp_mut(cid).unwrap().layers.push(l); + let img = crate::render_frame(&p, cid, Tick::ZERO, 1.0); + (img.data.iter().map(|px| px[3]).sum::(), alpha_bbox(&img).unwrap()) + }; + let (plain, pb) = ink(TextDoc::default()); + let (bold, bb) = ink(TextDoc { faux_bold: true, ..Default::default() }); + assert!(bold > plain * 1.15, "faux bold adds ink: {plain} → {bold}"); + // 0.03 em = 1.8 px on every side. + let grow = (bb[2] - bb[0]) - (pb[2] - pb[0]); + assert!((3..=6).contains(&grow), "and widens the word by about 3.6 px: {pb:?} → {bb:?}"); + // Bundled Inter has no Bold Italic face: the italic face is drawn with synthetic bold. + let (italic, _) = ink(TextDoc { style: "Italic".into(), ..Default::default() }); + let (bold_italic, _) = ink(TextDoc { style: "Bold Italic".into(), ..Default::default() }); + assert!(bold_italic > italic * 1.15, "Bold Italic is bolder than Italic: {italic} → {bold_italic}"); + // A stroke follows the bold outline: as wide as before, moved out with it. + let stroked = TextDoc { apply_fill: false, apply_stroke: true, stroke: [1.0; 4], stroke_width: 4.0, ..Default::default() }; + let (ring, rb) = ink(stroked.clone()); + let (bold_ring, brb) = ink(TextDoc { faux_bold: true, ..stroked }); + assert!(bold_ring > ring * 0.95 && bold_ring < ring * 1.3, "the stroke keeps its width: {ring} → {bold_ring}"); + assert!(brb[0] < rb[0] && brb[2] > rb[2], "{rb:?} → {brb:?}"); +} + /// #1011: what dragging a Range Selector bar to fraction `f` of the text writes. #[test] fn range_bar_drag_values() { From d3fd2c58d7a3e0045575c6dfdbaa92f1552cd673 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Mon, 19 Oct 2026 13:04:16 +1100 Subject: [PATCH 08/44] Key browser preview caches on a file generation, and hash bytes only when that key misses. Replacing a virtual file bumps a counter the UI lookup reads. The content hash runs on that miss, from a callback, so the frame path does not stat every footage item or hash on every store write. --- apps/effectcraft-web/src/app.rs | 1 + apps/effectcraft-web/src/frames.rs | 1 + apps/effectcraft-web/src/store.rs | 61 ++++++++++++++++++++++++-- crates/engine/src/remote.rs | 11 ++++- crates/render/src/disk_cache.rs | 60 ++++++++++++++++++++++---- crates/ui-egui/src/frames.rs | 69 +++++++++++++++++++++++------- 6 files changed, 175 insertions(+), 28 deletions(-) diff --git a/apps/effectcraft-web/src/app.rs b/apps/effectcraft-web/src/app.rs index 1a6952408..68fd1a40c 100644 --- a/apps/effectcraft-web/src/app.rs +++ b/apps/effectcraft-web/src/app.rs @@ -257,6 +257,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { if n > 0 { app.frames.set_remote(Some(Arc::new(frames::WebFrames::start(n, !query_flag("nogpuworkers"))))); } + app.frames.set_virtual_media(files::STORE.media_epoch(), Arc::new(|path| files::STORE.file_identity(path))); let (tx, rx) = std::sync::mpsc::channel(); api::set_sender(tx); app = app.with_control(rx); diff --git a/apps/effectcraft-web/src/frames.rs b/apps/effectcraft-web/src/frames.rs index 523fd03c8..c7f350526 100644 --- a/apps/effectcraft-web/src/frames.rs +++ b/apps/effectcraft-web/src/frames.rs @@ -416,6 +416,7 @@ fn post_reply(r: &FrameReply, px: Option<&[u8]>) { fn new_server() -> FrameServer { let mut s = FrameServer::new(crate::worker::pool(), Some(Arc::new(effectcraft_expr::Expressions))); s.set_layer_store(Some(Arc::new(effectcraft_engine::render::PrefetchStore::default()))); + s.set_virtual_id(Some(Arc::new(|path| crate::files::STORE.file_identity(path)))); s } diff --git a/apps/effectcraft-web/src/store.rs b/apps/effectcraft-web/src/store.rs index b3a858def..a44a2eac7 100644 --- a/apps/effectcraft-web/src/store.rs +++ b/apps/effectcraft-web/src/store.rs @@ -20,6 +20,7 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; use effectcraft_engine::config::{ConfigStore, FileOps}; @@ -45,6 +46,8 @@ pub struct Entry { /// Which write this is: new with every write of the key, so a file replaced by another of /// the same size still reads as changed (workers are sent it again, #218). pub version: u64, + /// FNV-1a of `data`, filled the first time a disk-cache key asks for it. + hash: Option, } /// A queued write for the browser storage: `data` = `None` deletes the key. @@ -69,13 +72,13 @@ impl Mirror { /// An entry read from the browser's storage at startup (nothing to flush). pub fn load(&mut self, key: &str, data: Arc<[u8]>, modified: f64) { let version = self.next_version(); - self.entries.insert(key.to_string(), Entry { data, modified, persist: true, version }); + self.entries.insert(key.to_string(), Entry { data, modified, persist: true, version, hash: None }); } pub fn put(&mut self, key: &str, data: Arc<[u8]>, modified: f64, persist: bool) { let was_persisted = self.entries.get(key).is_some_and(|e| e.persist); let version = self.next_version(); - self.entries.insert(key.to_string(), Entry { data, modified, persist, version }); + self.entries.insert(key.to_string(), Entry { data, modified, persist, version, hash: None }); if persist || was_persisted { self.touch(key); } @@ -149,14 +152,31 @@ pub fn key_path(key: &str) -> Option<&str> { key.strip_prefix(FILES).filter(|p| p.starts_with('/')) } +/// FNV-1a 64. Computed when a disk-cache key first needs the file, not on every write. +fn content_hash(data: &[u8]) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for &b in data { + h ^= u64::from(b); + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + h +} + /// The persistent store: a [`Mirror`] and a hook that wakes the flusher after changes. -#[derive(Default)] pub struct Store { pub mirror: Mutex, /// Called after every change that needs flushing (the web host schedules a flush). pub on_change: OnceLock, /// Clock in milliseconds since the Unix epoch (tests pin it). pub clock: OnceLock f64>, + /// Bumped when a virtual file is written. Preview cache lookups key on it; they do not hash. + media_epoch: Arc, +} + +impl Default for Store { + fn default() -> Self { + Self { mirror: Mutex::default(), on_change: OnceLock::new(), clock: OnceLock::new(), media_epoch: Arc::new(AtomicU64::new(0)) } + } } impl Store { @@ -179,12 +199,40 @@ impl Store { /// Store a file under `path` (persisted unless `persist` is false: render outputs). pub fn put_file(&self, path: &str, data: Arc<[u8]>, persist: bool) { let now = self.now(); - self.lock().put(&file_key(path), data, now, persist); + let key = file_key(path); + let replaced = self.lock().get(&key).is_none_or(|e| !Arc::ptr_eq(&e.data, &data)); + self.lock().put(&key, data, now, persist); + if replaced { + self.media_epoch.fetch_add(1, Ordering::Relaxed); + } if persist { self.changed(); } } + /// The generation preview cache lookups key on. It changes when [`Self::put_file`] writes + /// a different blob, and it is not a content hash. + pub fn media_epoch(&self) -> Arc { + self.media_epoch.clone() + } + + /// Size and content hash of the file under `path`. The hash is stored on the entry so a + /// later key does not walk the bytes again. + pub fn file_identity(&self, path: &str) -> Option<(u64, u64)> { + let key = file_key(path); + let mut mirror = self.lock(); + let entry = mirror.entries.get(&key)?; + if let Some(hash) = entry.hash { + return Some((entry.data.len() as u64, hash)); + } + let hash = content_hash(&entry.data); + let len = entry.data.len() as u64; + if let Some(entry) = mirror.entries.get_mut(&key) { + entry.hash = Some(hash); + } + Some((len, hash)) + } + pub fn file(&self, path: &str) -> Option> { self.lock().get(&file_key(path)).map(|e| e.data.clone()) } @@ -446,6 +494,11 @@ mod tests { assert_ne!(a, b); assert_eq!(&data[..], b"blue"); assert_eq!(s.file_versioned("/collision.png").map(|f| f.1), Some(b), "reading doesn't change it"); + let epoch = s.media_epoch().load(Ordering::Relaxed); + let id = s.file_identity("/collision.png").expect("identity"); + assert_eq!(id, (4, content_hash(b"blue"))); + assert_eq!(s.file_identity("/collision.png"), Some(id), "the hash is kept on the entry"); + assert!(epoch >= 2, "each new blob bumps the generation the preview keys on"); } /// A write that failed is flushed again with the key's latest state (#215). diff --git a/crates/engine/src/remote.rs b/crates/engine/src/remote.rs index 207367b07..7d592abe2 100644 --- a/crates/engine/src/remote.rs +++ b/crates/engine/src/remote.rs @@ -263,6 +263,8 @@ pub struct FrameServer { /// The disk cache's layer buffers (see the module docs) and the project's salt. layer_store: Option>, salt: u64, + /// Size and content hash for footage paths with no native metadata (the browser file table). + virtual_id: Option Option<(u64, u64)> + Send + Sync>>, } /// A frame whose readbacks are in flight. @@ -294,9 +296,16 @@ impl FrameServer { pending: None, layer_store: None, salt: 0, + virtual_id: None, } } + /// Identity of virtual footage files, used when native metadata cannot describe the path. + /// Called from [`FrameServer::load`], not from the UI thread's cache lookup. + pub fn set_virtual_id(&mut self, id: Option Option<(u64, u64)> + Send + Sync>>) { + self.virtual_id = id; + } + /// The store behind the layer cache when a request asks for `layers` (the browser: the /// disk cache's layer buffers, fetched before the request). pub fn set_layer_store(&mut self, store: Option>) { @@ -338,7 +347,7 @@ impl FrameServer { fn load(&mut self, revision: u64) -> Result<(), String> { let p = Project::from_json(&self.doc.to_string()).map_err(|e| e.to_string())?; - self.salt = effectcraft_render::disk_cache::footage_salt(&p); + self.salt = effectcraft_render::disk_cache::footage_salt_with(&p, self.virtual_id.as_deref()); self.cache.set_store_salt(self.salt); self.project = Some(Arc::new(p)); self.revision = Some(revision); diff --git a/crates/render/src/disk_cache.rs b/crates/render/src/disk_cache.rs index 4140b7923..f867a614a 100644 --- a/crates/render/src/disk_cache.rs +++ b/crates/render/src/disk_cache.rs @@ -779,8 +779,12 @@ fn hash_debug(h: &mut Hash128, v: &impl std::fmt::Debug) { h.write(&[0xff]); } -/// A file's size and modification time (so edited footage gets new keys). -fn file_stamp(h: &mut Hash128, path: &str) { +/// Size and content hash of a path that has no native metadata (the browser file table). +pub type VirtualFileId<'a> = &'a (dyn Fn(&str) -> Option<(u64, u64)> + Send + Sync); + +/// A file's size and modification time (so edited footage gets new keys). When the path has no +/// native metadata, `virtual_id` supplies a durable size and content hash instead. +fn file_stamp(h: &mut Hash128, path: &str, virtual_id: Option>) { if path.is_empty() { return; } @@ -791,26 +795,36 @@ fn file_stamp(h: &mut Hash128, path: &str) { { h.write_u64(d.as_nanos() as u64); } + return; + } + if let Some((len, hash)) = virtual_id.and_then(|id| id(path)) { + h.write_u64(len); + h.write_u64(hash); } } -fn footage_stamp(h: &mut Hash128, f: &effectcraft_project::Footage) { - file_stamp(h, &f.path); +fn footage_stamp(h: &mut Hash128, f: &effectcraft_project::Footage, virtual_id: Option>) { + file_stamp(h, &f.path, virtual_id); for p in &f.sequence { - file_stamp(h, p); + file_stamp(h, p, virtual_id); } } /// Salt for layer cache keys on disk: the renderer version and every footage file's identity /// (layer keys name footage items, not file contents). pub fn footage_salt(project: &effectcraft_project::Project) -> u64 { + footage_salt_with(project, None) +} + +/// [`footage_salt`] with [`VirtualFileId`] for paths `std::fs::metadata` cannot describe. +pub fn footage_salt_with(project: &effectcraft_project::Project, virtual_id: Option>) -> u64 { let mut h = Hash128::default(); h.write(RENDER_VERSION.as_bytes()); for (id, it) in &project.items { if let effectcraft_project::ItemKind::Footage(f) = &it.kind { h.write_u64(id.0); hash_debug(&mut h, f); - footage_stamp(&mut h, f); + footage_stamp(&mut h, f, virtual_id); } } let v = h.finish(); @@ -822,6 +836,11 @@ pub fn footage_salt(project: &effectcraft_project::Project) -> u64 { /// files' size and time). With expressions in play (which can read any comp) the whole project /// counts. Switches that don't change pixels (Audio, Lock, Shy, Hide Shy Layers) don't count. pub fn comp_content_key(project: &effectcraft_project::Project, comp: effectcraft_project::ItemId) -> u128 { + comp_content_key_with(project, comp, None) +} + +/// [`comp_content_key`] with [`VirtualFileId`] for paths `std::fs::metadata` cannot describe. +pub fn comp_content_key_with(project: &effectcraft_project::Project, comp: effectcraft_project::ItemId, virtual_id: Option>) -> u128 { use effectcraft_project::{ItemKind, LayerSource}; let mut h = Hash128::default(); h.write(RENDER_VERSION.as_bytes()); @@ -855,11 +874,11 @@ pub fn comp_content_key(project: &effectcraft_project::Project, comp: effectcraf k => hash_debug(&mut h, k), } if let ItemKind::Footage(f) = &it.kind { - footage_stamp(&mut h, f); + footage_stamp(&mut h, f, virtual_id); } hash_debug(&mut h, &it.proxy); if let Some(px) = &it.proxy { - footage_stamp(&mut h, &px.footage); + footage_stamp(&mut h, &px.footage, virtual_id); } } h.write_u64(comp.0); @@ -1082,4 +1101,29 @@ mod tests { assert_eq!(b.get_layer(k).unwrap().img, buf(k as u32, 16, 16).img); } } + + /// A path with no native metadata takes its key from the caller's size and content hash (#1253). + #[test] + fn virtual_file_bytes_change_content_keys() { + use effectcraft_project::build; + use effectcraft_project::{Comp, Footage, ItemKind, LayerSource, Project}; + use effectcraft_time::{FrameRate, Tick}; + + let mut p = Project::default(); + let f = Footage { path: "/virtual.mov".into(), width: 16, height: 9, duration: Tick::from_seconds_f64(1.0), has_video: true, ..Default::default() }; + let fid = p.add_item("virtual.mov", effectcraft_color::Label::None, None, ItemKind::Footage(f)); + let mut comp = Comp::new(16, 9, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + comp.layers = vec![build::layer(&mut p, &comp, "clip", LayerSource::Footage { item: fid }, (16, 9), None)]; + let cid = p.add_item("C", effectcraft_color::Label::None, None, ItemKind::Comp(comp.into())); + + let id = |mark: u64| move |path: &str| (path == "/virtual.mov").then_some((8, mark)); + let (one, two) = (id(1), id(2)); + let a = footage_salt_with(&p, Some(&one)); + let b = footage_salt_with(&p, Some(&two)); + let ka = comp_content_key_with(&p, cid, Some(&one)); + let kb = comp_content_key_with(&p, cid, Some(&two)); + assert_ne!(a, b, "replaced virtual media must not reuse the salt"); + assert_ne!(ka, kb, "replaced virtual media must not reuse the frame key"); + assert_eq!(comp_content_key(&p, cid), comp_content_key_with(&p, cid, None)); + } } diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 059bc3a03..a0a893452 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -24,7 +24,7 @@ //! them (Info panel, eyedroppers, histograms). use std::collections::{BTreeSet, HashMap, HashSet, VecDeque}; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use effectcraft_engine::project::{Comp, ItemId, ItemKind, LayerSource, Node, Project, PropGroup}; @@ -258,8 +258,10 @@ pub struct RenderSource { /// ([`Frames::identity`]). type Identity = (u64, Option>); -/// Content keys of (project revision, comp), computed once per revision. -type ContentKeys = Arc>>; +/// Content keys of (project revision, comp, virtual-file generation). The generation is one +/// counter bumped when a browser file is replaced, so a lookup stays a map read. Hashing the +/// new bytes happens only when that generation misses. +type ContentKeys = Arc>>; #[cfg(not(target_arch = "wasm32"))] #[derive(Default)] @@ -268,16 +270,16 @@ struct ContentKeyJobs { pending: VecDeque<(Arc, u64, u64)>, } -fn content_key(keys: &ContentKeys, project: &Project, revision: u64, comp: u64) -> u128 { - if let Some(k) = keys.lock().ok().and_then(|m| m.get(&(revision, comp)).copied()) { +fn content_key(keys: &ContentKeys, project: &Project, revision: u64, comp: u64, epoch: u64, virtual_id: Option>) -> u128 { + if let Some(k) = keys.lock().ok().and_then(|m| m.get(&(revision, comp, epoch)).copied()) { return k; } - let k = disk_cache::comp_content_key(project, ItemId(comp)); + let k = disk_cache::comp_content_key_with(project, ItemId(comp), virtual_id); if let Ok(mut m) = keys.lock() { if m.len() > 256 { m.clear(); } - m.insert((revision, comp), k); + m.insert((revision, comp, epoch), k); } k } @@ -363,8 +365,15 @@ pub fn opts_hash(o: &RenderOpts) -> u64 { } /// Disk key of a viewer frame (render options included: region of interest, draft…). -fn frame_disk_key(keys: &ContentKeys, project: &Project, key: &FrameKey, opts_hash: u64) -> u128 { - let content = content_key(keys, project, key.revision, key.comp); +fn frame_disk_key( + keys: &ContentKeys, + project: &Project, + key: &FrameKey, + opts_hash: u64, + epoch: u64, + virtual_id: Option>, +) -> u128 { + let content = content_key(keys, project, key.revision, key.comp, epoch, virtual_id); disk_cache::frame_key(content, key.frame, key.scale, key.view ^ opts_hash) } @@ -441,6 +450,10 @@ pub struct Frames { /// Render time of the last viewer (urgent) frame in ms, for the Info panel / perf readout. pub last_ms: Arc>, content_keys: ContentKeys, + /// Bumped when a virtual file is replaced. Lookups key on it instead of statting footage. + virtual_epoch: Arc, + /// Size and content hash for paths with no native metadata. Called only on a cache miss. + virtual_id: Option Option<(u64, u64)> + Send + Sync>>, #[cfg(not(target_arch = "wasm32"))] content_key_jobs: Arc>, gpu_retired: Arc, @@ -477,6 +490,8 @@ impl Default for Frames { ctx: None, last_ms: Arc::new(Mutex::new(0.0)), content_keys: Arc::default(), + virtual_epoch: Arc::new(AtomicU64::new(0)), + virtual_id: None, #[cfg(not(target_arch = "wasm32"))] content_key_jobs: Arc::default(), gpu_retired: Arc::default(), @@ -768,9 +783,10 @@ impl Frames { if src.disk.is_none() && remote.is_none() { return vec![]; } + let epoch = self.virtual_epoch.load(Ordering::Relaxed); #[cfg(not(target_arch = "wasm32"))] let content = { - let known = self.content_keys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&(base.revision, base.comp)).copied(); + let known = self.content_keys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&(base.revision, base.comp, epoch)).copied(); let Some(content) = known else { self.request_content_key(src.project.clone(), base.revision, base.comp); return vec![]; @@ -778,7 +794,7 @@ impl Frames { content }; #[cfg(target_arch = "wasm32")] - let content = content_key(&self.content_keys, &src.project, base.revision, base.comp); + let content = content_key(&self.content_keys, &src.project, base.revision, base.comp, epoch, self.virtual_id.as_deref()); let ram: HashSet = self.cached_frames(base).into_iter().collect(); let oh = opts_hash(opts); (0..frames.clamp(0, 100_000)) @@ -803,6 +819,7 @@ impl Frames { jobs.running = true; drop(jobs); let (jobs, keys, ctx) = (self.content_key_jobs.clone(), self.content_keys.clone(), self.ctx.clone()); + let (epoch, id) = (self.virtual_epoch.clone(), self.virtual_id.clone()); let work = move || loop { let next = { let mut jobs = jobs.lock().unwrap_or_else(std::sync::PoisonError::into_inner); @@ -813,7 +830,9 @@ impl Frames { next }; let Some((project, revision, comp)) = next else { return }; - if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| content_key(&keys, &project, revision, comp))).is_err() { + let epoch = epoch.load(Ordering::Relaxed); + let id = id.clone(); + if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| content_key(&keys, &project, revision, comp, epoch, id.as_deref()))).is_err() { log::error!("could not compute a preview disk-cache key; the cache indicator will retry"); } if let Some(ctx) = &ctx { @@ -964,10 +983,22 @@ impl Frames { ctx: self.ctx.clone(), last: self.last_ms.clone(), content_keys: self.content_keys.clone(), + virtual_epoch: self.virtual_epoch.clone(), + virtual_id: self.virtual_id.clone(), gpu_retired: self.gpu_retired.clone(), } } + /// Browser file table: `epoch` bumps on each replacement, and `id` is the size and content + /// hash used when a content key is rebuilt. Neither runs on a cache hit. + pub fn set_virtual_media(&mut self, epoch: Arc, id: Arc Option<(u64, u64)> + Send + Sync>) { + self.virtual_epoch = epoch; + self.virtual_id = Some(id); + if let Ok(mut keys) = self.content_keys.lock() { + keys.clear(); + } + } + /// Render frames with `remote` from now on (`None`: on this thread again). pub fn set_remote(&mut self, remote: Option>) { self.remote = remote; @@ -1036,7 +1067,8 @@ impl Frames { let fw = self.worker(); let busy = self.remote_busy.clone(); let t0 = web_time::Instant::now(); - let disk_key = remote.disk().then(|| frame_disk_key(&w.content_keys, &job.src.project, &key, opts_hash(&job.opts))); + let epoch = w.virtual_epoch.load(Ordering::Relaxed); + let disk_key = remote.disk().then(|| frame_disk_key(&w.content_keys, &job.src.project, &key, opts_hash(&job.opts), epoch, w.virtual_id.as_deref())); let rj = RemoteJob { project: job.src.project.clone(), revision: key.revision, comp: job.comp, t: job.t, opts: job.opts, disk_key }; remote.start( rj, @@ -1093,6 +1125,8 @@ struct Worker { ctx: Option, last: Arc>, content_keys: ContentKeys, + virtual_epoch: Arc, + virtual_id: Option Option<(u64, u64)> + Send + Sync>>, gpu_retired: Arc, } @@ -1147,7 +1181,12 @@ impl Worker { job }; let t0 = web_time::Instant::now(); - let disk = job.src.disk.as_ref().map(|dc| (dc.clone(), frame_disk_key(content_keys, &job.src.project, &job.key, opts_hash(&job.opts)))); + let epoch = self.virtual_epoch.load(Ordering::Relaxed); + let disk = job + .src + .disk + .as_ref() + .map(|dc| (dc.clone(), frame_disk_key(content_keys, &job.src.project, &job.key, opts_hash(&job.opts), epoch, self.virtual_id.as_deref()))); let from_disk = disk.as_ref().and_then(|(dc, k)| dc.get_frame(*k)); let disk_hit = from_disk.is_some(); let ci = match from_disk { @@ -1428,7 +1467,7 @@ mod tests { assert_eq!(frames.content_key_jobs.lock().unwrap().pending.len(), 1); release.send(()).unwrap(); let until = Instant::now() + Duration::from_secs(3); - while !frames.content_keys.lock().unwrap().contains_key(&(base.revision, base.comp)) { + while !frames.content_keys.lock().unwrap().contains_key(&(base.revision, base.comp, 0)) { assert!(Instant::now() < until); std::thread::sleep(Duration::from_millis(1)); } From 4bc40c7ac3d7c37d4fb113b335afd8cffaca2c7a Mon Sep 17 00:00:00 2001 From: heojunfo Date: Mon, 19 Oct 2026 11:04:36 +0900 Subject: [PATCH 09/44] Text: grow Faux Bold per axis under a non-uniform scale (#1676) Synthetic bold grows a glyph's outline in its own space, so a non-uniform character Scale stretches it more along one axis. The patch and layer bounds reserved it through mean_scale and could clip the stretched side; they now reserve how far the bold reaches along x and y. A run of solid fills grows its glyphs together in layer space by one radius, which only matches when the bold stays round there: a bold glyph whose matrix stretches or skews is no longer gathered into a run. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/text.rs | 36 +++++++++++++++++++++++++-------- crates/render/src/text/tests.rs | 28 +++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 856b2f578..f74cf7c8f 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -505,6 +505,19 @@ impl PlacedGlyph { fn bold_in_layer(&self) -> f64 { self.bold * self.m2().mean_scale() } + /// Whether [`Self::bold`] grows the outline as evenly in layer space as in character space: + /// no bold, or a matrix that only rotates and scales uniformly. + fn bold_is_even(&self) -> bool { + let [[a, b, _], [c, d, _], _] = self.m2().0; + let n = a * a + b * b + c * c + d * d; + self.bold == 0.0 || ((a * a + c * c - b * b - d * d).abs() <= 1e-9 * n && (a * b + c * d).abs() <= 1e-9 * n) + } +} + +/// How far a circle of radius `r` reaches along x and along y once mapped by `m`. +fn axis_reach(m: &Mat3, r: f64) -> [f64; 2] { + let [[a, b, _], [c, d, _], _] = m.0; + [r * a.hypot(b), r * c.hypot(d)] } /// How far Faux Bold grows a glyph's outline on every side, as a fraction of its size: Inter @@ -929,12 +942,15 @@ fn runs(geom: &TextGeom) -> Vec<(Vec, bool)> { if g.xf.opacity <= 0.0 { continue; } + // A run grows its outline in layer space by one radius, which matches each glyph grown in + // its own space only when the bold stays round there. if geom.blend == BlendMode::Normal && solid_fill(g, stroke) + && g.bold_is_even() && let Some((run, false)) = out.last_mut() && run.last().is_some_and(|&p| { let q = &geom.glyphs[p]; - solid_fill(q, false) && q.fill == g.fill && (q.bold_in_layer() - g.bold_in_layer()).abs() < 1e-6 + solid_fill(q, false) && q.fill == g.fill && q.bold_is_even() && (q.bold_in_layer() - g.bold_in_layer()).abs() < 1e-6 }) { run.push(gi); @@ -967,13 +983,16 @@ fn draw_glyphs(geom: &TextGeom, img: &mut Image, to_px: &Mat3, s: f64, only: Opt }; let Some(b) = effectcraft_path::bounds(&paths) else { continue }; let blur = [g.xf.blur[0] * s, g.xf.blur[1] * s]; - let pad = (g.stroke_width + 2.0 * bold) * m.mean_scale() + 2.0 + blur[0].max(blur[1]) * 1.5; + // Stroke and bold grow the outline in the paths' space: under a non-uniform `m` they reach + // further along one axis than the other. + let [px, py] = axis_reach(&m, g.stroke_width + 2.0 * bold); + let pad = 2.0 + blur[0].max(blur[1]) * 1.5; let r = m.map_rect(&effectcraft_geom::Rect::new(b.x0, b.y0, b.x1, b.y1)); let rect = [ - ((r.x0 - pad).floor() as i64).max(0), - ((r.y0 - pad).floor() as i64).max(0), - ((r.x1 + pad).ceil() as i64).min(w), - ((r.y1 + pad).ceil() as i64).min(h), + ((r.x0 - px - pad).floor() as i64).max(0), + ((r.y0 - py - pad).floor() as i64).max(0), + ((r.x1 + px + pad).ceil() as i64).min(w), + ((r.y1 + py + pad).ceil() as i64).min(h), ]; if rect[2] <= rect[0] || rect[3] <= rect[1] { continue; @@ -990,8 +1009,9 @@ pub fn render(ctx: &EvalCtx, layer: &Layer, s: f64) -> Buf { let mut bounds: Option = None; for g in &geom.glyphs { if let Some(b) = effectcraft_path::bounds(&[g.path()]) { - let pad = g.stroke_width + 2.0 * g.bold_in_layer() + 2.0 + g.xf.blur[0].max(g.xf.blur[1]) * 1.5; - let b = b.inflate(pad, pad); + let [bx, by] = axis_reach(&g.m2(), 2.0 * g.bold); + let pad = g.stroke_width + 2.0 + g.xf.blur[0].max(g.xf.blur[1]) * 1.5; + let b = b.inflate(pad + bx, pad + by); bounds = Some(bounds.map_or(b, |a| a.union(b))); } } diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index 3e6800aba..4b1b66809 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -582,6 +582,34 @@ fn faux_bold_and_synthetic_bold_thicken_the_glyphs() { assert!(brb[0] < rb[0] && brb[2] > rb[2], "{rb:?} → {brb:?}"); } +/// #1676: under a non-uniform character Scale the bold is grown in the character's space, so it +/// reaches further along the stretched axis; it is neither clipped nor grown evenly in a run. +#[test] +fn faux_bold_under_a_non_uniform_scale() { + use kurbo::Shape; + let glyph = |x0: f64| PlacedGlyph { + local: kurbo::Rect::new(x0, 4.0, x0 + 4.0, 16.0).to_path(0.1), + m: Mat4::scale(vec3(9.0, 1.0, 1.0)), + xf: CharXf::default(), + fill: [1.0; 4], + stroke: [0.0; 4], + stroke_width: 0.0, + bold: 2.0, + apply_fill: true, + char_index: 0, + rest: [0.0; 2], + }; + let geom = |glyphs| TextGeom { doc: TextDoc::default(), glyphs, fill_stroke: 0, blend: BlendMode::Normal }; + assert_eq!(runs(&geom(vec![glyph(2.0), glyph(6.0)])).len(), 2, "bold glyphs stretched unevenly are drawn one by one"); + // x 6..10 → 54..90, grown by 2 × 9 = 18 → 36..108; y 4..16 grown by 2 → 2..18. + let mut img = crate::Image::new(120, 24); + draw_glyphs(&geom(vec![glyph(6.0)]), &mut img, &Mat3::IDENTITY, 1.0, None); + for (x, y) in [(37, 10), (106, 10), (70, 3)] { + assert!(img.get(x, y)[3] > 0.99, "bold ink at ({x}, {y}): {:?}", img.get(x, y)); + } + assert!(img.get(34, 10)[3] < 0.01 && img.get(110, 10)[3] < 0.01 && img.get(70, 0)[3] < 0.01); +} + /// #1011: what dragging a Range Selector bar to fraction `f` of the text writes. #[test] fn range_bar_drag_values() { From bf7aff11f89f1461515bdbc7ab37fd5d56169ca9 Mon Sep 17 00:00:00 2001 From: Camille Moine Date: Mon, 19 Oct 2026 09:54:16 +0200 Subject: [PATCH 10/44] Timeline: layer switches act on every selected layer (#1849) A switch click on one of several selected layers passed that layer alone to `layer.setSwitch`, so Lock, 3D, Video (and the rest of the A/V Features and Switches columns) had to be set one layer at a time. The click now gives the switch to the whole selection, with the layers it doesn't apply to left out (3D on a camera, Effect on a layer without effects, Audio on a layer without audio, ...). The targets are picked when the press happens, so drawing a row costs nothing more than before. A row that isn't selected still changes that layer alone, pressing and dragging over rows is unchanged, and Alt-clicking an A/V feature sets the selected layers apart. --- crates/ui-egui/src/panels/timeline.rs | 109 ++++++++++--- crates/ui-egui/tests/ui_batch_switches.rs | 179 ++++++++++++++++++++++ 2 files changed, 269 insertions(+), 19 deletions(-) create mode 100644 crates/ui-egui/tests/ui_batch_switches.rs diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index b34fbd9d3..d6b79744f 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -8,7 +8,7 @@ use effectcraft_engine::color::BlendMode; use effectcraft_engine::keyframe::{Interp, Value}; -use effectcraft_engine::project::{Comp, GroupKind, ItemId, Layer, LayerId, LayerSource, MatteKind, Node, ParamUi, PropGroup, Property}; +use effectcraft_engine::project::{Comp, GroupKind, ItemId, Layer, LayerId, LayerSource, MatteKind, Node, ParamUi, Project, PropGroup, Property}; use effectcraft_engine::render::EvalCtx; use effectcraft_engine::time::Tick; use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; @@ -1314,6 +1314,33 @@ fn switch_gesture(ui: &egui::Ui, resp: &egui::Response, row: Rect, switch: &'sta (a.min(b) < row.max.y && a.max(b) >= row.min.y).then_some((d.value, d.merge)) } +/// The layers an edit made on `layer`'s row applies to (a switch, its label, its blending mode): +/// the selection when the row is selected, else that layer alone, as in After Effects. +fn edited_layers(layer: LayerId, selected: &[LayerId]) -> Vec { + if selected.contains(&layer) { selected.iter().map(|l| l.0).collect() } else { vec![layer.0] } +} + +/// Whether the Timeline shows switch `name`'s cell on `layer`'s row (A/V Features and Switches +/// columns): a layer that has no such cell isn't one a click gives the switch to either. +fn switch_applies(layer: &Layer, name: &str, project: &Project) -> bool { + match name { + "video" | "lock" | "shy" => true, + "audio" => layer.props.sub("audio").is_some(), + "solo" | "quality" | "motionBlur" | "adjustment" => layer.source.is_av(), + "collapse" => collapse_applies(layer, project), + "fx" => layer.effects().is_some_and(|f| !f.children.is_empty()), + "frameBlend" => matches!(layer.source, LayerSource::Footage { .. } | LayerSource::Comp { .. }), + "threeD" => layer.source.is_av() || matches!(layer.source, LayerSource::Null), + _ => true, + } +} + +/// The layers a click on the `name` switch of `layer`'s row acts on: [`edited_layers`] without +/// the layers the switch doesn't apply to. +fn switch_targets(comp: &Comp, project: &Project, selected: &[LayerId], layer: u64, name: &str) -> Vec { + edited_layers(LayerId(layer), selected).into_iter().filter(|l| comp.layer(LayerId(*l)).is_some_and(|l| switch_applies(l, name, project))).collect() +} + /// A layer's switch (A/V Features and Switches columns) in `row`, with the /// [`switch_gesture`]. #[allow(clippy::too_many_arguments)] @@ -1328,20 +1355,29 @@ fn layer_switch( layer: u64, name: &'static str, others: bool, + comp: &Comp, + project: &Project, + selected: &[LayerId], actions: &mut Vec<(String, serde_json::Value)>, ) { let resp = widgets::icon_toggle(ui, br, icon, on, t, id, Sense::click_and_drag()); // Alt-click on an A/V feature sets the layer apart: the other layers go off (or locked), - // and come back on the next Alt-click. + // and come back on the next Alt-click. With several layers selected, the whole selection is + // set apart. if others && resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_pressed() && i.modifiers.alt) { - actions.push(("layer.setSwitch".into(), json!({"layers": [layer], "switch": name, "others": true}))); + let layers = edited_layers(LayerId(layer), selected); + actions.push(("layer.setSwitch".into(), json!({"layers": layers, "switch": name, "others": true}))); return; } + // A press on a selected layer's switch gives the switch to every selected layer, as in After + // Effects; the targets are picked when the press happens, never while the row is drawn. if let Some((value, merge)) = switch_gesture(ui, &resp, row, name, on) { - actions.push(("layer.setSwitch".into(), json!({"layers": [layer], "switch": name, "value": value, "merge": merge}))); + let layers = switch_targets(comp, project, selected, layer, name); + actions.push(("layer.setSwitch".into(), json!({"layers": layers, "switch": name, "value": value, "merge": merge}))); } else if resp.clicked() && !resp.clicked_by(egui::PointerButton::Primary) { // Keyboard or accessibility activation (a pointer press was handled above). - actions.push(("layer.setSwitch".into(), json!({"layers": [layer], "switch": name}))); + let layers = switch_targets(comp, project, selected, layer, name); + actions.push(("layer.setSwitch".into(), json!({"layers": layers, "switch": name, "value": !on}))); } } @@ -2000,10 +2036,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // as in After Effects: it hides a null's outline in the viewer and turns a camera or // light off. let av_items: [(Icon, bool, &str, bool); 4] = [ - (Icon::Eye, sw.video, "video", true), - (Icon::Speaker, sw.audio, "audio", layer.props.sub("audio").is_some()), - (Icon::Solo, sw.solo, "solo", layer.source.is_av()), - (Icon::Lock, sw.locked, "lock", true), + (Icon::Eye, sw.video, "video", switch_applies(layer, "video", &app.session.project)), + (Icon::Speaker, sw.audio, "audio", switch_applies(layer, "audio", &app.session.project)), + (Icon::Solo, sw.solo, "solo", switch_applies(layer, "solo", &app.session.project)), + (Icon::Lock, sw.locked, "lock", switch_applies(layer, "lock", &app.session.project)), ]; for (i, (icon, on, name, applicable)) in av_items.into_iter().enumerate() { let br = Rect::from_center_size(pos2(cw.av + 10.0 + 18.0 * i as f32, cy), vec2(15.0, 15.0)); @@ -2011,7 +2047,22 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { continue; } lp.rect_filled(br.shrink(0.5), 1.0, t.switch_well); - layer_switch(ui, &t, r, br, icon, on, egui::Id::new(("av", layer.id.0, i)), layer.id.0, name, true, &mut actions); + layer_switch( + ui, + &t, + r, + br, + icon, + on, + egui::Id::new(("av", layer.id.0, i)), + layer.id.0, + name, + true, + &comp, + &app.session.project, + &selected, + &mut actions, + ); app.auto.add(&format!("timeline.layer.{}.{name}", layer.id.0), br, name); } // Label swatch. @@ -2122,19 +2173,24 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { 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), - (Icon::Collapse, sw.collapse, "collapse", collapse_applies(layer, &app.session.project)), - (Icon::Quality, sw.quality == effectcraft_engine::project::Quality::Best, "quality", layer.source.is_av()), - (Icon::Fx, sw.effects, "fx", layer.effects().is_some_and(|f| !f.children.is_empty())), + (Icon::Shy, sw.shy, "shy", switch_applies(layer, "shy", &app.session.project)), + (Icon::Collapse, sw.collapse, "collapse", switch_applies(layer, "collapse", &app.session.project)), + ( + Icon::Quality, + sw.quality == effectcraft_engine::project::Quality::Best, + "quality", + switch_applies(layer, "quality", &app.session.project), + ), + (Icon::Fx, sw.effects, "fx", switch_applies(layer, "fx", &app.session.project)), ( Icon::FrameBlend, sw.frame_blend != effectcraft_engine::project::FrameBlend::Off, "frameBlend", - matches!(layer.source, LayerSource::Footage { .. } | LayerSource::Comp { .. }), + switch_applies(layer, "frameBlend", &app.session.project), ), - (Icon::MotionBlur, sw.motion_blur, "motionBlur", layer.source.is_av()), - (Icon::Adjustment, sw.adjustment, "adjustment", layer.source.is_av()), - (Icon::Cube, layer.is_3d(), "threeD", layer.source.is_av() || matches!(layer.source, LayerSource::Null)), + (Icon::MotionBlur, sw.motion_blur, "motionBlur", switch_applies(layer, "motionBlur", &app.session.project)), + (Icon::Adjustment, sw.adjustment, "adjustment", switch_applies(layer, "adjustment", &app.session.project)), + (Icon::Cube, layer.is_3d(), "threeD", switch_applies(layer, "threeD", &app.session.project)), ]; for (i, (icon, on, name, applicable)) in sws.into_iter().enumerate() { if !applicable || !vis.switches { @@ -2142,7 +2198,22 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } let br = Rect::from_center_size(pos2(cw.switches + SW * i as f32 + SW / 2.0, cy), vec2(16.0, 16.0)); lp.rect_filled(br.shrink(1.0), 1.0, t.switch_well); - layer_switch(ui, &t, r, br, icon, on, egui::Id::new(("sw", layer.id.0, i)), layer.id.0, name, false, &mut actions); + layer_switch( + ui, + &t, + r, + br, + icon, + on, + egui::Id::new(("sw", layer.id.0, i)), + layer.id.0, + name, + false, + &comp, + &app.session.project, + &selected, + &mut actions, + ); app.auto.add(&format!("timeline.layer.{}.switch.{name}", layer.id.0), br, name); } // Modes. diff --git a/crates/ui-egui/tests/ui_batch_switches.rs b/crates/ui-egui/tests/ui_batch_switches.rs new file mode 100644 index 000000000..8534c77f4 --- /dev/null +++ b/crates/ui-egui/tests/ui_batch_switches.rs @@ -0,0 +1,179 @@ +//! A Timeline switch clicked on one of several selected layers applies to the whole selection +//! (#1849), as in After Effects. The engine commands already took several layers; the row +//! controls used to pass the clicked layer alone. + +use effectcraft_engine::Session; +use effectcraft_engine::project::{Layer, LayerId, Switches}; +use effectcraft_ui_egui::EffectcraftApp; +use egui::{Event, Modifiers, Pos2, Rect, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +/// Comp "Main" with solids A (top) to D (bottom); returns their ids top to bottom. +fn harness() -> (Harness<'static, EffectcraftApp>, Vec) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap(); + let mut ids: Vec = ["D", "C", "B", "A"] + .iter() + .map(|name| s.execute("layer.newSolid", json!({"name": name, "color": "#406080"})).unwrap()["layer"].as_u64().unwrap()) + .collect(); + ids.reverse(); + s.execute("edit.deselectAll", json!({})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + (h, ids) +} + +fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone(); + Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])) +} + +/// Press on `from`, move to `to` in `steps`, release there. +fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, steps: u32) { + h.event(Event::PointerMoved(from)); + h.step(); + h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=steps { + h.event(Event::PointerMoved(from + (to - from) * (k as f32 / steps as f32))); + h.step(); + } + h.event(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +/// A click on the Timeline control `id`. +fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let p = rect(h, id).center(); + drag(h, p, p, 1); +} + +fn alt_click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let p = rect(h, id).center(); + let modifiers = Modifiers::ALT; + h.event(Event::PointerMoved(p)); + h.event(Event::ModifiersChanged(modifiers)); + h.step(); + h.event(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers }); + h.step(); + h.event(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers }); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(3); +} + +/// Select `ids` in the Timeline, as a Shift range or a Cmd toggle would. +fn select(h: &mut Harness<'_, EffectcraftApp>, ids: &[u64]) { + h.state_mut().session.execute("layer.select", json!({"layers": ids})).unwrap(); + h.run_steps(2); +} + +/// The state of switch `f` on each layer of `ids`, top to bottom. +fn states(h: &Harness<'_, EffectcraftApp>, ids: &[u64], f: fn(&Layer) -> bool) -> Vec { + let comp = h.state().session.active_comp().unwrap(); + ids.iter().map(|id| f(comp.layer(LayerId(*id)).unwrap())).collect() +} + +fn switches(h: &Harness<'_, EffectcraftApp>, id: u64) -> Switches { + h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().switches.clone() +} + +fn locked(l: &Layer) -> bool { + l.switches.locked +} + +fn video(l: &Layer) -> bool { + l.switches.video +} + +fn three_d(l: &Layer) -> bool { + l.switches.three_d +} + +fn solo(l: &Layer) -> bool { + l.switches.solo +} + +fn shy(l: &Layer) -> bool { + l.switches.shy +} + +fn motion_blur(l: &Layer) -> bool { + l.switches.motion_blur +} + +/// #1849: a click on the Lock, 3D, Solo, Shy or Motion Blur switch of a selected layer gives the +/// switch to every selected layer, in one undo step. +#[test] +fn switches_on_a_selected_row_set_the_whole_selection() { + let (mut h, ids) = harness(); + select(&mut h, &[ids[0], ids[2]]); + + let steps = h.state().session.history.undo.len(); + click(&mut h, &format!("timeline.layer.{}.lock", ids[0])); + assert_eq!(states(&h, &ids, locked), [true, false, true, false]); + assert_eq!(h.state().session.history.undo.len(), steps + 1, "one undo step"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.run_steps(2); + assert_eq!(states(&h, &ids, locked), [false; 4]); + + click(&mut h, &format!("timeline.layer.{}.switch.threeD", ids[0])); + assert_eq!(states(&h, &ids, three_d), [true, false, true, false]); + // The switch of the other selected layer acts the same way. + click(&mut h, &format!("timeline.layer.{}.solo", ids[2])); + assert_eq!(states(&h, &ids, solo), [true, false, true, false]); + click(&mut h, &format!("timeline.layer.{}.switch.shy", ids[2])); + assert_eq!(states(&h, &ids, shy), [true, false, true, false]); + click(&mut h, &format!("timeline.layer.{}.switch.motionBlur", ids[0])); + assert_eq!(states(&h, &ids, motion_blur), [true, false, true, false]); + // A second click turns both off again, whatever their own state. + click(&mut h, &format!("timeline.layer.{}.switch.threeD", ids[2])); + assert_eq!(states(&h, &ids, three_d), [false; 4]); +} + +/// A switch on a row that isn't selected still changes that layer alone, and leaves the +/// selection. +#[test] +fn a_switch_on_an_unselected_row_sets_that_layer_only() { + let (mut h, ids) = harness(); + select(&mut h, &[ids[0], ids[2]]); + click(&mut h, &format!("timeline.layer.{}.lock", ids[1])); + assert_eq!(states(&h, &ids, locked), [false, true, false, false]); + assert_eq!(h.state().session.state.selected_layers, vec![LayerId(ids[0]), LayerId(ids[2])]); +} + +/// The targets skip the selected layers a switch doesn't apply to: 3D reaches a null but not a +/// camera (3D by kind), the Effect switch not a layer without effects. +#[test] +fn switch_targets_skip_the_layers_the_switch_does_not_apply_to() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap(); + let solid = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + let null = s.execute("layer.newNull", json!({"name": "Ctrl"})).unwrap()["layer"].as_u64().unwrap(); + let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("effect.apply", json!({"effect": "ec.blur.gaussian", "layers": [solid]})).unwrap(); + s.execute("edit.deselectAll", json!({})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + select(&mut h, &[solid, null, cam]); + + click(&mut h, &format!("timeline.layer.{solid}.switch.threeD")); + assert!(switches(&h, solid).three_d && switches(&h, null).three_d, "3D reaches the null"); + assert!(!switches(&h, cam).three_d, "a camera is 3D by kind, its own switch stays off"); + + click(&mut h, &format!("timeline.layer.{solid}.switch.fx")); + assert!(!switches(&h, solid).effects, "the Effect switch reaches the layer that has an effect"); + assert!(switches(&h, null).effects && switches(&h, cam).effects, "no effects, no Effect switch"); +} + +/// Alt-click on an A/V feature sets the selected layers apart (the others off), and the next +/// Alt-click brings the others back — with several layers, the whole selection stands apart. +#[test] +fn alt_click_sets_the_selected_layers_apart() { + let (mut h, ids) = harness(); + select(&mut h, &[ids[0], ids[2]]); + alt_click(&mut h, &format!("timeline.layer.{}.video", ids[0])); + assert_eq!(states(&h, &ids, video), [true, false, true, false]); + alt_click(&mut h, &format!("timeline.layer.{}.video", ids[0])); + assert_eq!(states(&h, &ids, video), [true; 4]); +} From 16d43346c4d5e92720360981bf8886c5d23e92f7 Mon Sep 17 00:00:00 2001 From: Camille Moine Date: Mon, 19 Oct 2026 09:55:24 +0200 Subject: [PATCH 11/44] Timeline: the label swatch and the Modes column act on the selection (#1849) The label swatch's menu, the Blending Mode menu and Preserve Transparency passed the clicked layer alone, so a colour or a mode had to be set one layer at a time. They now act on the whole selection, with the Modes column edits going only to the selected layers that have video, as its cells do. The preserve-transparency checkbox also gets an automation id, like the cells around it. --- crates/ui-egui/src/panels/timeline.rs | 13 ++++-- crates/ui-egui/tests/ui_batch_switches.rs | 52 ++++++++++++++++++++++- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index d6b79744f..ebe076dc8 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1341,6 +1341,12 @@ fn switch_targets(comp: &Comp, project: &Project, selected: &[LayerId], layer: u edited_layers(LayerId(layer), selected).into_iter().filter(|l| comp.layer(LayerId(*l)).is_some_and(|l| switch_applies(l, name, project))).collect() } +/// The layers a Modes column edit on `layer`'s row (its blending mode, Preserve Transparency) +/// acts on: [`edited_layers`] that have video. +fn mode_targets(comp: &Comp, layer: LayerId, selected: &[LayerId]) -> Vec { + edited_layers(layer, selected).into_iter().filter(|l| comp.layer(LayerId(*l)).is_some_and(|l| l.source.is_av())).collect() +} + /// A layer's switch (A/V Features and Switches columns) in `row`, with the /// [`switch_gesture`]. #[allow(clippy::too_many_arguments)] @@ -2077,7 +2083,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { widgets::open_popup(ui, pop); } if let Some(lab) = widgets::label_popup(ui, pop, lr.left_bottom(), layer.label, |l| app.session.prefs.label_name(l), &t) { - actions.push(("edit.label".into(), json!({"layers": [layer.id.0], "label": lab.name()}))); + actions.push(("edit.label".into(), json!({"layers": edited_layers(layer.id, &selected), "label": lab.name()}))); } } if vis.num { @@ -2230,11 +2236,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { .flat_map(|m| if m.ends_group() { vec![m.label().to_string(), "-".to_string()] } else { vec![m.label().to_string()] }) .collect(); if let Some(i) = widgets::popup_menu(ui, pop, mr.left_bottom(), &opts, opts.iter().position(|o| o == layer.blend_mode.label())) { - actions.push(("layer.setBlendMode".into(), json!({"layers": [layer.id.0], "mode": opts[i]}))); + actions.push(("layer.setBlendMode".into(), json!({"layers": mode_targets(&comp, layer.id, &selected), "mode": opts[i]}))); } let tr = Rect::from_center_size(pos2(cw.mode + 76.0 + 6.0, cy), vec2(14.0, 14.0)); + app.auto.add(&format!("timeline.layer.{}.preserveTransparency", layer.id.0), tr, "Preserve Transparency"); if widgets::checkbox(ui, tr, layer.preserve_transparency, &t, egui::Id::new(("pt", layer.id.0))).clicked() { - actions.push(("layer.setSwitch".into(), json!({"layers": [layer.id.0], "switch": "preserveTransparency"}))); + actions.push(("layer.setSwitch".into(), json!({"layers": mode_targets(&comp, layer.id, &selected), "switch": "preserveTransparency"}))); } // Track matte: layer picker + kind. let mr = Rect::from_min_size(pos2(cw.trkmat + 2.0, cy - 9.0), vec2(104.0, 18.0)); diff --git a/crates/ui-egui/tests/ui_batch_switches.rs b/crates/ui-egui/tests/ui_batch_switches.rs index 8534c77f4..acbc91f87 100644 --- a/crates/ui-egui/tests/ui_batch_switches.rs +++ b/crates/ui-egui/tests/ui_batch_switches.rs @@ -1,12 +1,15 @@ -//! A Timeline switch clicked on one of several selected layers applies to the whole selection -//! (#1849), as in After Effects. The engine commands already took several layers; the row +//! A Timeline row control clicked on one of several selected layers applies to the whole +//! selection (#1849), as in After Effects: the A/V Features and Switches columns, the label +//! swatch and the Modes column. The engine commands already took several layers; the row //! controls used to pass the clicked layer alone. use effectcraft_engine::Session; +use effectcraft_engine::color::{BlendMode, Label}; use effectcraft_engine::project::{Layer, LayerId, Switches}; use effectcraft_ui_egui::EffectcraftApp; use egui::{Event, Modifiers, Pos2, Rect, pos2, vec2}; use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; use serde_json::json; /// Comp "Main" with solids A (top) to D (bottom); returns their ids top to bottom. @@ -49,6 +52,11 @@ fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { drag(h, p, p, 1); } +/// A click at `p` (a menu entry, found by its label). +fn click_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) { + drag(h, p, p, 1); +} + fn alt_click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { let p = rect(h, id).center(); let modifiers = Modifiers::ALT; @@ -102,6 +110,10 @@ fn motion_blur(l: &Layer) -> bool { l.switches.motion_blur } +fn preserved(l: &Layer) -> bool { + l.preserve_transparency +} + /// #1849: a click on the Lock, 3D, Solo, Shy or Motion Blur switch of a selected layer gives the /// switch to every selected layer, in one undo step. #[test] @@ -177,3 +189,39 @@ fn alt_click_sets_the_selected_layers_apart() { alt_click(&mut h, &format!("timeline.layer.{}.video", ids[0])); assert_eq!(states(&h, &ids, video), [true; 4]); } + +/// The label swatch menu of a selected layer sets the label of every selected layer. +#[test] +fn the_label_menu_sets_the_selections_label() { + let (mut h, ids) = harness(); + select(&mut h, &[ids[0], ids[2]]); + let green = h.state().session.prefs.label_name(Label::Green); + click(&mut h, &format!("timeline.layer.{}.label", ids[0])); + let entry = h.query_by_label(&green).expect("the label menu is open").rect(); + click_at(&mut h, entry.center()); + let label = |id: u64| h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().label; + assert_eq!([label(ids[0]), label(ids[1]), label(ids[2]), label(ids[3])], [Label::Green, Label::Red, Label::Green, Label::Red]); +} + +/// The Modes column: the Blending Mode menu and the Preserve Transparency checkbox of a selected +/// layer set both selected layers, one undo step each. +#[test] +fn the_modes_column_sets_the_whole_selection() { + let (mut h, ids) = harness(); + h.state_mut().ui.timeline.show_modes = true; + h.run_steps(3); + select(&mut h, &[ids[0], ids[2]]); + + click(&mut h, &format!("timeline.layer.{}.mode", ids[0])); + let entry = h.query_by_label("Multiply").expect("the blend mode menu is open").rect(); + click_at(&mut h, entry.center()); + let mode = |id: u64| h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().blend_mode; + assert_eq!([mode(ids[0]), mode(ids[1]), mode(ids[2]), mode(ids[3])], [BlendMode::Multiply, BlendMode::Normal, BlendMode::Multiply, BlendMode::Normal]); + + click(&mut h, &format!("timeline.layer.{}.preserveTransparency", ids[0])); + assert_eq!(states(&h, &ids, preserved), [true, false, true, false]); + + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.run_steps(2); + assert_eq!(states(&h, &ids, preserved), [false; 4], "one undo step put both back"); +} From 4f3fa4d0fb914b89a3d1fa4a3375b5cdf8d6466e Mon Sep 17 00:00:00 2001 From: Camille Moine Date: Mon, 19 Oct 2026 09:55:40 +0200 Subject: [PATCH 12/44] docs: the Timeline row controls act on the selection (#1849) One bullet in the Timeline section of docs/agents.md and a note on the ui-parity Timeline row, next to the other multi-selection fixes. --- docs/agents.md | 6 ++++++ docs/ui-parity.md | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/agents.md b/docs/agents.md index c47bfb603..f4fcc96f3 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -595,6 +595,12 @@ Extension points and the bundled extensions: [plugins.md](plugins.md). properties, FF missing effects, EE Expressions, LL Waveform, UU everything changed from its default. Shift+key adds or removes a set; the same key again hides it; Ctrl/Cmd+` twirls the selected layers open or closed. With no layer selected they apply to every layer. +* A row control clicked on one of several selected layers applies to every selected layer, as in + After Effects: the A/V switches (video, audio, solo, lock), the Switches column (shy, collapse, + quality, fx, frame blending, motion blur, adjustment, 3D), the label swatch and the Modes column + (blending mode, Preserve Transparency), one undo step, with the layers the switch doesn't apply + to left out. A control on an unselected row changes that layer alone; Alt-clicking an A/V feature + still sets the selected layers apart from the rest. * `keys.toggleTransform {"layers"?: [...], "prop": "position"}` (Alt+Shift+A/P/S/R/T) adds or removes a keyframe at the current time on that Transform property of each layer, in one undo step (separated X/Y/Z Position; Orientation and X/Y/Z Rotation on 3D layers). diff --git a/docs/ui-parity.md b/docs/ui-parity.md index ccce70e0f..f2956918e 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -17,7 +17,7 @@ feel and platform differences, and the 26.x interface changes are not in yet. |---|---|---:|---| | Menu bar | After Effects' menus, order, submenus and labels; native on macOS; keyboard-operable in-window bar | 95% | 587 entries in `crates/engine/src/menus.rs`; Adobe-service entries omitted by design; `ui_menus` tests (#279) | | Panels and workspaces | Every After Effects panel; all standard workspaces; drag-to-dock, tabs, floating panels, maximise | 75% | `ui_tabs`, `ui_panels`; panels move into their own native windows on any screen (#486 open: no dragging tabs between those windows); #302, #301 and #320 (docking, stacked-panel headers, workspace persistence) are closed | -| Timeline | Switches, modes, parent & link, twirl-downs, reveal shortcuts (U, UU, P, S, R, T, A, E, M, F, Alt+Shift…), pick whips, graph editor, work area, markers | 75% | `ui_keyframes`, `ui_timeline_*`; expand precomps in place (#601); #456, #457, #569 and #355 (Graph Editor jitter and buttons, negative rotation, bulk keyframing) are closed; Proportional Scrubbing (26.2: Ctrl+Alt / Cmd+Opt scrub of hot text spreads the change over the selected layers by selection order, #770) matched for layers selected as a whole, not yet for individually selected properties; every layer has the Video switch, nulls, cameras and lights included (a null with its eye off has no box or handles in the viewer and isn't picked there) | +| Timeline | Switches, modes, parent & link, twirl-downs, reveal shortcuts (U, UU, P, S, R, T, A, E, M, F, Alt+Shift…), pick whips, graph editor, work area, markers | 75% | `ui_keyframes`, `ui_timeline_*`; expand precomps in place (#601); #456, #457, #569 and #355 (Graph Editor jitter and buttons, negative rotation, bulk keyframing) are closed; Proportional Scrubbing (26.2: Ctrl+Alt / Cmd+Opt scrub of hot text spreads the change over the selected layers by selection order, #770) matched for layers selected as a whole, not yet for individually selected properties; every layer has the Video switch, nulls, cameras and lights included (a null with its eye off has no box or handles in the viewer and isn't picked there); a row control (a switch, the label swatch, a Modes cell) clicked on one of several selected layers acts on the whole selection, with the layers the switch doesn't apply to left out (#1849) | | Composition viewer | Selection, handles with direction-aware cursors, rulers, guides, grids, snapping, channels, exposure, region of interest, snapshots, 3D views, Spacebar hand | 70% | `ui_viewer`, `ui_viewers`; Cmd/Ctrl +/− doesn't zoom (#570); anchor point grab on Linux (#492); #352 (snapping) is closed; no 3D transform gizmos (#392, #347); percentage guides (26.5) exist as View ▸ Add Percentage Guide and Convert Guides to Percentage / Pixels (a guide stores a percent of the comp, so it follows comp-size changes; behaviour inferred from the release note, not compared with After Effects); Copy Frame to Clipboard (26.3) is in the Composition menu and the viewer's context menu | | Tools panel | All After Effects tools; long-press flyouts; Tool Creates Shape / Mask; Fill / Stroke options | 80% | #213, #227 fixed; #553 (pen and vertex tools on motion paths) is closed | | Nudging and modifiers | Arrow nudge (1 px, Shift 10 px), Alt/Option duplicates and slips, Shift constrains | 75% | #290 nudging fixed; #491 (Linux multi-layer edits) is closed | From f72e17a2bb2a594375e813a9cae4f9459e7906b4 Mon Sep 17 00:00:00 2001 From: Camille Moine Date: Mon, 19 Oct 2026 10:25:17 +0200 Subject: [PATCH 13/44] Engine: Video's Alt-click and Hide Other Video take cameras and lights along The Timeline draws the Video switch (the eye) on every layer's row, nulls, cameras and lights included, but set_switch_others and hide_other_video skipped cameras and lights: Alt-clicking the eye of a selected layer neither set them apart with the selection nor hid them with the rest. Both now use the rule the eye cell shows: every layer but track mattes. --- crates/engine/src/commands/layer.rs | 6 ++++-- crates/engine/src/commands/layer_menu.rs | 8 ++++---- crates/engine/src/tests_menu_cmds.rs | 23 +++++++++++++++++++++++ 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index b798493b1..0832163d1 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -417,8 +417,10 @@ fn set_switch_others(s: &mut Session, cid: ItemId, ids: &[LayerId], name: &str, let applies = |l: &Layer| match name { "audio" => l.props.sub("audio").is_some(), "lock" | "locked" => true, - // Nulls have a Video switch too (it hides their outline in the viewer). - "video" | "eye" => (l.source.is_av() || matches!(l.source, LayerSource::Null)) && !mattes.contains(&l.id), + // Every layer has a Video switch (it hides a null's outline in the viewer and turns a + // camera or light off), as the Timeline's eye cell shows; track mattes keep theirs off + // by design. + "video" | "eye" => !mattes.contains(&l.id), _ => l.source.is_av() && !mattes.contains(&l.id), }; let mut alone = true; diff --git a/crates/engine/src/commands/layer_menu.rs b/crates/engine/src/commands/layer_menu.rs index 9cac80412..064b2fb33 100644 --- a/crates/engine/src/commands/layer_menu.rs +++ b/crates/engine/src/commands/layer_menu.rs @@ -114,10 +114,10 @@ fn frame_blending(s: &mut Session, p: &Value) -> Result { fn hide_other_video(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; s.edit("Hide Other Video", None, |proj, _| { - for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut() { - if !ids.contains(&l.id) && (l.source.is_av() || matches!(l.source, LayerSource::Null)) { - l.switches.video = false; - } + // Every layer has a Video switch (nulls, cameras and lights included, as the Timeline's + // eye cell shows): the selection stays shown. + for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| !ids.contains(&l.id)) { + l.switches.video = false; } Ok(()) })?; diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index 6da07dd40..d50257aa6 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -111,6 +111,29 @@ fn set_switch_others() { assert!(s.execute("layer.setSwitch", json!({"layers": [c], "switch": "nope", "others": true})).is_err()); } +/// The Video switch has a cell on every layer's row, cameras and lights included, so Alt-clicking +/// it (and Hide Other Video) takes them along with the rest. +#[test] +fn video_others_takes_cameras_and_lights() { + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap(); + let light = s.execute("layer.newLight", json!({"kind": "Point"})).unwrap()["layer"].as_u64().unwrap(); + let get = |s: &Session, f: fn(&effectcraft_project::Layer) -> bool| [a, cam, light].map(|l| f(&layer(s, l))); + // The camera and the light are hidden: Alt-click on the solid's eye brings them back, since + // they stand apart with it, and the next Alt-click hides them with every other layer. + s.execute("layer.setSwitch", json!({"layers": [cam, light], "switch": "video", "value": false})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": [a], "switch": "video", "others": true})).unwrap(); + assert_eq!(get(&s, |l| l.switches.video), [true; 3]); + s.execute("layer.setSwitch", json!({"layers": [a], "switch": "video", "others": true})).unwrap(); + assert_eq!(get(&s, |l| l.switches.video), [true, false, false]); + // Hide Other Video takes them too. + s.execute("layer.showAllVideo", json!({})).unwrap(); + assert_eq!(get(&s, |l| l.switches.video), [true; 3]); + s.execute("layer.hideOtherVideo", json!({"layers": [a]})).unwrap(); + assert_eq!(get(&s, |l| l.switches.video), [true, false, false]); +} + /// A null's Video switch (the eye) works like any layer's, as in After Effects: it toggles, /// Alt-click and Hide Other Video take nulls along, and its children still follow it while it is /// hidden (parenting ignores the eye). From 8756ec1846f26537e80d44383b0b6a15d74fca5b Mon Sep 17 00:00:00 2001 From: Camille Moine Date: Mon, 19 Oct 2026 10:25:17 +0200 Subject: [PATCH 14/44] Timeline: Preserve Transparency toggles from the clicked row (#1849) layer.setSwitch with no value toggles relative to the first selected layer in composition order, so with a mixed selection (A checked, C unchecked) clicking C's box could leave C unchanged. The checkbox now sends the value it toggles to, as the switches do. --- crates/ui-egui/src/panels/timeline.rs | 11 ++++++++++- crates/ui-egui/tests/ui_batch_switches.rs | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index ebe076dc8..1366b7504 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2241,7 +2241,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let tr = Rect::from_center_size(pos2(cw.mode + 76.0 + 6.0, cy), vec2(14.0, 14.0)); app.auto.add(&format!("timeline.layer.{}.preserveTransparency", layer.id.0), tr, "Preserve Transparency"); if widgets::checkbox(ui, tr, layer.preserve_transparency, &t, egui::Id::new(("pt", layer.id.0))).clicked() { - actions.push(("layer.setSwitch".into(), json!({"layers": mode_targets(&comp, layer.id, &selected), "switch": "preserveTransparency"}))); + // The click gives the selection the state the clicked row toggles to, + // whatever the first selected layer's value (as the switches do). + actions.push(( + "layer.setSwitch".into(), + json!({ + "layers": mode_targets(&comp, layer.id, &selected), + "switch": "preserveTransparency", + "value": !layer.preserve_transparency + }), + )); } // Track matte: layer picker + kind. let mr = Rect::from_min_size(pos2(cw.trkmat + 2.0, cy - 9.0), vec2(104.0, 18.0)); diff --git a/crates/ui-egui/tests/ui_batch_switches.rs b/crates/ui-egui/tests/ui_batch_switches.rs index acbc91f87..407ac194e 100644 --- a/crates/ui-egui/tests/ui_batch_switches.rs +++ b/crates/ui-egui/tests/ui_batch_switches.rs @@ -225,3 +225,20 @@ fn the_modes_column_sets_the_whole_selection() { h.run_steps(2); assert_eq!(states(&h, &ids, preserved), [false; 4], "one undo step put both back"); } + +/// Preserve Transparency toggles from the row it is clicked on, not from the first selected layer: +/// A on and C off, clicking C's box turns both on. +#[test] +fn preserve_transparency_toggles_from_the_clicked_row() { + let (mut h, ids) = harness(); + h.state_mut().ui.timeline.show_modes = true; + h.run_steps(3); + h.state_mut().session.execute("layer.setSwitch", json!({"layers": [ids[0]], "switch": "preserveTransparency", "value": true})).unwrap(); + h.run_steps(2); + select(&mut h, &[ids[0], ids[2]]); + click(&mut h, &format!("timeline.layer.{}.preserveTransparency", ids[2])); + assert_eq!(states(&h, &ids, preserved), [true, false, true, false]); + // The next click turns both off again. + click(&mut h, &format!("timeline.layer.{}.preserveTransparency", ids[2])); + assert_eq!(states(&h, &ids, preserved), [false; 4]); +} From c17f9eb959a4d27843dbabb905a0bea2feae3d0c Mon Sep 17 00:00:00 2001 From: SBaksa Date: Mon, 19 Oct 2026 21:08:16 +1100 Subject: [PATCH 15/44] Text Range Selector: Start, End and Offset show as counts with Units = Index (#1814) With Units set to Index the selector already counts characters (words, lines), but the timeline kept showing Start, End and Offset as percentages ("3.0%"), so switching looked like it did nothing. They are now shown without the % sign while Units is Index (render::text::in_index_units). --- crates/render/src/text.rs | 8 ++++++++ crates/render/src/text/tests.rs | 18 ++++++++++++++++++ crates/ui-egui/src/panels/timeline.rs | 4 +++- 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index fbb0feb24..6abc2ae96 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -187,6 +187,14 @@ pub fn range_value_of(f: f64, offset: f64, units_index: bool, n: usize) -> f64 { if units_index { (v * n as f64).round() } else { v * 100.0 } } +/// Whether property `uid` of `layer` is a Range Selector's Start, End or Offset while its Units +/// are Index: the value is then a count of characters (words, lines), not a percentage. +pub fn in_index_units(layer: &Layer, uid: effectcraft_project::Uid) -> bool { + let Some(sel) = layer.props.parent_of(uid).filter(|g| g.match_id == "rangeSelector") else { return false }; + let is_range_value = sel.props().any(|p| p.uid == uid && matches!(p.match_id.as_str(), "start" | "end" | "offset")); + is_range_value && sel.prop("advanced/units").is_some_and(|u| u.value == Value::Enum(1)) +} + /// Selection of every glyph by an animator's selectors, per dimension. pub fn animator_selection(ctx: &EvalCtx, layer: &Layer, anim: &PropGroup, lay: &TextLayout) -> Vec<[f64; 3]> { let sels: Vec<&PropGroup> = anim.sub("selectors").map(|s| s.groups().filter(|g| g.enabled).collect()).unwrap_or_default(); diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index 020e08517..7721d1fb2 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -99,6 +99,24 @@ fn range_based_on_words_and_lines() { assert_eq!(s, vec![0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0], "{s:?}"); } +/// With Units = Index a Range Selector's Start, End and Offset are character counts, not +/// percentages (#1814); the selector's other values, and other selectors, are unaffected. +#[test] +fn index_units_mark_start_end_and_offset() { + let (mut p, _cid, comp) = setup(400, 200); + let mut l = text_layer(&mut p, &comp, doc("ABCDEFG", 30.0)); + animator(&mut p, &mut l, &["opacity"], &["range", "wiggly"]); + let uid = |l: &Layer, path: &str| l.props.prop(path).unwrap_or_else(|| panic!("no {path}")).uid; + let base = "text/animators/#1/selectors/#1"; + let range_values = ["start", "end", "offset"].map(|k| uid(&l, &format!("{base}/{k}"))); + assert!(range_values.iter().all(|u| !in_index_units(&l, *u)), "Percentage, the default"); + set(&mut l, &format!("{base}/advanced/units"), Value::Enum(1)); + assert!(range_values.iter().all(|u| in_index_units(&l, *u))); + assert!(!in_index_units(&l, uid(&l, &format!("{base}/advanced/amount"))), "Amount stays a percentage"); + assert!(!in_index_units(&l, uid(&l, "text/animators/#1/properties/opacity"))); + assert!(!in_index_units(&l, uid(&l, "transform/opacity"))); +} + #[test] fn range_start_end_and_offset_are_capped() { let (mut p, cid, comp) = setup(400, 200); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index b34fbd9d3..493fd63b4 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -4117,7 +4117,9 @@ fn value_editor( set(actions, json!(nv)); } } else { - let suffix = if matches!(prop.ui, ParamUi::Percent) || prop.match_id == "opacity" { "%" } else { "" }; + // A Range Selector's Start, End and Offset count characters with Units = Index (#1814). + let pct = (matches!(prop.ui, ParamUi::Percent) || prop.match_id == "opacity") && !effectcraft_engine::render::text::in_index_units(layer, uid); + let suffix = if pct { "%" } else { "" }; let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, y), egui::Id::new(("v", uid, 0)), *v, speed, (min, max), dec.max(1), suffix, &t); app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name); if let Some(nv) = nv { From 71aafd6307cf7c6812fba0f2fcfe6229debabb7d Mon Sep 17 00:00:00 2001 From: SBaksa Date: Tue, 20 Oct 2026 01:05:15 +1100 Subject: [PATCH 16/44] Text Range Selector: test the Timeline shows Index-unit values without % (#1814) Start, End and Offset are read as painted in the Timeline: with % under Units = Percentage, without it under Units = Index. Fails on the old suffix rule ("0.0%"). --- crates/ui-egui/tests/ui_text_range_handles.rs | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/crates/ui-egui/tests/ui_text_range_handles.rs b/crates/ui-egui/tests/ui_text_range_handles.rs index 1d048be52..aab80100d 100644 --- a/crates/ui-egui/tests/ui_text_range_handles.rs +++ b/crates/ui-egui/tests/ui_text_range_handles.rs @@ -194,3 +194,46 @@ fn a_bar_on_a_box_handle_leaves_it_the_press() { assert_eq!(layer.props.find(start).unwrap().value.as_f64(), 0.0, "Start stays"); assert_ne!(scale(&h), before, "the handle scaled the layer"); } + +/// The text the Timeline paints in property `uid`'s value field. +fn timeline_value(h: &Harness<'_, EffectcraftApp>, uid: u64) -> String { + let e = h.state().auto.find(&format!("timeline.prop.{uid}.value")).unwrap_or_else(|| panic!("timeline.prop.{uid}.value isn't shown")); + let field = egui::Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])); + h.output() + .shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Text(t) if field.contains(t.visual_bounding_rect().center()) => Some(t.galley.text().to_string()), + _ => None, + }) + .collect() +} + +/// #1814: with Units = Index the Timeline shows the Range Selector's Start, End and Offset as +/// counts, without the % sign they carry with Units = Percentage. +#[test] +fn index_units_show_start_end_and_offset_without_a_percent_sign() { + let (s, ids) = setup(); + let l = LayerId(ids.layer); + let layer = s.active_comp().unwrap().layer(l).unwrap().clone(); + let text = layer.props.sub("text").unwrap(); + let anim = animator(&s, ids.layer); + let selectors = anim.sub("selectors").unwrap(); + let sel = selectors.groups().next().unwrap(); + let (offset, units) = (sel.get("offset").unwrap().uid, sel.prop("advanced/units").unwrap().uid); + let mut h = harness(s); + let tl = &mut h.state_mut().ui.timeline; + tl.open_layers.insert(ids.layer); + let animators = text.sub("animators").unwrap().uid; + tl.open_groups.extend([text.uid, animators, anim.uid, selectors.uid, sel.uid]); + h.run_steps(3); + for uid in [ids.start, ids.end, offset] { + assert!(timeline_value(&h, uid).ends_with('%'), "Percentage units: {}", timeline_value(&h, uid)); + } + h.state_mut().session.execute("prop.set", json!({"layer": ids.layer, "prop": units, "value": 1})).unwrap(); + h.run_steps(3); + for uid in [ids.start, ids.end, offset] { + let v = timeline_value(&h, uid); + assert!(!v.is_empty() && !v.contains('%'), "Index units: {v:?}"); + } +} From 60dab98e9b1052efc454a8a8abf4a61031130446 Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Mon, 19 Oct 2026 18:15:35 +0400 Subject: [PATCH 17/44] i18n: saved workspace names stay verbatim in the workspace menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only the built-in workspaces read the Window ▸ Workspace rows; a name the user saved (\"Composition\", say) no longer falls through to the panel catalog. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- crates/ui-egui/src/header.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index acbd2fdcb..c1272c41d 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -639,9 +639,15 @@ fn puppet_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter r.max.x + 16.0 } +/// A workspace's drawn name: built-ins read the menu bar's Window ▸ Workspace row; names the user +/// saved stay verbatim, even when one matches a catalog source such as "Composition". +fn workspace_label(name: &str) -> &str { + if crate::dock::WORKSPACES.contains(&name) { crate::i18n::command_label("window.workspace", name) } else { name } +} + fn ws_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui) { for w in app.workspace_names() { - if ui.selectable_label(app.ui.workspace == w, crate::i18n::command_label("window.workspace", &w)).clicked() { + if ui.selectable_label(app.ui.workspace == w, workspace_label(&w)).clicked() { app.set_workspace(&w); ui.close(); } @@ -692,6 +698,15 @@ mod tests { out.textures_delta.clear(); } + #[test] + fn saved_workspace_names_stay_verbatim() { + crate::i18n::set_current("zh-hans"); + assert_ne!(workspace_label("Default"), "Default"); + assert_ne!(crate::i18n::tr("Composition"), "Composition"); + assert_eq!(workspace_label("Composition"), "Composition"); + crate::i18n::set_current("en"); + } + #[test] fn long_press_type_keeps_the_tool_until_vertical_is_selected() { let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); From ad8db289cfed2eff2825d3fce695546200c8d855 Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Mon, 19 Oct 2026 18:15:35 +0400 Subject: [PATCH 18/44] i18n: Settings page titles read the menu bar's Settings rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel catalog's \"Type\" is the kind (Camera Settings, the Project column), so the Type page drew as \"kind\" in Chinese; its title now comes from the app.settings menu row (文字), as the Settings submenu draws it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- crates/ui-egui/src/i18n.rs | 18 ++++++++++++++++++ crates/ui-egui/src/panels/settings.rs | 4 ++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 702c6ffd1..dde67e225 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -173,6 +173,14 @@ pub(crate) fn command_label<'a>(command: &str, source: &'a str) -> &'a str { row(source).or_else(|| row(dotted.as_deref()?)).unwrap_or_else(|| tr(source)) } +/// A Settings page's title as the menu bar's Settings submenu draws it, without the ellipsis; the +/// panel catalog's row for pages that have no menu row. The panel catalog's "Type" is the kind +/// (Camera Settings, the Project panel's column), while the Type page is about text. +pub(crate) fn settings_page_title(title: &str) -> &str { + let menu = menu_catalog(ui::current()).and_then(|c| c.iter().find(|(id, en, _)| *id == "app.settings" && en.strip_suffix("...") == Some(title))); + menu.and_then(|(_, _, translated)| translated.strip_suffix("...")).unwrap_or_else(|| tr(title)) +} + /// `source` looked up in `catalog` by (command, English label); the source itself 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 { @@ -6691,6 +6699,16 @@ mod tests { assert_eq!(command_label("window.panel", "Project"), "Project"); } + #[test] + fn settings_page_titles_read_the_menu_row() { + set_current("zh-hans"); + assert_eq!(settings_page_title("Type"), "文字"); + assert_eq!(tr("Type"), "类型"); + assert_eq!(settings_page_title("Roto Brush"), tr("Roto Brush")); + set_current("en"); + assert_eq!(settings_page_title("Type"), "Type"); + } + #[test] fn match_system_takes_the_first_browser_language_with_a_catalog() { let tags = |t: &[&str]| t.iter().map(|s| s.to_string()).collect::>(); diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 28a7c0142..6954e1377 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -677,7 +677,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.painter().text( r.left_center() + vec2(10.0, 0.0), egui::Align2::LEFT_CENTER, - tr(p.title), + crate::i18n::settings_page_title(p.title), Tokens::ui(12.5), if sel { Color32::WHITE } else { t.text }, ); @@ -694,7 +694,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { // dialog scrolled sideways a little (#284). ui.set_width(ui.available_width()); let p = &all[idx]; - ui.label(RichText::new(tr(p.title)).font(Tokens::semibold(14.0)).color(t.tab_text_active)); + ui.label(RichText::new(crate::i18n::settings_page_title(p.title)).font(Tokens::semibold(14.0)).color(t.tab_text_active)); ui.add_space(6.0); egui::ScrollArea::vertical().max_height((body_h - 30.0).max(0.0)).auto_shrink([false, false]).show(ui, |ui| { page_ui(app, ui, p, &cur, t, &mut acts); From dd29665939553cc453ab3c742a19375cd2482848 Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Mon, 19 Oct 2026 18:16:21 +0400 Subject: [PATCH 19/44] text: the Georgian fallback test goes through fallback_for system_fallback is the scan-order search; fallback_for is where the Georgian families are preferred, so the test asks it for Mkhedruli in Inter. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- crates/text/src/fonts.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 3be0f1085..354d71f30 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -1152,9 +1152,9 @@ mod tests { } } - /// Native: the script search picks a Georgian family for Mkhedruli (a face already loaded - /// that happens to cover it, such as macOS Helvetica, still wins in `fallback_for`). Skipped - /// where no family from the list is installed. + /// Native: Mkhedruli in a Latin-only family falls back to a family from the Georgian list, + /// ahead of faces already loaded and of the scan order. Skipped where no family from the list + /// is installed. #[test] fn georgian_falls_back_to_a_georgian_system_family() { scan_system(); @@ -1163,7 +1163,7 @@ mod tests { eprintln!("no Georgian family from the list is installed; skipping"); return; } - let f = face(system_fallback(c, "Regular").unwrap()); + let f = face(fallback_for(c, resolve("Inter", "Regular").face)); assert!(f.has_char(c), "{} lacks it", f.info.family); assert!(GEORGIAN_FAMILIES.iter().any(|n| f.info.family.eq_ignore_ascii_case(n)), "{c} → {}", f.info.family); } From 2447b54856ad27bcb3f4564ce4e58ec2addc461a Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Mon, 19 Oct 2026 18:16:21 +0400 Subject: [PATCH 20/44] docs: Georgian catalog counts at this head Menus 628 rows (every menu catalog), panels 886 (695 shared with the Chinese catalogs plus 191 Georgian-only), effect names 307 + 21 categories; gaps.md and localization-parity.md now agree. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- docs/gaps.md | 2 +- docs/localization-parity.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/gaps.md b/docs/gaps.md index 8c8ba84c0..f8fcd64c2 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 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) | +| 16 | **Localization depth** | Menus in 11 languages (ja, zh-hans, zh-hant, uk, es, pt-br, ru, fr, de, cs, ka: 628 rows each); panels in 7 (ja, zh-hans, zh-hant, uk, pt-br, cs, ka); effect names translated in Simplified Chinese and Georgian (307, plus the generated Effect menu), their ~1,650 parameter names untranslated; Korean and Italian (After Effects ships them) absent; Hangul probe added (#599 closed) | After Effects is used heavily in Japan, China, Korea, Brazil, Europe | 60–110 h + native review | G9 · [localization-parity.md](localization-parity.md) | | 17 | **Media Browser lists files import refuses** | `media_browser.rs` lists AVI; `media::is_importable` has none of them (Targa and Radiance HDR import since #745; DPX and Cineon since #756, log shown as stored: no Cineon Settings conversion yet; MXF OP1a / OP-Atom since #767; FilmCraft has no AVI demuxer); #431 and #324 closed | Confusing failures on common VFX formats | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) | | 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) | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 701850477..f074c5e55 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -26,7 +26,7 @@ interface; Arabic and Hebrew text *in layers* is supported through its Middle Ea Traditional Chinese (506 each), Ukrainian (505), Brazilian Portuguese (400) and Czech (501); 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 and Georgian (306 rows, one per effect in the registry, plus its 21 categories). The registry's + Chinese and Georgian (307 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 @@ -83,7 +83,7 @@ Other languages EffectCraft ships (4): | 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 | +| Georgian | `ka` | 1,514 (≈ 39%): menus 628 rows, panels 886 (the Chinese catalogs' 695 plus 191 Georgian-only rows: form dialogs, context menus, column headers), effect names 307 + 21 categories; native-speaker reviewed | partial | 5–8 h | Russian (`ru`) has a menu catalog (624 rows, ≈ 16%); After Effects also ships Italian, which EffectCraft does not (8–12 h). @@ -105,7 +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-14 | minor | Georgian (`ka`): menus 628, panels 886 (191 Georgian-only, in `GEORGIAN_PANELS`), effect names and categories in `effects.rs` | | 2026-10-10 | major | First localization parity document: catalog rows counted for all six languages, the twelve-language table, After Effects' 11 shipped languages from its bundle | | 2026-10-11 | minor | Ukrainian panel catalog brought level with the Chinese ones (495 of 496 rows, counted at this commit); its share rises to ≈ 29%. The other languages' panel counts are the 10 October measurement | | 2026-10-13 | minor | Effect names and the generated Effect menu leave English: `i18n/effects.rs` adds 306 rows keyed by effect id (Simplified Chinese, from After Effects 2026's interface and Adobe's Chinese documentation for the `CC` series) and the 21 category names. Parameter names are still English; no other language has rows | From c628dc7d96982955e3cf437cf9b2715d4d4f0c93 Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Mon, 19 Oct 2026 21:42:50 +0400 Subject: [PATCH 21/44] docs: the Georgian panel count is at this revision; the other counts stay upstream's dated recount Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- docs/localization-parity.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/localization-parity.md b/docs/localization-parity.md index f074c5e55..9d53be6d3 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -24,7 +24,8 @@ 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; Georgian has every row the Chinese catalogs have. + counted at 470ea7a3. Georgian has every row the Chinese catalogs have at this revision (695, as + both Chinese catalogs now have), plus 191 for panels converted to the catalog. - **Effect names:** `crates/ui-egui/src/i18n/effects.rs`, keyed by **effect id**, in Simplified Chinese and Georgian (307 rows, one per effect in the registry, plus its 21 categories). The registry's English name stays the effect's identity: `effect.list`, `effect.apply {"effect": …}`, scripts From d6afa87641e3f57d96d7431ef6a2b19b53467df4 Mon Sep 17 00:00:00 2001 From: TimotheusG Date: Mon, 19 Oct 2026 15:30:35 -0400 Subject: [PATCH 22/44] fix(text): isolate CJK fallback language and cache entries (#1141) --- crates/text/src/fonts.rs | 172 ++++++++++++++++++++++++++++++------ crates/ui-egui/src/theme.rs | 18 ++-- 2 files changed, 156 insertions(+), 34 deletions(-) diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index aeabf1872..9ace1f9cd 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -753,14 +753,48 @@ fn requested_cjk_locale() -> Option { /// The CJK locale to use: [`set_cjk_locale`] when the host asked for one, otherwise the locale /// environment (`LANG`, `LC_ALL`, `LC_CTYPE`; `EFFECTCRAFT_CJK_LOCALE` overrides them). fn active_cjk_locale() -> String { - requested_cjk_locale().unwrap_or_else(|| { - ["EFFECTCRAFT_CJK_LOCALE", "LC_ALL", "LC_CTYPE", "LANG"] - .iter() - .filter_map(|k| std::env::var(k).ok()) - .map(|v| v.trim().to_ascii_lowercase()) - .find(|v| !v.is_empty() && v != "c" && v != "posix") - .unwrap_or_default() - }) + requested_cjk_locale().unwrap_or_else(environment_cjk_locale) +} + +/// Read the environment independently of another UI's process-wide language request. +fn environment_cjk_locale() -> String { + ["EFFECTCRAFT_CJK_LOCALE", "LC_ALL", "LC_CTYPE", "LANG"] + .iter() + .filter_map(|k| std::env::var(k).ok()) + .map(|v| v.trim().to_ascii_lowercase()) + .find(|v| !v.is_empty() && v != "c" && v != "posix") + .unwrap_or_default() +} + +/// The four distinct fallback policies, rather than unbounded user-supplied locale cache keys. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +enum CjkLocale { + Japanese, + Korean, + Simplified, + Traditional, +} + +impl CjkLocale { + fn from_tag(tag: &str) -> Self { + let tag = tag.trim().to_ascii_lowercase(); + if tag.starts_with("zh") { + if asks_traditional_chinese(&tag) { Self::Traditional } else { Self::Simplified } + } else if tag.starts_with("ko") { + Self::Korean + } else { + Self::Japanese + } + } + + fn tag(self) -> &'static str { + match self { + Self::Japanese => "ja", + Self::Korean => "ko", + Self::Simplified => "zh-hans", + Self::Traditional => "zh-hant", + } + } } /// The CJK language to try first for Han ideographs. Japanese when nothing says otherwise: the @@ -778,6 +812,7 @@ fn han_order_for(tag: &str) -> [FallbackScript; 3] { } /// The order for the locale in force ([`active_cjk_locale`]). +#[cfg(test)] fn han_order() -> [FallbackScript; 3] { han_order_for(&active_cjk_locale()) } @@ -796,15 +831,10 @@ fn chinese_families_for(tag: &str) -> &'static [&'static str] { if asks_traditional_chinese(tag) { TRADITIONAL_CHINESE_FAMILIES } else { SIMPLIFIED_CHINESE_FAMILIES } } -/// [`chinese_families_for`] for the locale in force ([`active_cjk_locale`]). -fn chinese_families() -> &'static [&'static str] { - chinese_families_for(&active_cjk_locale()) -} - -fn families_for(s: FallbackScript) -> &'static [&'static str] { +fn families_for(s: FallbackScript, locale: CjkLocale) -> &'static [&'static str] { match s { FallbackScript::Japanese => JAPANESE_FAMILIES, - FallbackScript::Han => chinese_families(), + FallbackScript::Han => chinese_families_for(locale.tag()), FallbackScript::Korean => KOREAN_FAMILIES, FallbackScript::Arabic => ARABIC_FAMILIES, FallbackScript::Hebrew => HEBREW_FAMILIES, @@ -881,9 +911,10 @@ fn fallback_cache() -> &'static RwLock &'static RwLock>>> { - static CACHE: OnceLock>>>> = OnceLock::new(); +/// The results of [`family_faces`], per script, preferred face and captured language policy. +/// A lookup that finishes after a language change cannot populate another language's entry. +fn family_faces_cache() -> &'static RwLock>>> { + static CACHE: OnceLock>>>> = OnceLock::new(); CACHE.get_or_init(Default::default) } @@ -893,6 +924,23 @@ fn family_faces_cache() -> &'static RwLock FaceId { + fallback_with_locale(c, prefer, CjkLocale::from_tag(&active_cjk_locale())) +} + +/// Choose a fallback for an explicit interface language, independent of concurrent global +/// language changes. Empty, `c` and `posix` use the OS environment rather than the global request. +pub fn fallback_for_locale(c: char, prefer: FaceId, tag: &str) -> FaceId { + let tag = tag.trim(); + let locale = if tag.is_empty() || tag.eq_ignore_ascii_case("c") || tag.eq_ignore_ascii_case("posix") { + CjkLocale::from_tag(&environment_cjk_locale()) + } else { + CjkLocale::from_tag(tag) + }; + fallback_with_locale(c, prefer, locale) +} + +/// Keep a single language snapshot through the family-order lookup and cache insertion. +fn fallback_with_locale(c: char, prefer: FaceId, locale: CjkLocale) -> FaceId { let p = face(prefer); if p.has_char(c) || c.is_control() { return prefer; @@ -913,7 +961,7 @@ pub fn fallback_for(c: char, prefer: FaceId) -> FaceId { if cfg!(not(target_arch = "wasm32")) && !system_scanned() { scan_system(); } - if let Some(&id) = family_faces(script, &p).iter().find(|&&id| face(id).has_char(c)) { + if let Some(&id) = family_faces(script, &p, locale).iter().find(|&&id| face(id).has_char(c)) { return id; } } @@ -942,23 +990,24 @@ pub fn fallback_for(c: char, prefer: FaceId) -> FaceId { /// The script-aware part of [`fallback_for`]: the installed faces of the platform's usual families /// for `script`, in the order they are tried, each in the style of `prefer`. For CJK, the other CJK /// languages' families follow in the locale's order. -fn family_faces(script: FallbackScript, prefer: &Face) -> Arc> { - if let Some(faces) = family_faces_cache().read().unwrap_or_else(|e| e.into_inner()).get(&(script, prefer.id)) { +fn family_faces(script: FallbackScript, prefer: &Face, locale: CjkLocale) -> Arc> { + if let Some(faces) = family_faces_cache().read().unwrap_or_else(|e| e.into_inner()).get(&(script, prefer.id, locale)) { return faces.clone(); } let order: Vec = match script { FallbackScript::Other => vec![], - FallbackScript::Han => han_order().to_vec(), + FallbackScript::Han => han_order_for(locale.tag()).to_vec(), s if s.is_cjk() => { let mut v = vec![s]; - v.extend(han_order().iter().copied().filter(|o| *o != s)); + v.extend(han_order_for(locale.tag()).iter().copied().filter(|o| *o != s)); v } s => vec![s], }; let style = &prefer.info.style; - let faces: Arc> = Arc::new(order.into_iter().flat_map(families_for).filter_map(|fam| resolve_in(fam, style)).map(|r| r.face).collect()); - family_faces_cache().write().unwrap_or_else(|e| e.into_inner()).insert((script, prefer.id), faces.clone()); + let faces: Arc> = + Arc::new(order.into_iter().flat_map(|s| families_for(s, locale)).filter_map(|fam| resolve_in(fam, style)).map(|r| r.face).collect()); + family_faces_cache().write().unwrap_or_else(|e| e.into_inner()).insert((script, prefer.id, locale), faces.clone()); faces } @@ -975,6 +1024,77 @@ fn system_fallback(c: char, style: &str) -> Option { mod tests { use super::*; + /// Concurrent language requests must neither choose another context's font nor reuse its + /// cached family order. Synthetic fonts in a child process keep this independent of the host + /// font installation and of other tests that exercise the process-global database/locale. + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn cjk_requests_keep_their_language_and_cache_entries() { + const CHILD: &str = "EFFECTCRAFT_TEST_ISOLATED_CJK"; + if std::env::var_os(CHILD).is_none() { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "fonts::tests::cjk_requests_keep_their_language_and_cache_entries", "--nocapture"]) + .env(CHILD, "1") + .output() + .unwrap(); + assert!(output.status.success(), "{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); + return; + } + let families = [("ja", "Yu Gothic"), ("zh-hans", "Microsoft YaHei"), ("zh-hant", "Microsoft JhengHei"), ("ko", "Malgun Gothic")]; + let mut expected = Vec::new(); + { + let mut d = db().write().unwrap(); + d.scanned = true; // This test process uses only bundled and synthetic fonts. + for (language, family) in families { + let mut data = test_font(family); + let table = data[12..12 + 8 * 16].as_chunks::<16>().0.iter().find(|t| &t[..4] == b"cmap").unwrap(); + let cmap = u32::from_be_bytes(table[8..12].try_into().unwrap()) as usize + 12; + // Map 页 to the fixture's triangle glyph instead of its private-use range. + // All four fonts cover the probe, so coverage cannot choose the right language. + for (offset, value) in [(16, '页' as u16), (24, '页' as u16), (30, 2u16.wrapping_sub('页' as u16))] { + data[cmap + offset..cmap + offset + 2].copy_from_slice(&value.to_be_bytes()); + } + let data = Arc::new(data); + let names = read_faces_bytes(&data).into_iter().next().unwrap(); + let id = push(&mut d, info_from(names, FaceData::Shared(data), "test")); + expected.push((language, id)); + } + } + let base = resolve("Inter", "Regular").face; + for &(_, id) in &expected { + assert!(face(id).has_char('页')); + } + // Force the original interleaving: Chinese starts, then Japanese replaces the global + // language before Chinese looks up its probe. The explicit request must still win. + set_cjk_locale("zh-hans"); + set_cjk_locale("ja"); + assert_eq!(fallback_for('页', base), expected[0].1); + assert_eq!(fallback_for_locale('页', base, "zh-hans"), expected[1].1); + // Do not clear the cache here: different policies must coexist in the same cache. + for _ in 0..3 { + for &(language, id) in &expected { + assert_eq!(fallback_for_locale('页', base, language), id, "{language}"); + } + } + let barrier = Arc::new(std::sync::Barrier::new(expected.len())); + let threads: Vec<_> = expected + .into_iter() + .map(|(language, id)| { + let barrier = barrier.clone(); + std::thread::spawn(move || { + barrier.wait(); + for _ in 0..128 { + set_cjk_locale(if language == "ja" { "zh-hans" } else { "ja" }); + assert_eq!(fallback_for_locale('页', base, language), id, "{language}"); + } + }) + }) + .collect(); + for thread in threads { + thread.join().unwrap(); + } + } + #[test] fn extra_font_dirs_come_from_a_path_list() { assert!(extra_dirs(None).is_empty()); @@ -1302,7 +1422,7 @@ mod tests { assert!(!TRADITIONAL_CHINESE_FAMILIES.iter().any(|f| f.contains(" SC") || f.contains("YaHei") || f.contains("WenQuanYi"))); assert!(TRADITIONAL_CHINESE_FAMILIES.iter().any(|f| f.contains("Heiti TC") || f.contains("Hiragino Sans CNS"))); // The arm the fallback itself walks: Han hands over the list the locale's script picked. - assert_eq!(families_for(FallbackScript::Han), chinese_families_for(&active_cjk_locale())); + assert_eq!(families_for(FallbackScript::Han, CjkLocale::from_tag(&active_cjk_locale())), chinese_families_for(&active_cjk_locale())); // Han text is the only thing these lists serve; the Japanese order does not change. assert_eq!(han_order_for("ja_JP.UTF-8")[0], FallbackScript::Japanese); } diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index f5a0664c2..fa1bfdcf1 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -409,7 +409,9 @@ fn install_script_fallbacks(family_fonts: &mut FontDefinitions, language: &str) let mut names = Vec::new(); let mut seen = Vec::new(); for &(probe, name) in cjk.iter().chain(SCRIPT_PROBES).chain(std::iter::once(&HANGUL)) { - let id = fonts::fallback_for(probe, base); + // Another context may install a different language concurrently. Its global + // request must not choose the faces registered into this context. + let id = fonts::fallback_for_locale(probe, base, cjk_locale(language)); if seen.contains(&id) { continue; } @@ -443,9 +445,9 @@ fn cjk_locale(language: &str) -> &str { } /// The Han character that picks the Chinese fallback face. A Simplified Chinese interface asks for -/// a simplified-only hanzi: a Japanese face covers the shared ideographs (文) but not 页, 选 or 删, -/// and the fallback takes a face already loaded first, so the Japanese face loaded for kana would -/// draw the Chinese interface with boxes (#1141). +/// a hanzi used by its catalog. Some Japanese faces also cover 页 while lacking 选 or 删, so +/// coverage of this probe alone is not enough: the fallback must use the interface's own language +/// policy, even while another context is installing Japanese fonts (#1141). #[cfg(not(target_arch = "wasm32"))] fn han_probe(language: &str) -> char { if language == "zh-hans" { '页' } else { '文' } @@ -595,9 +597,9 @@ mod script_font_tests { } } - /// #1141: switching the interface to Simplified Chinese drew most labels as boxes. The English - /// interface had already loaded a Japanese face for kana, and that face covers the Han probe, - /// so it became the Chinese fallback too, without the simplified-only hanzi. + /// #1141: switching the interface to Simplified Chinese drew most labels as boxes. A Japanese face + /// can cover the Han probe while lacking other catalog characters. The context must keep its + /// own language policy even when another context installs Japanese fallbacks concurrently. #[test] fn simplified_chinese_interface_draws_simplified_hanzi_after_another_language() { let ctx = egui::Context::default(); @@ -609,7 +611,7 @@ mod script_font_tests { let font = egui::FontId::new(13.0, egui::FontFamily::Proportional); let missing: String = ctx.fonts_mut(|fonts| { let replacement = fonts.layout_no_wrap("\u{10ffff}".into(), font.clone(), egui::Color32::WHITE).rows[0].glyphs[0].uv_rect; - // Simplified-only hanzi of the zh-hans catalog (选择, 删除, 图层, 设置, 导入, 页面). + // Representative hanzi of the zh-hans catalog (选择, 删除, 图层, 设置, 导入, 页面). "选删层设导页" .chars() .filter(|ch| { From 57326dc6a7d2fcd187cc45edba2dac4433894ac1 Mon Sep 17 00:00:00 2001 From: ded Date: Tue, 20 Oct 2026 01:51:23 +0300 Subject: [PATCH 23/44] G5: stills at a reduced render size are resampled once and kept at that size (#1812) The media pool honoured the requested size only for movies; stills came back at full size and the renderer resampled them on every frame. A 21120 px wide plate at Quarter was re-filtered each frame, and four of them (1.85 GB as f32) did not fit the 1 GiB pool, so they were also decoded again and again. Stills are now resampled once with effectcraft_raster::resample (the same call the renderer made, so pixels are unchanged) and the pool keeps the reduced copy under the sized key. #1812 project at Quarter, CLI bench: 410 ms -> 33 ms a frame (26 ms with the layer cache); desktop playback 2% -> 60-99% of real time. Co-Authored-By: Claude Opus 5.5 --- crates/media/src/pool.rs | 79 +++++++++++++++++++++++------------ crates/media/tests/footage.rs | 26 ++++++++++++ 2 files changed, 78 insertions(+), 27 deletions(-) diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 9a8ec5c28..d3b6da0d4 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -708,15 +708,15 @@ impl Inner { } } - /// The frame of `footage` at `t`; a movie's resampled to `size` when it can be made at that - /// size directly (see [`FootageSource::frame_at_size`]). + /// The frame of `footage` at `t`, resampled to `size` (see [`FootageSource::frame_at_size`]): + /// a movie's made at that size directly, a still's resampled once and kept at that size. fn frame_at(self: &Arc, footage: &Footage, t: Tick, playback: bool, size: Option<(u32, u32)>, float: FloatCurve) -> Result> { if !footage.has_video { return Err(MediaError::Unsupported(format!("{}: no video", footage.path))); } let mut loc = Self::locate(footage, t, float); let movie = loc.media_t.is_some(); - if movie && let Some(size) = size { + if let Some(size) = size { loc.key.size = size; } let mut c = lock(&self.cache); @@ -773,34 +773,59 @@ impl Inner { #[cfg(target_arch = "wasm32")] fn prefetch(self: &Arc, _footage: &Footage, _t: Tick, _size: Option<(u32, u32)>, _float: FloatCurve) {} + /// A still (image file or sequence frame) of the footage's own size, at `loc.key.size` when + /// one is set: resampled as the renderer would ([`FootageSource::frame_at_size`]), so the + /// pool keeps the reduced frame and a reduced-resolution render neither decodes nor resamples + /// a large still again for every frame (#1812: 21120-px plates at Quarter). + fn still_at_size(img: Image, loc: &Loc, footage: &Footage) -> Image { + let (w, h) = loc.key.size; + if w == 0 || h == 0 || (img.width, img.height) != (footage.width, footage.height) || (w, h) == (img.width, img.height) { + return img; + } + effectcraft_raster::resample(&img, w, h) + } + fn decode(&self, loc: &Loc, footage: &Footage) -> Result { + match loc.media_t { + None => self.decode_still(loc, footage).map(|img| Self::still_at_size(img, loc, footage)), + Some(_) => self.decode_media(loc, footage), + } + } + + fn decode_still(&self, loc: &Loc, footage: &Footage) -> Result { + let op = AlphaOp::of(footage, loc.float); + let path = &loc.key.path; + if &**path == PLACEHOLDER { + return Ok(colour_bars(footage.width, footage.height)); + } + let bytes = self.read(path)?; + if let Some(img) = crate::dpx::decode(path, &bytes)? { + return Ok(dynamic_to_image(&img, op)); + } + if let Some(img) = crate::layered::decode(path, &bytes, footage, op)? { + return Ok(img); + } + // (an OpenEXR compression the decoder lacks came out transparent or as a missing + // channel error) + if path.to_ascii_lowercase().ends_with(".exr") + && let Some(why) = crate::exr_channels::unsupported_compression(&bytes) + { + return Err(MediaError::Decode(format!("{path}: {why}"))); + } + let loaded = crate::probe::load_oriented(&bytes, crate::still_format(path, &bytes)); + let img = match loaded { + Ok(img) => img, + // A multi-layer OpenEXR file without an unnamed RGB layer: its colour layer. + Err(e) => crate::exr_channels::cached_layered_image(path, bytes).ok_or_else(|| MediaError::Decode(format!("{path}: {e}")))?, + }; + Ok(dynamic_to_image(&img, op)) + } + + fn decode_media(&self, loc: &Loc, footage: &Footage) -> Result { let op = AlphaOp::of(footage, loc.float); let path = &loc.key.path; match loc.media_t { - None if &**path == PLACEHOLDER => Ok(colour_bars(footage.width, footage.height)), - None => { - let bytes = self.read(path)?; - if let Some(img) = crate::dpx::decode(path, &bytes)? { - return Ok(dynamic_to_image(&img, op)); - } - if let Some(img) = crate::layered::decode(path, &bytes, footage, op)? { - return Ok(img); - } - // (an OpenEXR compression the decoder lacks came out transparent or as a missing - // channel error) - if path.to_ascii_lowercase().ends_with(".exr") - && let Some(why) = crate::exr_channels::unsupported_compression(&bytes) - { - return Err(MediaError::Decode(format!("{path}: {why}"))); - } - let loaded = crate::probe::load_oriented(&bytes, crate::still_format(path, &bytes)); - let img = match loaded { - Ok(img) => img, - // A multi-layer OpenEXR file without an unnamed RGB layer: its colour layer. - Err(e) => crate::exr_channels::cached_layered_image(path, bytes).ok_or_else(|| MediaError::Decode(format!("{path}: {e}")))?, - }; - Ok(dynamic_to_image(&img, op)) - } + None => self.decode_still(loc, footage), Some(mt) if gif_anim::is_animated(footage) => { let anim = self.gif(path)?; let frame = anim.frame_at(Tick(mt.0)).ok_or_else(|| MediaError::Decode(format!("{path}: no frames")))?; diff --git a/crates/media/tests/footage.rs b/crates/media/tests/footage.rs index 13ca605e2..9e68e3f6b 100644 --- a/crates/media/tests/footage.rs +++ b/crates/media/tests/footage.rs @@ -580,6 +580,32 @@ fn sequence_gaps_show_placeholders() { let _ = std::fs::remove_dir_all(&dir); } +/// #1812: a still asked for at a reduced size comes back as the renderer would resample it, and +/// is kept at that size: later frames at that size neither decode nor resample it again (a +/// parallax project's 21120-px plates were decoded and resampled for every frame at Quarter). +#[test] +fn stills_at_reduced_sizes_are_resampled_once() { + let dir = std::env::temp_dir().join(format!("ec-still-size-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("temp dir"); + let p = dir.join("plate.png"); + let px = |x: u32, y: u32| { + image::Rgba([(x * 7 % 256) as u8, (y * 13 % 256) as u8, ((x ^ y) % 256) as u8, if (x + y).is_multiple_of(5) { 0 } else { 255 - (x % 128) as u8 }]) + }; + image::RgbaImage::from_fn(613, 97, px).save(&p).expect("png"); + let f = probe(&p).expect("probe"); + let pool = MediaPool::new(); + let full = pool.frame(ItemId(1), &f, Tick::ZERO).expect("frame"); + for (w, h) in [(306, 48), (153, 24), (77, 12)] { + let sized = pool.frame_at_size(ItemId(1), &f, Tick::ZERO, w, h).expect("frame"); + assert_eq!(*sized, effectcraft_raster::resample(&full, w, h), "{w}×{h}: the renderer's resample"); + let misses = pool.stats().misses; + let again = pool.frame_at_size(ItemId(1), &f, Tick(3 * TICKS_PER_SECOND), w, h).expect("frame"); + assert!(Arc::ptr_eq(&sized, &again), "{w}×{h}: kept at that size"); + assert_eq!(pool.stats().misses, misses, "{w}×{h}: not decoded again"); + } + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn png_with_alpha() { let Some(p) = alpha_png() else { return }; From fd1250d224a5db35f7cb551667ce7bd6d7096145 Mon Sep 17 00:00:00 2001 From: ded Date: Tue, 20 Oct 2026 03:13:48 +0300 Subject: [PATCH 24/44] G5: still footage layers are kept in the layer cache across times (#1812) Footage layers had no layer cache key, so a still made a new layer buffer every frame on every preview worker, and the GPU compositor uploaded it again each time (its upload cache is keyed by buffer). Stills (without a proxy) are now keyed like text and shape layers, without the source time; movies and sequences are unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/cache.rs | 18 ++++++++++++++---- crates/render/src/tests_footage_size.rs | 25 +++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index bae12d1e3..3fabe473a 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -22,7 +22,7 @@ use std::hash::{Hash, Hasher}; use std::sync::{Arc, Mutex}; use effectcraft_effects::Buf; -use effectcraft_project::{ItemKind, Layer, LayerSource, Node, PropGroup}; +use effectcraft_project::{FootageKind, ItemKind, Layer, LayerSource, Node, PropGroup}; use crate::eval::EvalCtx; @@ -481,7 +481,7 @@ fn hash_values(h: &mut KeyHasher, ctx: &EvalCtx, layer: &Layer, g: &PropGroup) { } /// Cache key for the processed (source → masks → effects) buffer of `layer` at the context -/// time, or `None` when the layer must not be cached (footage, precomps, cameras, adjustment +/// time, or `None` when the layer must not be cached (movie footage, precomps, cameras, adjustment /// layers…). pub fn layer_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool) -> Option { // Time effects see property values at other times. Keyframes are part of the hashed @@ -514,6 +514,13 @@ fn reads_other_times(layer: &Layer) -> bool { }) } +/// A still image (no proxy): its layer is cached like a text or shape layer, so the GPU +/// compositor uploads it once, not every frame (#1812: four 21120 px plates re-uploaded by every +/// preview worker ran the GPU out of memory). Movies and sequences stay in the media pool only. +fn still_footage(ctx: &EvalCtx, item: effectcraft_project::ItemId) -> bool { + ctx.project.item(item).is_some_and(|it| it.proxy.is_none() && matches!(&it.kind, ItemKind::Footage(f) if f.kind == FootageKind::Still)) +} + fn has_expression(g: &PropGroup) -> bool { g.children.iter().any(|c| match c { Node::Prop(p) => p.has_expression(), @@ -552,14 +559,17 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo hash_debug(&mut h, s); } LayerSource::Text | LayerSource::Shape => {} - LayerSource::Footage { item } if footage => { + LayerSource::Footage { item } if footage || still_footage(ctx, *item) => { let it = ctx.project.item(*item)?; let ItemKind::Footage(f) = &it.kind else { return None }; hash_debug(&mut h, f); // A proxy (and its Use Proxy switch) changes the pixels. hash_debug(&mut h, &it.proxy); item.hash(&mut h); - ctx.source_time(layer).0.hash(&mut h); + // A still is the same at every time (unless a proxy, which may be a movie, stands in). + if f.kind != FootageKind::Still || it.proxy.is_some() { + ctx.source_time(layer).0.hash(&mut h); + } // Frame blending mixes neighbouring source frames. ctx.comp.enable_frame_blending.hash(&mut h); } diff --git a/crates/render/src/tests_footage_size.rs b/crates/render/src/tests_footage_size.rs index 88df726e9..065941d8a 100644 --- a/crates/render/src/tests_footage_size.rs +++ b/crates/render/src/tests_footage_size.rs @@ -81,3 +81,28 @@ fn reduced_renders_take_footage_resampled_by_the_source() { Renderer::new(&p, &sized, RenderOpts { scale: 0.5, ..Default::default() }).comp_frame(cid, Tick::ZERO); assert!(sized.sizes.unwrap().into_inner().unwrap().is_empty()); } + +/// #1812: a still's layer is kept in the layer cache across times (its texture then uploads to +/// the GPU once, not every frame); a movie's is fetched again at every time. +#[test] +fn still_layers_are_cached_across_times_and_movies_are_not() { + for (kind, fetches) in [(FootageKind::Still, 1), (FootageKind::Video, 3)] { + let (mut p, cid) = project(BitDepth::Bpc8, false); + let Some(LayerSource::Footage { item }) = p.comp(cid).map(|c| c.layers[0].source.clone()) else { panic!("footage layer") }; + if let Some(ItemKind::Footage(f)) = p.item_mut(item).map(|it| &mut it.kind) { + f.kind = kind; + } + let cache = crate::LayerCache::default(); + let src = Clip { sizes: Some(Mutex::default()) }; + let opts = RenderOpts { scale: 0.5, ..Default::default() }; + for t in [0.0, 0.25, 0.5] { + let t = Tick::from_seconds_f64(t); + let mut r = Renderer::new(&p, &src, opts); + r.cache = Some(&cache); + let frame = r.comp_frame(cid, t); + assert_eq!(frame, Renderer::new(&p, &src, opts).comp_frame(cid, t), "{kind:?} at {t:?}"); + } + // (each uncached render above fetches once more) + assert_eq!(src.sizes.unwrap().into_inner().unwrap().len(), fetches + 3, "{kind:?}"); + } +} From ff20f8ec41291aff48dc32e4c3921ffaaf0c73bf Mon Sep 17 00:00:00 2001 From: ded Date: Tue, 20 Oct 2026 03:34:33 +0300 Subject: [PATCH 25/44] G5: a playing preview prefetches past the frames it already cached The playback prefetch only looked ahead*3 frames (96 with 16 workers) for uncached frames. After a loop or a restart from the start those were all cached, so the workers stood idle until the playhead came within that window of the end of the cached run, then the preview stalled. A playing preview now looks as far ahead as the RAM preview holds (and the range spans) for the next uncached frames, still queuing at most ahead at a time; eviction already spares the frames the playhead reaches first (#595). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 14 +++++++-- crates/ui-egui/tests/ui_preview_align.rs | 39 ++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 72016cc53..541081af1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1238,8 +1238,16 @@ impl EffectcraftApp { } let step = plan.map_or(1, |p| p.step); // Never further ahead than the RAM preview holds: those frames would evict the ones the - // preview shows first, or be evicted before it gets there (#595). - let window = (ahead * 3).min(self.frames_that_fit(&c, scale) - 1); + // preview shows first, or be evicted before it gets there (#595). A playing preview + // looks that far for the next uncached frames: after a loop the frames just ahead are + // cached, and a shorter window left the workers idle until the playhead came near the + // end of the cached run. + let fit_ahead = self.frames_that_fit(&c, scale) - 1; + let window = match plan.filter(|_| self.playback.playing) { + Some(p) => fit_ahead.min((p.end - p.start) / p.step.max(1) + 1), + None => (ahead * 3).min(fit_ahead), + }; + let cached = self.frames.cached_frames(&series); for k in 0..window { if queued >= ahead as usize || !video { break; @@ -1257,7 +1265,7 @@ impl EffectcraftApp { f } }; - if !self.frames.is_cached(&FrameKey { frame: f, ..series }) { + if cached.binary_search(&f).is_err() { self.request_frame(cid, f, scale); queued += 1; } diff --git a/crates/ui-egui/tests/ui_preview_align.rs b/crates/ui-egui/tests/ui_preview_align.rs index e9b8d6c44..2b2ba1b44 100644 --- a/crates/ui-egui/tests/ui_preview_align.rs +++ b/crates/ui-egui/tests/ui_preview_align.rs @@ -259,6 +259,45 @@ fn cache_frames_when_idle_fills_the_work_area() { assert_eq!(n, 240, "the work area is cached while idle"); } +/// A preview that plays into frames it already cached (after a loop) keeps rendering the next +/// uncached ones: the prefetch window used to end within the cached run, and the workers stood +/// idle until the playhead came near its end. +#[test] +fn playback_prefetches_past_the_cached_frames_ahead() { + let mut h = harness(); + let cid = h.state().session.active_comp_id().unwrap(); + let series = h.state().shown_series(cid); + let scale = series.scale as f64 / 1000.0; + // The first 120 frames (5 s) are cached: more than the widest old window (16 workers × 6). + for f in 0..120 { + h.state().request_frame(cid, f, scale); + } + for _ in 0..4000 { + if h.state().frames.cached_frames(&series).len() >= 120 && h.state().frames.inflight() == 0 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + assert_eq!(h.state().frames.cached_frames(&series), (0..120).collect::>()); + h.state_mut().session.set_time(Tick::ZERO); + invoke(&mut h, "playback.toggle", json!({})); + h.run_steps(2); + assert!(h.state().playback.playing); + let cur = h.state().session.time(); + assert!(cur < Tick::from_seconds_f64(1.0), "{cur:?}"); + for _ in 0..4000 { + if h.state().frames.cached_frames(&series).iter().any(|&f| f >= 120) { + break; + } + h.step(); + std::thread::sleep(std::time::Duration::from_millis(1)); + if h.state().session.time() >= Tick::from_seconds_f64(1.0) { + break; + } + } + assert!(h.state().frames.cached_frames(&series).iter().any(|&f| f >= 120), "frames past the cached run render while it plays the first second"); +} + #[test] fn cache_frames_when_idle_survives_an_inverted_work_area() { let mut h = harness(); From c33d5c20df766508bea366c4cbe3754c7999741d Mon Sep 17 00:00:00 2001 From: ded Date: Tue, 20 Oct 2026 04:01:07 +0300 Subject: [PATCH 26/44] G5: a still asked for at its own size is the unsized frame (#1812) Review: frame_at_size at a still's native size made a second cache key, so the pool decoded and kept a second full-size copy of a frame thumbnail() or frame() already had. Co-Authored-By: Claude Opus 5.5 --- crates/media/src/pool.rs | 3 ++- crates/media/tests/footage.rs | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index d3b6da0d4..6bc4bd8dc 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -716,7 +716,8 @@ impl Inner { } let mut loc = Self::locate(footage, t, float); let movie = loc.media_t.is_some(); - if let Some(size) = size { + // A still asked for at its own size is the unsized frame (one decode, one entry). + if let Some(size) = size.filter(|s| movie || *s != (footage.width, footage.height)) { loc.key.size = size; } let mut c = lock(&self.cache); diff --git a/crates/media/tests/footage.rs b/crates/media/tests/footage.rs index 9e68e3f6b..2e882d73d 100644 --- a/crates/media/tests/footage.rs +++ b/crates/media/tests/footage.rs @@ -595,6 +595,11 @@ fn stills_at_reduced_sizes_are_resampled_once() { let f = probe(&p).expect("probe"); let pool = MediaPool::new(); let full = pool.frame(ItemId(1), &f, Tick::ZERO).expect("frame"); + // At its own size it is the unsized frame: no second decode, no second full-size copy. + let misses = pool.stats().misses; + let native = pool.frame_at_size(ItemId(1), &f, Tick::ZERO, f.width, f.height).expect("frame"); + assert!(Arc::ptr_eq(&full, &native), "the native size reuses the unsized frame"); + assert_eq!(pool.stats().misses, misses); for (w, h) in [(306, 48), (153, 24), (77, 12)] { let sized = pool.frame_at_size(ItemId(1), &f, Tick::ZERO, w, h).expect("frame"); assert_eq!(*sized, effectcraft_raster::resample(&full, w, h), "{w}×{h}: the renderer's resample"); From f41aa134a864dd0d02cfa95f4b8d0101eec7c163 Mon Sep 17 00:00:00 2001 From: SBaksa Date: Tue, 20 Oct 2026 12:09:16 +1100 Subject: [PATCH 27/44] Text Range Selector test: change Units through the Timeline popup (#1814) The Index-units test now opens the Units popup in the Timeline and picks Index, asserts the stored Units value, then reads the painted Start, End and Offset, so it also fails when the Units control doesn't update the session. --- crates/ui-egui/tests/ui_text_range_handles.rs | 38 +++++++++++++++++-- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/tests/ui_text_range_handles.rs b/crates/ui-egui/tests/ui_text_range_handles.rs index aab80100d..7ce301b37 100644 --- a/crates/ui-egui/tests/ui_text_range_handles.rs +++ b/crates/ui-egui/tests/ui_text_range_handles.rs @@ -209,8 +209,26 @@ fn timeline_value(h: &Harness<'_, EffectcraftApp>, uid: u64) -> String { .collect() } +/// The centre of the first painted text that reads `label` exactly (a popup's option). +fn text_at(h: &Harness<'_, EffectcraftApp>, label: &str) -> Option { + h.output().shapes.iter().find_map(|c| match &c.shape { + egui::Shape::Text(t) if t.galley.text() == label => Some(t.visual_bounding_rect().center()), + _ => None, + }) +} + +fn click_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) { + h.event(Event::PointerMoved(p)); + h.step(); + h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + /// #1814: with Units = Index the Timeline shows the Range Selector's Start, End and Offset as -/// counts, without the % sign they carry with Units = Percentage. +/// counts, without the % sign they carry with Units = Percentage. Units is changed the way a +/// user changes it: its Timeline popup, then the Index option. #[test] fn index_units_show_start_end_and_offset_without_a_percent_sign() { let (s, ids) = setup(); @@ -221,17 +239,29 @@ fn index_units_show_start_end_and_offset_without_a_percent_sign() { let selectors = anim.sub("selectors").unwrap(); let sel = selectors.groups().next().unwrap(); let (offset, units) = (sel.get("offset").unwrap().uid, sel.prop("advanced/units").unwrap().uid); + let advanced = sel.sub("advanced").unwrap().uid; let mut h = harness(s); let tl = &mut h.state_mut().ui.timeline; tl.open_layers.insert(ids.layer); let animators = text.sub("animators").unwrap().uid; - tl.open_groups.extend([text.uid, animators, anim.uid, selectors.uid, sel.uid]); + tl.open_groups.extend([text.uid, animators, anim.uid, selectors.uid, sel.uid, advanced]); h.run_steps(3); for uid in [ids.start, ids.end, offset] { assert!(timeline_value(&h, uid).ends_with('%'), "Percentage units: {}", timeline_value(&h, uid)); } - h.state_mut().session.execute("prop.set", json!({"layer": ids.layer, "prop": units, "value": 1})).unwrap(); - h.run_steps(3); + // Units ▸ Index through the Timeline: open its popup, pick the option. + let units_value = |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layer(l).unwrap().props.find(units).unwrap().value.clone(); + assert_eq!(units_value(&h), effectcraft_engine::keyframe::Value::Enum(0), "Percentage to start with"); + let field = h + .state() + .auto + .find(&format!("timeline.prop.{units}.value")) + .map(|e| pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)) + .expect("Units is shown"); + click_at(&mut h, field); + let index = text_at(&h, "Index").expect("the Units popup lists Index"); + click_at(&mut h, index); + assert_eq!(units_value(&h), effectcraft_engine::keyframe::Value::Enum(1), "the popup set Units to Index"); for uid in [ids.start, ids.end, offset] { let v = timeline_value(&h, uid); assert!(!v.is_empty() && !v.contains('%'), "Index units: {v:?}"); From ea1f2fe9840c5741aaa4d4f6dab37eca8c579a64 Mon Sep 17 00:00:00 2001 From: ded Date: Tue, 20 Oct 2026 04:21:14 +0300 Subject: [PATCH 28/44] G5: a still decoded larger than its nominal size is resampled like the renderer does (#1812) Review: still_at_size returned a smart object's pixels unresampled when they differed from the footage's nominal size. It now follows footage_buf: pixels larger than nominal are resampled to the requested size, smaller ones are left for the renderer to scale up. Co-Authored-By: Claude Opus 5.5 --- crates/media/src/pool.rs | 13 ++++++++----- crates/media/tests/footage.rs | 8 ++++++++ 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 6bc4bd8dc..062bdac54 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -774,13 +774,16 @@ impl Inner { #[cfg(target_arch = "wasm32")] fn prefetch(self: &Arc, _footage: &Footage, _t: Tick, _size: Option<(u32, u32)>, _float: FloatCurve) {} - /// A still (image file or sequence frame) of the footage's own size, at `loc.key.size` when - /// one is set: resampled as the renderer would ([`FootageSource::frame_at_size`]), so the - /// pool keeps the reduced frame and a reduced-resolution render neither decodes nor resamples - /// a large still again for every frame (#1812: 21120-px plates at Quarter). + /// A still (image file or sequence frame) at `loc.key.size` when one is set: resampled as the + /// renderer would ([`FootageSource::frame_at_size`]), so the pool keeps the reduced frame and a + /// reduced-resolution render neither decodes nor resamples a large still again for every frame + /// (#1812: 21120-px plates at Quarter). Like the renderer's `footage_buf`, an image decoded + /// larger than the footage's nominal size (a smart object's pixels) is resampled too, and one + /// decoded smaller is not (the renderer scales the layer instead). fn still_at_size(img: Image, loc: &Loc, footage: &Footage) -> Image { let (w, h) = loc.key.size; - if w == 0 || h == 0 || (img.width, img.height) != (footage.width, footage.height) || (w, h) == (img.width, img.height) { + let smaller = img.width == 0 || footage.width as f64 / img.width as f64 > 1.0 + 1e-9; + if w == 0 || h == 0 || smaller || (w, h) == (img.width, img.height) { return img; } effectcraft_raster::resample(&img, w, h) diff --git a/crates/media/tests/footage.rs b/crates/media/tests/footage.rs index 2e882d73d..0722e5d48 100644 --- a/crates/media/tests/footage.rs +++ b/crates/media/tests/footage.rs @@ -608,6 +608,14 @@ fn stills_at_reduced_sizes_are_resampled_once() { assert!(Arc::ptr_eq(&sized, &again), "{w}×{h}: kept at that size"); assert_eq!(pool.stats().misses, misses, "{w}×{h}: not decoded again"); } + // Pixels larger than the nominal size (a smart object's) are resampled as the renderer does; + // smaller ones are left as they are (the renderer scales the layer up instead). + let larger = Footage { width: 306, height: 48, ..f.clone() }; + let sized = MediaPool::new().frame_at_size(ItemId(1), &larger, Tick::ZERO, 153, 24).expect("frame"); + assert_eq!(*sized, effectcraft_raster::resample(&full, 153, 24)); + let smaller = Footage { width: 1226, height: 194, ..f.clone() }; + let kept = MediaPool::new().frame_at_size(ItemId(1), &smaller, Tick::ZERO, 306, 48).expect("frame"); + assert_eq!(*kept, *full); let _ = std::fs::remove_dir_all(&dir); } From e14cdd6009796c7ee56e82e8373c1d0ef0c896d8 Mon Sep 17 00:00:00 2001 From: ded Date: Tue, 20 Oct 2026 04:21:14 +0300 Subject: [PATCH 29/44] Layer cache: animated masks with mask motion blur key the layer time Review: mask motion blur draws an animated mask at the shutter's other times, but the layer key hashed its value at this time only. Two frames with the same mask value (a hold before a move) shared a cached layer, with the wrong blur. This applied to solids, text and shapes already, and to stills since this PR. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/cache.rs | 26 ++++++++++++++++++++++++++ crates/render/src/tests_mask_blur.rs | 25 +++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index 3fabe473a..17794d464 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -521,6 +521,27 @@ fn still_footage(ctx: &EvalCtx, item: effectcraft_project::ItemId) -> bool { ctx.project.item(item).is_some_and(|it| it.proxy.is_none() && matches!(&it.kind, ItemKind::Footage(f) if f.kind == FootageKind::Still)) } +/// Does the layer have an animated mask that motion blur may draw at other times (Layer ▸ Mask ▸ +/// Motion Blur On, or Same As Layer with the layer blurred)? +fn mask_motion_blur(ctx: &EvalCtx, layer: &Layer, blur: bool) -> bool { + use effectcraft_project::{GroupKind, MaskMotionBlur}; + fn animated(g: &PropGroup) -> bool { + g.children.iter().any(|c| match c { + Node::Prop(p) => !p.keys.is_empty() || p.has_expression(), + Node::Group(sub) => animated(sub), + }) + } + ctx.comp.enable_motion_blur + && layer.masks().is_some_and(|m| { + m.groups().any(|g| match g.kind { + GroupKind::Mask { motion_blur, .. } => { + (motion_blur == MaskMotionBlur::On || (motion_blur == MaskMotionBlur::SameAsLayer && blur)) && animated(g) + } + _ => false, + }) + }) +} + fn has_expression(g: &PropGroup) -> bool { g.children.iter().any(|c| match c { Node::Prop(p) => p.has_expression(), @@ -635,6 +656,11 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo effectcraft_effects::paint::cache_key(g, lt).0.hash(&mut h); } } + // Mask motion blur draws animated masks at the shutter's other times too: their values at + // this time don't tell two frames apart (a hold before a move). + if mask_motion_blur(ctx, layer, blur) { + layer.layer_time(ctx.time).0.hash(&mut h); + } if time_dependent(layer, effects != Some(0)) { layer.layer_time(ctx.time).0.hash(&mut h); // Effects also see the layer's time mapping. diff --git a/crates/render/src/tests_mask_blur.rs b/crates/render/src/tests_mask_blur.rs index 12b88bbba..07bc1e95f 100644 --- a/crates/render/src/tests_mask_blur.rs +++ b/crates/render/src/tests_mask_blur.rs @@ -41,6 +41,31 @@ fn alpha_at_25(p: &Project, cid: ItemId, motion_blur: bool) -> f32 { Renderer::new(p, &NoFootage, RenderOpts { motion_blur, ..Default::default() }).comp_frame(cid, half).get(25, 50)[3] } +/// A mask that holds from frame 0 to 2, then moves: frames 1 and 2 have the same mask value, but at +/// frame 2 the shutter already sees it move. The layer cache must not serve frame 1's layer there. +#[test] +fn mask_motion_blur_keys_the_layer_cache_by_time() { + let (mut p, cid) = project(true); + let rect = |x: f64| Value::Path(ShapePath::rect([x, 50.0], 20.0, 100.0)); + let l = &mut p.comp_mut(cid).unwrap().layers[0]; + let Some(Node::Group(m)) = l.props.sub_mut("masks").unwrap().children.first_mut() else { panic!("mask") }; + m.get_mut("path").unwrap().keys = vec![ + Keyframe::new(Tick::ZERO, rect(20.0)), + Keyframe::new(FrameRate::FPS_30.tick_of(2), rect(20.0)), + Keyframe::new(FrameRate::FPS_30.tick_of(3), rect(60.0)), + ]; + let cache = crate::LayerCache::default(); + let render = |t: Tick, cached: bool| { + let mut r = Renderer::new(&p, &NoFootage, RenderOpts { motion_blur: true, ..Default::default() }); + r.cache = cached.then_some(&cache); + r.comp_frame(cid, t) + }; + let (f1, f2) = (FrameRate::FPS_30.tick_of(1), FrameRate::FPS_30.tick_of(2)); + assert_ne!(render(f1, false), render(f2, false), "the shutter at frame 2 sees the move"); + render(f1, true); + assert_eq!(render(f2, true), render(f2, false)); +} + #[test] fn mask_motion_blur_follows_the_render_and_the_comp() { let (p, cid) = project(true); From 4cf6580b19539e02863bdf03b071a55698ee144c Mon Sep 17 00:00:00 2001 From: ded Date: Tue, 20 Oct 2026 10:42:22 +0300 Subject: [PATCH 30/44] G5: a reduced still's cache key includes its nominal size (#1812) Review: two footage items of one file with different nominal sizes could share a sized entry, though one is resampled and the other kept as decoded. Co-Authored-By: Claude Opus 5.5 --- crates/media/src/pool.rs | 10 ++++++++-- crates/media/tests/footage.rs | 7 ++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 062bdac54..09c9bb1d0 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -45,6 +45,9 @@ struct Key { matte: [u32; 3], /// A movie frame made at this size for a reduced-resolution render ((0, 0): as decoded). size: (u32, u32), + /// A still at a reduced `size`: the footage's nominal size, which decides whether it is + /// resampled (see `Inner::still_at_size`); (0, 0) otherwise. + nominal: (u32, u32), } struct Entry { @@ -678,7 +681,7 @@ impl Inner { FloatCurve::Gamma24 => 2, } << 2); let matte = if footage.alpha == AlphaMode::Premultiplied { footage.premul_color.map(f32::to_bits) } else { [0; 3] }; - let key = |path: &str, frame| Key { path: path.into(), frame, interp, matte, size: (0, 0) }; + let key = |path: &str, frame| Key { path: path.into(), frame, interp, matte, size: (0, 0), nominal: (0, 0) }; match footage.kind { FootageKind::Sequence if !footage.sequence.is_empty() => { let i = Self::frame_index(footage.frame_rate, t, footage.sequence_frames(), footage.loop_count); @@ -719,6 +722,9 @@ impl Inner { // A still asked for at its own size is the unsized frame (one decode, one entry). if let Some(size) = size.filter(|s| movie || *s != (footage.width, footage.height)) { loc.key.size = size; + if !movie { + loc.key.nominal = (footage.width, footage.height); + } } let mut c = lock(&self.cache); // sequential playback of a movie: read the next frame ahead @@ -1074,7 +1080,7 @@ mod tests { fn lru_respects_budget_and_recycles() { let mut c = Lru::default(); let img = || Arc::new(Image::new(16, 16)); // 4 KiB + overhead - let k = |i| Key { path: "a".into(), frame: i, interp: 0, matte: [0; 3], size: (0, 0) }; + let k = |i| Key { path: "a".into(), frame: i, interp: 0, matte: [0; 3], size: (0, 0), nominal: (0, 0) }; let budget = 3 * (4096 + 64); for i in 0..10 { c.insert(k(i), img(), budget); diff --git a/crates/media/tests/footage.rs b/crates/media/tests/footage.rs index 0722e5d48..f510109ea 100644 --- a/crates/media/tests/footage.rs +++ b/crates/media/tests/footage.rs @@ -610,11 +610,12 @@ fn stills_at_reduced_sizes_are_resampled_once() { } // Pixels larger than the nominal size (a smart object's) are resampled as the renderer does; // smaller ones are left as they are (the renderer scales the layer up instead). + // One pool, one file, one requested size: the two decisions don't share a cache entry. let larger = Footage { width: 306, height: 48, ..f.clone() }; - let sized = MediaPool::new().frame_at_size(ItemId(1), &larger, Tick::ZERO, 153, 24).expect("frame"); - assert_eq!(*sized, effectcraft_raster::resample(&full, 153, 24)); let smaller = Footage { width: 1226, height: 194, ..f.clone() }; - let kept = MediaPool::new().frame_at_size(ItemId(1), &smaller, Tick::ZERO, 306, 48).expect("frame"); + let sized = pool.frame_at_size(ItemId(2), &larger, Tick::ZERO, 153, 24).expect("frame"); + assert_eq!(*sized, effectcraft_raster::resample(&full, 153, 24)); + let kept = pool.frame_at_size(ItemId(3), &smaller, Tick::ZERO, 153, 24).expect("frame"); assert_eq!(*kept, *full); let _ = std::fs::remove_dir_all(&dir); } From ac5799bd260fe7539f1fd019a4f29ef49e8a2cf3 Mon Sep 17 00:00:00 2001 From: ded Date: Tue, 20 Oct 2026 11:43:30 +0300 Subject: [PATCH 31/44] =?UTF-8?q?G5:=20the=20playing=20prefetch=20window?= =?UTF-8?q?=20starts=20at=20the=20first=20uncached=20frame,=20ahead=20?= =?UTF-8?q?=C3=97=203=20wide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Looking up to the whole RAM preview for uncached frames let the workers spread over the preview while the playhead waited. On the GPU each worker then built a Classic 3D atlas for its own set of layers, and on a cold start of the #1812 project the device ran out of memory far more often (first pass 1-56 % of real time instead of 63-81 %). The window now keeps its old width but starts at the first frame ahead that isn't cached, so a loop or a restart still renders on at once. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 44 +++++++++++++++++++-------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 541081af1..2762188a0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1238,34 +1238,34 @@ impl EffectcraftApp { } let step = plan.map_or(1, |p| p.step); // Never further ahead than the RAM preview holds: those frames would evict the ones the - // preview shows first, or be evicted before it gets there (#595). A playing preview - // looks that far for the next uncached frames: after a loop the frames just ahead are - // cached, and a shorter window left the workers idle until the playhead came near the - // end of the cached run. + // preview shows first, or be evicted before it gets there (#595). The window starts at the + // first uncached frame ahead: after a loop or a restart the frames just ahead are cached, + // and a window from the playhead left the workers idle until it came near the end of the + // cached run. It stays ahead × 3 frames wide: workers spread over the whole preview render + // frames with different layers at once (on the GPU, an atlas each: #1812). let fit_ahead = self.frames_that_fit(&c, scale) - 1; - let window = match plan.filter(|_| self.playback.playing) { - Some(p) => fit_ahead.min((p.end - p.start) / p.step.max(1) + 1), - None => (ahead * 3).min(fit_ahead), + let frame = |k: i64| match plan { + Some(p) => p.frame_after(cur, k), + None => { + let f = cur + k; + Some(if f > wb { wa + (f - wb - 1).rem_euclid((wb - wa + 1).max(1)) } else { f }) + } }; let cached = self.frames.cached_frames(&series); - for k in 0..window { + let uncached = |f: i64| cached.binary_search(&f).is_err(); + let first = match plan.filter(|_| self.playback.playing) { + Some(p) => (0..fit_ahead.min((p.end - p.start) / p.step.max(1) + 1)) + .map_while(|k| frame(k).map(|f| (k, f))) + .find(|&(_, f)| uncached(f)) + .map_or(0, |(k, _)| k), + None => 0, + }; + for k in first..(first + ahead * 3).min(fit_ahead) { if queued >= ahead as usize || !video { break; } - let f = match plan { - Some(p) => match p.frame_after(cur, k) { - Some(f) => f, - None => break, - }, - None => { - let mut f = cur + k; - if f > wb { - f = wa + (f - wb - 1).rem_euclid((wb - wa + 1).max(1)); - } - f - } - }; - if cached.binary_search(&f).is_err() { + let Some(f) = frame(k) else { break }; + if uncached(f) { self.request_frame(cid, f, scale); queued += 1; } From 9c5494fd296acfecd0d29b55de4da25d882a220a Mon Sep 17 00:00:00 2001 From: ded Date: Tue, 20 Oct 2026 12:30:25 +0300 Subject: [PATCH 32/44] G5: a layer rendered by two workers at once is shared from the cache (#1812) Preview workers that start together miss the layer cache together, and each kept its own copy of every layer; on the GPU each copy was uploaded and packed into its own Classic 3D atlas, which ran the device out of memory on a cold start. LayerCache::insert now keeps the first buffer of a key and returns it, and the renderer goes on with the returned buffer, so the workers share one. Renders don't wait for each other: on the shared frame pool a blocking wait could end up beneath the work it waits for (tried, it stalled playback for the wait's timeout). Co-Authored-By: Claude Opus 5.5 --- crates/render/src/cache.rs | 53 +++++++++++++++++++++++++++++++------- crates/render/src/lib.rs | 39 +++++++++++++++++----------- crates/render/src/tests.rs | 29 +++++++++++++++++++++ 3 files changed, 96 insertions(+), 25 deletions(-) diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index bae12d1e3..26666c3ea 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -168,15 +168,23 @@ impl LayerCache { // Disk cache: a hit comes back into memory. let (dc, dk) = self.disk_key(key)?; let buf = dc.get_layer(dk)?; - self.insert_mem(key, buf.clone()); - Some(buf) + Some(self.insert_mem(key, buf)) } } } - pub fn insert(&self, key: u64, buf: Arc) { + /// Keep `buf` under `key`, and return the buffer the cache holds for it: the one kept first + /// when another render of the same layer got there before (#1812: preview workers starting + /// together each rendered every layer; sharing the first buffer, the GPU compositor uploads + /// and packs it once instead of once per worker, which ran it out of memory). Renders don't + /// wait for each other: on the frame workers' shared thread pool a wait can end up under + /// the very work it waits for. + pub fn insert(&self, key: u64, buf: Arc) -> Arc { if self.gated() { - return; + return buf; + } + if let Some(kept) = self.inner.lock().ok().and_then(|g| g.map.get(&key).map(|e| e.buf.clone())) { + return kept; } let started = self.inner.lock().ok().and_then(|mut g| g.missed_at.remove(&key)); if let Some((dc, dk)) = self.disk_key(key) { @@ -185,20 +193,24 @@ impl LayerCache { dc.put_layer(dk, &buf); } } - self.insert_mem(key, buf); + self.insert_mem(key, buf) } - fn insert_mem(&self, key: u64, buf: Arc) { + /// See [`LayerCache::insert`]: the buffer kept under `key` (`buf`, unless one was kept first + /// or `buf` is too large to keep). + fn insert_mem(&self, key: u64, buf: Arc) -> Arc { let bytes = buf.img.data.len() * std::mem::size_of::(); - let Ok(mut g) = self.inner.lock() else { return }; + let Ok(mut g) = self.inner.lock() else { return buf }; if bytes > g.budget / 4 { - return; + return buf; } g.clock += 1; let now = g.clock; - if let Some(old) = g.map.insert(key, Entry { buf, bytes, last_use: now }) { - g.bytes -= old.bytes; + if let Some(e) = g.map.get_mut(&key) { + e.last_use = now; + return e.buf.clone(); } + g.map.insert(key, Entry { buf: buf.clone(), bytes, last_use: now }); g.bytes += bytes; if g.bytes > g.budget { // Evict least-recently used entries down to 3/4 of the budget. @@ -214,6 +226,7 @@ impl LayerCache { } } } + buf } /// The memory budget in bytes. @@ -668,3 +681,23 @@ pub fn derive(key: u64, i: u64) -> u64 { i.hash(&mut h); h.finish() } + +#[cfg(test)] +mod shared_insert_tests { + use std::sync::Arc; + + use super::LayerCache; + use effectcraft_effects::Buf; + use effectcraft_raster::Image; + + /// Two renders of one layer kept under one key: both use the buffer kept first. + #[test] + fn the_buffer_kept_first_is_shared() { + let cache = LayerCache::default(); + let buf = || Arc::new(Buf { img: Image::new(4, 4), offset: [0.0; 2], scale: 1.0 }); + let (a, b) = (buf(), buf()); + assert!(Arc::ptr_eq(&cache.insert(3, a.clone()), &a)); + assert!(Arc::ptr_eq(&cache.insert(3, b), &a), "the later render gets the first buffer"); + assert!(Arc::ptr_eq(&cache.get(3).unwrap(), &a)); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index eccaed2f4..3876cb7ae 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1478,10 +1478,11 @@ impl<'a> Renderer<'a> { let mut nb = (*b).clone(); color::convert(&mut nb.img, &c); let nb = Arc::new(nb); - if let (Some(cache), Some(k)) = (self.cache_to_keep(), key) { - cache.insert(k, nb.clone()); + // The buffer the cache keeps (another worker's, if it kept one first). + match (self.cache_to_keep(), key) { + (Some(cache), Some(k)) => cache.insert(k, nb), + _ => nb, } - nb } fn layer_buf_keyed(&self, ctx: &EvalCtx, layer: &Layer) -> Option<(Arc, Option)> { @@ -1496,9 +1497,11 @@ impl<'a> Renderer<'a> { return Some((b, key)); } let flat = Arc::new(styles::Styled { passes: st.passes.iter().map(|(b, m)| ((**b).clone(), *m)).collect(), body: (*st.body).clone() }.flatten()); - if let (Some(c), Some(k)) = (self.cache_to_keep(), key) { - c.insert(k, flat.clone()); - } + // The buffer the cache keeps (another worker's, if it kept one first). + let flat = match (self.cache_to_keep(), key) { + (Some(c), Some(k)) => c.insert(k, flat), + _ => flat, + }; Some((flat, key)) } @@ -1527,9 +1530,11 @@ impl<'a> Renderer<'a> { } let buf = self.masked_source(ctx, layer)?; let buf = Arc::new(self.apply_effects_timed(ctx, layer, buf, false, usize::MAX, timing.map(|t| &mut t.effects))); - if let (Some(c), Some(k)) = (self.cache_to_keep(), key) { - c.insert(k, buf.clone()); - } + // The buffer the cache keeps (another worker's, if it kept one first). + let buf = match (self.cache_to_keep(), key) { + (Some(c), Some(k)) => c.insert(k, buf), + _ => buf, + }; Some((buf, key)) } @@ -1552,9 +1557,11 @@ impl<'a> Renderer<'a> { buf = self.effect_space_stack(ctx, layer, buf, false, effects, None, origin); } let buf = Arc::new(buf); - if let (Some(c), Some(k)) = (self.cache_to_keep(), key) { - c.insert(k, buf.clone()); - } + // The buffer the cache keeps (another worker's, if it kept one first). + let buf = match (self.cache_to_keep(), key) { + (Some(c), Some(k)) => c.insert(k, buf), + _ => buf, + }; Some(buf) } @@ -2215,9 +2222,11 @@ impl<'a> Renderer<'a> { let mut foot = self.source(ctx, layer)?; let _ = masks::apply(ctx, layer, &mut foot, self.mask_blur(ctx, layer)); let foot = Arc::new(foot); - if let (Some(c), Some(k)) = (self.cache_to_keep(), key) { - c.insert(k, foot.clone()); - } + // The buffer the cache keeps (another worker's, if it kept one first). + let foot = match (self.cache_to_keep(), key) { + (Some(c), Some(k)) => c.insert(k, foot), + _ => foot, + }; Some(foot) } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 72dd26507..f290e31ca 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1010,3 +1010,32 @@ fn a_selected_layer_reading_the_comp_scene_invalidates_its_owner() { let (a, b) = (particles_x(&here), particles_x(&panned)); assert!(b < a - 20.0, "the blended particles follow the camera: {a} -> {b}"); } + +/// #1812: preview workers that render a layer at once end up with one buffer (the one kept +/// first), so the GPU compositor uploads and packs it once, not once per worker. +#[test] +fn workers_rendering_a_layer_at_once_share_one_buffer() { + let (mut p, cid, comp) = setup(); + let mut l = solid(&mut p, &comp, [0.2, 0.5, 0.8], 1600, 1200); + add_effect(&mut p, &mut l, "ec.blur.gaussian", [1600.0, 1200.0], &[("blurriness", Value::Scalar(60.0))]); + p.comp_mut(cid).unwrap().layers.push(l); + let cache = crate::LayerCache::default(); + let start = std::sync::Barrier::new(8); + let bufs: Vec<_> = std::thread::scope(|s| { + let hs: Vec<_> = (0..8) + .map(|_| { + s.spawn(|| { + let mut r = crate::Renderer::new(&p, &crate::NoFootage, crate::RenderOpts::default()); + r.cache = Some(&cache); + let c = p.comp(cid).unwrap(); + let ctx = r.eval_ctx(cid, Tick::ZERO).unwrap(); + start.wait(); + r.layer_buf(&ctx, &c.layers[0]).unwrap() + }) + }) + .collect(); + hs.into_iter().map(|h| h.join().unwrap()).collect() + }); + assert!(bufs.iter().all(|b| std::sync::Arc::ptr_eq(b, &bufs[0])), "one shared buffer"); + assert_eq!(cache.stats().entries, 1); +} From 8d887fd6dad5948b5ffe47b87dbfedb57235b1c8 Mon Sep 17 00:00:00 2001 From: Camille Moine Date: Tue, 20 Oct 2026 12:18:46 +0200 Subject: [PATCH 33/44] Layer: replace a layer's source with Alt-drag and Ctrl+Alt+/ (#991, #813) Alt/Option-drag a Project item onto a Timeline layer, or press Ctrl+Alt+/ (Cmd+Opt+/) with layers and an item selected, to swap the layer's source. The layer keeps its name, timing, transforms, keyframes, expressions, effects, masks, switches, parenting and track matte; a source with audio adds Audio properties when missing. Precompositions can't be nested inside themselves. AVLayer.replaceSource now runs the same engine command and closes the replaceSource part of #813. --- crates/engine/src/commands/layer.rs | 110 ++++++++++++++- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_replace_source.rs | 158 ++++++++++++++++++++++ crates/script/src/prelude.js | 9 +- crates/script/src/tests.rs | 31 +++++ crates/ui-egui/src/panels/timeline.rs | 64 ++++++++- crates/ui-egui/tests/ui_drag_drop.rs | 41 ++++++ docs/footage.md | 9 ++ 8 files changed, 419 insertions(+), 5 deletions(-) create mode 100644 crates/engine/src/tests_replace_source.rs diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index b798493b1..4d55859fa 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -4,8 +4,8 @@ use effectcraft_color::{BlendMode, Label}; use effectcraft_keyframe::{Justify, TextDoc, Value as KV}; use effectcraft_project::build::{self, Ids}; use effectcraft_project::{ - Comp, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, MaskMode, MatteKind, Node, Project, PropGroup, Quality, Solid, TrackMatte, - limits, + Comp, FootageKind, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, MaskMode, MatteKind, Node, Project, PropGroup, Quality, Solid, + TrackMatte, limits, }; use effectcraft_time::Tick; use serde_json::{Value, json}; @@ -307,6 +307,103 @@ fn add_item(s: &mut Session, p: &Value) -> Result { Ok(json!({"layer": id.0})) } +/// A layer whose source is an item (footage, precomposition or solid), as in After Effects' +/// Replace Source. Text, shape, null, camera, light and 3D model layers have no AVItem source to +/// replace. +fn replaceable_layer(l: &Layer) -> bool { + matches!(l.source, LayerSource::Footage { .. } | LayerSource::Comp { .. } | LayerSource::Solid { .. }) +} + +/// The source a Project item gives a layer, or why it can't replace one. +fn replacement_source(item: ItemId, it: &effectcraft_project::Item) -> Result { + const CMD: &str = "layer.replaceSource"; + match &it.kind { + ItemKind::Comp(_) => Ok(LayerSource::Comp { item }), + ItemKind::Footage(f) if f.kind == FootageKind::Data => { + Err(bad(CMD, "data files can't be layer sources; read them in expressions with footage(\"name\").sourceData")) + } + ItemKind::Footage(f) if f.kind == FootageKind::Model => Err(bad(CMD, "3D model items can't replace a 2D layer source; add them as 3D layers")), + ItemKind::Footage(_) => Ok(LayerSource::Footage { item }), + ItemKind::Solid(_) => Ok(LayerSource::Solid { item }), + ItemKind::Folder => Err(bad(CMD, "folders can't be layer sources")), + } +} + +/// Whether a source brings audio properties (precompositions always can; footage when it has an +/// audio stream). +fn source_has_audio(project: &Project, source: &LayerSource) -> bool { + match source { + LayerSource::Comp { .. } => true, + LayerSource::Footage { item } => project.item(*item).is_some_and(|i| matches!(&i.kind, ItemKind::Footage(f) if f.has_audio)), + _ => false, + } +} + +fn has_replace_source(s: &Session) -> std::result::Result<(), String> { + has_comp(s)?; + let c = s.active_comp().ok_or("no composition")?; + if !s.state.selected_layers.iter().filter_map(|l| c.layer(*l)).any(replaceable_layer) { + return Err("select a footage, composition or solid layer".into()); + } + if s.state.project_selection.is_empty() { + return Err("select a footage item, a composition or a solid in the Project panel".into()); + } + Ok(()) +} + +/// Replace the source of the selected (or `layers`) AV layers with a Project item, as After +/// Effects' Alt-drag and Ctrl+Alt+/ do. The layer keeps every property: name, timing, transforms, +/// keyframes, expressions, effects, masks, switches, parenting and track matte. Only its source +/// changes; precompositions bring their Essential Properties group through `sync_project`, and a +/// source with audio adds an Audio group when the layer has none. +fn replace_source(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "layer.replaceSource"; + let (cid, ids) = layers_p(s, p)?; + let ids = unlocked(s, cid, ids, CMD)?; + if ids.is_empty() { + return Err(bad(CMD, "select a layer first")); + } + let item = match p.get("item") { + Some(Value::Number(n)) => ItemId(n.as_u64().ok_or_else(|| bad(CMD, "item: a project item id"))?), + Some(Value::String(name)) => s.project.find_by_name(name).map(|i| i.id).ok_or_else(|| bad(CMD, format!("no item `{name}`")))?, + _ => *s.state.project_selection.first().ok_or_else(|| bad(CMD, "select a footage item, a composition or a solid in the Project panel"))?, + }; + let it = s.project.item(item).ok_or_else(|| bad(CMD, format!("no item {}", item.0)))?; + let source = replacement_source(item, it)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + for lid in &ids { + let l = comp.layer(*lid).ok_or_else(|| bad(CMD, "no such layer"))?; + if !replaceable_layer(l) { + return Err(bad(CMD, format!("“{}” has no source item to replace", l.name))); + } + } + if let LayerSource::Comp { item: new } = &source + && s.project.comp_contains(*new, cid) + { + return Err(bad(CMD, "a composition can't contain itself")); + } + let has_audio = source_has_audio(&s.project, &source); + let source_for_edit = source.clone(); + s.edit("Replace Layer Source", None, |proj, _| { + for lid in &ids { + let needs_audio = has_audio && proj.comp(cid).and_then(|c| c.layer(*lid)).is_some_and(|l| l.props.sub("audio").is_none()); + let audio = if needs_audio { + let mut next = Ids(&mut proj.next_id); + Some(build::audio(&mut next)) + } else { + None + }; + let l = layer_mut(proj, cid, *lid)?; + l.source = source_for_edit.clone(); + if let Some(audio) = audio { + l.props.children.push(audio.into()); + } + } + Ok(()) + })?; + Ok(json!({"layers": ids.iter().map(|l| l.0).collect::>(), "item": item.0})) +} + fn select(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; @@ -1628,6 +1725,15 @@ pub fn specs() -> Vec { has_comp, add_item ), + cmd!( + "layer.replaceSource", + "Replace Layer Source", + [], + Some("Cmd+Alt+/"), + "{comp?, layers?, layer?, item?: id|name (default the Project panel selection), fixExpressions?: bool (accepted for After Effects compatibility)} → {layers, item}", + has_replace_source, + replace_source + ), cmd!("layer.select", "Select Layers", [], None, "{layers: [id|name|#n], add?, toggle?}", has_comp, select), cmd!("layer.selectNext", "Select Next Layer", [], Some("Cmd+ArrowDown"), "{add?}", has_comp, select_next), cmd!("layer.selectPrevious", "Select Previous Layer", [], Some("Cmd+ArrowUp"), "{add?}", has_comp, select_prev), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index cdfc7468e..96b3bf67d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1290,6 +1290,8 @@ mod tests_proxy; #[cfg(test)] mod tests_quick_offset; #[cfg(test)] +mod tests_replace_source; +#[cfg(test)] mod tests_rig3d; #[cfg(test)] mod tests_sequence; diff --git a/crates/engine/src/tests_replace_source.rs b/crates/engine/src/tests_replace_source.rs new file mode 100644 index 000000000..a5cb252ce --- /dev/null +++ b/crates/engine/src/tests_replace_source.rs @@ -0,0 +1,158 @@ +//! Layer ▸ Replace Source (#991, and the `replaceSource` part of #813): Alt-drag and Ctrl+Alt+/ +//! replace an AV layer's source while every other layer property stays. + +use std::sync::Arc; + +use effectcraft_color::Label; +use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, LayerSource, Solid}; +use serde_json::json; + +use crate::Session; + +fn comp() -> Session { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "frameRate": 30, "duration": 10})).unwrap(); + s +} + +fn layer(s: &Session, id: u64) -> effectcraft_project::Layer { + s.active_comp().unwrap().layer(effectcraft_project::LayerId(id)).unwrap().clone() +} + +fn solid(s: &mut Session, name: &str) -> u64 { + s.execute("layer.newSolid", json!({"name": name, "color": "#ff0000", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap() +} + +fn add_footage(s: &mut Session, name: &str, kind: FootageKind, width: u32, height: u32, has_audio: bool) -> ItemId { + let f = Footage { path: format!("/tmp/{name}"), kind, width, height, has_video: kind != FootageKind::Data, has_audio, ..Default::default() }; + Arc::make_mut(&mut s.project).add_item(name, Label::None, None, ItemKind::Footage(f)) +} + +/// The replacement changes `source` and nothing else: name, label, comment, timing, stretch, +/// switches, blending, parenting, track matte and the whole property tree (keys, expression, +/// effect, mask) stay. +#[test] +fn replace_source_keeps_every_layer_property() { + let mut s = comp(); + let a = solid(&mut s, "Hero"); + let parent = solid(&mut s, "Parent"); + let matte = solid(&mut s, "Matte"); + s.execute("layer.timing", json!({"layers": [a], "in": 1.0, "out": 5.0})).unwrap(); + s.execute("layer.timeStretch", json!({"layers": [a], "percent": 50})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": [a], "switch": "preserveTransparency", "value": true})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": [a], "switch": "motionBlur", "value": true})).unwrap(); + s.execute("layer.setBlendMode", json!({"layers": [a], "mode": "Multiply"})).unwrap(); + s.execute("layer.setParent", json!({"layers": [a], "parent": parent, "compensate": false})).unwrap(); + s.execute("layer.setTrackMatte", json!({"layer": a, "matte": matte, "kind": "alpha"})).unwrap(); + s.execute("layer.setComment", json!({"layers": [a], "comment": "keep me"})).unwrap(); + s.execute("effect.apply", json!({"layer": a, "effect": "Gaussian Blur"})).unwrap(); + s.execute("mask.new", json!({"layer": a, "vertices": [[10, 10], [90, 10], [90, 90]], "closed": true})).unwrap(); + s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": 1.0, "value": 25.0})).unwrap(); + s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": 2.0, "value": 75.0})).unwrap(); + s.execute("prop.setExpression", json!({"layer": a, "path": "transform/rotation", "expression": "time * 90"})).unwrap(); + let before = layer(&s, a); + let item = add_footage(&mut s, "new.png", FootageKind::Still, 120, 80, false); + s.execute("layer.replaceSource", json!({"layers": [a], "item": item.0})).unwrap(); + let after = layer(&s, a); + assert_eq!(after.source, LayerSource::Footage { item }); + assert_eq!(after.name, before.name); + assert_eq!(after.label, before.label); + assert_eq!(after.comment, before.comment); + assert_eq!((after.start_time, after.in_point, after.out_point, after.stretch), (before.start_time, before.in_point, before.out_point, before.stretch)); + assert_eq!((after.switches, after.blend_mode, after.preserve_transparency), (before.switches, before.blend_mode, before.preserve_transparency)); + assert_eq!((after.parent, after.track_matte), (before.parent, before.track_matte)); + assert_eq!(after.props, before.props); + assert_eq!(s.active_comp().unwrap().layers.len(), 3, "no layer is added or removed"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(layer(&s, a).source, before.source, "one undo step restores the source"); +} + +/// Composition, footage and solid sources are interchangeable; a precomposition adds its Audio +/// group when the layer has none, and an existing Audio group is kept when the next source has no +/// audio. +#[test] +fn replace_source_crosses_types_and_adds_audio() { + let mut s = comp(); + let a = solid(&mut s, "Layer"); + let inner = s.execute("comp.new", json!({"name": "Inner", "width": 200, "height": 100, "frameRate": 25, "duration": 3})).unwrap()["comp"].as_u64().unwrap(); + let main = s.project.items.values().find(|i| i.name == "Main").unwrap().id; + s.execute("comp.open", json!({"comp": main})).unwrap(); + let before = layer(&s, a); + s.execute("layer.replaceSource", json!({"layers": [a], "item": inner})).unwrap(); + let after = layer(&s, a); + assert_eq!(after.source, LayerSource::Comp { item: ItemId(inner) }); + assert_eq!(after.name, before.name); + assert!(after.props.sub("audio").is_some(), "a precomposition brings Audio"); + let footage = add_footage(&mut s, "still.png", FootageKind::Still, 64, 32, false); + s.execute("layer.replaceSource", json!({"layers": [a], "item": footage.0})).unwrap(); + let after = layer(&s, a); + assert_eq!(after.source, LayerSource::Footage { item: footage }); + assert!(after.props.sub("audio").is_some(), "the Audio group is preserved for the next source"); + let new_solid = Arc::make_mut(&mut s.project).add_item( + "New Solid", + Label::None, + None, + ItemKind::Solid(Solid { color: [0.0, 1.0, 0.0], width: 10, height: 10, pixel_aspect: 1.0 }), + ); + s.execute("layer.replaceSource", json!({"layers": [a], "item": new_solid.0})).unwrap(); + assert_eq!(layer(&s, a).source, LayerSource::Solid { item: new_solid }); + assert_eq!(s.active_comp().unwrap().layers.len(), 1); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(layer(&s, a).source, LayerSource::Footage { item: footage }); +} + +/// Folders, data files, 3D models, non-source layers and composition cycles are refused before +/// anything is edited. +#[test] +fn replace_source_refuses_invalid_sources_targets_and_cycles() { + let mut s = comp(); + let a = solid(&mut s, "Layer"); + let folder = Arc::make_mut(&mut s.project).add_item("Folder", Label::None, None, ItemKind::Folder); + let data = add_footage(&mut s, "data.txt", FootageKind::Data, 0, 0, false); + let model = add_footage(&mut s, "model.glb", FootageKind::Model, 10, 10, false); + for (item, needle) in [(folder, "folder"), (data, "data"), (model, "3D model")] { + let err = s.execute("layer.replaceSource", json!({"layers": [a], "item": item.0})).unwrap_err().to_string(); + assert!(err.contains(needle), "{err}"); + } + let footage = add_footage(&mut s, "still.png", FootageKind::Still, 10, 10, false); + let text = s.execute("layer.newText", json!({"text": "Hello"})).unwrap()["layer"].as_u64().unwrap(); + let err = s.execute("layer.replaceSource", json!({"layers": [text], "item": footage.0})).unwrap_err().to_string(); + assert!(err.contains("no source item"), "{err}"); + let inner = s.execute("comp.new", json!({"name": "Inner", "width": 100, "height": 100, "frameRate": 30, "duration": 2})).unwrap()["comp"].as_u64().unwrap(); + let main = s.project.items.values().find(|i| i.name == "Main").unwrap().id; + s.execute("comp.open", json!({"comp": main})).unwrap(); + let inner_layer = s.execute("layer.addItem", json!({"item": inner})).unwrap()["layer"].as_u64().unwrap(); + let err = s.execute("layer.replaceSource", json!({"layers": [inner_layer], "item": main.0})).unwrap_err().to_string(); + assert!(err.contains("contain"), "{err}"); +} + +/// Ctrl+Alt+/ takes the source from the Project panel selection and replaces every selected +/// layer; locked layers are left alone (and an all-locked selection is an error). +#[test] +fn replace_source_uses_the_project_selection_and_skips_locked_layers() { + let mut s = comp(); + let a = solid(&mut s, "A"); + let b = solid(&mut s, "B"); + let footage = add_footage(&mut s, "still.png", FootageKind::Still, 10, 10, false); + s.state.project_selection = vec![footage]; + let r = s.execute("layer.replaceSource", json!({"layers": [a, b]})).unwrap(); + assert_eq!(r["item"], json!(footage.0)); + assert!(matches!(layer(&s, a).source, LayerSource::Footage { item } if item == footage)); + assert!(matches!(layer(&s, b).source, LayerSource::Footage { item } if item == footage)); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": [b], "switch": "lock", "value": true})).unwrap(); + let old_b = layer(&s, b).source; + let r = s.execute("layer.replaceSource", json!({"layers": [a, b], "item": footage.0})).unwrap(); + assert_eq!(r["layers"], json!([a])); + assert!(matches!(layer(&s, a).source, LayerSource::Footage { item } if item == footage)); + assert_eq!(layer(&s, b).source, old_b); + let err = s.execute("layer.replaceSource", json!({"layers": [b], "item": footage.0})).unwrap_err().to_string(); + assert!(err.contains("locked"), "{err}"); +} + +/// After Effects' Ctrl+Alt+/ default (Cmd+Alt+/ in EffectCraft's shortcut syntax). +#[test] +fn replace_source_is_bound_to_ctrl_alt_slash() { + let b = crate::shortcuts::bindables(&[]).into_iter().find(|b| b.command == "layer.replaceSource").expect("layer.replaceSource is bindable"); + assert!(b.defaults.iter().any(|d| d == "Cmd+Alt+/"), "{:?}", b.defaults); +} diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index 2eeca86bf..ba5a3af52 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -1301,7 +1301,14 @@ AVLayer.prototype.constructor = AVLayer; } P.sourcePointToComp = function (p) { return pointIn(this, p, true); }; P.compPointToSource = function (p) { return pointIn(this, p, false); }; - P.replaceSource = function () { throw __err("replaceSource is not supported yet"); }; + // Replace the layer's source item, keeping every layer property (After Effects' + // AVLayer.replaceSource). `fixExpressions` is accepted for compatibility: only AV sources are + // replaced, so the property tree and the layer's name stay and expressions keep resolving. + P.replaceSource = function (newSource, fixExpressions) { + if (!newSource || newSource.__id === undefined) throw __err("replaceSource needs a project item"); + __call("layer.replaceSource", { comp: this.__comp, layers: [this.__id], item: newSource.__id, fixExpressions: !!fixExpressions }); + return null; + }; P.openInViewer = function () { __call("layer.openLayer", this.__p1()); return null; }; })(); diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 6f87f7572..0e1e509c3 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -963,6 +963,37 @@ fn footage_proxies_sequences_and_conform_rate() { let _ = std::fs::remove_dir_all(&dir); } +/// #813: `AVLayer.replaceSource` changes the source item and keeps the layer's name, timing and +/// properties; a folder is not a source. +#[test] +fn av_layer_replace_source_keeps_the_layer() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var main = app.project.items.addComp("Main", 100, 100, 1, 5, 25); + var layer = main.layers.addSolid([1, 0, 0], "Old", 100, 100, 1); + layer.transform.position.setValue([10, 20]); + layer.startTime = 1; + var nested = app.project.items.addComp("Nested", 200, 100, 1, 3, 25); + layer.replaceSource(nested, false); + [layer.source.name, layer.source.width, layer.source.height, layer.source instanceof CompItem, + layer.name, layer.transform.position.value[0], layer.startTime, main.numLayers] + "#, + ); + assert_eq!(o.result, json!(["Nested", 200, 100, true, "Old", 10, 1, 1])); + let bad = run_code( + &mut s, + r#" + var main = app.project.items.addComp("Other", 100, 100, 1, 2, 25); + var layer = main.layers.addSolid([1, 0, 0], "Layer", 100, 100, 1); + layer.replaceSource(app.project.items.addFolder("F"), false); + "#, + "replace-source.jsx", + ); + assert!(bad.error.as_ref().is_some_and(|e| e.message.contains("folder")), "{bad:?}"); +} + /// A flat green footage source, so `sampleImage` on a footage layer has known pixels. struct Flat; impl effectcraft_engine::render::FootageSource for Flat { diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index b34fbd9d3..5a7f40d3b 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -8,7 +8,7 @@ use effectcraft_engine::color::BlendMode; use effectcraft_engine::keyframe::{Interp, Value}; -use effectcraft_engine::project::{Comp, GroupKind, ItemId, Layer, LayerId, LayerSource, MatteKind, Node, ParamUi, PropGroup, Property}; +use effectcraft_engine::project::{Comp, FootageKind, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, MatteKind, Node, ParamUi, PropGroup, Property}; use effectcraft_engine::render::EvalCtx; use effectcraft_engine::time::Tick; use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; @@ -1382,6 +1382,26 @@ fn layer_drop_id() -> egui::Id { egui::Id::new("tl-layer-drop") } +/// Whether a Project item can replace a layer's source (the same set `layer.replaceSource` +/// accepts): compositions, footage and solids; not folders, data files or 3D models. +fn replaceable_item(it: &effectcraft_engine::project::Item) -> bool { + match &it.kind { + ItemKind::Comp(_) | ItemKind::Solid(_) => true, + ItemKind::Footage(f) => !matches!(f.kind, FootageKind::Data | FootageKind::Model), + ItemKind::Folder => false, + } +} + +/// The Alt/Option-drag target: the layer under the pointer can take this item as its source, and +/// the item wouldn't put the composition inside itself. +fn replace_source_target(app: &EffectcraftApp, cid: ItemId, item: ItemId, lid: LayerId, comp: &Comp) -> bool { + app.session.project.item(item).is_some_and(replaceable_item) + && !app.session.project.comp_contains(item, cid) + && comp + .layer(lid) + .is_some_and(|l| !l.switches.locked && matches!(l.source, LayerSource::Footage { .. } | LayerSource::Comp { .. } | LayerSource::Solid { .. })) +} + pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let ctx = ui.ctx().clone(); @@ -3356,7 +3376,27 @@ 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 matches!(payload.as_ref(), crate::panels::DragPayload::Effect(_) | crate::panels::DragPayload::Preset { .. }) { + // Alt/Option-drag from the Project panel replaces the source of the layer under the + // pointer (After Effects). The shortcut replaces every selected layer instead. + let alt_item = ctx.input(|i| i.modifiers.alt) && matches!(payload.as_ref(), crate::panels::DragPayload::Item(_)); + if alt_item { + if let crate::panels::DragPayload::Item(id) = payload.as_ref() { + let target = hit_rows.iter().find(|(r, _)| ptr.y >= r.min.y && ptr.y < r.max.y).map(|(_, row)| row.layer); + if let Some(l) = target + && replace_source_target(app, cid, ItemId(*id), l, &comp) + && let Some(span) = hit_rows.iter().filter(|(_, row)| row.layer == l).map(|(r, _)| *r).reduce(|a, b| a.union(b)) + { + let lit = span.intersect(rows_rect); + let rp = p.with_clip_rect(rows_rect); + rp.rect_filled(lit, 0.0, t.accent.gamma_multiply(0.18)); + rp.rect_stroke(lit, 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside); + app.auto.add("timeline.replaceSourceTarget", lit, "Replace the layer's source"); + if released { + actions.push(("layer.replaceSource".into(), json!({"layers": [l.0], "item": *id, "fixExpressions": false}))); + } + } + } + } else if matches!(payload.as_ref(), crate::panels::DragPayload::Effect(_) | crate::panels::DragPayload::Preset { .. }) { let target = hit_rows.iter().find(|(r, _)| ptr.y >= r.min.y && ptr.y < r.max.y).map(|(_, row)| row.layer); // The layer the effect would land on (its row and its open properties) is the one // that lights up, not the whole timeline. @@ -4264,6 +4304,26 @@ fn value_editor( mod tests { use super::*; + /// Alt/Option-drag takes the same Project items `layer.replaceSource` does: compositions, + /// footage and solids, never folders, data files or 3D models. + #[test] + fn replace_source_takes_visual_items_only() { + use effectcraft_engine::color::Label; + use effectcraft_engine::project::{Comp, Footage, Project, Solid}; + use effectcraft_engine::time::{FrameRate, Tick}; + let mut p = Project::default(); + let l = Label::None; + let comp = p.add_item("C", l, None, ItemKind::Comp(Comp::new(10, 10, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)).into())); + let folder = p.add_item("F", l, None, ItemKind::Folder); + let still = p.add_item("S", l, None, ItemKind::Footage(Footage { kind: FootageKind::Still, ..Default::default() })); + let data = p.add_item("D", l, None, ItemKind::Footage(Footage { kind: FootageKind::Data, ..Default::default() })); + let model = p.add_item("M", l, None, ItemKind::Footage(Footage { kind: FootageKind::Model, ..Default::default() })); + let solid = p.add_item("So", l, None, ItemKind::Solid(Solid { color: [0.0; 3], width: 10, height: 10, pixel_aspect: 1.0 })); + for (id, ok) in [(comp, true), (still, true), (solid, true), (folder, false), (data, false), (model, false)] { + assert_eq!(replaceable_item(p.item(id).unwrap()), ok, "{id:?}"); + } + } + /// Alt/Cmd-click twirls Audio's Waveform row (not in the property tree) with the rest. #[test] fn the_waveform_row_twirls_with_its_tree() { diff --git a/crates/ui-egui/tests/ui_drag_drop.rs b/crates/ui-egui/tests/ui_drag_drop.rs index 9b66f745c..e405bf777 100644 --- a/crates/ui-egui/tests/ui_drag_drop.rs +++ b/crates/ui-egui/tests/ui_drag_drop.rs @@ -108,6 +108,47 @@ fn project_items_land_where_they_are_dropped_in_the_timeline() { assert!((in_point(&h, 3) - 1.0).abs() < 1e-9, "Shift starts it at the current time"); } +/// Alt/Option-drag from the Project panel replaces the source of the layer under the pointer +/// instead of adding a layer (#991). +#[test] +fn alt_dragging_a_project_item_replaces_the_layer_source() { + let (mut h, clip) = harness(); + let top = layer_id(&h, "Top"); + let item = rect(&h, &format!("project.item.{clip}.name")).center(); + let row = rect(&h, &format!("timeline.layer.{top}.row")); + let before = stack(&h); + drag(&mut h, item, row.center(), Modifiers::ALT); + assert_eq!(stack(&h), before, "no layer is added"); + let layer = h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(top)).unwrap(); + assert_eq!(layer.source, effectcraft_engine::project::LayerSource::Comp { item: effectcraft_engine::project::ItemId(clip) }); + assert_eq!(layer.name, "Top", "the layer keeps its name"); +} + +/// Ctrl+Alt+/ (Cmd+Opt+/ on macOS) replaces every selected layer's source with the Project panel +/// selection (#991). +#[test] +fn ctrl_alt_slash_replaces_every_selected_layer_source() { + let (mut h, clip) = harness(); + let top = layer_id(&h, "Top"); + let middle = layer_id(&h, "Middle"); + h.state_mut().session.execute("layer.select", json!({"layers": [top, middle]})).unwrap(); + h.state_mut().session.state.project_selection = vec![effectcraft_engine::project::ItemId(clip)]; + h.run_steps(3); + let modifiers = Modifiers { command: true, alt: true, ..Default::default() }; + for pressed in [true, false] { + h.event(Event::Key { key: egui::Key::Slash, physical_key: Some(egui::Key::Slash), pressed, repeat: false, modifiers }); + } + h.run_steps(3); + let comp = h.state().session.active_comp().unwrap(); + for lid in [top, middle] { + assert_eq!( + comp.layer(effectcraft_engine::project::LayerId(lid)).unwrap().source, + effectcraft_engine::project::LayerSource::Comp { item: effectcraft_engine::project::ItemId(clip) } + ); + } + assert_eq!(stack(&h), ["Top", "Middle", "Bottom"], "no layer is added"); +} + /// #85: a Project item dropped on the Composition viewer becomes a layer centred where it was /// dropped (above the selected layer, like any new layer). #[test] diff --git a/docs/footage.md b/docs/footage.md index 84d4c49e6..e142fc4a1 100644 --- a/docs/footage.md +++ b/docs/footage.md @@ -110,6 +110,15 @@ vectors, positions and IDs are never the picture. EXtractoR reads every other la 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. +- **Replace a layer's source** without rebuilding the layer: Alt-drag (Option-drag on macOS) a + footage item, composition or solid from the Project panel onto a layer in the Timeline. The + layer keeps its name, timing, transforms, keyframes, expressions, effects, masks, switches, + parenting and track matte; only its source changes. A source with audio adds Audio properties + when the layer has none. To replace several selected layers with the selected Project item, press + Ctrl+Alt+/ (Cmd+Opt+/). Compositions, footage and solids are interchangeable; folders, data files + and 3D models are not sources, and a composition can't be nested in itself. Alt-dragging onto a + layer is one layer at a time, as in After Effects. + Agents: `layer.replaceSource {"item": id, "layers": [ids]}`. - **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 c7eb48dc3aebc3c768b03eb9fe19212b382dc4c3 Mon Sep 17 00:00:00 2001 From: Camille Moine Date: Tue, 20 Oct 2026 13:59:01 +0200 Subject: [PATCH 34/44] Layer: reveal layer sources in the Project panel (#1809) --- crates/engine/src/commands/layer_menu.rs | 30 +++++++++++---- crates/engine/src/tests_menu_cmds.rs | 31 +++++++++++++++ crates/ui-egui/src/menus.rs | 12 ++++++ crates/ui-egui/src/panels/project.rs | 34 ++++++++++++++++- crates/ui-egui/src/panels/timeline.rs | 25 +++++++++--- crates/ui-egui/src/panels/viewer.rs | 1 + .../ui-egui/tests/ui_blank_context_menus.rs | 38 +++++++++++++++++++ docs/footage.md | 10 +++++ 8 files changed, 168 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/commands/layer_menu.rs b/crates/engine/src/commands/layer_menu.rs index 9cac80412..cdc1dae41 100644 --- a/crates/engine/src/commands/layer_menu.rs +++ b/crates/engine/src/commands/layer_menu.rs @@ -754,17 +754,33 @@ fn reveal_in_finder(s: &mut Session, p: &Value) -> Result { } fn reveal_source(s: &mut Session, p: &Value) -> Result { - let item = source_item(s, p, "layer.revealSource")?; - s.state.project_selection = vec![item]; - frontend(s, "window.panel", &json!({"panel": "project"}))?; - Ok(json!({"item": item.0})) + let (cid, ids) = layers_p(s, p)?; + let mut items: Vec = Vec::new(); + { + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + for layer in ids.iter().filter_map(|id| comp.layer(*id)) { + if let Some(item) = layer.source.item() + && s.project.item(item).is_some() + && !items.contains(&item) + { + items.push(item); + } + } + } + if items.is_empty() { + return Err(bad("layer.revealSource", "the selected layers have no source item")); + } + s.state.project_selection = items.clone(); + let raw: Vec = items.iter().map(|item| item.0).collect(); + frontend(s, "project.reveal", &json!({"items": raw}))?; + Ok(json!({"item": items[0].0, "items": raw})) } fn reveal_comp(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; s.state.project_selection = vec![cid]; - frontend(s, "window.panel", &json!({"panel": "project"}))?; - Ok(json!({"item": cid.0})) + frontend(s, "project.reveal", &json!({"items": [cid.0]}))?; + Ok(json!({"item": cid.0, "items": [cid.0]})) } impl Session { @@ -909,7 +925,7 @@ pub fn specs() -> Vec { cmd!("layer.trackMatte", "Track Matte", [], None, "{layers?, op: none|alpha|alphaInverted|luma|lumaInverted|above|below}", has_layers, track_matte_op), cmd!("layer.openSource", "Open Layer Source", ["Layer"], None, "{layer?}", has_source_item, open_source), cmd!("layer.revealInFinder", "Reveal in Finder", ["Layer"], None, "{layer?}", has_footage_layer, reveal_in_finder), - cmd!("layer.revealSource", "Reveal Layer Source in Project", ["Layer", "Reveal"], None, "{layer?}", has_source_item, reveal_source), + cmd!("layer.revealSource", "Reveal Layer Source in Project", ["Layer", "Reveal"], None, "{layers?}", has_source_item, reveal_source), cmd!("comp.revealInProject", "Reveal Composition in Project", ["Layer", "Reveal"], None, "{comp?}", has_comp, reveal_comp), cmd!("layer.revealExpressionErrors", "Reveal Expression Errors", ["Layer", "Reveal"], None, "{comp?}", has_comp, reveal_expression_errors), cmd!( diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index 6da07dd40..fd19300d0 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -725,6 +725,37 @@ fn reveal_properties() { assert!(frontend_events(&mut s).iter().all(|(c, _)| c == "timeline.revealProps")); } +#[test] +fn reveal_layer_source_selects_each_layers_source_item() { + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + let b = solid(&mut s, "#00ff00"); + let ia = layer(&s, a).source.item().unwrap(); + let ib = layer(&s, b).source.item().unwrap(); + assert_ne!(ia, ib); + s.execute("layer.select", json!({"layers": [a, b]})).unwrap(); + let r = s.execute("layer.revealSource", json!({})).unwrap(); + assert_eq!(r["item"], ia.0); + assert_eq!(r["items"], json!([ia.0, ib.0])); + assert_eq!(s.state.project_selection, vec![ia, ib]); + assert_eq!(frontend_events(&mut s), vec![("project.reveal".to_string(), json!({"items": [ia.0, ib.0]}))]); + // A null has no source. Alone it is refused; in a mixed selection only the sources are revealed. + let n = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.select", json!({"layers": [n]})).unwrap(); + assert!(s.execute("layer.revealSource", json!({})).is_err()); + s.execute("layer.select", json!({"layers": [n, a]})).unwrap(); + let r = s.execute("layer.revealSource", json!({})).unwrap(); + assert_eq!(r["items"], json!([ia.0])); + assert_eq!(s.state.project_selection, vec![ia]); + // Reveal Composition in Project opens the same panel and folders. + let cid = s.active_comp_id().unwrap(); + s.drain_events(); + let r = s.execute("comp.revealInProject", json!({"comp": cid.0})).unwrap(); + assert_eq!(r["item"], cid.0); + assert_eq!(s.state.project_selection, vec![cid]); + assert_eq!(frontend_events(&mut s), vec![("project.reveal".to_string(), json!({"items": [cid.0]}))]); +} + #[test] fn sequence_layers() { let mut s = comp(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 92893dcb6..36a85aef5 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -939,6 +939,18 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu } // View ▸ New Viewer. "view.newViewer" => json!({"viewer": crate::panels::viewers::new_viewer(app)}), + // Reveal Layer Source / Reveal Composition: show the Project panel on the item, opening + // its folders and clearing the search so the row is visible. + "project.reveal" => { + let ids: Vec = p + .get("items") + .and_then(Value::as_array) + .map(|items| items.iter().filter_map(Value::as_u64).map(effectcraft_engine::project::ItemId).collect()) + .or_else(|| p.get("item").and_then(Value::as_u64).map(|item| vec![effectcraft_engine::project::ItemId(item)])) + .unwrap_or_default(); + crate::panels::project::reveal(app, ctx, &ids); + json!({"items": ids.iter().map(|item| item.0).collect::>()}) + } // Run by id (control channel, MCP): the Project panel's selection, whatever has focus. "project.deleteWithoutConfirmation" => crate::panels::delete_items::delete_confirmed(app, ctx, json!({}))?, // A gradient property's "Edit Gradient…" (the Timeline's value): the Gradient Editor. diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index c4e9894c4..ef34798b6 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -224,6 +224,11 @@ fn focused_row_id() -> egui::Id { egui::Id::new("project-focused-row") } +/// The item whose selection the panel last scrolled into view. +fn selection_seen_id() -> egui::Id { + egui::Id::new("proj-sel-seen") +} + /// The focused widget is one of the panel's rows, or nothing has keyboard focus: the panel's keys /// apply. Any other focused widget (a text field, a button reached with Tab) keeps them. fn keys_reach_rows(app: &EffectcraftApp, ctx: &egui::Context) -> bool { @@ -347,6 +352,33 @@ pub(crate) fn visible_rows(app: &EffectcraftApp) -> Vec<(ItemId, usize)> { rows } +/// Bring `items` into view: clear the panel's search, open every folder holding them, select +/// them, and show and focus the Project panel. Used by Reveal Layer Source in Project and +/// Reveal Composition in Project, so the item is visible even in a large, filtered project. +pub(crate) fn reveal(app: &mut EffectcraftApp, ctx: &egui::Context, items: &[ItemId]) { + let items: Vec = items.iter().copied().filter(|item| app.session.project.item(*item).is_some()).collect(); + if items.is_empty() { + return; + } + for item in &items { + let mut parent = app.session.project.item(*item).and_then(|item| item.parent); + let mut depth = 0; + while let Some(folder) = parent { + app.ui.project_open_folders.insert(folder.0); + parent = app.session.project.item(folder).and_then(|item| item.parent); + depth += 1; + if depth > 256 { + break; + } + } + } + app.ui.project_search.clear(); + app.session.state.project_selection = items; + // Force the panel's selection scroll on its next draw, even when the item was already selected. + ctx.data_mut(|d| d.remove_temp::>(selection_seen_id())); + app.show_panel(crate::dock::PanelKind::Project); +} + pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let p = ui.painter().clone(); @@ -562,7 +594,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } // A selection made elsewhere (a command, an agent, the viewer) scrolls into view. - let sel_key = egui::Id::new("proj-sel-seen"); + let sel_key = selection_seen_id(); let sel_now = app.session.state.project_selection.last().map(|i| i.0); if ctx.data(|d| d.get_temp::>(sel_key)) != Some(sel_now) { ctx.data_mut(|d| d.insert_temp(sel_key, sel_now)); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index b34fbd9d3..44a24b140 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -3883,18 +3883,25 @@ fn collapse_applies(layer: &Layer, project: &effectcraft_engine::project::Projec } } -/// Which Create ▸ conversions the layer context menu offers, from the targeted layer's kind. +/// Which Create ▸ conversions and Reveal ▸ actions the layer context menu offers, from the +/// targeted layer's kind. #[derive(Clone, Copy, Default)] pub(crate) struct LayerMenuCaps { /// SVG / PDF / AI / EPS footage: Create Shapes from Vector Layer. pub vector: bool, /// A text layer: Create Shapes from Text / Create Masks from Text. pub text: bool, + /// The layer has a source item to reveal in the Project panel. + pub source: bool, } impl LayerMenuCaps { pub(crate) fn of(project: &effectcraft_engine::project::Project, layer: &Layer) -> Self { - Self { vector: vector_footage_layer(project, layer), text: matches!(layer.source, LayerSource::Text) } + Self { + vector: vector_footage_layer(project, layer), + text: matches!(layer.source, LayerSource::Text), + source: layer.source.item().is_some_and(|item| project.item(item).is_some()), + } } } @@ -3916,6 +3923,10 @@ 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] }; + // The menu acts on the whole selection: offer Reveal when any target has a source, even if + // the layer under the pointer is a text, shape or null layer. + let any_source = targets.iter().any(|id| comp.layer(LayerId(*id)).is_some_and(|l| l.source.item().is_some())); + let caps = LayerMenuCaps { source: caps.source || any_source, ..caps }; crate::widgets::context_menu(resp, |ui| layer_menu(ui, &targets, caps, (auto, "timeline.layerMenu"), actions)); } @@ -3951,6 +3962,9 @@ pub(crate) fn layer_menu( item(ui, "Adjustment Layer", "layer.newAdjustment", json!({})); }); item(ui, "Layer Settings…", "layer.settings", json!({})); + if caps.source { + item(ui, "Reveal Layer Source in Project", "layer.revealSource", json!({"layers": layers})); + } if caps.vector { item(ui, "Create Shapes from Vector Layer", "layer.create", json!({"op": "shapesFromVector", "layers": layers})); } @@ -4656,20 +4670,21 @@ mod tests { /// the categories the Effect menu does; the actions it records stay the same. #[test] fn the_layer_context_menu_draws_in_the_interface_language() { - let caps = LayerMenuCaps { vector: true, text: false }; + let caps = LayerMenuCaps { vector: true, text: false, source: true }; let (mut auto, mut actions) = (crate::automation::Registry::default(), Vec::new()); crate::i18n::set_current("zh-hans"); let menu = drawn_text(|ui| layer_menu(ui, &[7], caps, (&mut auto, "t"), &mut actions)); let effects = drawn_text(|ui| effects_submenu(ui, &[7], &mut |_: &mut egui::Ui, _: &str, _: &str, _: serde_json::Value| {})); crate::i18n::set_current("en"); let english = drawn_text(|ui| layer_menu(ui, &[7], caps, (&mut auto, "t"), &mut actions)); - for label in ["新建", "图层设置...", "从矢量图层创建形状", "预合成...", "重复", "拆分图层", "删除"] { + for label in ["新建", "图层设置...", "在项目中显示图层源", "从矢量图层创建形状", "预合成...", "重复", "拆分图层", "删除"] + { assert!(menu.iter().any(|t| t == label), "{label} missing from {menu:?}"); } assert!(!menu.iter().any(|t| t.is_ascii()), "English left in {menu:?}"); assert!(effects.iter().any(|t| t == "模糊和锐化"), "{effects:?}"); assert!(!effects.iter().any(|t| t == "Blur & Sharpen"), "{effects:?}"); - for label in ["Layer Settings…", "Duplicate", "Delete"] { + for label in ["Layer Settings…", "Reveal Layer Source in Project", "Duplicate", "Delete"] { assert!(english.iter().any(|t| t == label), "{label} missing from {english:?}"); } assert!(actions.is_empty()); diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 984ad696f..61e7336f9 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1311,6 +1311,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let c = super::timeline::LayerMenuCaps::of(&app.session.project, layer); caps.vector |= c.vector; caps.text |= c.text; + caps.source |= c.source; caps }, ); diff --git a/crates/ui-egui/tests/ui_blank_context_menus.rs b/crates/ui-egui/tests/ui_blank_context_menus.rs index 20f7467cc..9e7363dbc 100644 --- a/crates/ui-egui/tests/ui_blank_context_menus.rs +++ b/crates/ui-egui/tests/ui_blank_context_menus.rs @@ -1,5 +1,6 @@ //! Right-click actions in the blank Project and Timeline areas, and on Timeline property names. use effectcraft_engine::Session; +use effectcraft_engine::project::ItemKind; use effectcraft_ui_egui::{Dialog, EffectcraftApp, dock::PanelKind}; use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2}; use egui_kittest::{Harness, kittest::Queryable as _}; @@ -299,6 +300,43 @@ fn timeline_layer_context_menu_has_layer_styles_submenu() { assert!(l.layer_styles().is_some_and(|g| g.sub("dropShadow").is_some())); } +/// #1809: Reveal Layer Source in Project shows the source in a large project: it clears the +/// Project panel's search, opens the folders holding the item and selects it. +#[test] +fn timeline_layer_context_menu_reveals_the_source_in_project() { + let mut h = harness(); + let layer = h.state_mut().session.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + let solid = h.state().session.project.items.values().find(|i| matches!(i.kind, ItemKind::Solid(_))).unwrap().id; + let outer = h.state_mut().session.execute("project.newFolder", json!({"name": "Assets"})).unwrap()["item"].as_u64().unwrap(); + let inner = h.state_mut().session.execute("project.newFolder", json!({"name": "Plates"})).unwrap()["item"].as_u64().unwrap(); + h.state_mut().session.execute("project.move", json!({"items": [inner], "folder": outer})).unwrap(); + h.state_mut().session.execute("project.move", json!({"items": [solid.0], "folder": inner})).unwrap(); + h.state_mut().session.execute("project.select", json!({"items": []})).unwrap(); + h.state_mut().ui.project_open_folders.clear(); + h.state_mut().ui.project_search = "no such item".into(); + h.run_steps(3); + assert!(h.state().auto.find(&format!("project.item.{}", solid.0)).is_none(), "the source starts hidden"); + let r = rect(&h, &format!("timeline.layer.{layer}.row")); + right_click(&mut h, pos2(r.min.x + 210.0, r.center().y)); + h.get_by_label("Reveal Layer Source in Project").click(); + h.run_steps(3); + assert_eq!(h.state().session.state.project_selection, vec![solid]); + assert_eq!(h.state().ui.focused, PanelKind::Project); + assert!(h.state().ui.project_search.is_empty(), "the search is cleared"); + assert!([outer, inner].iter().all(|folder| h.state().ui.project_open_folders.contains(folder)), "the folders holding the source open"); + assert!(h.state().auto.find(&format!("project.item.{}", solid.0)).is_some(), "the source row is visible"); + // In a mixed selection the command still appears when the right-clicked layer has no source, + // because the menu acts on the solid layer too. + let shape = h.state_mut().session.execute("layer.newShape", json!({"kind": "none"})).unwrap()["layer"].as_u64().unwrap(); + h.state_mut().session.execute("layer.select", json!({"layers": [shape, layer]})).unwrap(); + h.run_steps(3); + let r = rect(&h, &format!("timeline.layer.{shape}.row")); + right_click(&mut h, pos2(r.min.x + 210.0, r.center().y)); + h.get_by_label("Reveal Layer Source in Project").click(); + h.run_steps(3); + assert_eq!(h.state().session.state.project_selection, vec![solid], "the solid's source, not the shape"); +} + /// #976: a layer's context menu has After Effects' Markers ▸ Add Marker, which marks the layer that /// was right-clicked (not the one selected before, and not the composition) at the current time. #[test] diff --git a/docs/footage.md b/docs/footage.md index 84d4c49e6..660f8ac3d 100644 --- a/docs/footage.md +++ b/docs/footage.md @@ -70,6 +70,16 @@ next to it. Agents: `file.importProject {"path": "/projects/titles.ecproj"}` returns the comps, the root comps, `primaryComp` (the only root comp, if there is one) and any missing media. +## Finding a layer's source in the Project panel + +Right-click a layer in the Timeline or Composition panel and choose **Reveal Layer Source in +Project** (also Layer ▸ Reveal ▸ Reveal Layer Source in Project). The Project panel opens, its +search is cleared, the folders holding the source open, and the source item is selected. + +Layers whose image is generated inside the composition — text, shape, null, camera, light and +3D primitive layers — have no Project item, so the command is not offered for them. Adjustment +layers do have one: the solid that backs them. + ## Interpret Footage Select a footage item in the Project panel and choose **File ▸ Interpret Footage ▸ Main…** From a33011047669688bce56eac117a1d2930496ee1d Mon Sep 17 00:00:00 2001 From: ded Date: Tue, 20 Oct 2026 16:12:22 +0300 Subject: [PATCH 35/44] G5: a styled layer rendered by two workers at once shares its body and passes (#1812) Review: styled() kept the buffers it rendered instead of the ones LayerCache::insert returned, so without a blending-space conversion concurrent misses still returned separate body and pass buffers (and the GPU uploaded each). Co-Authored-By: Claude Opus 5.5 --- crates/render/src/lib.rs | 11 ++++++----- crates/render/src/tests_styles.rs | 32 +++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 3876cb7ae..73b86e6b6 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1589,12 +1589,13 @@ impl<'a> Renderer<'a> { t.cached = false; t.effects.push(("layerStyles".into(), t0.elapsed().as_secs_f64() * 1e3)); } - let body = Arc::new(st.body); - let passes: Vec<_> = st.passes.into_iter().map(|(b, m)| (Arc::new(b), m)).collect(); + let mut body = Arc::new(st.body); + let mut passes: Vec<_> = st.passes.into_iter().map(|(b, m)| (Arc::new(b), m)).collect(); + // The buffers the cache keeps (another worker's, if it kept them first). if let (Some(c), Some(k)) = (self.cache_to_keep(), key) { - c.insert(k, body.clone()); - for (i, (b, _)) in passes.iter().enumerate() { - c.insert(cache::derive(k, i as u64 + 1), b.clone()); + body = c.insert(k, body); + for (i, (b, _)) in passes.iter_mut().enumerate() { + *b = c.insert(cache::derive(k, i as u64 + 1), b.clone()); } } Some(StyledLayer { passes, body, content, key, ckey, plain: false }) diff --git a/crates/render/src/tests_styles.rs b/crates/render/src/tests_styles.rs index 147ff1590..b59f009de 100644 --- a/crates/render/src/tests_styles.rs +++ b/crates/render/src/tests_styles.rs @@ -475,3 +475,35 @@ fn gradient_glow_uses_gradient() { let px = img.straight(121, 50); assert!(px[0] > px[2], "near the edge the glow takes the gradient's start colour {px:?}"); } + +/// #1812 (review of #1928): a styled layer rendered by several workers at once shares its body and +/// style passes, as unstyled layers share their buffer. +#[test] +fn workers_styling_a_layer_at_once_share_its_buffers() { + let (p, cid) = scene([1.0; 3], None, |p, c, l| { + add(p, c, l, "dropShadow"); + add(p, c, l, "stroke"); + }); + let cache = crate::LayerCache::default(); + let start = std::sync::Barrier::new(8); + let styled: Vec<_> = std::thread::scope(|s| { + let hs: Vec<_> = (0..8) + .map(|_| { + s.spawn(|| { + let mut r = Renderer::new(&p, &NoFootage, RenderOpts::default()); + r.cache = Some(&cache); + let ctx = r.eval_ctx(cid, Tick::ZERO).unwrap(); + start.wait(); + r.styled_layer(&ctx, &p.comp(cid).unwrap().layers[0]).unwrap() + }) + }) + .collect(); + hs.into_iter().map(|h| h.join().unwrap()).collect() + }); + let first = &styled[0]; + assert!(!first.passes.is_empty(), "the drop shadow is a pass"); + for st in &styled { + assert!(std::sync::Arc::ptr_eq(&st.body, &first.body), "one body"); + assert!(st.passes.iter().zip(&first.passes).all(|(a, b)| std::sync::Arc::ptr_eq(&a.0, &b.0)), "one set of passes"); + } +} From d4f06b5a9d6a9828b841315a03b1dcab8396e328 Mon Sep 17 00:00:00 2001 From: Yura Izmailov Date: Tue, 20 Oct 2026 20:43:14 +0200 Subject: [PATCH 36/44] UI-FONT: cover the Chinese interface catalog with system fallback faces --- crates/ui-egui/src/i18n.rs | 2 ++ crates/ui-egui/src/i18n/ui.rs | 7 +++++++ crates/ui-egui/src/theme.rs | 12 ++++++++++-- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 8b8b01b51..ce22433be 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -10,6 +10,8 @@ pub(crate) mod effects; /// Translations for the panels, tooltips and dialogs below the menu bar (see the module docs). mod ui; use effectcraft_engine::menus::{MenuEntry, MenuNode}; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use ui::catalog_characters; pub(crate) use ui::{set_current, tr, tr_args}; /// Every language with a catalog, in the order Settings ▸ General ▸ Language lists them. diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 434f2b79c..79a8c8a52 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -60,6 +60,13 @@ fn catalog(language: &str) -> Option<&'static Index> { Some(index.get_or_init(|| rows.iter().copied().collect())) } +/// Characters required by the fixed panel catalog, for system-font coverage selection. +/// Sorted and deduplicated so fallback registration is deterministic. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn catalog_characters(language: &str) -> Vec { + catalog(language).into_iter().flat_map(|c| c.values()).flat_map(|s| s.chars()).collect::>().into_iter().collect() +} + /// Translate one interface string: the current language's row for `text`, or `text` itself. pub(crate) fn tr(text: &str) -> &str { catalog(current()).and_then(|c| c.get(text)).copied().unwrap_or(text) diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index f5a0664c2..7f9018fc7 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -408,7 +408,14 @@ fn install_script_fallbacks(family_fonts: &mut FontDefinitions, language: &str) let cjk = if is_chinese(language) { [han, KANA] } else { [KANA, han] }; let mut names = Vec::new(); let mut seen = Vec::new(); - for &(probe, name) in cjk.iter().chain(SCRIPT_PROBES).chain(std::iter::once(&HANGUL)) { + // A font covering one Han probe may be a sparse subset. Register additional faces + // needed by the actual interface catalog instead of assuming whole-script coverage. + let extra = if is_chinese(language) { crate::i18n::catalog_characters(language) } else { Vec::new() }; + let extra: Vec<_> = extra.into_iter().filter(|ch| *ch >= '\u{2e80}').map(|ch| (ch, "catalog-system")).collect(); + for &(probe, name) in cjk.iter().chain(SCRIPT_PROBES).chain(std::iter::once(&HANGUL)).chain(&extra) { + if name == "catalog-system" && seen.iter().any(|&id| fonts::face(id).has_char(probe)) { + continue; + } let id = fonts::fallback_for(probe, base); if seen.contains(&id) { continue; @@ -422,7 +429,8 @@ fn install_script_fallbacks(family_fonts: &mut FontDefinitions, language: &str) // Use the already-read, parsed bytes rather than reading a font file twice. let mut data = FontData::from_owned(font.data().as_bytes().to_vec()); data.index = face.info.index; - family_fonts.font_data.insert(name.to_string(), Arc::new(data)); + let name = if name == "catalog-system" { format!("{name}-{:x}", probe as u32) } else { name.to_string() }; + family_fonts.font_data.insert(name.clone(), Arc::new(data)); for family in family_fonts.families.values_mut() { family.push(name.to_string()); } From b48a77cf70b5e97cc1dba644636511638558902f Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Wed, 21 Oct 2026 21:46:39 +0530 Subject: [PATCH 37/44] Preview: a zoom or resolution change evicts the other sizes' frames first `crates/ui-egui/src/frames.rs`: - The cache remembers the scale of the frame on screen (the active viewer's urgent request). Prefetch and passive viewers don't change it, so a second viewer at another zoom can't evict the active viewer's frames. - RAM and GPU eviction take frames of other scales first, then fall back to the existing playhead-aware order. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/frames.rs | 101 ++++++++++++++++++++++++++++------- 1 file changed, 81 insertions(+), 20 deletions(-) diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 059bc3a03..6a354e456 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -152,6 +152,8 @@ struct Cache { gpu_budget: usize, /// The playing preview, if any: evictions spare the frames it shows next. playhead: Option, + /// Scale of the frame on screen (the newest urgent request): frames of other scales go first. + scale: Option, } impl Cache { @@ -173,8 +175,14 @@ impl Cache { /// recently shown of the frames it doesn't reach, then the one it reaches last. Plain LRU /// evicted the frames just ahead of the playhead first (rendered a while ago, not shown yet) /// to keep the ones just shown, so a preview longer than the cache played into a gap that - /// prefetch kept refilling while it evicted the next frames (#595). + /// prefetch kept refilling while it evicted the next frames (#595). Frames of another scale + /// than the one on screen go first (a zoom or resolution change). fn victim(&self) -> Option { + if let Some(s) = self.scale + && let Some(i) = self.order.iter().position(|k| k.scale != s) + { + return Some(i); + } let Some(p) = &self.playhead else { return (!self.order.is_empty()).then_some(0) }; let mut farthest: Option<(i64, usize)> = None; for (i, k) in self.order.iter().enumerate() { @@ -186,10 +194,13 @@ impl Cache { } farthest.map(|(_, i)| i) } - /// Video memory: drop the oldest GPU frames past their own budget. + /// Video memory: drop the oldest GPU frames past their own budget, frames of another scale + /// than the one on screen first (a zoom or resolution change). fn evict_gpu(&mut self) { while self.gpu_bytes > self.gpu_budget { - let Some(i) = self.order.iter().position(|k| self.map.get(k).is_some_and(FrameImage::is_gpu)) else { break }; + let is_gpu = |k: &FrameKey| self.map.get(k).is_some_and(FrameImage::is_gpu); + let other_scale = self.order.iter().position(|k| Some(k.scale) != self.scale && is_gpu(k)); + let Some(i) = other_scale.or_else(|| self.order.iter().position(is_gpu)) else { break }; if let Some(old) = self.order.remove(i) { self.remove(&old); } @@ -462,7 +473,7 @@ impl Default for Frames { #[cfg(not(target_arch = "wasm32"))] let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(2, 16); Frames { - cache: Arc::new(Mutex::new(Cache { map: HashMap::new(), order: VecDeque::new(), bytes: 0, gpu_bytes: 0, budget: 3 << 30, gpu_budget: GPU_BUDGET, playhead: None })), + cache: Arc::new(Mutex::new(Cache { map: HashMap::new(), order: VecDeque::new(), bytes: 0, gpu_bytes: 0, budget: 3 << 30, gpu_budget: GPU_BUDGET, playhead: None, scale: None })), inflight: Arc::new(Mutex::new(HashSet::new())), queue: Arc::new(Mutex::new(Queue::default())), #[cfg(not(target_arch = "wasm32"))] @@ -893,7 +904,19 @@ impl Frames { } fn request_with(&self, src: &RenderSource, key: FrameKey, comp: ItemId, t: Tick, opts: RenderOpts, urgent: bool) { - if self.is_cached(&key) { + // The frame on screen sets the scale evictions keep: a prefetch or a passive viewer at + // another zoom must not evict the active viewer's frames. + let cached = self + .cache + .lock() + .map(|mut c| { + if urgent { + c.scale = Some(key.scale); + } + c.map.contains_key(&key) + }) + .unwrap_or(false); + if cached { return; } let Ok(mut q) = self.queue.lock() else { return }; @@ -1795,21 +1818,17 @@ mod tests { assert!(!f.is_cached(&key(1, 13, 0)) && f.is_cached(&key(1, 50, 0))); } - /// #595: prefetch renders the frames the preview reaches first, whatever order they were - /// asked for in (a frame evicted and asked for again waited behind every frame further - /// ahead); queued frames the preview no longer reaches are dropped and not left in flight. - #[test] - fn prefetch_renders_the_frames_the_playhead_reaches_first() { - struct Idle; - impl RemoteFrames for Idle { - fn slots(&self) -> usize { - 0 - } - fn start(&self, _: RemoteJob, _: RemoteDone) {} + /// A frame source that queues jobs and never renders them: the tests check the queue. + struct Idle; + impl RemoteFrames for Idle { + fn slots(&self) -> usize { + 0 } - let mut f = Frames::default(); - f.set_remote(Some(Arc::new(Idle))); - let src = RenderSource { + fn start(&self, _: RemoteJob, _: RemoteDone) {} + } + + fn idle_source() -> RenderSource { + RenderSource { project: Arc::default(), footage: Arc::new(effectcraft_engine::render::NoFootage), expr: None, @@ -1817,7 +1836,17 @@ mod tests { gpu: None, gpu_display: false, disk: None, - }; + } + } + + /// #595: prefetch renders the frames the preview reaches first, whatever order they were + /// asked for in (a frame evicted and asked for again waited behind every frame further + /// ahead); queued frames the preview no longer reaches are dropped and not left in flight. + #[test] + fn prefetch_renders_the_frames_the_playhead_reaches_first() { + let mut f = Frames::default(); + f.set_remote(Some(Arc::new(Idle))); + let src = idle_source(); for frame in [40, 30, 20, 5, 12, 11] { f.request(&src, key(1, frame, 0), ItemId(1), Tick::ZERO, RenderOpts::default()); } @@ -1834,6 +1863,38 @@ mod tests { assert_eq!(f.inflight(), 6, "frame 5, behind the playhead, is no longer in flight"); } + /// The cache is full. A zoom (or preview-resolution) change left the frames of the old + /// size to the plain least-recently-shown order, so the frames of the zoom the viewer is back + /// at went before the ones of the abandoned zoom. The other size's frames go first, then the + /// order is as before. + #[test] + fn a_size_change_evicts_frames_of_other_sizes_first() { + let mut f = Frames::default(); + f.set_remote(Some(Arc::new(Idle))); + f.set_budget(4 * 16); + let at = |frame, scale| FrameKey { scale, ..key(1, frame, 0) }; + // 100% (frames 0..=1) is cached, then 50% (2..=3): the 100% frames are the oldest. + for (frame, scale) in [(0, 1000), (1, 1000), (2, 500), (3, 500)] { + insert(&f, at(frame, scale)); + } + // A passive viewer at 50% asks for a frame: that doesn't change which size is kept. + f.request(&idle_source(), at(9, 500), ItemId(1), Tick::ZERO, RenderOpts::default()); + // Back at 100%: the viewer asks for frame 4. + f.request_urgent(&idle_source(), at(4, 1000), ItemId(1), Tick::ZERO, RenderOpts::default()); + f.request(&idle_source(), at(10, 500), ItemId(1), Tick::ZERO, RenderOpts::default()); + insert(&f, at(4, 1000)); + assert!( + f.is_cached(&at(0, 1000)) && f.is_cached(&at(1, 1000)) && !f.is_cached(&at(2, 500)) && f.is_cached(&at(3, 500)), + "the first frame of the old size goes, not the cached ones of the current size" + ); + insert(&f, at(5, 1000)); + assert!(!f.is_cached(&at(3, 500)), "then the last frame of the old size"); + assert_eq!(f.cached_frames(&at(0, 1000)), [0, 1, 4, 5]); + // No other size left: least recently shown first again. + insert(&f, at(6, 1000)); + assert_eq!(f.cached_frames(&at(0, 1000)), [1, 4, 5, 6]); + } + #[test] fn frames_are_keyed_by_content_and_series_are_kept_apart() { let f = Frames::default(); From 506a5b327f07c43cf1e61307022c37a9c6711059 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Wed, 21 Oct 2026 21:47:18 +0530 Subject: [PATCH 38/44] Timeline: the disk-cache bar checks only the visible frames - `Frames::disk_frames` takes a frame range instead of a frame count, still clamped to the existing 100,000-frame limit. - The Timeline passes the visible range (`disk_bar_range`), including frames only partly in view, and clips the blue bar to the work-area row. - Membership stays live: nothing is cached, so the bar updates as before when frames are written, evicted or promoted to RAM. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/frames.rs | 109 +++++++++++++++++++-- crates/ui-egui/src/panels/timeline.rs | 38 ++++++- crates/ui-egui/tests/ui_web_gpu_storage.rs | 2 +- 3 files changed, 139 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 059bc3a03..7e4036a64 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -758,7 +758,12 @@ impl Frames { /// Frames of the series of `base` in the disk cache but not in RAM — the blue cache bar. /// `opts` are the viewer's render options (part of the disk key). Native queries return /// no blue frames until the content key is ready; hashing never blocks timeline painting. - pub fn disk_frames(&self, src: &RenderSource, base: &FrameKey, frames: i64, opts: &RenderOpts) -> Vec { + pub fn disk_frames(&self, src: &RenderSource, base: &FrameKey, range: std::ops::Range, opts: &RenderOpts) -> Vec { + let start = range.start.clamp(0, 100_000); + let end = range.end.clamp(0, 100_000); + if start >= end { + return vec![]; + } let remote = self.remote.as_ref().filter(|r| r.disk()); let contains = |k: u128| match (&src.disk, remote) { (Some(dc), _) => dc.contains(disk_cache::Kind::Frame, k), @@ -781,10 +786,7 @@ impl Frames { let content = content_key(&self.content_keys, &src.project, base.revision, base.comp); let ram: HashSet = self.cached_frames(base).into_iter().collect(); let oh = opts_hash(opts); - (0..frames.clamp(0, 100_000)) - .filter(|f| !ram.contains(f)) - .filter(|f| contains(disk_cache::frame_key(content, *f, base.scale, base.view ^ oh))) - .collect() + (start..end).filter(|f| !ram.contains(f)).filter(|f| contains(disk_cache::frame_key(content, *f, base.scale, base.view ^ oh))).collect() } #[cfg(not(target_arch = "wasm32"))] @@ -1375,6 +1377,54 @@ impl Queue { mod tests { use super::*; + struct ProbeDisk { + keys: Mutex>, + probes: std::sync::atomic::AtomicUsize, + } + + impl RemoteFrames for ProbeDisk { + fn slots(&self) -> usize { + 0 + } + + fn disk(&self) -> bool { + true + } + + fn disk_contains(&self, key: u128) -> bool { + self.probes.fetch_add(1, Ordering::Relaxed); + self.keys.lock().unwrap().contains(&key) + } + + fn start(&self, _: RemoteJob, _: RemoteDone) {} + } + + fn disk_fixture(stored_frames: &[i64]) -> (Frames, RenderSource, FrameKey, RenderOpts, Arc, u128) { + let mut session = effectcraft_engine::Session::default(); + session.execute("comp.new", serde_json::json!({"width": 32, "height": 18, "duration": 1})).unwrap(); + let comp = session.active_comp_id().unwrap(); + let opts = RenderOpts::default(); + let content = 1234; + let mut base = key(1, 0, opts_hash(&opts)); + base.revision = 7; + base.comp = comp.0; + let disk = Arc::new(ProbeDisk { keys: Mutex::new(HashSet::new()), probes: 0.into() }); + disk.keys.lock().unwrap().extend(stored_frames.iter().map(|frame| disk_cache::frame_key(content, *frame, base.scale, base.view ^ opts_hash(&opts)))); + let mut frames = Frames::default(); + frames.set_remote(Some(disk.clone())); + frames.content_keys.lock().unwrap().insert((base.revision, base.comp), content); + let source = RenderSource { + project: session.project.clone(), + footage: session.footage.clone(), + expr: None, + layer_cache: Arc::default(), + gpu: None, + gpu_display: false, + disk: None, + }; + (frames, source, base, opts, disk, content) + } + #[cfg(not(target_arch = "wasm32"))] #[test] fn disk_indicator_does_not_hash_on_ui_thread_and_coalesces_edits() { @@ -1422,7 +1472,7 @@ mod tests { base.comp = cid.0; for revision in 1..50 { base.revision = revision; - assert!(frames.disk_frames(&src, &base, 30, &opts).is_empty()); + assert!(frames.disk_frames(&src, &base, 0..30, &opts).is_empty()); } assert!(frames.content_keys.lock().unwrap().is_empty(), "painting computed a content hash while the worker was blocked"); assert_eq!(frames.content_key_jobs.lock().unwrap().pending.len(), 1); @@ -1432,10 +1482,55 @@ mod tests { assert!(Instant::now() < until); std::thread::sleep(Duration::from_millis(1)); } - assert_eq!(frames.disk_frames(&src, &base, 30, &opts), vec![2], "the blue indicator must return once its key is ready"); + assert_eq!(frames.disk_frames(&src, &base, 0..30, &opts), vec![2], "the blue indicator must return once its key is ready"); assert_eq!(frames.content_keys.lock().unwrap().len(), 1, "superseded revisions were hashed"); } + #[test] + fn disk_indicator_range_tracks_live_membership() { + let (frames, src, base, opts, disk, _) = disk_fixture(&[10, 12]); + assert_eq!(frames.disk_frames(&src, &base, 10..13, &opts), vec![10, 12]); + assert_eq!(disk.probes.load(Ordering::Relaxed), 3); + + disk.keys.lock().unwrap().insert(disk_cache::frame_key(1234, 11, base.scale, base.view ^ opts_hash(&opts))); + assert_eq!(frames.disk_frames(&src, &base, 10..13, &opts), vec![10, 11, 12]); + + insert(&frames, FrameKey { frame: 11, ..base }); + disk.probes.store(0, Ordering::Relaxed); + assert_eq!(frames.disk_frames(&src, &base, 10..13, &opts), vec![10, 12]); + assert_eq!(disk.probes.load(Ordering::Relaxed), 2); + + disk.keys.lock().unwrap().remove(&disk_cache::frame_key(1234, 12, base.scale, base.view ^ opts_hash(&opts))); + assert_eq!(frames.disk_frames(&src, &base, 10..13, &opts), vec![10]); + + let mut revised = base; + revised.revision += 1; + frames.content_keys.lock().unwrap().insert((revised.revision, revised.comp), 5678); + assert!(frames.disk_frames(&src, &revised, 10..13, &opts).is_empty()); + + let changed = FrameKey { scale: base.scale + 1, ..base }; + assert!(frames.disk_frames(&src, &changed, 10..13, &opts).is_empty()); + let changed = FrameKey { view: base.view + 1, ..base }; + assert!(frames.disk_frames(&src, &changed, 10..13, &opts).is_empty()); + assert!(frames.disk_frames(&src, &base, 10..13, &RenderOpts { draft: true, ..opts }).is_empty()); + } + + #[test] + fn disk_indicator_range_is_bounded() { + let (frames, src, base, opts, disk, _) = disk_fixture(&[0, 1, 99_999]); + assert_eq!(frames.disk_frames(&src, &base, -5..2, &opts), vec![0, 1]); + assert_eq!(disk.probes.load(Ordering::Relaxed), 2); + disk.probes.store(0, Ordering::Relaxed); + assert_eq!(frames.disk_frames(&src, &base, 99_999..i64::MAX, &opts), vec![99_999]); + assert_eq!(disk.probes.load(Ordering::Relaxed), 1); + disk.probes.store(0, Ordering::Relaxed); + assert!(frames.disk_frames(&src, &base, 2..2, &opts).is_empty()); + // Written as a struct literal: `3..2` trips clippy::reversed_empty_ranges. + let reversed = std::ops::Range { start: 3, end: 2 }; + assert!(frames.disk_frames(&src, &base, reversed, &opts).is_empty()); + assert_eq!(disk.probes.load(Ordering::Relaxed), 0); + } + /// Frames larger than the texture limit are averaged down by a whole factor (#201: an /// 11000×2200 comp at Full resolution against an 8192 limit). #[test] diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index b34fbd9d3..6ba329e58 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -515,6 +515,19 @@ impl TMap { } } +fn disk_bar_range(tm: &TMap, clip: Rect, fd: f64, frames: i64) -> std::ops::Range { + if clip.width() <= 0.0 || clip.height() <= 0.0 || !fd.is_finite() || fd <= 0.0 || !tm.pps.is_finite() || tm.pps <= 0.0 { + return 0..0; + } + let start = tm.t(clip.min.x) / fd; + let end = tm.t(clip.max.x) / fd; + if !start.is_finite() || !end.is_finite() { + return 0..0; + } + let limit = frames.clamp(0, 100_000) as f64; + (start.clamp(0.0, limit).floor() as i64)..(end.clamp(0.0, limit).ceil() as i64) +} + /// A Timeline ruler tick label for comp frame `fno`: seconds:frames (`00:15f`, AE) or, when /// the project's Time Display Style is Frames, the frame number the current-time display shows /// (`00015`, counted from Start Numbering Frames At) (#710). @@ -1674,11 +1687,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if app.session.disk_cache.is_some() || app.frames.remote_disk() { let frames = comp.frame_rate.frame_at(comp.duration); let opts = app.frame_opts(cid, series.scale as f64 / 1000.0); - let disk = app.frames.disk_frames(&app.render_source(), &series, frames, &opts); + let clip = wa_row.intersect(p.clip_rect()); + let range = disk_bar_range(&tm, clip, fd, frames); + let disk = app.frames.disk_frames(&app.render_source(), &series, range, &opts); + let blue_painter = p.with_clip_rect(clip); let draw_blue = |a: i64, b: i64| { let x0 = tm.x(a as f64 * fd); let x1 = tm.x((b + 1) as f64 * fd); - p.rect_filled(Rect::from_min_max(pos2(x0, cy0), pos2(x1, cy0 + 2.5)), 0.0, t.cache_blue); + blue_painter.rect_filled(Rect::from_min_max(pos2(x0, cy0), pos2(x1, cy0 + 2.5)), 0.0, t.cache_blue); }; let mut run: Option<(i64, i64)> = None; for f in disk { @@ -4720,4 +4736,22 @@ mod tests { let sel = bar_color(red, true); assert!(sel.r() > bar.r()); } + + #[test] + fn disk_bar_range_covers_partial_frames() { + let clip = Rect::from_min_max(pos2(0.0, 0.0), pos2(2.0, 1.0)); + let tm = TMap { x0: 0.0, start: 10.25, pps: 1.0 }; + assert_eq!(disk_bar_range(&tm, clip, 1.0, 100), 10..13); + + let exact = TMap { start: 10.0, ..tm }; + assert_eq!(disk_bar_range(&exact, clip, 1.0, 100), 10..12); + let off_comp = TMap { start: 200.0, ..tm }; + assert_eq!(disk_bar_range(&off_comp, clip, 1.0, 100), 100..100); + assert_eq!(disk_bar_range(&tm, clip, f64::NAN, 100), 0..0); + assert_eq!(disk_bar_range(&TMap { pps: 0.0, ..tm }, clip, 1.0, 100), 0..0); + assert_eq!(disk_bar_range(&TMap { start: f64::NAN, ..tm }, clip, 1.0, 100), 0..0); + assert_eq!(disk_bar_range(&tm, Rect::from_min_max(pos2(2.0, 0.0), pos2(2.0, 1.0)), 1.0, 100), 0..0); + let above_limit = TMap { start: 100_001.0, ..tm }; + assert_eq!(disk_bar_range(&above_limit, clip, 1.0, i64::MAX), 100_000..100_000); + } } diff --git a/crates/ui-egui/tests/ui_web_gpu_storage.rs b/crates/ui-egui/tests/ui_web_gpu_storage.rs index d706a382b..9977858c9 100644 --- a/crates/ui-egui/tests/ui_web_gpu_storage.rs +++ b/crates/ui-egui/tests/ui_web_gpu_storage.rs @@ -172,7 +172,7 @@ fn gpu_worker_renders_effect_frames_and_the_disk_cache_survives_a_reload() { assert!(frames2.remote_disk()); let until = std::time::Instant::now() + std::time::Duration::from_secs(5); let bar = loop { - let bar = frames2.disk_frames(&source(&s, None), &key(fx), 30, &opts); + let bar = frames2.disk_frames(&source(&s, None), &key(fx), 0..30, &opts); if !bar.is_empty() { break bar; } From 7fe051389461305c9002fcd04ca6bbec789559c1 Mon Sep 17 00:00:00 2001 From: Madhav Agarwal Date: Thu, 22 Oct 2026 19:13:36 +0530 Subject: [PATCH 39/44] engine: split layers above the original by default (#2083) --- crates/engine/src/prefs.rs | 3 ++- crates/engine/src/tests_m145.rs | 12 ++++++------ docs/preferences.md | 3 ++- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index a2ce579ea..10f7418e7 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -110,7 +110,8 @@ page!(General { /// Synchronize Time of All Related Items. sync_time_related_items: bool = true, expression_pick_whip_compact: bool = true, - create_split_layers_above: bool = false, + /// Create Split Layers Above Original Layer (After Effects' default). + create_split_layers_above: bool = true, use_system_color_picker: bool = false, /// Number of projects in File ▸ Open Recent. recent_items: u32 = 10, diff --git a/crates/engine/src/tests_m145.rs b/crates/engine/src/tests_m145.rs index fe0a16936..68b897617 100644 --- a/crates/engine/src/tests_m145.rs +++ b/crates/engine/src/tests_m145.rs @@ -42,14 +42,14 @@ fn split_layers_go_above_or_below_the_original() { let mut s = session(); let a = solid(&mut s, "#ff0000"); s.execute("time.set", json!({"time": 1.0})).unwrap(); - let below = s.execute("edit.splitLayer", json!({"layers": [a]})).unwrap()[0].as_u64().unwrap(); - let order: Vec = s.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect(); - assert_eq!(order, vec![a, below], "off (default): the new part goes below"); - s.execute("edit.undo", json!({})).unwrap(); - s.execute("prefs.set", json!({"key": "general.createSplitLayersAbove", "value": true})).unwrap(); let above = s.execute("edit.splitLayer", json!({"layers": [a]})).unwrap()[0].as_u64().unwrap(); let order: Vec = s.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect(); - assert_eq!(order, vec![above, a]); + assert_eq!(order, vec![above, a], "on (default): the new part goes above"); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("prefs.set", json!({"key": "general.createSplitLayersAbove", "value": false})).unwrap(); + let below = s.execute("edit.splitLayer", json!({"layers": [a]})).unwrap()[0].as_u64().unwrap(); + let order: Vec = s.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect(); + assert_eq!(order, vec![a, below]); } #[test] diff --git a/docs/preferences.md b/docs/preferences.md index 8b86528a1..2c8f987ae 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -91,7 +91,8 @@ Commands (CLI, MCP, control channel): one and in the comps that nest it (through the precomp layers' timing) - `general.expressionPickWhipCompact`: off, the pick whip writes match names (`thisComp.layer("A")("transform")("opacity")`) -- `general.createSplitLayersAbove`: where Edit ▸ Split Layer puts the new layer +- `general.createSplitLayersAbove`: where Edit ▸ Split Layer puts the new layer (on by default, + as in After Effects) - `startup.showHomeOnOpenProject`: the Home screen comes up after File ▸ Open / Open Recent - `composition.motionPath`, `composition.motionPathSeconds`, `composition.motionPathKeyframes`: how much of a position motion path the viewer draws (all, none, N seconds or N keyframes From 3306bf229681e41c2593f49bac246626084e7134 Mon Sep 17 00:00:00 2001 From: Madhav Agarwal Date: Thu, 22 Oct 2026 19:15:34 +0530 Subject: [PATCH 40/44] ui: Spacebar hand pans timeline rows vertically (#2083) --- crates/ui-egui/src/panels/timeline.rs | 17 ++++--- crates/ui-egui/tests/ui_timeline_pan.rs | 61 +++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 6 deletions(-) create mode 100644 crates/ui-egui/tests/ui_timeline_pan.rs diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index b34fbd9d3..f79939c88 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -3330,8 +3330,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { super::markers_ui::comp_markers(app, ui, &comp, tm, mstrip, mrows); super::markers_ui::marker_bin(app, ui, &comp, tm, bin, mstrip); - // Spacebar held: the Hand tool. Dragging the ruler or the time graph scrolls it in time when - // zoomed in (registered last, so it takes the drag from the bars, keys and markers there). + // Spacebar held: the Hand tool. Dragging pans the rows vertically as well as scrolling the + // time graph in time when zoomed in (registered last, so it takes the drag from the bars, + // keys and markers there). if super::space_hand(&ctx) { let area = Rect::from_min_max(pos2(graph_x0, ruler.min.y), pos2(rect.max.x, rows_rect.max.y)); let hand = ui.interact(area, egui::Id::new("tl-hand"), Sense::drag()); @@ -3339,10 +3340,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if hand.hovered() || hand.dragged() { ctx.set_cursor_icon(if hand.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab }); } - if hand.dragged() && app.ui.timeline.pps.is_some() { - let start = app.ui.timeline.start; - let last = (comp.duration.seconds() - (tm.t(graph_x1) - tm.start)).max(start).max(0.0); - app.ui.timeline.start = (start - hand.drag_delta().x as f64 / pps).clamp(0.0, last); + if hand.dragged() { + let dy = ui.input(|i| i.pointer.delta().y); + app.ui.timeline.scroll_y = (app.ui.timeline.scroll_y - dy).clamp(0.0, max_scroll); + if app.ui.timeline.pps.is_some() { + let start = app.ui.timeline.start; + let last = (comp.duration.seconds() - (tm.t(graph_x1) - tm.start)).max(start).max(0.0); + app.ui.timeline.start = (start - hand.drag_delta().x as f64 / pps).clamp(0.0, last); + } } } diff --git a/crates/ui-egui/tests/ui_timeline_pan.rs b/crates/ui-egui/tests/ui_timeline_pan.rs new file mode 100644 index 000000000..8523ded30 --- /dev/null +++ b/crates/ui-egui/tests/ui_timeline_pan.rs @@ -0,0 +1,61 @@ +//! Spacebar-drag panning in the Timeline: the Hand tool scrolls the layer rows vertically as well +//! as the time graph horizontally (#2083). + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use egui::{Event, Modifiers, Pos2, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +/// A 10 s comp with 40 solids, enough rows to scroll. +fn harness() -> (Harness<'static, EffectcraftApp>, u64) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Many", "width": 320, "height": 180, "frameRate": 30, "duration": 10})).unwrap(); + let mut top = 0; + for i in 0..40 { + top = s.execute("layer.newSolid", json!({"name": format!("Layer {i}"), "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + } + s.execute("layer.select", json!({"layers": []})).unwrap(); + let app = EffectcraftApp::new(s); + let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).build_eframe(|_| app); + h.run_steps(4); + (h, top) +} + +fn space(h: &mut Harness<'_, EffectcraftApp>, pressed: bool) { + h.event(Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }); + h.step(); +} + +fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) { + h.event(Event::PointerMoved(from)); + h.step(); + h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=10 { + h.event(Event::PointerMoved(from + (to - from) * (k as f32 / 10.0))); + h.step(); + } + h.event(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +#[test] +fn spacebar_drag_pans_the_rows_vertically() { + let (mut h, layer) = harness(); + assert_eq!(h.state().ui.timeline.scroll_y, 0.0); + let bar = h.state().auto.find(&format!("timeline.layer.{layer}.bar")).expect("layer bar").rect; + let from = pos2(bar[0] + 40.0, bar[1] + bar[3] / 2.0); + let timing = |h: &Harness<'_, EffectcraftApp>| { + let l = &h.state().session.active_comp().unwrap().layers[0]; + (l.in_point.seconds(), l.out_point.seconds(), l.start_time.seconds()) + }; + let before = timing(&h); + space(&mut h, true); + drag(&mut h, from, from - vec2(0.0, 180.0)); + space(&mut h, false); + let y = h.state().ui.timeline.scroll_y; + assert!(y > 100.0, "the rows scrolled vertically: {y}"); + assert_eq!(h.state().ui.timeline.start, 0.0, "the time graph did not pan sideways"); + assert_eq!(timing(&h), before, "the layer under the hand did not move"); +} From a32048db4dc7a995201040ce896f538c62cad96b Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 21:31:57 +0000 Subject: [PATCH 41/44] Batch fix: fonts: read the CJK locale only for scripts with usual families (#1907) fallback_for read the locale (and the environment) on every call, including the layout hot path; it is computed lazily, only for a character of a script with usual families. --- crates/text/src/fonts.rs | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 688b0bc02..d9c54b1f7 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -924,23 +924,27 @@ fn family_faces_cache() -> &'static RwLock FaceId { - fallback_with_locale(c, prefer, CjkLocale::from_tag(&active_cjk_locale())) + fallback_with_locale(c, prefer, || CjkLocale::from_tag(&active_cjk_locale())) } /// Choose a fallback for an explicit interface language, independent of concurrent global /// language changes. Empty, `c` and `posix` use the OS environment rather than the global request. pub fn fallback_for_locale(c: char, prefer: FaceId, tag: &str) -> FaceId { let tag = tag.trim(); - let locale = if tag.is_empty() || tag.eq_ignore_ascii_case("c") || tag.eq_ignore_ascii_case("posix") { - CjkLocale::from_tag(&environment_cjk_locale()) - } else { - CjkLocale::from_tag(tag) + let locale = || { + if tag.is_empty() || tag.eq_ignore_ascii_case("c") || tag.eq_ignore_ascii_case("posix") { + CjkLocale::from_tag(&environment_cjk_locale()) + } else { + CjkLocale::from_tag(tag) + } }; fallback_with_locale(c, prefer, locale) } -/// Keep a single language snapshot through the family-order lookup and cache insertion. -fn fallback_with_locale(c: char, prefer: FaceId, locale: CjkLocale) -> FaceId { +/// Keep a single language snapshot through the family-order lookup and cache insertion. `locale` +/// is read only for a script with usual families: reading it takes a lock and may consult the +/// environment, and layout asks for a fallback for every character. +fn fallback_with_locale(c: char, prefer: FaceId, locale: impl FnOnce() -> CjkLocale) -> FaceId { let p = face(prefer); if p.has_char(c) || c.is_control() { return prefer; @@ -961,7 +965,7 @@ fn fallback_with_locale(c: char, prefer: FaceId, locale: CjkLocale) -> FaceId { if cfg!(not(target_arch = "wasm32")) && !system_scanned() { scan_system(); } - if let Some(&id) = family_faces(script, &p, locale).iter().find(|&&id| face(id).has_char(c)) { + if let Some(&id) = family_faces(script, &p, locale()).iter().find(|&&id| face(id).has_char(c)) { return id; } } From 3744adc310fa504340b2a44da528e07da6a5b7bd Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 21:32:24 +0000 Subject: [PATCH 42/44] Batch fix: interface fonts: skip catalog characters in blocks that already found no face (#1973) On a machine with no font covering the catalog, each uncovered 256-character block re-read every installed font file on the UI thread; blocks that already failed are skipped. --- crates/ui-egui/src/theme.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index eca8b2fb2..9f5faddbb 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -412,8 +412,12 @@ fn install_script_fallbacks(family_fonts: &mut FontDefinitions, language: &str) // needed by the actual interface catalog instead of assuming whole-script coverage. let extra = if is_chinese(language) { crate::i18n::catalog_characters(language) } else { Vec::new() }; let extra: Vec<_> = extra.into_iter().filter(|ch| *ch >= '\u{2e80}').map(|ch| (ch, "catalog-system")).collect(); + // 256-character blocks where a catalog character found no face: with nothing installed + // covering them, each lookup would read every installed font file again. + let mut uncovered_blocks = std::collections::HashSet::new(); for &(probe, name) in cjk.iter().chain(SCRIPT_PROBES).chain(std::iter::once(&HANGUL)).chain(&extra) { - if name == "catalog-system" && seen.iter().any(|&id| fonts::face(id).has_char(probe)) { + let catalog = name == "catalog-system"; + if catalog && (uncovered_blocks.contains(&(probe as u32 >> 8)) || seen.iter().any(|&id| fonts::face(id).has_char(probe))) { continue; } // The interface asks for the platform's UI face first; the order a composed frame @@ -427,13 +431,16 @@ fn install_script_fallbacks(family_fonts: &mut FontDefinitions, language: &str) (!r.missing && fonts::face(r.face).has_char(probe)).then_some(r.face) }) .unwrap_or_else(|| fonts::fallback_for_locale(probe, base, cjk_locale(language))); - if seen.contains(&id) { - continue; - } let face = fonts::face(id); if !face.has_char(probe) { + if catalog { + uncovered_blocks.insert(probe as u32 >> 8); + } continue; // nothing installed covers this script } + if seen.contains(&id) { + continue; + } let Some(font) = face.font() else { continue }; seen.push(id); // Use the already-read, parsed bytes rather than reading a font file twice. From eb89902c9c13830496794e78fb6bf237efab34c5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 21:49:37 +0000 Subject: [PATCH 43/44] Batch fix: web: Match System skips languages the bundled fonts can't draw (#1842) --- apps/effectcraft-web/src/app.rs | 2 +- crates/ui-egui/src/i18n.rs | 71 +++++++++++++++++++++++---------- 2 files changed, 52 insertions(+), 21 deletions(-) diff --git a/apps/effectcraft-web/src/app.rs b/apps/effectcraft-web/src/app.rs index 573c2db6c..3f462bd2a 100644 --- a/apps/effectcraft-web/src/app.rs +++ b/apps/effectcraft-web/src/app.rs @@ -188,7 +188,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { // 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()); + effectcraft_ui_egui::i18n::set_system_locales(query_value("lang"), 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 ff989170b..2c0cbb0f7 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -81,27 +81,44 @@ fn system_language() -> &'static str { LANGUAGE.get_or_init(|| supported(sys_locale::get_locale().as_deref())) } #[cfg(target_arch = "wasm32")] - first_supported(SYSTEM_LOCALES.get().map(Vec::as_slice).unwrap_or_default()) + { + let (requested, browser) = SYSTEM_LOCALES.get().map(|(r, b)| (r.as_deref(), b.as_slice())).unwrap_or_default(); + first_supported(requested, browser, web_draws) + } } -/// 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(); +/// The page's `?lang=`, then the browser's preferred languages, most preferred first +/// (`navigator.languages`), for Match System in the web build. +static SYSTEM_LOCALES: std::sync::OnceLock<(Option, Vec)> = 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); +/// Tell Match System the language the page asks for (`?lang=`) and the browser'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(requested: Option, browser: Vec) { + let _ = SYSTEM_LOCALES.set((requested, browser)); } -/// The first of `tags` EffectCraft has a catalog for (English counts), else English: someone who -/// prefers Dutch, then Czech, gets Czech. +/// `requested` when EffectCraft has a catalog for it, else the first of `browser` it has a +/// catalog for and `drawable` accepts (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) +fn first_supported(requested: Option<&str>, browser: &[String], drawable: impl Fn(&str) -> bool) -> &'static str { + let english = |t: &str| t.split(['-', '_']).next().is_some_and(|p| p.eq_ignore_ascii_case("en")); + requested + .map(|t| (t, true)) + .into_iter() + .chain(browser.iter().map(|t| (t.as_str(), false))) + .map(|(t, asked)| (t, asked, supported(Some(t)))) + .find(|(t, asked, code)| (*code != "en" && (*asked || drawable(code))) || english(t)) + .map_or("en", |(_, _, code)| code) +} + +/// Whether the web build can draw `language`'s interface. It installs no system fonts and its +/// bundled faces have no CJK or Georgian glyphs, so Match System skips those browser languages +/// rather than draw boxes; `?lang=` and a language chosen in Settings still select them. +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] +fn web_draws(language: &str) -> bool { + !matches!(language, "ja" | "zh-hans" | "zh-hant" | "ka") } /// The language EffectCraft shows for a BCP 47 locale (`ja-JP` → `ja`, `zh-Hans-CN` → `zh-hans`, @@ -7378,11 +7395,25 @@ mod tests { #[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"); + let any = |_: &str| true; + assert_eq!(super::first_supported(None, &tags(&["cs-CZ", "cs", "en"]), any), "cs"); + assert_eq!(super::first_supported(None, &tags(&["nl-NL", "cs"]), any), "cs"); + assert_eq!(super::first_supported(None, &tags(&["en-US", "cs"]), any), "en"); + assert_eq!(super::first_supported(None, &tags(&["nl-NL"]), any), "en"); + assert_eq!(super::first_supported(None, &tags(&[]), any), "en"); + assert_eq!(super::first_supported(Some("uk"), &tags(&["cs"]), any), "uk"); + assert_eq!(super::first_supported(Some("nl"), &tags(&["cs"]), any), "cs"); + } + + /// The web build installs no CJK or Georgian font: Match System passes over those browser + /// languages, while `?lang=` still selects them. + #[test] + fn web_match_system_skips_languages_without_a_font() { + let tags = |t: &[&str]| t.iter().map(|s| s.to_string()).collect::>(); + assert_eq!(super::first_supported(None, &tags(&["ja-JP", "zh-CN", "zh-TW", "ka-GE", "cs"]), super::web_draws), "cs"); + assert_eq!(super::first_supported(None, &tags(&["ka"]), super::web_draws), "en"); + assert_eq!(super::first_supported(Some("ka"), &tags(&["cs"]), super::web_draws), "ka"); + assert_eq!(super::first_supported(Some("ja"), &tags(&[]), super::web_draws), "ja"); } fn keys(nodes: &[MenuNode], out: &mut BTreeSet<(String, String)>) { From 5bd164980c3e0e7adc75d893615b06ba79fd1b72 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 22:21:30 +0000 Subject: [PATCH 44/44] Batch fix: docs: Georgian is the one catalog with native-speaker review (#1843) --- docs/localization-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 3fcdf06de..d642af43c 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -105,7 +105,7 @@ needs ≈ 2,900 more strings (≈ 5–9 h per language at the pace of #398 and # Effects' 10 non-English languages: **≈ 60–110 h**; the four extra languages of the standard list (Hindi, Arabic, Indonesian, Vietnamese) add ≈ 50–80 h, most of it right-to-left layout and complex shaping in the interface. Every language needs native-speaker review, which only a human -can give; none has had it. +can give; only Georgian, contributed by a native speaker (#1843), has had it. ## Revision history