Merge pull request #1199 from cjcwwww/i18n/effect-names

# Conflicts:
#	crates/ui-egui/src/panels/timeline.rs
#	docs/gaps.md
#	docs/localization-parity.md
This commit is contained in:
Brandon Thomas 2026-10-13 04:33:20 -04:00
commit 32792ff172
10 changed files with 856 additions and 25 deletions

View File

@ -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": <id>}` 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<String> {
(!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" => {

View File

@ -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<HashMap<&'static str, (&'static str, &'static str)>>,
}
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}`");
}
}
}

View File

@ -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));

View File

@ -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() {

View File

@ -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::<String>(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()

View File

@ -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<Apply> = 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::<DragPayload>(&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);

View File

@ -769,7 +769,11 @@ fn search_rows(l: &Layer, q: &str) -> Vec<Row> {
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));

View File

@ -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<String> {
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");
}

View File

@ -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) |

View File

@ -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 |