From 0aa2ea5e123ec6104c64a6a7ab3e66f3fe9508a3 Mon Sep 17 00:00:00 2001 From: WaWu Date: Tue, 13 Oct 2026 13:39:16 +0800 Subject: [PATCH 1/3] i18n: an effect's name draws in the interface language, the generated Effect menu included (#821) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interface below the menu bar is localized (618 fixed menu entries, ~490 panel rows) but an effect's name still rendered in English everywhere it appeared: the generated Effect menu, Effect Controls, the Timeline and Effects & Presets. The registry's English name is the effect's identity: `effect.list`, `effect.apply {"effect": …}`, scripts, expressions and the property tree's group name all match on it. Only the drawing changes. `crates/ui-egui/src/i18n/effects.rs` keys the names by effect **id** rather than by the English string — a repeat is stored as `Gaussian Blur 2` (an English key would miss it) and upstream renames a display name now and then, while the id is stable. It carries 306 rows for Simplified Chinese, one per effect in the registry, taken from the installed After Effects 2026 (26.5.0.89) Simplified Chinese interface, with the `CC` series following Adobe's Chinese documentation (which names 68 of the 69 that its interface leaves in English). `Cryptomatte`, `EXtractoR`, `IDentifier`, `Key Light` and `Unmult` stay in English as brand names; `Face Measurements` and `Face Track Points` are EffectCraft's own effects. The 21 category folder names are in the same file, worded after the effect names (channel → 通道, noise → 杂色, matte → 遮罩 …). What draws the catalog now: - **Effect menu:** the entries and the category submenus. The command and its `{"effect": }` params are untouched, so the menu still applies by id. - **Effect Controls:** the header, its Rename field, the accessible name and the About popup. - **Timeline:** effect group rows, and the search (a Chinese interface is searchable in Chinese). - **Effects & Presets:** the rows, the drag ghost, the folder rows and the search. Kept as they are: the effect id, the property tree's `PropGroup.name`, every automation id and label, `effect.list`'s `name`/`category`, and the undo label (`Apply Gaussian Blur`). A name the user typed in Effect Controls stays verbatim, and a repeated instance keeps the index the engine appends (`Gaussian Blur 2` → `高斯模糊 2`). No other language has rows yet, Traditional Chinese included, so those draw the registry's English name. Tests: nine unit tests under `cargo test -p effectcraft-ui-egui --lib i18n` cover the data (every effect has a row, the row still carries the registry's English name, ids are unique, only the five brand names repeat their English name, every category has a row, a row names an effect the registry knows) and the behaviour (the instance index, user renames, English and untranslated languages, search) plus a scan pinning the drawing sites. `crates/ui-egui/tests/ui_effect_names.rs` checks the Effect Controls header, the generated Effect menu and that the ids, automation labels and `effect.list` stay English. `docs/localization-parity.md` and `docs/gaps.md` no longer record effect names as deliberately English; effect *parameter* names are still untranslated and stay the next piece. Refs #821 --- crates/ui-egui/src/i18n.rs | 23 +- crates/ui-egui/src/i18n/effects.rs | 582 +++++++++++++++++++ crates/ui-egui/src/menus.rs | 5 +- crates/ui-egui/src/native_menu.rs | 12 +- crates/ui-egui/src/panels/effect_controls.rs | 22 +- crates/ui-egui/src/panels/effects_presets.rs | 19 +- crates/ui-egui/src/panels/timeline.rs | 19 +- crates/ui-egui/tests/ui_effect_names.rs | 102 ++++ docs/gaps.md | 2 +- docs/localization-parity.md | 24 +- 10 files changed, 783 insertions(+), 27 deletions(-) create mode 100644 crates/ui-egui/src/i18n/effects.rs create mode 100644 crates/ui-egui/tests/ui_effect_names.rs diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 4e814ea2..56302318 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -5,9 +5,11 @@ use crate::EffectcraftApp; +/// Effect names, keyed by effect id (#821). +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; +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. @@ -138,6 +140,15 @@ fn lookup<'a>(catalog: &[(&str, &str, &'a str)], command: &str, source: &'a str) } pub(crate) fn entry(app: &EffectcraftApp, e: &MenuEntry, shown: String) -> String { + // The generated Effect menu's entries label themselves with the effect's registry name; the + // catalog replaces it with the interface language's (#821). The entry keeps its command and + // its `{"effect": }` params, so a script, an expression or the control channel is + // unaffected by what the menu draws. + if e.command == "effect.apply" + && let Some(id) = e.params.get("effect").and_then(|v| v.as_str()) + { + return effects::name(id, &e.label); + } // Undo/redo names come from the history, so they are formatted per language here rather than // looked up in a catalog. An English (or untranslated) interface keeps the shown label. let (undo, no_undo, redo, no_redo) = match language(app) { @@ -168,6 +179,16 @@ pub(crate) fn entry(app: &EffectcraftApp, e: &MenuEntry, shown: String) -> Strin } } +/// The label to draw for a generated Effect-menu category submenu, or `None` for every other +/// submenu (whose label goes through [`submenu`]). +/// +/// The generated submenus are the only ones whose children are all `effect.apply` entries, which +/// is what identifies them: their label is one of the registry's 21 categories, drawn as a +/// submenu in the Effect menu and as a folder row in Effects & Presets (#821). +pub(crate) fn effect_category(children: &[MenuNode], source: &str) -> Option { + (!children.is_empty() && children.iter().all(|c| matches!(c, MenuNode::Item(e) if e.command == "effect.apply"))).then(|| effects::category(source)) +} + pub(crate) fn submenu(app: &EffectcraftApp, source: &str, shown: String) -> String { match language(app) { "cs" => { diff --git a/crates/ui-egui/src/i18n/effects.rs b/crates/ui-egui/src/i18n/effects.rs new file mode 100644 index 00000000..5b7fe670 --- /dev/null +++ b/crates/ui-egui/src/i18n/effects.rs @@ -0,0 +1,582 @@ +//! Effect names in the interface language, keyed by effect id (#821). +//! +//! The generated Effect menu, Effect Controls, the Timeline and Effects & Presets draw an +//! effect's name. The registry (`effectcraft_effects`) keeps the English name: scripts, +//! expressions, MCP and the control channel match on it, and the property tree stores it as the +//! effect group's name. Only the *drawing* goes through this catalog, so a project file stays +//! identical whichever interface language opens it. +//! +//! Rows are keyed by id rather than by the English source string, for two reasons: +//! +//! - a second instance of an effect is stored as `Gaussian Blur 2` (the engine appends the +//! index to the group name), which an English-string key would miss; +//! - upstream renames a display name now and then (26.5 turned `Twirl` into `Twirl (Legacy)`); +//! the id is stable, so a translation never becomes an orphan. +//! +//! Each row repeats the registry's English name so that a rename upstream fails +//! [`tests::every_effect_has_a_row`] instead of silently pointing a translation at another +//! effect. +//! +//! Only Simplified Chinese has rows. Every other language — Traditional Chinese included — +//! draws the registry's English name; a translator adds rows to [`TRADITIONAL_CHINESE`] keyed +//! by the same ids (the test below refuses an id that Simplified Chinese does not have). +//! +//! The names come from the installed After Effects 2026 (26.5.0.89) Simplified Chinese +//! interface; the `CC` series follows Adobe's Chinese *documentation*, which names 68 of the 69 +//! that the interface leaves in English (see the PR for #821). `Cryptomatte`, `EXtractoR`, +//! `IDentifier`, `Key Light` and `Unmult` stay in English as brand names, and `Face +//! Measurements` / `Face Track Points` are EffectCraft's own effects, so their wording is ours. + +use std::collections::HashMap; +use std::sync::OnceLock; + +use super::ui::current; + +/// One row: effect id, the registry's English name, the translation. +type Row = (&'static str, &'static str, &'static str); + +/// The effect's name to draw: the catalog row for `id`, else the registry's `english`. +pub(crate) fn name(id: &str, english: &str) -> String { + row(id).map_or_else(|| english.to_string(), |(_, translated)| translated.to_string()) +} + +/// The name to draw for an effect group the property tree stores as `stored`: the catalog name, +/// plus the instance index the engine appends to a repeat (`Gaussian Blur 2` → `高斯模糊 2`). +/// +/// A name that is neither the registry's nor the registry's plus an index was typed by the user +/// (Effect Controls ▸ Rename), so it stays verbatim — as does the whole string in a language +/// with no row. +pub(crate) fn group(id: &str, stored: &str) -> String { + let Some((english, translated)) = row(id) else { return stored.to_string() }; + if stored == english { + return translated.to_string(); + } + if let Some(index) = stored.strip_prefix(english).and_then(|rest| rest.strip_prefix(' ')) + && !index.is_empty() + && index.bytes().all(|b| b.is_ascii_digit()) + { + return format!("{translated} {index}"); + } + stored.to_string() +} + +/// The label to draw for the effect category `source` (the generated Effect menu's submenus and +/// Effects & Presets' folder rows). +pub(crate) fn category(source: &str) -> String { + if current() != "zh-hans" { + return source.to_string(); + } + CATEGORIES.iter().find(|(english, _)| *english == source).map_or_else(|| source.to_string(), |(_, translated)| translated.to_string()) +} + +/// Does effect `id` (`english` when the language has no row) match the Effects & Presets search +/// `query` (lowercase)? Both names match, so a Chinese interface can be searched either way. +pub(crate) fn matches(id: &str, english: &str, query: &str) -> bool { + english.to_lowercase().contains(query) || row(id).is_some_and(|(_, translated)| translated.to_lowercase().contains(query)) +} + +/// `id`'s (English name, translation) in the current language. +fn row(id: &str) -> Option<(&'static str, &'static str)> { + index(current()).and_then(|rows| rows.get(id).copied()) +} + +type Index = HashMap<&'static str, (&'static str, &'static str)>; + +/// The catalog for `language`, indexed by effect id (built on first use); `None` for English and +/// any language with no rows. +fn index(language: &str) -> Option<&'static Index> { + static HANS: OnceLock = OnceLock::new(); + static HANT: OnceLock = OnceLock::new(); + let (slot, rows) = match language { + "zh-hans" => (&HANS, SIMPLIFIED_CHINESE), + "zh-hant" => (&HANT, TRADITIONAL_CHINESE), + _ => return None, + }; + Some(slot.get_or_init(|| rows.iter().map(|(id, english, translated)| (*id, (*english, *translated))).collect())) +} + +/// Simplified Chinese (`zh-hans`): one row per effect in the registry. +const SIMPLIFIED_CHINESE: &[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 交叉模糊"), + ("ec.blur.ccradial", "CC Radial Blur", "CC 径向模糊"), + ("ec.blur.ccradialfast", "CC Radial Fast Blur", "CC 径向快速模糊"), + ("ec.blur.ccvector", "CC Vector Blur", "CC 矢量模糊"), + ("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 合成"), + ("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 颜色中和器"), + ("ec.color.cccoloroffset", "CC Color Offset", "CC 颜色偏移"), + ("ec.color.cckernel", "CC Kernel", "CC 内核"), + ("ec.color.cctoner", "CC Toner", "CC 调色器"), + ("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 颜色"), + ("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 弯曲器"), + ("ec.distort.ccbendit", "CC Bend It", "CC 弯曲"), + ("ec.distort.ccblobbylize", "CC Blobbylize", "CC 融化"), + ("ec.distort.ccflomotion", "CC Flo Motion", "CC 流动运动"), + ("ec.distort.ccgriddler", "CC Griddler", "CC 网格生成器"), + ("ec.distort.cclens", "CC Lens", "CC 镜头"), + ("ec.distort.ccpageturn", "CC Page Turn", "CC 翻页"), + ("ec.distort.ccpowerpin", "CC Power Pin", "CC 强力边角定位"), + ("ec.distort.ccripplepulse", "CC Ripple Pulse", "CC 波纹脉冲"), + ("ec.distort.ccslant", "CC Slant", "CC 倾斜"), + ("ec.distort.ccsmear", "CC Smear", "CC 漩涡条纹"), + ("ec.distort.ccsplit", "CC Split", "CC 拆分"), + ("ec.distort.ccsplit2", "CC Split 2", "CC 拆分 2"), + ("ec.distort.cctiler", "CC Tiler", "CC 拼贴器"), + ("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 胶水枪"), + ("ec.generate.cclightburst", "CC Light Burst 2.5", "CC 光束放射 2.5"), + ("ec.generate.cclightrays", "CC Light Rays", "CC 光线"), + ("ec.generate.cclightsweep", "CC Light Sweep", "CC 光线扫描"), + ("ec.generate.ccthreads", "CC Threads", "CC 编织条"), + ("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", "四色渐变"), + ("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 简单丝线移除"), + ("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.refinehard", "Refine Hard Matte", "调整实边遮罩"), + ("ec.matte.refinesoft", "Refine Soft Matte", "调整柔和遮罩"), + ("ec.matte.rotobrush", "Roto Brush & Refine Edge", "Roto 笔刷和细化边缘"), + ("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", "杂色 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", "斜面 Alpha"), + ("ec.perspective.beveledges", "Bevel Edges", "边缘斜面"), + ("ec.perspective.cameratracker", "3D Camera Tracker", "3D 摄像机跟踪器"), + ("ec.perspective.cccylinder", "CC Cylinder", "CC 圆柱体"), + ("ec.perspective.ccenvironment", "CC Environment", "CC 环境"), + ("ec.perspective.ccsphere", "CC Sphere", "CC 球体"), + ("ec.perspective.ccspotlight", "CC Spotlight", "CC 聚光"), + ("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 球动作"), + ("ec.sim.ccbubbles", "CC Bubbles", "CC 气泡"), + ("ec.sim.ccdrizzle", "CC Drizzle", "CC 细雨波纹"), + ("ec.sim.cchair", "CC Hair", "CC 毛发"), + ("ec.sim.ccmrmercury", "CC Mr. Mercury", "CC 水银流体"), + ("ec.sim.ccparticlesystems2", "CC Particle Systems II", "CC 粒子系统 II"), + ("ec.sim.ccparticleworld", "CC Particle World", "CC 粒子世界"), + ("ec.sim.ccpixelpolly", "CC Pixel Polly", "CC 像素多边形"), + ("ec.sim.ccrainfall", "CC Rainfall", "CC 降雨"), + ("ec.sim.ccscatterize", "CC Scatterize", "CC 散射"), + ("ec.sim.ccsnowfall", "CC Snowfall", "CC 降雪"), + ("ec.sim.ccstarburst", "CC Star Burst", "CC 星爆"), + ("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 块加载"), + ("ec.stylize.ccburnfilm", "CC Burn Film", "CC 烧毁胶片"), + ("ec.stylize.ccglass", "CC Glass", "CC 玻璃"), + ("ec.stylize.cchextile", "CC HexTile", "CC 六边形拼贴"), + ("ec.stylize.cckaleida", "CC Kaleida", "CC 万花筒"), + ("ec.stylize.ccmrsmoothie", "CC Mr. Smoothie", "CC 像素溶解"), + ("ec.stylize.ccplastic", "CC Plastic", "CC 塑料"), + ("ec.stylize.ccrepetile", "CC RepeTile", "CC 重复拼贴"), + ("ec.stylize.ccthreshold", "CC Threshold", "CC 阈值"), + ("ec.stylize.ccthresholdrgb", "CC Threshold RGB", "CC 阈值 RGB"), + ("ec.stylize.ccvignette", "CC Vignette", "CC 暗角"), + ("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 强制运动模糊"), + ("ec.time.ccwidetime", "CC Wide Time", "CC 宽泛时间"), + ("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", "时间扭曲"), + ("ec.transition.blockdissolve", "Block Dissolve", "块溶解"), + ("ec.transition.cardwipe", "Card Wipe", "卡片擦除"), + ("ec.transition.ccglasswipe", "CC Glass Wipe", "CC 玻璃擦除"), + ("ec.transition.ccgridwipe", "CC Grid Wipe", "CC 网格擦除"), + ("ec.transition.ccimagewipe", "CC Image Wipe", "CC 图像擦除"), + ("ec.transition.ccjaws", "CC Jaws", "CC 锯齿"), + ("ec.transition.cclightwipe", "CC Light Wipe", "CC 光线擦除"), + ("ec.transition.cclinesweep", "CC Line Sweep", "CC 线条扫掠"), + ("ec.transition.ccradialscalewipe", "CC Radial ScaleWipe", "CC 径向缩放擦除"), + ("ec.transition.ccscalewipe", "CC Scale Wipe", "CC 缩放擦除"), + ("ec.transition.cctwister", "CC Twister", "CC 扭曲器"), + ("ec.transition.ccwarpomatic", "CC WarpoMatic", "CC 自动扭曲"), + ("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 高光溢出"), + ("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 球面到平面"), +]; + +/// Traditional Chinese (`zh-hant`): no rows yet, so the language draws the English names. +const TRADITIONAL_CHINESE: &[Row] = &[]; + +/// The 21 category folder names the generated Effect menu and Effects & Presets draw. The wording +/// follows the effect names above (channel → 通道, blur → 模糊, noise → 杂色, matte → 遮罩 …); +/// Adobe's Chinese interface has not been read for these 21, so they are our wording. +const 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", "实用工具"), +]; + +#[cfg(test)] +mod tests { + use super::*; + use crate::i18n::set_current; + use effectcraft_engine::effects; + + /// Every effect in the registry has a row, and that row still carries the registry's English + /// name. A new effect, a rename or a removal fails here rather than drawing an English name + /// in a Chinese interface, or attaching a translation to a different effect. + #[test] + fn every_effect_has_a_row() { + let registry = effects::registry(); + assert_eq!(SIMPLIFIED_CHINESE.len(), registry.len(), "the catalog has {} rows for {} effects", SIMPLIFIED_CHINESE.len(), registry.len()); + for spec in registry { + let Some((_, english, translated)) = SIMPLIFIED_CHINESE.iter().find(|(id, _, _)| *id == spec.id) else { + panic!("no catalog row for {} ({})", spec.id, spec.name); + }; + assert_eq!(*english, spec.name, "{}: the catalog says {english:?}, the registry {:?}", spec.id, spec.name); + assert!(!translated.is_empty(), "{}: empty translation", spec.id); + } + } + + /// Rows are keyed by id, so a repeated id would shadow a translation. + #[test] + fn ids_are_unique() { + let mut ids: Vec<&str> = SIMPLIFIED_CHINESE.iter().map(|(id, _, _)| *id).collect(); + let listed = ids.len(); + ids.sort_unstable(); + ids.dedup(); + assert_eq!(listed, ids.len(), "an effect id is listed twice"); + } + + /// The five names Adobe leaves in English as brand names, and the only rows that may: any + /// other row repeating its English name is a translation that never happened. + #[test] + fn only_the_brand_names_stay_in_english() { + const BRANDS: [&str; 5] = ["ec.3d.cryptomatte", "ec.3d.extractor", "ec.3d.identifier", "ec.keying.keylight", "ec.key.unmult"]; + for (id, english, translated) in SIMPLIFIED_CHINESE { + assert_eq!(english == translated, BRANDS.contains(id), "{id}: {english:?} is drawn as {translated:?}"); + } + } + + /// Traditional Chinese may lag Simplified Chinese but may not invent ids or disagree about + /// which effect an id names: a translator fills this table row by row. + #[test] + fn traditional_chinese_only_uses_known_ids() { + for (id, english, translated) in TRADITIONAL_CHINESE { + let Some((_, hans_english, _)) = SIMPLIFIED_CHINESE.iter().find(|(hans_id, _, _)| hans_id == id) else { + panic!("zh-hant row {id} is not an effect the zh-hans catalog knows"); + }; + assert_eq!(*english, *hans_english, "{id}: the two catalogs name the effect differently"); + assert!(!translated.is_empty(), "{id}: empty translation"); + } + } + + /// Every category the registry and its plug-ins report has a row, and every row names a + /// category that exists: the generated Effect menu and Effects & Presets draw both. + #[test] + fn every_category_has_a_row() { + let categories = effects::categories(); + for category in &categories { + assert!(CATEGORIES.iter().any(|(english, _)| english == category), "no catalog row for category {category}"); + } + for (english, translated) in CATEGORIES { + assert!(categories.contains(english), "the catalog lists {english}, which is not a category"); + assert!(!translated.is_empty(), "empty translation for {english}"); + } + } + + /// A repeated effect carries the index the engine appends to the group name; a name the user + /// typed in Effect Controls stays verbatim. + #[test] + fn group_names_keep_the_instance_index_and_user_renames() { + set_current("zh-hans"); + assert_eq!(group("ec.blur.gaussian", "Gaussian Blur"), "高斯模糊"); + assert_eq!(group("ec.blur.gaussian", "Gaussian Blur 2"), "高斯模糊 2"); + assert_eq!(group("ec.blur.gaussian", "Gaussian Blur 12"), "高斯模糊 12"); + assert_eq!(group("ec.blur.gaussian", "My Blur"), "My Blur", "a rename is not a row"); + assert_eq!(group("ec.blur.gaussian", "Gaussian Blur X"), "Gaussian Blur X"); + assert_eq!(group("ec.blur.gaussian", "Gaussian Blur 2x"), "Gaussian Blur 2x"); + assert_eq!(group("ec.blur.gaussian", "高斯模糊"), "高斯模糊", "an already translated name stays"); + set_current("en"); + } + + /// English, and every language without rows (Traditional Chinese included), draws the + /// registry's name; an id with no row keeps it too rather than drawing an empty label. + #[test] + fn languages_without_rows_draw_the_registry_name() { + for language in ["en", "ja", "zh-hant", "uk", "cs"] { + set_current(language); + assert_eq!(name("ec.blur.gaussian", "Gaussian Blur"), "Gaussian Blur", "{language}"); + assert_eq!(group("ec.blur.gaussian", "Gaussian Blur 2"), "Gaussian Blur 2", "{language}"); + assert_eq!(category("Blur & Sharpen"), "Blur & Sharpen", "{language}"); + } + set_current("zh-hans"); + assert_eq!(name("ec.blur.gaussian", "Gaussian Blur"), "高斯模糊"); + assert_eq!(group("ec.blur.gaussian", "Gaussian Blur 2"), "高斯模糊 2"); + assert_eq!(category("Blur & Sharpen"), "模糊和锐化"); + assert_eq!(name("ec.unknown.effect", "Mystery"), "Mystery"); + assert_eq!(category("Not A Category"), "Not A Category"); + set_current("en"); + } + + /// The Effects & Presets search answers to either name, so a Chinese interface can be + /// searched in Chinese while an English one still finds the effect by its registry name. + #[test] + fn search_matches_either_name() { + set_current("zh-hans"); + assert!(matches("ec.blur.gaussian", "Gaussian Blur", "blur")); + assert!(matches("ec.blur.gaussian", "Gaussian Blur", "gaussian")); + assert!(matches("ec.blur.gaussian", "Gaussian Blur", "高斯")); + assert!(!matches("ec.blur.gaussian", "Gaussian Blur", "sharpen")); + set_current("en"); + assert!(matches("ec.blur.gaussian", "Gaussian Blur", "blur")); + assert!(!matches("ec.blur.gaussian", "Gaussian Blur", "高斯")); + } + + /// The drawing sites ask this catalog instead of the registry's English name (#821). Like the + /// panel catalog's literal scan this is a heuristic: it pins the expressions that used to draw + /// the English name, so a revert fails here. + #[test] + fn the_drawing_sites_ask_the_catalog() { + /// A file that must ask the catalog. + const ASKS: &[(&str, &str, &str)] = &[ + ("i18n.rs", include_str!("../i18n.rs"), "effects::name("), + ("i18n.rs", include_str!("../i18n.rs"), "effects::category("), + ("panels/effects_presets.rs", include_str!("../panels/effects_presets.rs"), "effects::name("), + ("panels/effects_presets.rs", include_str!("../panels/effects_presets.rs"), "effects::category("), + ("panels/effect_controls.rs", include_str!("../panels/effect_controls.rs"), "effects::group("), + ("panels/timeline.rs", include_str!("../panels/timeline.rs"), "effects::group("), + ]; + /// An expression that drew the registry's English name and must be gone. + const RAW: &[(&str, &str, &str)] = &[ + ("panels/effects_presets.rs", include_str!("../panels/effects_presets.rs"), "pos2(r.min.x + 56.0 + indent, r.center().y), e.name, query, &t)"), + ("panels/effect_controls.rs", include_str!("../panels/effect_controls.rs"), "Align2::LEFT_CENTER, &g.name,"), + ("panels/timeline.rs", include_str!("../panels/timeline.rs"), "Align2::LEFT_CENTER, name,"), + ]; + for (path, source, call) in ASKS { + assert!(source.contains(call), "{path} does not ask the effect-name catalog (`{call}`)"); + } + for (path, source, raw) in RAW { + assert!(!source.contains(raw), "{path} draws the registry's English name again: `{raw}`"); + } + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b10b0e31..c5c3aef0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1914,7 +1914,10 @@ fn menu_nodes( } MenuNode::Submenu { label, children } => { let ws = app.ui.workspace.clone(); - let shown = crate::i18n::submenu(app, label, effectcraft_engine::menus::submenu_label(&app.session, label, &dyn_ctx(&ws, &[]))); + // The generated Effect menu's category submenus draw the catalog's name (#821); + // every other submenu goes through the menu catalog. + let shown = crate::i18n::effect_category(children, label) + .unwrap_or_else(|| crate::i18n::submenu(app, label, effectcraft_engine::menus::submenu_label(&app.session, label, &dyn_ctx(&ws, &[])))); let r = ui.menu_button((gutter(false), shown.as_str()), |ui| { ui.set_min_width(if children.len() > 30 { 200.0 } else { 240.0 }); crate::widgets::menu_scroll(ui, |ui| menu_nodes(app, ui, children, clicked, nav, depth + 1)); diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs index 5d0a3c53..beb1e95f 100644 --- a/crates/ui-egui/src/native_menu.rs +++ b/crates/ui-egui/src/native_menu.rs @@ -169,8 +169,16 @@ pub fn build(app: &EffectcraftApp) -> NativeMenu { MenuNode::Submenu { label, children } => { let mut kids = vec![]; rec(app, children, &id, &mut kids); - let shown = effectcraft_engine::menus::submenu_label(&app.session, label, &crate::menus::dyn_ctx(&app.ui.workspace, &[])); - out.push(NativeNode::Submenu { label: crate::i18n::submenu(app, label, shown), children: kids }); + // The generated Effect menu's category submenus draw the catalog's name + // (#821); every other submenu goes through the menu catalog. + let shown = crate::i18n::effect_category(children, label).unwrap_or_else(|| { + crate::i18n::submenu( + app, + label, + effectcraft_engine::menus::submenu_label(&app.session, label, &crate::menus::dyn_ctx(&app.ui.workspace, &[])), + ) + }); + out.push(NativeNode::Submenu { label: shown, children: kids }); } MenuNode::Item(e) => { let role = match e.command.as_str() { diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index d8de61d8..eafced3c 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -1100,6 +1100,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut tops: Vec<(u64, f32)> = vec![]; for (fi, g) in fx.iter().enumerate() { let GroupKind::Effect { effect } = &g.kind else { continue }; + // The header draws the effect's name in the interface language; the automation labels + // below keep the property tree's name, which is the registry's English name (#821). + let shown = crate::i18n::effects::group(effect, &g.name); let r = Rect::from_min_size(pos2(body.min.x, y), vec2(body.width(), ROW + 2.0)); tops.push((g.uid, r.min.y)); y += ROW + 2.0; @@ -1140,14 +1143,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let commit = response.lost_focus() || (response.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))); if escape || commit { ctx.data_mut(|d| d.remove::(rename_id)); - if !escape && !value.trim().is_empty() && value != g.name { + // The field opens with the drawn name, so committing it unchanged is not a rename. + if !escape && !value.trim().is_empty() && value != shown { actions.push(("prop.renameGroup".into(), json!({"layer": layer.id.0, "prop": g.uid, "name": value}))); } } else { ctx.data_mut(|d| d.insert_temp(rename_id, value)); } } else { - let name = bp.text(pos2(tw.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, &g.name, Tokens::semibold(12.0), t.text); + let name = bp.text(pos2(tw.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, &shown, Tokens::semibold(12.0), t.text); app.auto.add(&format!("effectControls.effect.{}.name", g.uid), name, &g.name); } let rresp = ui.interact(reset, egui::Id::new(("ec-reset", g.uid)), Sense::click()); @@ -1166,7 +1170,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { widgets::open_popup(ui, apop); } if let Some(spec) = effectcraft_engine::effects::find(effect) { - let lines = vec![spec.name.to_string(), format!("Category: {}", spec.category), "EffectCraft built-in effect (MIT OR Apache-2.0)".to_string()]; + let lines = vec![ + crate::i18n::effects::name(spec.id, spec.name), + format!("Category: {}", crate::i18n::effects::category(spec.category)), + "EffectCraft built-in effect (MIT OR Apache-2.0)".to_string(), + ]; let _ = widgets::popup_menu(ui, apop, about.left_bottom(), &lines, None); } let hresp = ui.interact( @@ -1175,10 +1183,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if editing.is_some() { Sense::hover() } else { Sense::click_and_drag() }, ); if hresp.double_clicked() { - ctx.data_mut(|d| d.insert_temp(rename_id, g.name.clone())); + ctx.data_mut(|d| d.insert_temp(rename_id, shown.clone())); ctx.data_mut(|d| d.insert_temp(rename_id.with("start"), true)); } - hresp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, active, &g.name)); + hresp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, active, &shown)); app.auto.add(&format!("effectControls.effect.{}", g.uid), r, &g.name); if hresp.clicked() || hresp.drag_started() || hresp.secondary_clicked() { let add = ui.input(|i| i.modifiers.shift || i.modifiers.command); @@ -1207,7 +1215,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { item(ui, "Paste", has_clip, "effect.paste", json!({"layers": [lid]})); item(ui, "Duplicate", true, "effect.duplicate", json!({"layer": lid, "effect": g.uid})); if ui.button(crate::i18n::tr("Rename")).clicked() { - ctx.data_mut(|d| d.insert_temp(rename_id, g.name.clone())); + ctx.data_mut(|d| d.insert_temp(rename_id, shown.clone())); ctx.data_mut(|d| d.insert_temp(rename_id.with("start"), true)); ui.close(); } @@ -1532,7 +1540,7 @@ pub fn viewer_hook( let Some(resp) = resp else { continue }; if resp.hovered() { ctx.set_cursor_icon(egui::CursorIcon::Move); - resp.clone().on_hover_text(format!("{}: {}", g.name, pr.name)); + resp.clone().on_hover_text(format!("{}: {}", crate::i18n::effects::group(&g.match_id, &g.name), pr.name)); } if resp.dragged() && let Some(pos) = resp.interact_pointer_pos() diff --git a/crates/ui-egui/src/panels/effects_presets.rs b/crates/ui-egui/src/panels/effects_presets.rs index da6812be..561cf57d 100644 --- a/crates/ui-egui/src/panels/effects_presets.rs +++ b/crates/ui-egui/src/panels/effects_presets.rs @@ -185,7 +185,10 @@ fn effect_row( lp.rect_filled(r, 0.0, t.hover); } icons::paint(lp, Rect::from_center_size(pos2(r.min.x + 44.0 + indent, r.center().y), vec2(12.0, 12.0)), Icon::Fx, t.text_dim); - highlighted(lp, pos2(r.min.x + 56.0 + indent, r.center().y), e.name, query, &t); + // The row draws the effect's name in the interface language; the automation label keeps the + // registry's English name, which is what scripts and the control channel address (#821). + let shown = crate::i18n::effects::name(e.id, e.name); + highlighted(lp, pos2(r.min.x + 56.0 + indent, r.center().y), &shown, query, &t); if e.float { lp.text(pos2(r.max.x - 10.0, r.center().y), Align2::RIGHT_CENTER, "32", Tokens::ui(9.5), t.text_faint); } @@ -322,8 +325,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut y = list.min.y - scroll.offset; let mut apply: Option = None; let reg = effectcraft_engine::effects::all(); - let keep = - |e: &effectcraft_engine::effects::EffectSpec| depth_keeps(&view.depth, e) && (query.is_empty() || effectcraft_engine::effects::name_matches(e, &query)); + let keep = |e: &effectcraft_engine::effects::EffectSpec| { + depth_keeps(&view.depth, e) + && (query.is_empty() || effectcraft_engine::effects::name_matches(e, &query) || crate::i18n::effects::matches(e.id, e.name, &query)) + }; let lookup = |id: &str| reg.iter().find(|e| e.id == id); // Favorites and Recently Used. @@ -405,7 +410,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if items.is_empty() { continue; } - if !folder_row(app, ui, &lp, list, &mut y, cat, cat, 0.0, searching, None) { + // The folder draws the category in the interface language; `cat` stays the key (the + // open/closed state and the automation id are the registry's English name). + if !folder_row(app, ui, &lp, list, &mut y, cat, &crate::i18n::effects::category(cat), 0.0, searching, None) { continue; } for e in items { @@ -421,9 +428,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if let Some(DragPayload::Effect(id)) = egui::DragAndDrop::payload::(&ctx).as_deref() && let Some(pos) = ctx.pointer_hover_pos() { - let name = effectcraft_engine::effects::find(id).map(|e| e.name).unwrap_or(""); + let name = effectcraft_engine::effects::find(id).map(|e| crate::i18n::effects::name(e.id, e.name)).unwrap_or_default(); let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("dnd-fx"))); - let g = painter.layout_no_wrap(name.to_string(), Tokens::ui(12.0), t.text); + let g = painter.layout_no_wrap(name, Tokens::ui(12.0), t.text); let r = Rect::from_min_size(pos + vec2(12.0, 8.0), g.size() + vec2(16.0, 8.0)); painter.rect_filled(r, 4.0, Color32::from_rgba_premultiplied(40, 40, 40, 230)); painter.rect_stroke(r, 4.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 72f10429..8e034fba 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -684,7 +684,11 @@ fn search_rows(l: &Layer, q: &str) -> Vec { for c in &g.children { match c { Node::Group(sg) if depth > 1 || group_visible(sg, l) => { - if sg.name.to_lowercase().contains(q) { + // An effect group also matches the name the row draws, so a search finds + // `高斯模糊` in a Chinese interface and `Gaussian Blur` in an English one. + let hit = sg.name.to_lowercase().contains(q) + || matches!(&sg.kind, GroupKind::Effect { effect } if crate::i18n::effects::matches(effect, &sg.name, q)); + if hit { out.push(group_row(l, sg, depth, g)); all(l, sg, depth + 1, out); continue; @@ -2158,6 +2162,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } RowKind::Group { uid, name, open, has_children, fx, eye } => { + // The row draws an effect's name in the interface language (#821); `name` stays + // the property tree's, which the automation labels below keep. Any other group + // (a mask, a shape group, a layer style) draws its own name. + let group = layer.props.find_group(*uid); + let shown = match group.and_then(|g| match &g.kind { + GroupKind::Effect { effect } => Some(effect.as_str()), + _ => None, + }) { + Some(effect) => crate::i18n::effects::group(effect, name), + None => name.clone(), + }; // Selected groups (an effect, a mask, a shape group…) highlight like selected // property rows. let sel_group = app.session.state.selected_props.contains(&(layer.id, *uid)); @@ -2197,7 +2212,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } app.auto.add(&format!("timeline.group.{uid}.eye"), er, name); } - lp.text(pos2(indent + 10.0, cy), Align2::LEFT_CENTER, name, Tokens::ui(12.0), t.text); + lp.text(pos2(indent + 10.0, cy), Align2::LEFT_CENTER, &shown, Tokens::ui(12.0), t.text); text_anim_popups(app, ui, &lp, layer, *uid, cw.switches, cy, &mut actions); shape_add_popup(app, ui, &lp, layer, *uid, cw.switches, cy, &mut actions); if *uid == GEOM_BIT | layer.id.0 { diff --git a/crates/ui-egui/tests/ui_effect_names.rs b/crates/ui-egui/tests/ui_effect_names.rs new file mode 100644 index 00000000..c65d2cea --- /dev/null +++ b/crates/ui-egui/tests/ui_effect_names.rs @@ -0,0 +1,102 @@ +//! Effect names in a Simplified Chinese interface (#821): the generated Effect menu and Effect +//! Controls draw the catalog's name, while the effect ids, the property tree's group name, the +//! automation labels and `effect.list` stay English. + +use effectcraft_engine::Session; +use effectcraft_engine::project::LayerId; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; +use effectcraft_ui_egui::native_menu::{NativeNode, build as native_menu}; +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +/// The effect under test, and the names the registry and the catalog give it. +const BLUR: &str = "ec.blur.gaussian"; +const BLUR_EN: &str = "Gaussian Blur"; +const BLUR_ZH: &str = "高斯模糊"; + +fn harness(language: &str) -> (Harness<'static, EffectcraftApp>, u64) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "frameRate": 30, "duration": 10})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Back", "color": "#20c040"})).unwrap(); + let layer = s.execute("layer.newSolid", json!({"name": "Blurred", "color": "#ff0000", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap(); + let fx = s.execute("effect.apply", json!({"layers": [layer], "effect": BLUR})).unwrap()["effects"][0].as_u64().unwrap(); + // A second instance: the engine stores it as "Gaussian Blur 2", which the catalog must keep. + s.execute("effect.apply", json!({"layers": [layer], "effect": BLUR})).unwrap(); + s.state.selected_layers = vec![LayerId(layer)]; + s.state.selected_props.clear(); + s.prefs.general.language = language.to_string(); + let mut app = EffectcraftApp::new(s); + app.show_panel(PanelKind::EffectControls); + app.show_panel(PanelKind::EffectsPresets); + app.show_panel(PanelKind::Timeline); + let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(|_| app); + h.run_steps(4); + (h, fx) +} + +/// How many widgets draw a label containing `needle` (the Effect Controls header registers its +/// drawn name with the accessibility tree, so a translated name shows up here). +fn drawn(h: &Harness<'_, EffectcraftApp>, needle: &str) -> usize { + h.query_all_by_label_contains(needle).count() +} + +/// The label the native menu gives the `effect.apply` entry for effect `id`, under top-level menu +/// `top` and category `category`. The in-window menu bar builds its labels through the same +/// `entry_label`, so this covers both menu bars. +fn menu_label(h: &Harness<'_, EffectcraftApp>, top: &str, category: &str, id: &str) -> Option { + let menu = native_menu(h.state()); + let effect = menu.menus.iter().find_map(|n| match n { + NativeNode::Submenu { label, children } if label == top => Some(children), + _ => None, + })?; + effect.iter().find_map(|cat| match cat { + NativeNode::Submenu { label, children } if label == category => children.iter().find_map(|item| match item { + NativeNode::Item(i) if i.command == "effect.apply" && i.params.get("effect").and_then(|v| v.as_str()) == Some(id) => Some(i.label.clone()), + _ => None, + }), + _ => None, + }) +} + +/// Effect Controls draws the catalog's name, including the instance index the engine appends to +/// a repeat; the English interface draws the registry's name. +#[test] +fn the_effect_controls_header_follows_the_interface_language() { + let (h, _fx) = harness("zh-hans"); + assert!(drawn(&h, BLUR_ZH) > 0, "the header draws the Chinese name"); + assert_eq!(drawn(&h, "高斯模糊 2"), 1, "the second instance keeps its index"); + assert_eq!(drawn(&h, BLUR_EN), 0, "nothing draws the English name"); + + let (en, _fx) = harness("en"); + assert!(drawn(&en, BLUR_EN) > 0); + assert_eq!(drawn(&en, BLUR_ZH), 0); +} + +/// The generated Effect menu — its category submenus and its entries — follows the catalog. +#[test] +fn the_generated_effect_menu_is_translated() { + let (h, _fx) = harness("zh-hans"); + assert_eq!(menu_label(&h, "效果", "模糊和锐化", BLUR).as_deref(), Some(BLUR_ZH), "the generated entry is translated"); + + let (en, _fx) = harness("en"); + assert_eq!(menu_label(&en, "Effect", "Blur & Sharpen", BLUR).as_deref(), Some(BLUR_EN), "the English menu is unchanged"); +} + +/// Everything an agent or a script addresses stays English: the automation label, the property +/// tree's group name and `effect.list`. +#[test] +fn the_ids_automation_labels_and_effect_list_stay_english() { + let (mut h, fx) = harness("zh-hans"); + assert_eq!( + h.state().auto.find(&format!("effectControls.effect.{fx}")).map(|e| e.label.clone()), + Some(BLUR_EN.to_string()), + "the automation label is the property tree's name" + ); + let listed = h.state_mut().session.execute("effect.list", json!({})).unwrap(); + let entry = listed.as_array().and_then(|a| a.iter().find(|e| e["id"] == BLUR)).cloned().unwrap_or_default(); + assert_eq!(entry["name"], BLUR_EN, "effect.list keeps the registry's name"); + assert_eq!(entry["category"], "Blur & Sharpen", "effect.list keeps the registry's category"); +} diff --git a/docs/gaps.md b/docs/gaps.md index e621ee87..af227a43 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) | Not present; MobileSAM is already in `effectcraft-segment` and could back it | The new one-click roto; Roto Brush covers part of it | 15–30 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; 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 6 languages; panels in 4; effect names and ~1,650 effect parameter names untranslated; French, German, Korean, Italian, Russian (After Effects ships them) absent; #599 Hangul boxes on macOS | 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 6 languages; panels in 4; effect names translated in Simplified Chinese (306, plus the generated Effect menu), their ~1,650 parameter names untranslated; French, German, Korean, Italian, Russian (After Effects ships them) absent; #599 Hangul boxes on macOS | 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 any image imports as a sequence; #324 MP3 goes silent | 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 61dfe5f1..8ff1ea3c 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Ukrainian panel catalog recounted after it caught up with the Chinese ones) · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (effect names and the generated Effect menu go through a catalog, in Simplified Chinese) · **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; @@ -19,13 +19,22 @@ interface; Arabic and Hebrew text *in layers* is supported through its Middle Ea - **Menus:** `crates/ui-egui/src/i18n.rs`, one catalog per language keyed by `(command, English label)`. A test (`every_translation_key_exists_in_the_actual_menu_tree`) fails if any fixed menu entry is untranslated or a row is stale, so every catalog covers all - **618** fixed entries. The generated Effect menu (effect names) is deliberately left in English. + **618** fixed entries. The generated Effect menu is not part of that fixed tree: its entries + and category submenus take their labels from the effect catalog below. - **Panels, dialogs, buttons, tooltips:** `crates/ui-egui/src/i18n/ui.rs`, keyed by the English source string, with English fallback. Catalogs exist for Japanese, Simplified and Traditional Chinese, Ukrainian and Czech. -- **Not translated anywhere yet:** effect names and their ≈ 1,650 parameter names - (`crates/effects`), engine-generated text (property names, errors, undo labels), the Home - screen's unwrapped literals (#574), Learn tutorials and documentation. +- **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 + English name stays the effect's identity: `effect.list`, `effect.apply {"effect": …}`, scripts + and expressions match on it, and the property tree stores it as the effect group's name. Only + the drawing — the Effect menu, Effect Controls, the Timeline and Effects & Presets — goes + through the catalog, so a project file is identical whichever interface language opens it. + A repeat (`Gaussian Blur 2`) keeps the index the engine appends, and a group the user renamed + in Effect Controls stays verbatim. No other language has rows yet, so they draw English. +- **Not translated anywhere yet:** effect *parameter* names (≈ 1,650, `crates/effects`), + engine-generated text (property names, errors, undo labels), the Home screen's unwrapped + literals (#574), Learn tutorials and documentation. - **Language choice:** Settings ▸ General ▸ Language, or Match System (`match_system_uses_the_system_language_where_there_is_a_catalog`). - **Fonts:** no bundled CJK font; the UI installs system fallbacks probed by script (Kana, Han, @@ -48,7 +57,7 @@ the true percentages are slightly lower. | Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | To `full` | |---|---|---|---|---|---|---|---| | English | `en` | ≈ 3,900 (100%, source) | All; docs in English | — | yes | full | — | -| Simplified Chinese | `zh-hans` | 1,081 (≈ 28%): menus 618/618, panels 463 | Panels and dialogs partly (≈ 35% of panel strings); no help, effects untranslated; Home screen literals unwrapped (#574) | System CJK fallback; Han follows the UI language; CJK text in comps renders wrongly (#453); IME in text layers | no | partial | 6–9 h + review | +| Simplified Chinese | `zh-hans` | 1,081 (≈ 28%): menus 618/618, panels 463 | Panels and dialogs partly (≈ 35% of panel strings); no help, effect names translated (306) and their parameter names not; Home screen literals unwrapped (#574) | System CJK fallback; Han follows the UI language; CJK text in comps renders wrongly (#453); IME in text layers | no | partial | 6–9 h + review | | Spanish | `es` | 618 (≈ 16%) | Menus only | Latin, fine | no | menus only | 7–10 h + review | | Hindi | `hi` | 0 | — | UI: Devanagari fallback font but no shaping; text layers shape Devanagari | no | none (After Effects: none) | 9–13 h + shaping in the UI | | Arabic | `ar` | 0 | — | UI: Arabic fallback font but no shaping and no RTL layout; text layers: bidi + shaping | no | none (After Effects: none) | 9–13 h + 15–25 h RTL UI | @@ -75,7 +84,7 @@ After Effects also ships Italian and Russian, which EffectCraft does not (8–12 German, Korean, Italian and Russian 0%). This is the localization row in [target-app-parity.md](target-app-parity.md#by-dimension). -**Effort.** One-time work first: route effect names, parameter names and engine messages through +**Effort.** One-time work first: route parameter names and engine messages through a catalog, wrap the remaining panel literals, and add a Hangul probe (10–20 h). Then each language needs ≈ 2,900 more strings (≈ 5–9 h per language at the pace of #398 and #401). Parity with After Effects' 10 non-English languages: **≈ 60–110 h**; the four extra languages of the standard list @@ -89,3 +98,4 @@ can give; none has had it. |---|---|---| | 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 40a8e50de93a80d7f290c952a741ca46708e5ec6 Mon Sep 17 00:00:00 2001 From: WaWu Date: Tue, 13 Oct 2026 13:53:08 +0800 Subject: [PATCH 2/3] i18n: the effect categories take the Chinese interface's own wording (#821) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verification against the installed After Effects 2026 (26.5.0.89) Simplified Chinese interface (the checklist's `07-类别` sheet) finds two of the 21 category names differ from our first suggestion: - `Keying` is 抠像, not 键控 — the interface names the folder 抠像 while the effects inside it keep 键 (颜色差值键, 亮度键, 内部/外部键), so the two do not contradict each other; - `Obsolete` is 过时, not 已废弃 — the effects in the folder say 旧版 for the legacy ones. The other nineteen are confirmed as they were. `Color Correction` (颜色校正), `Matte` (遮罩) and `Noise & Grain` (杂色和颗粒) were the three where the checklist's older machine-translated suggestion differed; the interface's wording matches what the catalog already had, so those stay. `CATEGORIES`' doc comment now records where the 21 names come from instead of calling them our wording. Refs #821 --- crates/ui-egui/src/i18n/effects.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/i18n/effects.rs b/crates/ui-egui/src/i18n/effects.rs index 5b7fe670..9e1f8c8e 100644 --- a/crates/ui-egui/src/i18n/effects.rs +++ b/crates/ui-egui/src/i18n/effects.rs @@ -408,9 +408,10 @@ const SIMPLIFIED_CHINESE: &[Row] = &[ /// Traditional Chinese (`zh-hant`): no rows yet, so the language draws the English names. const TRADITIONAL_CHINESE: &[Row] = &[]; -/// The 21 category folder names the generated Effect menu and Effects & Presets draw. The wording -/// follows the effect names above (channel → 通道, blur → 模糊, noise → 杂色, matte → 遮罩 …); -/// Adobe's Chinese interface has not been read for these 21, so they are our wording. +/// The 21 category folder names the generated Effect menu and Effects & Presets draw. 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 our first suggestion +/// differed (`Keying` is 抠像, not 键控; `Obsolete` is 过时, not 已废弃). const CATEGORIES: &[(&str, &str)] = &[ ("3D Channel", "3D 通道"), ("Audio", "音频"), @@ -421,10 +422,10 @@ const CATEGORIES: &[(&str, &str)] = &[ ("Expression Controls", "表达式控制"), ("Generate", "生成"), ("Immersive Video", "沉浸式视频"), - ("Keying", "键控"), + ("Keying", "抠像"), ("Matte", "遮罩"), ("Noise & Grain", "杂色和颗粒"), - ("Obsolete", "已废弃"), + ("Obsolete", "过时"), ("Paint", "绘画"), ("Perspective", "透视"), ("Simulation", "模拟"), From e6551593829eb2d1d4588e104fbcef15cb79eaca Mon Sep 17 00:00:00 2001 From: WaWu Date: Tue, 13 Oct 2026 14:13:28 +0800 Subject: [PATCH 3/3] i18n: the effect catalog takes a language's tables, with Japanese's slot reserved (#821) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Japanese data point in #821 (zitann2020) offered to write its rows against whatever mechanism this PR lands, so both languages share one catalog instead of inventing two. This makes adding a language a data change and reserves `ja`: - `Catalog` holds a language's effect rows and its category labels, and `catalog()` resolves a language code to one; adding a language is two tables and one arm, with no change to the lookups or to any panel. - `JAPANESE_EFFECTS` and `JAPANESE_CATEGORIES` are declared, keyed by the same effect ids and by the registry's English category names, and `ja` resolves to them. Both are empty, so a Japanese interface draws exactly what it drew before — the slot changes no behaviour on its own. - `a_pending_language_only_uses_known_ids` guards the pending tables (Japanese and Traditional Chinese) as rows arrive: an id Simplified Chinese does not know, a different English name for one of them, an empty translation or a repeated id fails. `languages_without_rows_draw_the_registry_name` asserts each pending language has an arm in `catalog()` (a table with no arm would leave a translator's rows dead) and is still row-less, so the day one gets rows the test says where its coverage moved. - `only_the_brand_names_stay_in_english` now documents that it pins *Simplified Chinese's* five brand names and is deliberately not shared: AE's Japanese interface keeps the whole `CC`, `VR` and `OCIO` sets in English while its Chinese one translates them, so repeating the English name is a language's choice and not an unfinished row. The row check reflects that (a repeated English name is accepted, an empty one is not). docs/localization-parity.md records the two empty tables and that each language decides for itself which names stay in English. Refs #821 --- crates/ui-egui/src/i18n/effects.rs | 197 +++++++++++++++++++---------- docs/localization-parity.md | 7 +- 2 files changed, 139 insertions(+), 65 deletions(-) diff --git a/crates/ui-egui/src/i18n/effects.rs b/crates/ui-egui/src/i18n/effects.rs index 9e1f8c8e..f910112f 100644 --- a/crates/ui-egui/src/i18n/effects.rs +++ b/crates/ui-egui/src/i18n/effects.rs @@ -17,15 +17,18 @@ //! [`tests::every_effect_has_a_row`] instead of silently pointing a translation at another //! effect. //! -//! Only Simplified Chinese has rows. Every other language — Traditional Chinese included — -//! draws the registry's English name; a translator adds rows to [`TRADITIONAL_CHINESE`] keyed -//! by the same ids (the test below refuses an id that Simplified Chinese does not have). +//! A language decides for itself which names stay English: After Effects' Japanese interface +//! leaves the `CC` series, the `VR` set and the `OCIO` set in English, while its Simplified +//! Chinese one translates them (the `CC` names come from Adobe's Chinese *documentation*, which +//! names 68 of the 69). Repeating the English name is therefore a language-level choice, not a +//! missing row: [`tests::only_the_brand_names_stay_in_english`] pins *Simplified Chinese's* five +//! brand names and is deliberately not shared by the other languages. //! -//! The names come from the installed After Effects 2026 (26.5.0.89) Simplified Chinese -//! interface; the `CC` series follows Adobe's Chinese *documentation*, which names 68 of the 69 -//! that the interface leaves in English (see the PR for #821). `Cryptomatte`, `EXtractoR`, -//! `IDentifier`, `Key Light` and `Unmult` stay in English as brand names, and `Face -//! Measurements` / `Face Track Points` are EffectCraft's own effects, so their wording is ours. +//! The Simplified Chinese names come from the installed After Effects 2026 (26.5.0.89) +//! Simplified Chinese interface, its 21 category folder names included; `Cryptomatte`, +//! `EXtractoR`, `IDentifier`, `Key Light` and `Unmult` stay in English as brand names, and +//! `Face Measurements` / `Face Track Points` are EffectCraft's own effects, so their wording is +//! ours. use std::collections::HashMap; use std::sync::OnceLock; @@ -35,9 +38,51 @@ use super::ui::current; /// One row: effect id, the registry's English name, the translation. type Row = (&'static str, &'static str, &'static str); +/// One language's catalog: its effect rows and its category labels, the latter keyed by the +/// registry's English category name. +struct Catalog { + rows: &'static [Row], + categories: &'static [(&'static str, &'static str)], + /// `rows` indexed by effect id, built on first use: the panels look a name up once per drawn + /// row and per frame. + index: OnceLock>, +} + +impl Catalog { + fn row(&self, id: &str) -> Option<(&'static str, &'static str)> { + self.index.get_or_init(|| self.rows.iter().map(|(id, english, translated)| (*id, (*english, *translated))).collect()).get(id).copied() + } + + fn category(&self, source: &str) -> Option<&'static str> { + self.categories.iter().find(|(english, _)| *english == source).map(|(_, translated)| *translated) + } +} + +/// The catalog for `language`; `None` for English (the source of every name, needing no lookup) +/// and for a language with no table. +/// +/// **Adding a language** is two tables and one arm: write its rows (one per effect, ids from +/// [`SIMPLIFIED_CHINESE`]) and its category labels, name them in the `static` below and in this +/// `match`. Nothing else changes — not the lookups, not one panel. `zh-hant` and `ja` are listed +/// with empty tables, so a translator fills rows in without touching this function, and until +/// then those languages draw the registry's English name. +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() }; + 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), + _ => None, + } +} + /// The effect's name to draw: the catalog row for `id`, else the registry's `english`. pub(crate) fn name(id: &str, english: &str) -> String { - row(id).map_or_else(|| english.to_string(), |(_, translated)| translated.to_string()) + catalog(current()).and_then(|c| c.row(id)).map_or_else(|| english.to_string(), |(_, translated)| translated.to_string()) } /// The name to draw for an effect group the property tree stores as `stored`: the catalog name, @@ -47,7 +92,7 @@ pub(crate) fn name(id: &str, english: &str) -> String { /// (Effect Controls ▸ Rename), so it stays verbatim — as does the whole string in a language /// with no row. pub(crate) fn group(id: &str, stored: &str) -> String { - let Some((english, translated)) = row(id) else { return stored.to_string() }; + let Some((english, translated)) = catalog(current()).and_then(|c| c.row(id)) else { return stored.to_string() }; if stored == english { return translated.to_string(); } @@ -61,38 +106,16 @@ pub(crate) fn group(id: &str, stored: &str) -> String { } /// The label to draw for the effect category `source` (the generated Effect menu's submenus and -/// Effects & Presets' folder rows). +/// Effects & Presets' folder rows), else `source`. pub(crate) fn category(source: &str) -> String { - if current() != "zh-hans" { - return source.to_string(); - } - CATEGORIES.iter().find(|(english, _)| *english == source).map_or_else(|| source.to_string(), |(_, translated)| translated.to_string()) + catalog(current()).and_then(|c| c.category(source)).map_or_else(|| source.to_string(), str::to_string) } /// Does effect `id` (`english` when the language has no row) match the Effects & Presets search -/// `query` (lowercase)? Both names match, so a Chinese interface can be searched either way. +/// `query` (lowercase)? Both names match, so a translated interface can be searched either way. pub(crate) fn matches(id: &str, english: &str, query: &str) -> bool { - english.to_lowercase().contains(query) || row(id).is_some_and(|(_, translated)| translated.to_lowercase().contains(query)) -} - -/// `id`'s (English name, translation) in the current language. -fn row(id: &str) -> Option<(&'static str, &'static str)> { - index(current()).and_then(|rows| rows.get(id).copied()) -} - -type Index = HashMap<&'static str, (&'static str, &'static str)>; - -/// The catalog for `language`, indexed by effect id (built on first use); `None` for English and -/// any language with no rows. -fn index(language: &str) -> Option<&'static Index> { - static HANS: OnceLock = OnceLock::new(); - static HANT: OnceLock = OnceLock::new(); - let (slot, rows) = match language { - "zh-hans" => (&HANS, SIMPLIFIED_CHINESE), - "zh-hant" => (&HANT, TRADITIONAL_CHINESE), - _ => return None, - }; - Some(slot.get_or_init(|| rows.iter().map(|(id, english, translated)| (*id, (*english, *translated))).collect())) + english.to_lowercase().contains(query) + || catalog(current()).and_then(|c| c.row(id)).is_some_and(|(_, translated)| translated.to_lowercase().contains(query)) } /// Simplified Chinese (`zh-hans`): one row per effect in the registry. @@ -404,15 +427,30 @@ const SIMPLIFIED_CHINESE: &[Row] = &[ ("ec.vr.sharpen", "VR Sharpen", "VR 锐化"), ("ec.vr.spheretoplane", "VR Sphere to Plane", "VR 球面到平面"), ]; - -/// Traditional Chinese (`zh-hant`): no rows yet, so the language draws the English names. +/// Traditional Chinese (`zh-hant`): no rows yet. A translator fills one row per effect, ids from +/// [`SIMPLIFIED_CHINESE`]; a row may repeat the English name, which is what the language draws +/// for that effect. const TRADITIONAL_CHINESE: &[Row] = &[]; -/// The 21 category folder names the generated Effect menu and Effects & Presets draw. 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 our first suggestion -/// differed (`Keying` is 抠像, not 键控; `Obsolete` is 过时, not 已废弃). -const CATEGORIES: &[(&str, &str)] = &[ +/// Japanese (`ja`): reserved for the Japanese data point on #821, so both languages share this +/// one catalog instead of inventing a second mechanism. A translator fills one row per effect, +/// ids from [`SIMPLIFIED_CHINESE`]; the names Adobe's Japanese interface leaves in English (the +/// `CC` series, the `VR` set, the `OCIO` set, `Cryptomatte`, `EXtractoR`, `IDentifier`, +/// `Key Light`, `Unmult`, `Twirl (Legacy)` and the two EffectCraft-only effects) repeat that +/// English name here rather than being left out. +const JAPANESE_EFFECTS: &[Row] = &[]; + +/// Traditional Chinese (`zh-hant`) category labels, keyed by the registry's English category. +const TRADITIONAL_CHINESE_CATEGORIES: &[(&str, &str)] = &[]; + +/// Japanese (`ja`) category labels, keyed by the registry's English category. +const JAPANESE_CATEGORIES: &[(&str, &str)] = &[]; + +/// 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 +/// our first suggestion differed (`Keying` is 抠像, not 键控; `Obsolete` is 过时, not 已废弃). +const SIMPLIFIED_CHINESE_CATEGORIES: &[(&str, &str)] = &[ ("3D Channel", "3D 通道"), ("Audio", "音频"), ("Blur & Sharpen", "模糊和锐化"), @@ -468,8 +506,10 @@ mod tests { assert_eq!(listed, ids.len(), "an effect id is listed twice"); } - /// The five names Adobe leaves in English as brand names, and the only rows that may: any - /// other row repeating its English name is a translation that never happened. + /// The five names Adobe's Simplified Chinese interface leaves in English as brand names, and + /// the only zh-hans rows that may repeat their English name. Deliberately not shared: another + /// language keeps different names in English (Japanese leaves the whole `CC`, `VR` and `OCIO` + /// sets in English), so this is zh-hans's own choice, not an invariant of the tables. #[test] fn only_the_brand_names_stay_in_english() { const BRANDS: [&str; 5] = ["ec.3d.cryptomatte", "ec.3d.extractor", "ec.3d.identifier", "ec.keying.keylight", "ec.key.unmult"]; @@ -478,30 +518,46 @@ mod tests { } } - /// Traditional Chinese may lag Simplified Chinese but may not invent ids or disagree about - /// which effect an id names: a translator fills this table row by row. + /// The languages that have no rows yet, with the table a translator fills in for each. A new + /// language is added here and to [`catalog`]; the test then guards its rows as they arrive. + const PENDING: &[(&str, &[Row])] = &[("zh-hant", TRADITIONAL_CHINESE), ("ja", JAPANESE_EFFECTS)]; + + /// A pending language may lag Simplified Chinese but may not invent ids or disagree about + /// which effect an id names: a translator fills these tables row by row. While they are + /// empty the test is a no-op, which is why the slot ships before the data does. #[test] - fn traditional_chinese_only_uses_known_ids() { - for (id, english, translated) in TRADITIONAL_CHINESE { - let Some((_, hans_english, _)) = SIMPLIFIED_CHINESE.iter().find(|(hans_id, _, _)| hans_id == id) else { - panic!("zh-hant row {id} is not an effect the zh-hans catalog knows"); - }; - assert_eq!(*english, *hans_english, "{id}: the two catalogs name the effect differently"); - assert!(!translated.is_empty(), "{id}: empty translation"); + fn a_pending_language_only_uses_known_ids() { + for (language, rows) in PENDING { + let mut ids: Vec<&str> = vec![]; + for (id, english, translated) in *rows { + let Some((_, hans_english, _)) = SIMPLIFIED_CHINESE.iter().find(|(hans_id, _, _)| hans_id == id) else { + panic!("{language} row {id} is not an effect the zh-hans catalog knows"); + }; + assert_eq!(*english, *hans_english, "{id}: {language} and zh-hans name the effect differently"); + assert!(!translated.is_empty(), "{id}: empty {language} translation (repeat the English name if it stays)"); + ids.push(id); + } + let listed = ids.len(); + ids.sort_unstable(); + ids.dedup(); + assert_eq!(listed, ids.len(), "{language} lists an effect id twice"); } } - /// Every category the registry and its plug-ins report has a row, and every row names a - /// category that exists: the generated Effect menu and Effects & Presets draw both. + /// Every category the registry and its plug-ins report has a Simplified Chinese row, and + /// every row of every language names a category that exists — else the generated Effect menu + /// and Effects & Presets would draw a label no folder has. #[test] fn every_category_has_a_row() { let categories = effects::categories(); for category in &categories { - assert!(CATEGORIES.iter().any(|(english, _)| english == category), "no catalog row for category {category}"); + assert!(SIMPLIFIED_CHINESE_CATEGORIES.iter().any(|(english, _)| english == category), "no catalog row for category {category}"); } - for (english, translated) in CATEGORIES { - assert!(categories.contains(english), "the catalog lists {english}, which is not a category"); - assert!(!translated.is_empty(), "empty translation for {english}"); + for (language, rows) in [("zh-hans", SIMPLIFIED_CHINESE_CATEGORIES), ("zh-hant", TRADITIONAL_CHINESE_CATEGORIES), ("ja", JAPANESE_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}"); + } } } @@ -520,11 +576,24 @@ mod tests { set_current("en"); } - /// English, and every language without rows (Traditional Chinese included), draws the - /// registry's name; an id with no row keeps it too rather than drawing an empty label. + /// English, and a language with no catalog or no row for this effect, draws the registry's + /// name rather than an empty label. + /// + /// The pending languages are also checked to still be row-less and to have an arm in + /// [`catalog`]: an arm with no table would leave a translator's rows dead, and the day a + /// pending language gets rows this fails so its slot moves out of [`PENDING`], where the + /// coverage for it lives. #[test] fn languages_without_rows_draw_the_registry_name() { - for language in ["en", "ja", "zh-hant", "uk", "cs"] { + for language in ["en", "uk", "cs"] { + set_current(language); + assert_eq!(name("ec.blur.gaussian", "Gaussian Blur"), "Gaussian Blur", "{language}"); + assert_eq!(group("ec.blur.gaussian", "Gaussian Blur 2"), "Gaussian Blur 2", "{language}"); + assert_eq!(category("Blur & Sharpen"), "Blur & Sharpen", "{language}"); + } + for (language, rows) in PENDING { + assert!(catalog(language).is_some(), "{language} has a table but no arm in `catalog`: its rows would be dead"); + assert!(rows.is_empty(), "{language} has rows now: move it out of PENDING and cover it here"); set_current(language); assert_eq!(name("ec.blur.gaussian", "Gaussian Blur"), "Gaussian Blur", "{language}"); assert_eq!(group("ec.blur.gaussian", "Gaussian Blur 2"), "Gaussian Blur 2", "{language}"); diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 8ff1ea3c..52c51c67 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -31,7 +31,12 @@ interface; Arabic and Hebrew text *in layers* is supported through its Middle Ea the drawing — the Effect menu, Effect Controls, the Timeline and Effects & Presets — goes through the catalog, so a project file is identical whichever interface language opens it. A repeat (`Gaussian Blur 2`) keeps the index the engine appends, and a group the user renamed - in Effect Controls stays verbatim. No other language has rows yet, so they draw English. + in Effect Controls stays verbatim. Adding a language there is two tables and one arm, and no + panel changes: Traditional Chinese and Japanese have their tables in place and empty, so those + languages draw English until a translator fills them (the Japanese set for #821 is what that + slot is for). Each language decides for itself which names stay English — Adobe's Japanese + interface leaves the `CC`, `VR` and `OCIO` sets in English while its Chinese one translates + them — so a row repeating the English name is a choice, not a missing row. - **Not translated anywhere yet:** effect *parameter* names (≈ 1,650, `crates/effects`), engine-generated text (property names, errors, undo labels), the Home screen's unwrapped literals (#574), Learn tutorials and documentation.