diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index df4a4142..4caae8dd 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..f910112f --- /dev/null +++ b/crates/ui-egui/src/i18n/effects.rs @@ -0,0 +1,652 @@ +//! 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. +//! +//! 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 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; + +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 { + 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, +/// 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)) = catalog(current()).and_then(|c| c.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), else `source`. +pub(crate) fn category(source: &str) -> 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 translated interface can be searched either way. +pub(crate) fn matches(id: &str, english: &str, query: &str) -> bool { + 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. +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. 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] = &[]; + +/// 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", "模糊和锐化"), + ("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'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"]; + for (id, english, translated) in SIMPLIFIED_CHINESE { + assert_eq!(english == translated, BRANDS.contains(id), "{id}: {english:?} is drawn as {translated:?}"); + } + } + + /// 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 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 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!(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 (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}"); + } + } + } + + /// 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 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", "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}"); + 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 04f55a02..ab2285b3 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -2043,7 +2043,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 c3266acf..3e098449 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 b3c17156..d61936ba 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -1163,6 +1163,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; @@ -1212,14 +1215,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()); @@ -1238,7 +1242,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( @@ -1247,10 +1255,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); @@ -1280,7 +1288,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(); } @@ -1622,7 +1630,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 af02a5e0..76977ed7 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -769,7 +769,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; @@ -2362,6 +2366,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)); 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 d7b8211c..c22a97fb 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 and ~1,650 effect 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 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) | | 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 72a82e66..5606a857 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -19,14 +19,28 @@ 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 - fixed entries (624 catalog rows each at 470ea7a3, counted). The generated Effect menu (effect names) is deliberately left in English. + fixed entries (624 catalog rows each at 470ea7a3, counted). 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 (410 rows), Simplified and Traditional Chinese (506 each), Ukrainian (505), Brazilian Portuguese (400) and Czech (501); counted at 470ea7a3. -- **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. 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. - **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, @@ -50,7 +64,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,130 (≈ 29%): menus 624 rows, panels 506 | Panels and dialogs partly; no help, effects untranslated; Home screen literals unwrapped (#574, closed) | System CJK fallback; Han follows the UI language; CJK text in comps renders wrongly (#453); IME in text layers | no | partial | 6–9 h + review | +| Simplified Chinese | `zh-hans` | 1,130 (≈ 29%): menus 624 rows, panels 506 | Panels and dialogs partly; no help, effect names translated (306) and their parameter names not; Home screen literals unwrapped (#574, closed) | System CJK fallback; Han follows the UI language; CJK text in comps renders wrongly (#453); IME in text layers | no | partial | 6–9 h + review | | Spanish | `es` | 624 (≈ 16%) | Menus only (panel catalog in open PR #996) | 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 | @@ -77,7 +91,7 @@ Russian (`ru`) has a menu catalog (624 rows, ≈ 16%); After Effects also ships German and Russian 16%, Korean and Italian 0%; 175 / 10, was ≈ 12% on 10 October). 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 @@ -92,3 +106,4 @@ can give; none has had it. | 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-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 |