From b6cdc9dd7ac285966b1cf6691aee9a2b7ab9790e Mon Sep 17 00:00:00 2001 From: txiki739 <111882715+txiki739@users.noreply.github.com> Date: Thu, 8 Oct 2026 01:41:48 +0200 Subject: [PATCH 01/15] i18n: Spanish for the whole interface, catalogs and a remembered language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Translations move from Rust tables to one catalog per language (crates/ui-egui/src/i18n/.tsv), looked up through HashMaps. UI code wraps its strings in tl!("…") and tlf!("…{name}", name); names from registries (effects and parameters, settings, commands, panels, workspaces, export and effect presets) are translated where they are drawn, so command ids, automation ids and preference values stay English. Spanish now covers menus, panels, dialogs, the Settings pages, the Effects and Effect Controls registry, the Keyboard Shortcuts dialog and status messages (about 3,350 entries). Tests fail when a tl!/tlf! literal, a menu label or a registry name has no Spanish entry, and check catalog syntax, duplicates, placeholders and ellipses. Searches match the Spanish label as well as the English source. Window titles keep their English egui ids. The language is stored in the general.interfaceLanguage preference (also in Settings > General) and restored on startup; a failed preferences write still switches the interface and says it could not be saved. Template values are inserted literally, so a file name containing braces is not re-substituted. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- apps/filmcraft/src/main.rs | 30 +- crates/engine/src/settings.rs | 4 + crates/engine/src/settings_tests.rs | 15 + crates/ui-egui/README.md | 25 + crates/ui-egui/src/dock.rs | 4 +- crates/ui-egui/src/header.rs | 30 +- crates/ui-egui/src/i18n.rs | 656 ---- crates/ui-egui/src/i18n/catalog.rs | 97 + crates/ui-egui/src/i18n/es.tsv | 3378 +++++++++++++++++ crates/ui-egui/src/i18n/ja.tsv | 93 + crates/ui-egui/src/i18n/mod.rs | 619 +++ crates/ui-egui/src/lib.rs | 112 +- crates/ui-egui/src/menus.rs | 14 +- crates/ui-egui/src/panels/audio_fx_editor.rs | 84 +- crates/ui-egui/src/panels/clip_dialogs.rs | 479 +-- crates/ui-egui/src/panels/color_dialogs.rs | 91 +- crates/ui-egui/src/panels/dialogs.rs | 337 +- crates/ui-egui/src/panels/effect_controls.rs | 92 +- crates/ui-egui/src/panels/effects.rs | 28 +- crates/ui-egui/src/panels/essential_sound.rs | 105 +- crates/ui-egui/src/panels/events.rs | 34 +- crates/ui-egui/src/panels/export_mode.rs | 372 +- crates/ui-egui/src/panels/file_dialogs.rs | 30 +- crates/ui-egui/src/panels/graphics.rs | 135 +- .../ui-egui/src/panels/graphics_templates.rs | 137 +- crates/ui-egui/src/panels/import_mode.rs | 16 +- .../ui-egui/src/panels/interchange_export.rs | 39 +- crates/ui-egui/src/panels/lumetri.rs | 136 +- crates/ui-egui/src/panels/masks.rs | 38 +- crates/ui-egui/src/panels/media_browser.rs | 93 +- crates/ui-egui/src/panels/media_dialogs.rs | 510 +-- crates/ui-egui/src/panels/menu_dialogs.rs | 711 ++-- crates/ui-egui/src/panels/metadata.rs | 16 +- crates/ui-egui/src/panels/misc.rs | 38 +- crates/ui-egui/src/panels/mixer.rs | 136 +- crates/ui-egui/src/panels/mod.rs | 18 +- crates/ui-egui/src/panels/monitor.rs | 101 +- crates/ui-egui/src/panels/monitor_view.rs | 239 +- crates/ui-egui/src/panels/multicam.rs | 305 +- crates/ui-egui/src/panels/presets.rs | 82 +- crates/ui-egui/src/panels/project.rs | 114 +- crates/ui-egui/src/panels/project_dialogs.rs | 58 +- crates/ui-egui/src/panels/project_views.rs | 101 +- crates/ui-egui/src/panels/reference.rs | 12 +- crates/ui-egui/src/panels/remix.rs | 73 +- crates/ui-egui/src/panels/scopes.rs | 54 +- crates/ui-egui/src/panels/settings.rs | 90 +- crates/ui-egui/src/panels/shortcuts_dialog.rs | 155 +- crates/ui-egui/src/panels/text.rs | 89 +- crates/ui-egui/src/panels/timecode.rs | 19 +- crates/ui-egui/src/panels/timeline.rs | 54 +- .../ui-egui/src/panels/timeline_automation.rs | 19 +- .../ui-egui/src/panels/timeline_captions.rs | 2 +- crates/ui-egui/src/panels/tools.rs | 4 +- crates/ui-egui/src/panels/trim_monitor.rs | 20 +- crates/ui-egui/src/panels/voiceover.rs | 145 +- crates/ui-egui/src/panels/workspaces.rs | 133 +- crates/ui-egui/tests/scripted.rs | 20 + 59 files changed, 7308 insertions(+), 3335 deletions(-) create mode 100644 crates/ui-egui/README.md delete mode 100644 crates/ui-egui/src/i18n.rs create mode 100644 crates/ui-egui/src/i18n/catalog.rs create mode 100644 crates/ui-egui/src/i18n/es.tsv create mode 100644 crates/ui-egui/src/i18n/ja.tsv create mode 100644 crates/ui-egui/src/i18n/mod.rs diff --git a/ROADMAP.md b/ROADMAP.md index 553a6fcf..3202c586 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -205,4 +205,4 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining ### Interface localization -Edit > Language switches English/Japanese and persists in UI state. Commands `app.language.english` and `app.language.japanese` are reachable through the control channel. Core native/in-window menu labels are translated; untranslated labels use English. Japanese text uses the Japanese fonts from [craft-fonts](https://github.com/storytold/craft-fonts) when FilmCraft is built with them (`CRAFT_FONTS_DIR`; all official releases, including the web build), otherwise a Japanese font already installed on the system; with neither, switching to Japanese is refused with a message. Dialog/panel translation coverage remains open. Existing vertical text support is preserved. +Edit > Language switches English/Japanese/Spanish and persists across restarts through `general.interfaceLanguage` (also available in Settings > General). Commands `app.language.english`, `app.language.japanese` and `app.language.spanish` are reachable through the control channel. Translations live in one catalog per language (`crates/ui-egui/src/i18n/.tsv`); UI code wraps its strings in `tl!("…")` / `tlf!("…{name}", name)`, and names that come from registries (effects and their parameters, settings, commands, panels, workspaces, presets) are translated where they are drawn. **Spanish covers the whole interface**: menus, panels, dialogs, the Settings pages, the Effects/Effect Controls registry, the Keyboard Shortcuts dialog and status messages; tests fail when a `tl!` literal, a menu label or a registry name has no Spanish entry. Searches match Spanish display labels as well as English sources. Japanese covers the core menus; untranslated labels use English. Engine error messages, the control channel, the CLI and MCP stay in English. Japanese text uses the Japanese fonts from [craft-fonts](https://github.com/storytold/craft-fonts) when FilmCraft is built with them (`CRAFT_FONTS_DIR`; all official releases, including the web build), otherwise a Japanese font already installed on the system; with neither, switching to Japanese is refused with a message. Existing vertical text support is preserved. diff --git a/apps/filmcraft/src/main.rs b/apps/filmcraft/src/main.rs index 2e465902..82755ddb 100644 --- a/apps/filmcraft/src/main.rs +++ b/apps/filmcraft/src/main.rs @@ -169,13 +169,27 @@ fn main() -> eframe::Result { app.audio = Some(Box::new(out)); } app.hooks.pick_files = Some(Box::new(|exts: &[&str]| { - rfd::FileDialog::new().add_filter("Media", exts).pick_files().unwrap_or_default().into_iter().map(|p| p.to_string_lossy().to_string()).collect() + rfd::FileDialog::new() + .add_filter(filmcraft_ui_egui::i18n::t("Media"), exts) + .pick_files() + .unwrap_or_default() + .into_iter() + .map(|p| p.to_string_lossy().to_string()) + .collect() })); app.hooks.pick_save = Some(Box::new(|name: &str| { - rfd::FileDialog::new().add_filter("FilmCraft Project", &["fcproj"]).set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()) + rfd::FileDialog::new() + .add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &["fcproj"]) + .set_file_name(name) + .save_file() + .map(|p| p.to_string_lossy().to_string()) })); app.hooks.pick_save_as = Some(Box::new(|filter: &str, exts: &[&str], name: &str| { - rfd::FileDialog::new().add_filter(filter, exts).set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()) + rfd::FileDialog::new() + .add_filter(filmcraft_ui_egui::i18n::t(filter), exts) + .set_file_name(name) + .save_file() + .map(|p| p.to_string_lossy().to_string()) })); app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string()))); app.hooks.open_path = Some(Box::new(open_path)); @@ -183,10 +197,14 @@ fn main() -> eframe::Result { window_raise::raise_without_focus(); })); app.hooks.pick_open_file = Some(Box::new(|filter: &str, exts: &[&str]| { - rfd::FileDialog::new().add_filter(filter, exts).pick_file().map(|p| p.to_string_lossy().to_string()) + rfd::FileDialog::new().add_filter(filmcraft_ui_egui::i18n::t(filter), exts).pick_file().map(|p| p.to_string_lossy().to_string()) + })); + app.hooks.pick_open_project = Some(Box::new(|| { + rfd::FileDialog::new() + .add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &["fcproj"]) + .pick_file() + .map(|p| p.to_string_lossy().to_string()) })); - app.hooks.pick_open_project = - Some(Box::new(|| rfd::FileDialog::new().add_filter("FilmCraft Project", &["fcproj"]).pick_file().map(|p| p.to_string_lossy().to_string()))); #[cfg(target_os = "macos")] { let (rx, update) = native_menu::install(&app, cc.egui_ctx.clone()); diff --git a/crates/engine/src/settings.rs b/crates/engine/src/settings.rs index c9a5d430..7d0eb84b 100644 --- a/crates/engine/src/settings.rs +++ b/crates/engine/src/settings.rs @@ -33,6 +33,8 @@ pub const PREFS_VERSION: u32 = 2; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct GeneralPrefs { + /// Interface language (persisted independently of projects): `en`, `ja`, or `es`. + pub interface_language: String, /// "At Startup": `showHome` (FilmCraft: the demo project), `openMostRecent`, `emptyProject`. pub at_startup: String, /// "When Opening a Project": `showOpenDialog` | `showHome`. @@ -59,6 +61,7 @@ pub struct GeneralPrefs { impl Default for GeneralPrefs { fn default() -> Self { Self { + interface_language: "en".into(), at_startup: "showHome".into(), when_opening_project: "showOpenDialog".into(), bins_double_click: "openInPlace".into(), @@ -672,6 +675,7 @@ static CATEGORIES: &[Category] = &[ id: "general", title: "General", rows: &[ + f("general.interfaceLanguage", "Interface Language", Kind::Choice(&[("en", "English"), ("ja", "日本語"), ("es", "Español")]), true), f("general.atStartup", "At Startup", Kind::Choice(STARTUP), true), f("general.whenOpeningProject", "When Opening a Project", Kind::Choice(OPENING), false), Row::Group( diff --git a/crates/engine/src/settings_tests.rs b/crates/engine/src/settings_tests.rs index c0ec49af..601023f0 100644 --- a/crates/engine/src/settings_tests.rs +++ b/crates/engine/src/settings_tests.rs @@ -22,6 +22,21 @@ fn set(s: &mut Session, key: &str, value: Value) { s.execute("prefs.set", json!({"key": key, "value": value})).unwrap_or_else(|e| panic!("{key}: {e}")); } +#[test] +fn interface_language_persists_and_rejects_hostile_values() { + let dir = tmp_dir("interface-language"); + let path = dir.join("preferences.json"); + let mut s = Session { prefs_path: Some(path.clone()), ..Session::default() }; + set(&mut s, "general.interfaceLanguage", json!("es")); + assert_eq!(Preferences::load(&path).general.interface_language, "es"); + for value in [json!("xx"), json!("es-MX"), json!(null), json!(-1), json!({}), json!("x".repeat(4096))] { + assert!(s.execute("prefs.set", json!({"key": "general.interfaceLanguage", "value": value})).is_err()); + assert_eq!(s.prefs.general.interface_language, "es"); + } + assert_eq!(Preferences::load(&path).general.interface_language, "es"); + std::fs::remove_dir_all(dir).unwrap(); +} + fn write_png(path: &Path, w: u32, h: u32) { image::RgbaImage::from_pixel(w, h, image::Rgba([200, 40, 40, 255])).save(path).unwrap(); } diff --git a/crates/ui-egui/README.md b/crates/ui-egui/README.md new file mode 100644 index 00000000..75c11e1b --- /dev/null +++ b/crates/ui-egui/README.md @@ -0,0 +1,25 @@ +# FilmCraft interface + +The egui frontend draws panels and dialogs, and dispatches project changes through the engine. +Interactive controls keep stable automation ids regardless of the interface language. + +## Localisation + +Edit > Language offers English, Japanese and Spanish. The language is stored in the engine's +`general.interfaceLanguage` preference, restored on startup, and also available in Settings > General. +The `app.language.*` UI commands and `prefs.set` reach it through the control channel. +Japanese requires a craft-fonts build or a suitable installed font. + +English source strings are lookup keys in `src/i18n/.tsv`. `tl!` translates literals, `tlf!` +fills translated templates, and `i18n::t` translates names from registries. Placeholder values +(including user filenames containing braces) are inserted literally. Catalog translations are +original work using ordinary language, without proprietary localisation resources. + +Spanish covers menus, panels, dialogs, settings and registry labels. Searches accept both the +translated label and its English source, including Unicode capitals. Project content, command ids +and preference values retain their original values. Engine errors, CLI and MCP messages remain +English; Japanese currently covers core menus and falls back to English elsewhere. + +Verification: `cargo test -p filmcraft-ui-egui` checks catalog syntax, duplicate keys, placeholders, +literal/menu/registry coverage and UI behaviour; `cargo xtask ci` runs the workspace gates. Visual +checks use the control channel to switch language and capture the resulting panels. diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 41d31bf1..f98036e4 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -536,9 +536,9 @@ pub fn draw_group_chrome( tabs.extend(seqs.open.iter().map(|(id, name)| Tab { panel: *p, seq: Some(*id), title: name.clone(), active: seqs.active == Some(*id) })); } else if *p == PanelKind::Timeline { let name = seqs.open.iter().find(|(id, _)| seqs.active == Some(*id)).map(|(_, n)| n.clone()); - tabs.push(Tab { panel: *p, seq: None, title: name.unwrap_or_else(|| "Timeline: (no sequences)".into()), active }); + tabs.push(Tab { panel: *p, seq: None, title: name.unwrap_or_else(|| tl!("Timeline: (no sequences)").into()), active }); } else { - tabs.push(Tab { panel: *p, seq: None, title: p.title().to_string(), active }); + tabs.push(Tab { panel: *p, seq: None, title: crate::i18n::t(p.title()).to_string(), active }); } } let activate = |tab: &Tab, actions: &mut Vec| match tab.seq { diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index ed1fd388..8615b827 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -26,7 +26,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut x = rect.min.x + if app.integrated_titlebar { 104.0 } else { 14.0 }; // Home let home = Rect::from_center_size(pos2(x + 10.0, rect.center().y), vec2(26.0, 26.0)); - let hresp = ui.interact(home, egui::Id::new("hdr-home"), Sense::click()).on_hover_text("Home"); + let hresp = ui.interact(home, egui::Id::new("hdr-home"), Sense::click()).on_hover_text(tl!("Home")); app.auto.add("header.home", home, "Home"); if hresp.hovered() { p.rect_filled(home, 4.0, t.hover); @@ -37,9 +37,9 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } x = home.max.x + 16.0; // Mode tabs (14 pt; active = primary text with a 2 pt underline under the label) - for (m, label) in [(Mode::Import, "Import"), (Mode::Edit, "Edit"), (Mode::Export, "Export")] { + for (m, label, shown) in [(Mode::Import, "Import", tl!("Import")), (Mode::Edit, "Edit", tl!("Edit")), (Mode::Export, "Export", tl!("Export"))] { let active = app.ui.mode == m; - let galley = p.layout_no_wrap(label.to_string(), Tokens::ui(14.0), if active { t.tab_text_active } else { t.tab_text }); + let galley = p.layout_no_wrap(shown.to_string(), Tokens::ui(14.0), if active { t.tab_text_active } else { t.tab_text }); let r = Rect::from_min_size(pos2(x - 6.0, rect.min.y + 6.0), vec2(galley.size().x + 12.0, rect.height() - 12.0)); let resp = ui.interact(r, egui::Id::new(("mode", label)), Sense::click()); app.auto.add(&format!("header.mode.{}", label.to_ascii_lowercase()), r, label); @@ -66,7 +66,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { left_end = mu.min_rect().max.x; } // Document title, centred - let title = format!("{}{}", app.session.project.name, if app.session.is_dirty() { " - Edited" } else { "" }); + let title = if app.session.is_dirty() { tlf!("{name} - Edited", name = app.session.project.name) } else { app.session.project.name.clone() }; // Right cluster: icons at ~38 pt pitch, then workspace name in caps. let mut rx = rect.max.x - 14.0; let mut btn = |ui: &mut egui::Ui, icon: Icon, id: &str, tip: &str, app: &mut FilmcraftApp| -> egui::Response { @@ -80,29 +80,29 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { icons::paint(ui.painter(), r.shrink(6.0), icon, if resp.hovered() { t.text } else { t.text_dim }); resp }; - if btn(ui, Icon::Fullscreen, "fullscreen", "Full screen", app).clicked() { + if btn(ui, Icon::Fullscreen, "fullscreen", tl!("Full screen"), app).clicked() { let fs = ui.ctx().input(|i| i.viewport().fullscreen.unwrap_or(false)); ui.ctx().send_viewport_cmd(egui::ViewportCommand::Fullscreen(!fs)); } - if btn(ui, Icon::Speaker, "volume", "Volume", app).clicked() { - app.ui.status = "Master volume: use the Audio Track Mixer".into(); + if btn(ui, Icon::Speaker, "volume", tl!("Volume"), app).clicked() { + app.ui.status = tl!("Master volume: use the Audio Track Mixer").into(); } - if btn(ui, Icon::Search, "search", "Search", app).clicked() { + if btn(ui, Icon::Search, "search", tl!("Search"), app).clicked() { app.show_panel(crate::dock::PanelKind::Effects); } - if btn(ui, Icon::Bell, "notifications", "Progress", app).clicked() { + if btn(ui, Icon::Bell, "notifications", tl!("Progress"), app).clicked() { app.ui.mode = Mode::Export; } // Quick Export: a popup with File Name & Location, a preset list and Export (Premiere 26) let qx = rect.max.x - 14.0 - 4.0 * 38.0; // the fifth button from the right - if btn(ui, Icon::Export, "quickExport", "Quick Export", app).clicked() { + if btn(ui, Icon::Export, "quickExport", tl!("Quick Export"), app).clicked() { app.ui.export.quick_open = !app.ui.export.quick_open; ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("quick-export-toggled"), true)); } crate::panels::export_mode::quick_export(app, ui.ctx(), pos2(qx - 340.0, rect.max.y + 4.0)); - let ws_resp = btn(ui, Icon::Workspaces, "workspaces", "Workspaces", app); + let ws_resp = btn(ui, Icon::Workspaces, "workspaces", tl!("Workspaces"), app); // workspace name (caps) - let ws = app.ui.workspace.to_uppercase(); + let ws = crate::i18n::t(&app.ui.workspace).to_uppercase(); let wg = p.layout_no_wrap(ws.clone(), Tokens::ui(11.0), t.text_dim); let wr = Rect::from_min_size(pos2(rx - wg.size().x + 10.0, rect.center().y - 10.0), vec2(wg.size().x + 8.0, 20.0)); let wresp = ui.interact(wr, egui::Id::new("hdr-ws-name"), Sense::click()); @@ -114,7 +114,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let g = p.layout_no_wrap(label.to_string(), Tokens::ui(12.0), Color32::WHITE); let w = g.size().x + 34.0; let r = Rect::from_min_size(pos2(wr.min.x - w - 14.0, rect.center().y - 12.0), vec2(w, 24.0)); - let resp = ui.interact(r, egui::Id::new("hdr-discord"), Sense::click()).on_hover_text("Join the ArtCraft Discord (discord.gg/artcraft)"); + let resp = ui.interact(r, egui::Id::new("hdr-discord"), Sense::click()).on_hover_text(tl!("Join the ArtCraft Discord (discord.gg/artcraft)")); app.auto.add("header.discord", r, "Join the ArtCraft Discord"); ui.painter().rect_filled(r, 12.0, if resp.hovered() { t.accent_hover } else { t.accent }); icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 14.0, r.center().y), vec2(14.0, 14.0)), Icon::Chat, Color32::WHITE); @@ -152,13 +152,13 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.set_min_width(220.0); for w in crate::dock::names(&app.workspaces) { let sel = app.ui.workspace == w; - if ui.selectable_label(sel, &w).clicked() { + if ui.selectable_label(sel, crate::i18n::t(&w)).clicked() { app.set_workspace(&w); ui.ctx().data_mut(|d| d.insert_temp(popup_id, false)); } } ui.separator(); - if ui.button("Reset to Saved Layout").clicked() { + if ui.button(tl!("Reset to Saved Layout")).clicked() { let n = app.ui.workspace.clone(); app.set_workspace(&n); ui.ctx().data_mut(|d| d.insert_temp(popup_id, false)); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs deleted file mode 100644 index a18ced6e..00000000 --- a/crates/ui-egui/src/i18n.rs +++ /dev/null @@ -1,656 +0,0 @@ -//! Interface translations. Command ids, document text and file names remain stable. -//! Untranslated labels fall back to English so coverage can grow incrementally. -//! -//! Japanese text uses the Japanese craft-fonts when FilmCraft was built with them (`CRAFT_FONTS_DIR`; -//! `theme::install` already puts them in every font family, see [`craft_japanese_font`]), otherwise -//! a font already installed on the system ([`system_japanese_font`]); with neither, switching to -//! Japanese is refused with a message. - -use std::sync::{Arc, OnceLock}; - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum Language { - #[default] - En, - Ja, - Es, -} - -impl Language { - pub const ALL: [Self; 3] = [Self::En, Self::Ja, Self::Es]; - - pub fn name(self) -> &'static str { - match self { - Self::En => "English", - Self::Ja => "日本語", - Self::Es => "Español", - } - } - - pub fn parse(code: &str) -> Option { - match code { - "en" => Some(Self::En), - "ja" => Some(Self::Ja), - "es" => Some(Self::Es), - _ => None, - } - } - - pub fn tr(self, text: &str) -> &str { - let table = match self { - Self::En => return text, - Self::Ja => JAPANESE, - Self::Es => SPANISH, - }; - table.iter().find(|(english, _)| *english == text).map_or(text, |(_, translated)| translated) - } -} - -/// Text every Japanese interface font must cover (menus use kanji, hiragana and katakana). -const JAPANESE_SAMPLE: &str = "日本語ファイル編集あア"; - -/// Installed families preferred for Japanese interface text, best first (Gothic / sans-serif faces -/// read best at menu sizes). Any other installed face that covers [`JAPANESE_SAMPLE`] is used if -/// none of these is present. -const PREFERRED_JAPANESE: &[&str] = &[ - "Hiragino Sans", - "Hiragino Kaku Gothic ProN", - "Hiragino Kaku Gothic Pro", - "Yu Gothic UI", - "Yu Gothic", - "Meiryo UI", - "Meiryo", - "Noto Sans CJK JP", - "Noto Sans JP", - "Source Han Sans JP", - "Source Han Sans", - "IPAexGothic", - "IPAGothic", - "TakaoGothic", - "VL Gothic", -]; - -const JAPANESE_FONT: &str = "system-japanese"; - -/// A Japanese font already installed on this system, for the interface (none is bundled). Looked up -/// once per process: the system font folders are scanned on first use (name tables only), then the -/// chosen face's file is read. `None` on the web and on systems without a Japanese font. -pub fn system_japanese_font() -> Option> { - static FONT: OnceLock>> = OnceLock::new(); - FONT.get_or_init(|| { - filmcraft_text::fonts::scan_system(); - let faces: Vec<_> = filmcraft_text::fonts::all_faces().into_iter().filter(|f| f.info.origin == "system" && !f.info.italic).collect(); - let covers = |f: &filmcraft_text::fonts::Face| JAPANESE_SAMPLE.chars().all(|c| f.has_char(c)); - // within a family, the face closest to regular weight - let by_weight = |f: &&Arc| f.info.weight.abs_diff(400); - let preferred = - PREFERRED_JAPANESE.iter().find_map(|name| faces.iter().filter(|f| f.info.family.eq_ignore_ascii_case(name) && covers(f)).min_by_key(by_weight)); - let face = preferred.or_else(|| faces.iter().filter(|f| covers(f)).min_by_key(by_weight))?; - // kept for the life of the process (one font, read once) so installing it again after a - // theme change shares the bytes instead of copying the whole file - let bytes: &'static [u8] = Box::leak(face.data()?.into_boxed_slice()); - Some(Arc::new(egui::FontData { font: std::borrow::Cow::Borrowed(bytes), index: face.info.index, tweak: Default::default() })) - }) - .clone() -} - -/// Whether the craft-fonts build input (empty unless built with `CRAFT_FONTS_DIR`) supplies a -/// Japanese interface font: some craft-fonts face covers [`JAPANESE_SAMPLE`]. `theme::install` adds -/// these faces to every font family, so nothing else needs installing (and no system scan runs). -pub fn craft_japanese_font() -> bool { - filmcraft_text::fonts::craft_japanese().next().is_some() - && filmcraft_text::fonts::all_faces() - .iter() - .any(|f| f.info.origin == filmcraft_text::fonts::CRAFT_ORIGIN && JAPANESE_SAMPLE.chars().all(|c| f.has_char(c))) -} - -/// Japanese for the interface: true when built with the craft-fonts (already installed by -/// `theme::install`). Otherwise add the system's Japanese font as the last fallback of every theme font family, from the next -/// pass on. Returns false (and changes nothing) when no Japanese font is installed. Call it again -/// after `theme::install`, which replaces the font definitions. -pub fn install_japanese_font(ctx: &egui::Context) -> bool { - if craft_japanese_font() { - return true; - } - let Some(font) = system_japanese_font() else { return false }; - let families = crate::theme::font_families() - .into_iter() - .map(|family| egui::epaint::text::InsertFontFamily { family, priority: egui::epaint::text::FontPriority::Lowest }) - .collect(); - // queued for the next pass (works before the first frame); a no-op when already installed - ctx.add_font(egui::epaint::text::FontInsert { name: JAPANESE_FONT.into(), data: (*font).clone(), families }); - true -} - -const SPANISH: &[(&str, &str)] = &[ - ("About FilmCraft", "Acerca de FilmCraft"), - ("Add Audio Submix Track", "Añadir pista de submezcla de audio"), - ("Add Caption at Playhead", "Añadir subtítulo en el cabezal"), - ("Add Chapter Marker…", "Añadir marcador de capítulo…"), - ("Add Edit", "Añadir edición"), - ("Add Edit to All Tracks", "Añadir edición a todas las pistas"), - ("Add Flash Cue Marker…", "Añadir marcador de referencia Flash…"), - ("Add Frame Hold", "Añadir fotograma congelado"), - ("Add Guide…", "Añadir guía…"), - ("Add Marker", "Añadir marcador"), - ("Add New Caption Track…", "Añadir nueva pista de subtítulos…"), - ("Add Range Marker", "Añadir marcador de rango"), - ("Add Range Marker to In and Out", "Añadir marcador de rango de entrada a salida"), - ("Add Tracks…", "Añadir pistas…"), - ("Adjustment Layer…", "Capa de ajuste…"), - ("Align to Selection", "Alinear con la selección"), - ("Align to Video Frame", "Alinear con el fotograma de video"), - ("Align to Video Frame as Group", "Alinear con el fotograma de video como grupo"), - ("All Panels", "Todos los paneles"), - ("Alpha", "Alfa"), - ("Appearance", "Apariencia"), - ("Appearance…", "Apariencia…"), - ("Apply Audio Transition", "Aplicar transición de audio"), - ("Apply Default Transitions to Selection", "Aplicar transiciones predeterminadas a la selección"), - ("Apply Video Transition", "Aplicar transición de video"), - ("Arrange", "Organizar"), - ("ArtCraft Website", "Sitio web de ArtCraft"), - ("Assembly", "Ensamblaje"), - ("Attach Proxies…", "Adjuntar proxies…"), - ("Audio Channels…", "Canales de audio…"), - ("Audio Clip Mixer", "Mezclador de clips de audio"), - ("Audio Gain…", "Ganancia de audio…"), - ("Audio Hardware…", "Hardware de audio…"), - ("Audio In", "Entrada de audio"), - ("Audio Meters", "Medidores de audio"), - ("Audio Options", "Opciones de audio"), - ("Audio Out", "Salida de audio"), - ("Audio Track Mixer", "Mezclador de pistas de audio"), - ("Audio Waveform", "Forma de onda de audio"), - ("Auto Save…", "Guardado automático…"), - ("Automate to Sequence…", "Automatizar a secuencia…"), - ("Bars and Tone…", "Barras y tono…"), - ("Bin", "Bandeja"), - ("Bin From Selection", "Bandeja a partir de la selección"), - ("Black Video…", "Video negro…"), - ("Blue", "Azul"), - ("Bottom", "Abajo"), - ("Breakout to Mono", "Separar en mono"), - ("Bring Forward", "Traer adelante"), - ("Bring to Front", "Traer al frente"), - ("Brown", "Marrón"), - ("Captions", "Subtítulos"), - ("Captions and Graphics", "Subtítulos y gráficos"), - ("Captions…", "Subtítulos…"), - ("Caribbean", "Caribe"), - ("Center Horizontally", "Centrar horizontalmente"), - ("Center Vertically", "Centrar verticalmente"), - ("Cerulean", "Cerúleo"), - ("Clear", "Borrar"), - ("Clear Guides", "Borrar guías"), - ("Clear In", "Borrar entrada"), - ("Clear In and Out", "Borrar entrada y salida"), - ("Clear Markers", "Borrar marcadores"), - ("Clear Out", "Borrar salida"), - ("Clear Selected Marker", "Borrar marcador seleccionado"), - ("Close", "Cerrar"), - ("Close All Other Projects", "Cerrar todos los demás proyectos"), - ("Close All Projects", "Cerrar todos los proyectos"), - ("Close Gap", "Cerrar hueco"), - ("Close Project", "Cerrar proyecto"), - ("Color Management…", "Gestión del color…"), - ("Color Matte…", "Mate de color…"), - ("Comparison View", "Vista de comparación"), - ("Composite Video", "Video compuesto"), - ("Consolidate Duplicates", "Consolidar duplicados"), - ("Copy", "Copiar"), - ("Copy Paste Includes Sequence Markers", "Copiar y pegar incluye marcadores de secuencia"), - ("Create Captions from Transcript…", "Crear subtítulos a partir de la transcripción…"), - ("Create Multi-Camera Source Sequence…", "Crear secuencia de origen multicámara…"), - ("Create Proxies…", "Crear proxies…"), - ("Cut", "Cortar"), - ("Darkest", "Más oscuro"), - ("Delete Render Files", "Eliminar archivos de procesamiento"), - ("Delete Render Files In to Out", "Eliminar archivos de procesamiento de entrada a salida"), - ("Delete Tracks…", "Eliminar pistas…"), - ("Delete Transcript", "Eliminar transcripción"), - ("Demo Footage", "Material de demostración"), - ("Demo Project", "Proyecto de demostración"), - ("Deselect All", "Deseleccionar todo"), - ("Detach Proxies", "Separar proxies"), - ("Display Mode", "Modo de visualización"), - ("Distribute", "Distribuir"), - ("Distribute Horizontally", "Distribuir horizontalmente"), - ("Distribute Space Horizontally", "Distribuir espacio horizontalmente"), - ("Distribute Space Vertically", "Distribuir espacio verticalmente"), - ("Distribute Vertically", "Distribuir verticalmente"), - ("Duplicate", "Duplicar"), - ("Dynamic Audio Waveforms", "Formas de onda de audio dinámicas"), - ("Edit", "Edición"), - ("Edit Offline…", "Editar sin conexión…"), - ("Edit Original", "Editar original"), - ("Edit Subclip…", "Editar subclip…"), - ("Editing", "Edición"), - ("Effect Controls", "Controles de efectos"), - ("Effects", "Efectos"), - ("Ellipse", "Elipse"), - ("Enable", "Activar"), - ("Enable Remix", "Activar remezcla"), - ("Essential Graphics", "Gráficos esenciales"), - ("Essential Sound", "Sonido esencial"), - ("Events", "Eventos"), - ("Export", "Exportar"), - ("Export As Motion Graphics Template…", "Exportar como plantilla de animación…"), - ("Extract", "Extraer"), - ("Extract Audio", "Extraer audio"), - ("Field Options…", "Opciones de campo…"), - ("File", "Archivo"), - ("File…", "Archivo…"), - ("Fill frame", "Rellenar fotograma"), - ("FilmCraft Help…", "Ayuda de FilmCraft…"), - ("FilmCraft on GitHub", "FilmCraft en GitHub"), - ("FilmCraft on getartcraft.com", "FilmCraft en getartcraft.com"), - ("Find Next", "Buscar siguiente"), - ("Find…", "Buscar…"), - ("Fit", "Ajustar"), - ("Fit to frame", "Ajustar al fotograma"), - ("Flatten", "Acoplar"), - ("Forest", "Bosque"), - ("Frame Blending", "Fusión de fotogramas"), - ("Frame Hold Options…", "Opciones de fotograma congelado…"), - ("Frame Sampling", "Muestreo de fotogramas"), - ("From Bin", "Desde la bandeja"), - ("From Source Monitor", "Desde el monitor de origen"), - ("From Source Monitor, Match Frame", "Desde el monitor de origen, fotograma coincidente"), - ("From file…", "Desde archivo…"), - ("Full", "Completa"), - ("Generate Audio Waveform", "Generar forma de onda de audio"), - ("Get Media File Properties for", "Obtener propiedades del archivo multimedia de"), - ("Go to Gap", "Ir al hueco"), - ("Go to In", "Ir a la entrada"), - ("Go to Next Caption Segment", "Ir al siguiente segmento de subtítulo"), - ("Go to Next Marker", "Ir al siguiente marcador"), - ("Go to Out", "Ir a la salida"), - ("Go to Previous Caption Segment", "Ir al segmento de subtítulo anterior"), - ("Go to Previous Marker", "Ir al marcador anterior"), - ("Go to Split", "Ir a la división"), - ("Graphics and Titles", "Gráficos y títulos"), - ("Graphics…", "Gráficos…"), - ("Green", "Verde"), - ("Group", "Agrupar"), - ("Guide Templates", "Plantillas de guías"), - ("Help", "Ayuda"), - ("Hide All Caption Tracks", "Ocultar todas las pistas de subtítulos"), - ("High Quality Playback", "Reproducción de alta calidad"), - ("History", "Historial"), - ("Import From", "Importar desde"), - ("Import Image Sequence…", "Importar secuencia de imágenes…"), - ("Import from Media Browser", "Importar desde el navegador de medios"), - ("Import…", "Importar…"), - ("Info", "Información"), - ("Ingest Settings…", "Ajustes de ingesta…"), - ("Insert", "Insertar"), - ("Insert Frame Hold Segment", "Insertar segmento de fotograma congelado"), - ("Install Motion Graphics Template…", "Instalar plantilla de animación…"), - ("Interpret Footage…", "Interpretar material…"), - ("Join the ArtCraft Discord…", "Únete al Discord de ArtCraft…"), - ("Keyboard Shortcuts…", "Atajos de teclado…"), - ("Label", "Etiqueta"), - ("Labels…", "Etiquetas…"), - ("Language", "Idioma"), - ("Lavender", "Lavanda"), - ("Learning", "Aprendizaje"), - ("Left", "Izquierda"), - ("Libraries", "Bibliotecas"), - ("Lift", "Levantar"), - ("Light", "Claro"), - ("Link", "Vincular"), - ("Link Media…", "Vincular medios…"), - ("Linked Selection", "Selección vinculada"), - ("Lock Guides", "Bloquear guías"), - ("Lumetri Color", "Color Lumetri"), - ("Lumetri Scopes", "Ámbitos Lumetri"), - ("Magnification", "Ampliación"), - ("Make Offline…", "Desconectar…"), - ("Make Subclip…", "Crear subclip…"), - ("Make Subsequence", "Crear subsecuencia"), - ("Manage Guides…", "Gestionar guías…"), - ("Mark Clip", "Marcar clip"), - ("Mark In", "Marcar entrada"), - ("Mark Out", "Marcar salida"), - ("Mark Selection", "Marcar selección"), - ("Mark Split", "Marcar división"), - ("Markers", "Marcadores"), - ("Match Frame", "Fotograma coincidente"), - ("Media Analysis & Transcription…", "Análisis y transcripción de medios…"), - ("Media Browser", "Navegador de medios"), - ("Media Cache…", "Caché de medios…"), - ("Media…", "Medios…"), - ("Medium", "Medio"), - ("Memory…", "Memoria…"), - ("Merge Clips…", "Combinar clips…"), - ("Metadata", "Metadatos"), - ("Modify", "Modificar"), - ("Motion Graphics Template…", "Plantilla de animación…"), - ("Multi-Camera", "Multicámara"), - ("Multi-Camera View", "Vista multicámara"), - ("Nest…", "Anidar…"), - ("New", "Nuevo"), - ("New Layer", "Nueva capa"), - ("New Project Panel", "Nuevo panel de proyecto"), - ("Next in Sequence", "Siguiente en la secuencia"), - ("Next in Track", "Siguiente en la pista"), - ("Normalize Mix Track…", "Normalizar pista de mezcla…"), - ("Offline File…", "Archivo sin conexión…"), - ("Open Project…", "Abrir proyecto…"), - ("Optical Flow", "Flujo óptico"), - ("Overwrite", "Sobrescribir"), - ("Paste", "Pegar"), - ("Paste Attributes…", "Pegar atributos…"), - ("Paste Insert", "Pegar e insertar"), - ("Paused Resolution", "Resolución en pausa"), - ("Playback Resolution", "Resolución de reproducción"), - ("Playback…", "Reproducción…"), - ("Plugins…", "Complementos…"), - ("Polygon", "Polígono"), - ("Preferences", "Preferencias"), - ("Previous in Sequence", "Anterior en la secuencia"), - ("Previous in Track", "Anterior en la pista"), - ("Program", "Programa"), - ("Progress", "Progreso"), - ("Project", "Proyecto"), - ("Project Manager…", "Administrador de proyectos…"), - ("Project Settings", "Ajustes del proyecto"), - ("Project…", "Proyecto…"), - ("Properties", "Propiedades"), - ("Purple", "Púrpura"), - ("Reconnect Full Resolution Media…", "Reconectar medios de resolución completa…"), - ("Recover Unsaved Changes…", "Recuperar cambios no guardados…"), - ("Rectangle", "Rectángulo"), - ("Red", "Rojo"), - ("Redo", "Rehacer"), - ("Reference Monitor", "Monitor de referencia"), - ("Remix", "Remezcla"), - ("Remix Properties…", "Propiedades de remezcla…"), - ("Remove Attributes…", "Quitar atributos…"), - ("Remove Filler Words", "Quitar muletillas"), - ("Remove Pauses", "Quitar pausas"), - ("Remove Unused", "Quitar no utilizados"), - ("Rename…", "Cambiar nombre…"), - ("Render Audio", "Procesar audio"), - ("Render Effects In to Out", "Procesar efectos de entrada a salida"), - ("Render In to Out", "Procesar de entrada a salida"), - ("Render Selection", "Procesar selección"), - ("Replace Fonts in Projects…", "Reemplazar fuentes en proyectos…"), - ("Replace With Clip", "Reemplazar con clip"), - ("Report an Issue…", "Informar de un problema…"), - ("Reset All Parameters", "Restablecer todos los parámetros"), - ("Reset Duration", "Restablecer duración"), - ("Reset to Saved Layout", "Restablecer al diseño guardado"), - ("Restore Captions from Source Clip", "Restaurar subtítulos del clip de origen"), - ("Reveal Log Files…", "Mostrar archivos de registro…"), - ("Reverse Match Frame", "Fotograma coincidente inverso"), - ("Revert", "Revertir"), - ("Revert Remix", "Revertir remezcla"), - ("Review", "Revisión"), - ("Right", "Derecha"), - ("Ripple Delete", "Eliminar y cerrar hueco"), - ("Ripple Sequence Markers", "Desplazar marcadores de secuencia"), - ("Rose", "Rosa"), - ("Safe Margins", "Márgenes seguros"), - ("Save", "Guardar"), - ("Save All", "Guardar todo"), - ("Save As…", "Guardar como…"), - ("Save Guides as Template…", "Guardar guías como plantilla…"), - ("Save a Copy…", "Guardar una copia…"), - ("Save as Template…", "Guardar como plantilla…"), - ("Scale to Frame Size", "Escalar al tamaño del fotograma"), - ("Scene Edit Detection…", "Detección de cambios de escena…"), - ("Scratch Disks…", "Discos de memoria virtual…"), - ("Search Bin", "Bandeja de búsqueda"), - ("Select", "Seleccionar"), - ("Select All", "Seleccionar todo"), - ("Select All Matching", "Seleccionar todos los coincidentes"), - ("Select Label Group", "Seleccionar grupo de etiquetas"), - ("Select Next Graphic", "Seleccionar siguiente gráfico"), - ("Select Next Layer", "Seleccionar siguiente capa"), - ("Select Previous Graphic", "Seleccionar gráfico anterior"), - ("Select Previous Layer", "Seleccionar capa anterior"), - ("Selection Follows Playhead", "La selección sigue al cabezal"), - ("Selection as FilmCraft Project…", "Selección como proyecto de FilmCraft…"), - ("Selection…", "Selección…"), - ("Send Backward", "Enviar atrás"), - ("Send to Back", "Enviar al fondo"), - ("Sequence", "Secuencia"), - ("Sequence From Clip", "Secuencia a partir de clip"), - ("Sequence Settings…", "Ajustes de secuencia…"), - ("Sequence…", "Secuencia…"), - ("Show Active Caption Tracks Only", "Mostrar solo pistas de subtítulos activas"), - ("Show All Caption Tracks", "Mostrar todas las pistas de subtítulos"), - ("Show All Marker Colors", "Mostrar todos los colores de marcador"), - ("Show Guides", "Mostrar guías"), - ("Show Rulers", "Mostrar reglas"), - ("Show Through Edits", "Mostrar ediciones pasantes"), - ("Simplify Sequence…", "Simplificar secuencia…"), - ("Snap in Program Monitor", "Ajustar en el monitor de programa"), - ("Snap in Timeline", "Ajustar en la línea de tiempo"), - ("Source", "Origen"), - ("Source Settings…", "Ajustes de origen…"), - ("Speed/Duration…", "Velocidad/duración…"), - ("Synchronize…", "Sincronizar…"), - ("System Compatibility Report…", "Informe de compatibilidad del sistema…"), - ("Tan", "Canela"), - ("Teal", "Verde azulado"), - ("Text", "Texto"), - ("Time Interpolation", "Interpolación de tiempo"), - ("Timecode", "Código de tiempo"), - ("Timecode…", "Código de tiempo…"), - ("Timeline", "Línea de tiempo"), - ("Timeline…", "Línea de tiempo…"), - ("Toggle Proxies", "Alternar proxies"), - ("Tools", "Herramientas"), - ("Top", "Arriba"), - ("Transcribe Sequence…", "Transcribir secuencia…"), - ("Transcribe…", "Transcribir…"), - ("Transcript", "Transcripción"), - ("Transparent Video…", "Video transparente…"), - ("Trim Edit", "Recortar edición"), - ("Trim…", "Recorte…"), - ("Undo", "Deshacer"), - ("Ungroup", "Desagrupar"), - ("Universal Counting Leader…", "Cuenta atrás universal…"), - ("Update Metadata…", "Actualizar metadatos…"), - ("Upgrade Caption to Graphic", "Convertir subtítulo en gráfico"), - ("Upgrade to Source Graphic", "Convertir en gráfico de origen"), - ("Vertical Text", "Texto vertical"), - ("Video In", "Entrada de video"), - ("Video Options", "Opciones de video"), - ("Video Out", "Salida de video"), - ("Video and Audio Waveform Split", "Video y forma de onda de audio divididos"), - ("View", "Ver"), - ("Violet", "Violeta"), - ("Window", "Ventana"), - ("Workspaces", "Espacios de trabajo"), - ("Yellow", "Amarillo"), - ("Zoom In", "Acercar"), - ("Zoom Out", "Alejar"), - ("Zoom to Sequence", "Ajustar zoom a la secuencia"), -]; - -const JAPANESE: &[(&str, &str)] = &[ - ("Clip", "クリップ"), - ("Sequence", "シーケンス"), - ("Markers", "マーカー"), - ("Graphics and Titles", "グラフィックスとタイトル"), - ("Settings", "環境設定"), - ("Object", "オブジェクト"), - ("Effect", "効果"), - ("Settings…", "環境設定…"), - ("Image", "画像"), - ("Layer", "レイヤー"), - ("Type", "書式"), - ("Select", "選択"), - ("Filter", "フィルター"), - ("Window", "ウィンドウ"), - ("Language", "表示言語"), - ("Save As…", "別名で保存…"), - ("Exit", "終了"), - ("New…", "新規…"), - ("New", "新規"), - ("Horizontal", "横書き"), - ("Vertical", "縦書き"), - ("Orientation", "組み方向"), - ("Layers", "レイヤー"), - ("History", "履歴"), - ("Properties", "プロパティ"), - ("Color", "カラー"), - ("Brush Settings", "ブラシ設定"), - ("Tools", "ツール"), - ("Options", "オプション"), - ("Zoom In", "ズームイン"), - ("Zoom Out", "ズームアウト"), - ("Fit on Screen", "画面に合わせる"), - ("Copy", "コピー"), - ("Cut", "切り取り"), - ("Paste", "貼り付け"), - ("Select All", "すべて選択"), - ("Deselect", "選択を解除"), - ("Export", "書き出し"), - ("Export As…", "形式を指定して書き出し…"), - ("Search…", "検索…"), - ("Theme", "テーマ"), - ("Menu", "メニュー"), - ("File", "ファイル"), - ("Edit", "編集"), - ("Pages", "ページ"), - ("View", "表示"), - ("Help", "ヘルプ"), - ("Preferences", "環境設定"), - ("Preferences…", "環境設定…"), - ("Interface language", "表示言語"), - ("Open…", "開く…"), - ("New blank PDF", "空白の PDF を作成"), - ("Create PDF from file…", "ファイルから PDF を作成…"), - ("Create PDF from images…", "画像から PDF を作成…"), - ("Create PDF from clipboard", "クリップボードから PDF を作成"), - ("Combine files…", "ファイルを結合…"), - ("Save", "保存"), - ("Save as…", "別名で保存…"), - ("Close file", "ファイルを閉じる"), - ("Close all", "すべて閉じる"), - ("Revert", "保存済みの状態に戻す"), - ("Print…", "印刷…"), - ("Document properties…", "文書のプロパティ…"), - ("Undo", "取り消し"), - ("Redo", "やり直し"), - ("Find…", "検索…"), - ("Advanced search…", "高度な検索…"), - ("Copy pages", "ページをコピー"), - ("Cut pages", "ページを切り取り"), - ("Paste pages", "ページを貼り付け"), - ("Fit visible", "表示範囲に合わせる"), - ("Marquee zoom", "範囲指定ズーム"), - ("Take a snapshot", "スナップショットを作成"), - ("Full screen mode", "全画面表示"), - ("Read mode", "閲覧モード"), - ("Switch light / dark theme", "明るい/暗いテーマを切り替え"), - ("Comments panel", "コメントパネル"), - ("Form fields panel", "フォームフィールドパネル"), - ("Clear form", "フォームをクリア"), - ("Find tools and commands…", "ツールとコマンドを検索…"), - ("Zoom", "ズーム"), - ("Actual size", "実際のサイズ"), - ("Zoom to page level", "ページ全体を表示"), - ("Fit to width", "幅に合わせる"), - ("Display theme", "表示テーマ"), - ("Side panels", "サイドパネル"), - ("OK", "OK"), -]; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn translations_are_unique_and_preserve_unknown_text() { - for (i, (en, ja)) in JAPANESE.iter().enumerate() { - assert!(!ja.is_empty()); - assert!(JAPANESE.iter().take(i).all(|(other, _)| en != other)); - assert_eq!(Language::En.tr(en), *en); - } - assert_eq!(Language::Ja.tr("File"), "ファイル"); - for (i, (en, es)) in SPANISH.iter().enumerate() { - assert!(!es.is_empty()); - assert!(SPANISH.iter().take(i).all(|(other, _)| en != other)); - } - assert_eq!(Language::Es.tr("File"), "Archivo"); - assert_eq!(Language::Es.tr("mi video.mp4"), "mi video.mp4"); - assert_eq!(Language::parse("es"), Some(Language::Es)); - assert_eq!(Language::Ja.tr("日本語の文書.pdf"), "日本語の文書.pdf"); - assert_eq!(Language::parse("xx"), None); - } - - #[test] - fn spanish_entries_are_menu_labels() { - // a renamed command would otherwise leave a dead entry and an untranslated menu item - let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); - let items = crate::menus::menu_items(&app); - let known = |text: &str| crate::menus::MENUS.contains(&text) || items.iter().any(|it| it.label == text || it.path.iter().any(|p| p == text)); - for (en, _) in SPANISH { - assert!(known(en), "not a menu label: {en}"); - } - } - - #[test] - fn language_commands_switch_and_persist_without_a_document() { - let mut app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); - let ctx = egui::Context::default(); - crate::menus::invoke(&mut app, &ctx, "app.language.japanese", serde_json::json!({})).unwrap(); - assert_eq!(app.ui.language, Language::Ja); - let saved = serde_json::to_string(&app.ui).unwrap(); - let restored: crate::state::UiState = serde_json::from_str(&saved).unwrap(); - assert_eq!(restored.language, Language::Ja); - crate::menus::invoke(&mut app, &ctx, "app.language.spanish", serde_json::json!({})).unwrap(); - assert_eq!(app.ui.language, Language::Es); - assert!(crate::menus::menu_items(&app).iter().any(|it| it.id == "app.language.spanish" && it.checked == Some(true))); - crate::menus::invoke(&mut app, &ctx, "app.language.english", serde_json::json!({})).unwrap(); - assert_eq!(app.ui.language, Language::En); - } - - #[test] - fn japanese_needs_an_installed_font() { - let mut app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); - let ctx = egui::Context::default(); - crate::theme::install(&ctx, &crate::theme::Tokens::for_kind(crate::theme::ThemeKind::default())); - let r = crate::menus::invoke(&mut app, &ctx, "app.language.japanese", serde_json::json!({})); - let craft = craft_japanese_font(); - if !craft && system_japanese_font().is_none() { - // no craft-fonts and no system font: refused, and the interface stays English - assert!(r.is_err(), "{r:?}"); - assert_eq!(app.ui.language, Language::En); - return; - } - assert!(r.is_ok(), "{r:?}"); - let mut output = ctx.run_ui(egui::RawInput::default(), |_| {}); - output.textures_delta.clear(); - ctx.fonts_mut(|fonts| { - // every theme family falls back to the craft-fonts (when built with them; no system - // font is added then) or to the system Japanese font - for family in crate::theme::font_families() { - let stack = fonts.definitions().families.get(&family).cloned().unwrap_or_default(); - if craft { - assert!(stack.last().is_some_and(|n| n.starts_with("craft:")), "{family:?}: {stack:?}"); - assert!(!stack.iter().any(|n| n == JAPANESE_FONT), "{family:?}: {stack:?}"); - } else { - assert_eq!(stack.last().map(String::as_str), Some(JAPANESE_FONT), "{family:?}: {stack:?}"); - } - } - // and the glyphs resolve. (Only families whose replacement-box face is another font: - // egui's `has_glyph` reports false for any character served by the face it also uses - // for the replacement box, which in the Inter-only "medium"/"semibold" stacks is the - // Japanese font itself.) - for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] { - let font = egui::FontId::new(13.0, family); - for ch in JAPANESE_SAMPLE.chars() { - assert!(fonts.has_glyph(&font, ch), "missing {ch} in {font:?}"); - } - } - }); - } -} diff --git a/crates/ui-egui/src/i18n/catalog.rs b/crates/ui-egui/src/i18n/catalog.rs new file mode 100644 index 00000000..6422eeaf --- /dev/null +++ b/crates/ui-egui/src/i18n/catalog.rs @@ -0,0 +1,97 @@ +//! Parser and lookup tables for translation catalogs (`*.tsv`, format documented in `es.tsv`). + +use std::collections::HashMap; + +/// A parsed catalog. Built once per language and kept for the life of the process. +#[derive(Debug, Default)] +pub struct Catalog { + /// context-free strings: English source → translation (looked up every frame) + plain: HashMap, + /// strings with a disambiguating context, keyed `context \u{1} source` + contextual: HashMap, +} + +/// A catalog entry as read from the file: (context, source, translation). +pub type Entry = (String, String, String); + +fn unescape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut it = s.chars(); + while let Some(c) = it.next() { + if c != '\\' { + out.push(c); + continue; + } + match it.next() { + Some('n') => out.push('\n'), + Some('t') => out.push('\t'), + Some('\\') => out.push('\\'), + Some(o) => { + out.push('\\'); + out.push(o); + } + None => out.push('\\'), + } + } + out +} + +/// Read the entries of a catalog file. Malformed lines are returned as errors (and skipped), so a +/// bad translation never breaks the UI; the tests insist the bundled catalogs have none. +pub fn parse_entries(text: &str) -> (Vec, Vec) { + let mut entries = Vec::new(); + let mut errors = Vec::new(); + for (n, line) in text.lines().enumerate() { + if line.trim().is_empty() || line.starts_with('#') { + continue; + } + let mut cols = line.split('\t'); + match (cols.next(), cols.next(), cols.next(), cols.next()) { + (Some(ctx), Some(src), Some(tr), None) if !src.is_empty() && !tr.is_empty() => { + entries.push((unescape(ctx), unescape(src), unescape(tr))); + } + _ => errors.push(format!("line {}: expected `contextsourcetranslation`", n + 1)), + } + } + (entries, errors) +} + +impl Catalog { + pub fn parse(text: &str) -> Catalog { + let mut c = Catalog::default(); + for (ctx, src, tr) in parse_entries(text).0 { + if ctx.is_empty() { + c.plain.insert(src, tr); + } else { + c.contextual.insert(format!("{ctx}\u{1}{src}"), tr); + } + } + c + } + + pub fn plain(&self, s: &str) -> Option<&str> { + self.plain.get(s).map(String::as_str) + } + + pub fn contextual(&self, ctx: &str, s: &str) -> Option<&str> { + self.contextual.get(&format!("{ctx}\u{1}{s}")).map(String::as_str) + } +} + +/// `{name}` placeholders of a template, in order of appearance. +#[cfg(test)] +pub fn placeholders(s: &str) -> Vec<&str> { + let mut out = Vec::new(); + let mut rest = s; + while let Some(a) = rest.find('{') { + let after = &rest[a + 1..]; + match after.find('}') { + Some(b) => { + out.push(&after[..b]); + rest = &after[b + 1..]; + } + None => break, + } + } + out +} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv new file mode 100644 index 00000000..4fc40977 --- /dev/null +++ b/crates/ui-egui/src/i18n/es.tsv @@ -0,0 +1,3378 @@ +# Spanish (es) catalog. Columns, separated by a TAB: context source translation +# +# context empty a plain UI string; `source` is the English text in the code (`tl!("…")`, +# a menu label, or a name the UI translates with `i18n::t`) +# anything else a disambiguating context passed to `tr_ctx` (one English word that +# needs two Spanish translations) +# escapes \n \t \\ inside a column. `{name}` placeholders must appear in both columns; +# translators may reorder them. A trailing "…" (dialog commands) is kept. +# lines starting with # and blank lines are ignored. +# +# Clean-room: every entry was written from the plain meaning of the English text using ordinary +# Spanish video-editing vocabulary (pista, cabezal, secuencia, bandeja, procesar…). No proprietary +# localisation resource, string table or product UI was consulted or copied; add entries the same +# way. Product and technology names (FilmCraft, ArtCraft, Lumetri, ProRes, LUFS…) keep their +# spelling. "video" is written without an accent, as in the rest of this catalog. + + About FilmCraft Acerca de FilmCraft + Add Audio Submix Track Añadir pista de submezcla de audio + Add Caption at Playhead Añadir subtítulo en el cabezal + Add Chapter Marker… Añadir marcador de capítulo… + Add Edit Añadir edición + Add Edit to All Tracks Añadir edición a todas las pistas + Add Flash Cue Marker… Añadir marcador de referencia Flash… + Add Frame Hold Añadir fotograma congelado + Add Guide… Añadir guía… + Add Marker Añadir marcador + Add New Caption Track… Añadir nueva pista de subtítulos… + Add Range Marker Añadir marcador de rango + Add Range Marker to In and Out Añadir marcador de rango de entrada a salida + Add Tracks… Añadir pistas… + Adjustment Layer… Capa de ajuste… + Align to Selection Alinear con la selección + Align to Video Frame Alinear con el fotograma de video + Align to Video Frame as Group Alinear con el fotograma de video como grupo + All Panels Todos los paneles + Alpha Alfa + Appearance Apariencia + Appearance… Apariencia… + Apply Audio Transition Aplicar transición de audio + Apply Default Transitions to Selection Aplicar transiciones predeterminadas a la selección + Apply Video Transition Aplicar transición de video + Arrange Organizar + ArtCraft Website Sitio web de ArtCraft + Assembly Ensamblaje + Attach Proxies… Adjuntar proxies… + Audio Channels… Canales de audio… + Audio Clip Mixer Mezclador de clips de audio + Audio Gain… Ganancia de audio… + Audio Hardware… Hardware de audio… + Audio In Entrada de audio + Audio Meters Medidores de audio + Audio Options Opciones de audio + Audio Out Salida de audio + Audio Track Mixer Mezclador de pistas de audio + Audio Waveform Forma de onda de audio + Auto Save… Guardado automático… + Automate to Sequence… Automatizar a secuencia… + Bars and Tone… Barras y tono… + Bin Bandeja + Bin From Selection Bandeja a partir de la selección + Black Video… Video negro… + Blue Azul + Bottom Abajo + Breakout to Mono Separar en mono + Bring Forward Traer adelante + Bring to Front Traer al frente + Brown Marrón + Captions Subtítulos + Captions and Graphics Subtítulos y gráficos + Captions… Subtítulos… + Caribbean Caribe + Center Horizontally Centrar horizontalmente + Center Vertically Centrar verticalmente + Cerulean Cerúleo + Clear Borrar + Clear Guides Borrar guías + Clear In Borrar entrada + Clear In and Out Borrar entrada y salida + Clear Markers Borrar marcadores + Clear Out Borrar salida + Clear Selected Marker Borrar marcador seleccionado + Close Cerrar + Close All Other Projects Cerrar todos los demás proyectos + Close All Projects Cerrar todos los proyectos + Close Gap Cerrar hueco + Close Project Cerrar proyecto + Color Management… Gestión del color… + Color Matte… Mate de color… + Comparison View Vista de comparación + Composite Video Video compuesto + Consolidate Duplicates Consolidar duplicados + Copy Copiar + Copy Paste Includes Sequence Markers Copiar y pegar incluye marcadores de secuencia + Create Captions from Transcript… Crear subtítulos a partir de la transcripción… + Create Multi-Camera Source Sequence… Crear secuencia de origen multicámara… + Create Proxies… Crear proxies… + Cut Cortar + Darkest Más oscuro + Delete Render Files Eliminar archivos de procesamiento + Delete Render Files In to Out Eliminar archivos de procesamiento de entrada a salida + Delete Tracks… Eliminar pistas… + Delete Transcript Eliminar transcripción + Demo Footage Material de demostración + Demo Project Proyecto de demostración + Deselect All Deseleccionar todo + Detach Proxies Separar proxies + Display Mode Modo de visualización + Distribute Distribuir + Distribute Horizontally Distribuir horizontalmente + Distribute Space Horizontally Distribuir espacio horizontalmente + Distribute Space Vertically Distribuir espacio verticalmente + Distribute Vertically Distribuir verticalmente + Duplicate Duplicar + Dynamic Audio Waveforms Formas de onda de audio dinámicas + Edit Edición + Edit Offline… Editar sin conexión… + Edit Original Editar original + Edit Subclip… Editar subclip… + Editing Edición + Effect Controls Controles de efectos + Effects Efectos + Ellipse Elipse + Enable Activar + Enable Remix Activar remezcla + Essential Graphics Gráficos esenciales + Essential Sound Sonido esencial + Events Eventos + Export Exportar + Export As Motion Graphics Template… Exportar como plantilla de animación… + Extract Extraer + Extract Audio Extraer audio + Field Options… Opciones de campo… + File Archivo + File… Archivo… + Fill frame Rellenar fotograma + FilmCraft Help… Ayuda de FilmCraft… + FilmCraft on GitHub FilmCraft en GitHub + FilmCraft on getartcraft.com FilmCraft en getartcraft.com + Find Next Buscar siguiente + Find… Buscar… + Fit Ajustar + Fit to frame Ajustar al fotograma + Flatten Acoplar + Forest Bosque + Frame Blending Fusión de fotogramas + Frame Hold Options… Opciones de fotograma congelado… + Frame Sampling Muestreo de fotogramas + From Bin Desde la bandeja + From Source Monitor Desde el monitor de origen + From Source Monitor, Match Frame Desde el monitor de origen, fotograma coincidente + From file… Desde archivo… + Full Completa + Generate Audio Waveform Generar forma de onda de audio + Get Media File Properties for Obtener propiedades del archivo multimedia de + Go to Gap Ir al hueco + Go to In Ir a la entrada + Go to Next Caption Segment Ir al siguiente segmento de subtítulo + Go to Next Marker Ir al siguiente marcador + Go to Out Ir a la salida + Go to Previous Caption Segment Ir al segmento de subtítulo anterior + Go to Previous Marker Ir al marcador anterior + Go to Split Ir a la división + Graphics and Titles Gráficos y títulos + Graphics… Gráficos… + Green Verde + Group Agrupar + Guide Templates Plantillas de guías + Help Ayuda + Hide All Caption Tracks Ocultar todas las pistas de subtítulos + High Quality Playback Reproducción de alta calidad + History Historial + Import From Importar desde + Import Image Sequence… Importar secuencia de imágenes… + Import from Media Browser Importar desde el navegador de medios + Import… Importar… + Info Información + Ingest Settings… Ajustes de ingesta… + Insert Insertar + Insert Frame Hold Segment Insertar segmento de fotograma congelado + Install Motion Graphics Template… Instalar plantilla de animación… + Interpret Footage… Interpretar material… + Join the ArtCraft Discord… Únete al Discord de ArtCraft… + Keyboard Shortcuts… Atajos de teclado… + Label Etiqueta + Labels… Etiquetas… + Language Idioma + Lavender Lavanda + Learning Aprendizaje + Left Izquierda + Libraries Bibliotecas + Lift Levantar + Light Claro + Link Vincular + Link Media… Vincular medios… + Linked Selection Selección vinculada + Lock Guides Bloquear guías + Lumetri Color Color Lumetri + Lumetri Scopes Ámbitos Lumetri + Magnification Ampliación + Make Offline… Desconectar… + Make Subclip… Crear subclip… + Make Subsequence Crear subsecuencia + Manage Guides… Gestionar guías… + Mark Clip Marcar clip + Mark In Marcar entrada + Mark Out Marcar salida + Mark Selection Marcar selección + Mark Split Marcar división + Markers Marcadores + Match Frame Fotograma coincidente + Media Analysis & Transcription… Análisis y transcripción de medios… + Media Browser Navegador de medios + Media Cache… Caché de medios… + Media… Medios… + Medium Medio + Memory… Memoria… + Merge Clips… Combinar clips… + Metadata Metadatos + Modify Modificar + Motion Graphics Template… Plantilla de animación… + Multi-Camera Multicámara + Multi-Camera View Vista multicámara + Nest… Anidar… + New Nuevo + New Layer Nueva capa + New Project Panel Nuevo panel de proyecto + Next in Sequence Siguiente en la secuencia + Next in Track Siguiente en la pista + Normalize Mix Track… Normalizar pista de mezcla… + Offline File… Archivo sin conexión… + Open Project… Abrir proyecto… + Optical Flow Flujo óptico + Overwrite Sobrescribir + Paste Pegar + Paste Attributes… Pegar atributos… + Paste Insert Pegar e insertar + Paused Resolution Resolución en pausa + Playback Resolution Resolución de reproducción + Playback… Reproducción… + Plugins… Complementos… + Polygon Polígono + Preferences Preferencias + Previous in Sequence Anterior en la secuencia + Previous in Track Anterior en la pista + Program Programa + Progress Progreso + Project Proyecto + Project Manager… Administrador de proyectos… + Project Settings Ajustes del proyecto + Project… Proyecto… + Properties Propiedades + Purple Púrpura + Reconnect Full Resolution Media… Reconectar medios de resolución completa… + Recover Unsaved Changes… Recuperar cambios no guardados… + Rectangle Rectángulo + Red Rojo + Redo Rehacer + Reference Monitor Monitor de referencia + Remix Remezcla + Remix Properties… Propiedades de remezcla… + Remove Attributes… Quitar atributos… + Remove Filler Words Quitar muletillas + Remove Pauses Quitar pausas + Remove Unused Quitar no utilizados + Rename… Cambiar nombre… + Render Audio Procesar audio + Render Effects In to Out Procesar efectos de entrada a salida + Render In to Out Procesar de entrada a salida + Render Selection Procesar selección + Replace Fonts in Projects… Reemplazar fuentes en proyectos… + Replace With Clip Reemplazar con clip + Report an Issue… Informar de un problema… + Reset All Parameters Restablecer todos los parámetros + Reset Duration Restablecer duración + Reset to Saved Layout Restablecer al diseño guardado + Restore Captions from Source Clip Restaurar subtítulos del clip de origen + Reveal Log Files… Mostrar archivos de registro… + Reverse Match Frame Fotograma coincidente inverso + Revert Revertir + Revert Remix Revertir remezcla + Review Revisión + Right Derecha + Ripple Delete Eliminar y cerrar hueco + Ripple Sequence Markers Desplazar marcadores de secuencia + Rose Rosa + Safe Margins Márgenes seguros + Save Guardar + Save All Guardar todo + Save As… Guardar como… + Save Guides as Template… Guardar guías como plantilla… + Save a Copy… Guardar una copia… + Save as Template… Guardar como plantilla… + Scale to Frame Size Escalar al tamaño del fotograma + Scene Edit Detection… Detección de cambios de escena… + Scratch Disks… Discos de memoria virtual… + Search Bin Bandeja de búsqueda + Select Seleccionar + Select All Seleccionar todo + Select All Matching Seleccionar todos los coincidentes + Select Label Group Seleccionar grupo de etiquetas + Select Next Graphic Seleccionar siguiente gráfico + Select Next Layer Seleccionar siguiente capa + Select Previous Graphic Seleccionar gráfico anterior + Select Previous Layer Seleccionar capa anterior + Selection Follows Playhead La selección sigue al cabezal + Selection as FilmCraft Project… Selección como proyecto de FilmCraft… + Selection… Selección… + Send Backward Enviar atrás + Send to Back Enviar al fondo + Sequence Secuencia + Sequence From Clip Secuencia a partir de clip + Sequence Settings… Ajustes de secuencia… + Sequence… Secuencia… + Show Active Caption Tracks Only Mostrar solo pistas de subtítulos activas + Show All Caption Tracks Mostrar todas las pistas de subtítulos + Show All Marker Colors Mostrar todos los colores de marcador + Show Guides Mostrar guías + Show Rulers Mostrar reglas + Show Through Edits Mostrar ediciones pasantes + Simplify Sequence… Simplificar secuencia… + Snap in Program Monitor Ajustar en el monitor de programa + Snap in Timeline Ajustar en la línea de tiempo + Source Origen + Source Settings… Ajustes de origen… + Speed/Duration… Velocidad/duración… + Synchronize… Sincronizar… + System Compatibility Report… Informe de compatibilidad del sistema… + Tan Canela + Teal Verde azulado + Text Texto + Time Interpolation Interpolación de tiempo + Timecode Código de tiempo + Timecode… Código de tiempo… + Timeline Línea de tiempo + Timeline… Línea de tiempo… + Toggle Proxies Alternar proxies + Tools Herramientas + Top Arriba + Transcribe Sequence… Transcribir secuencia… + Transcribe… Transcribir… + Transcript Transcripción + Transparent Video… Video transparente… + Trim Edit Recortar edición + Trim… Recorte… + Undo Deshacer + Ungroup Desagrupar + Universal Counting Leader… Cuenta atrás universal… + Update Metadata… Actualizar metadatos… + Upgrade Caption to Graphic Convertir subtítulo en gráfico + Upgrade to Source Graphic Convertir en gráfico de origen + Vertical Text Texto vertical + Video In Entrada de video + Video Options Opciones de video + Video Out Salida de video + Video and Audio Waveform Split Video y forma de onda de audio divididos + View Ver + Violet Violeta + Window Ventana + Workspaces Espacios de trabajo + Yellow Amarillo + Zoom In Acercar + Zoom Out Alejar + Zoom to Sequence Ajustar zoom a la secuencia + Home Inicio + Import Importar + {name} - Edited {name} - Editado + Full screen Pantalla completa + Volume Volumen + Master volume: use the Audio Track Mixer Volumen maestro: usa el mezclador de pistas de audio + Search Buscar + Quick Export Exportación rápida + Join the ArtCraft Discord (discord.gg/artcraft) Únete al Discord de ArtCraft (discord.gg/artcraft) + Cancel Cancelar + OK Aceptar + Interpret Footage Interpretar material + Color Management · {n} item Gestión del color · {n} elemento + Color Management · {n} items Gestión del color · {n} elementos + Media colour space (from file metadata): {detected} Espacio de color del medio (según los metadatos del archivo): {detected} + Override media colour space: Sustituir el espacio de color del medio: + Use file metadata Usar los metadatos del archivo + Sequence Color Management Gestión del color de la secuencia + Working Color Space Espacio de color de trabajo + Wide gamut color (composite in BT.2020) Color de gama amplia (componer en BT.2020) + Auto tone map media (HDR and log into SDR) Mapeo de tonos automático de los medios (HDR y log a SDR) + Audio Gain Ganancia de audio + Set Gain to: Establecer ganancia en: + Adjust Gain by: Ajustar ganancia en: + Normalize Max Peak to: Normalizar pico máximo a: + Normalize All Peaks to: Normalizar todos los picos a: + Peak Amplitude: {peak} Amplitud de pico: {peak} + Standard Estándar + Adaptive Adaptable + Mono Mono + Stereo Estéreo + Add Tracks Añadir pistas + Amount Cantidad + Placement Ubicación + Track type Tipo de pista + Add video tracks Añadir pistas de video + Add audio tracks Añadir pistas de audio + Add audio submix tracks Añadir pistas de submezcla de audio + After {name} Después de {name} + Before First Track Antes de la primera pista + Delete Tracks Eliminar pistas + Video Tracks Pistas de video + Audio Tracks Pistas de audio + Delete Video Tracks Eliminar pistas de video + Delete Audio Tracks Eliminar pistas de audio + All Empty Tracks Todas las pistas vacías + Version {version} Versión {version} + A clean-room, pure-Rust non-linear video editor. Part of the ArtCraft family. Un editor de video no lineal en Rust puro, desarrollado en sala limpia. Parte de la familia ArtCraft. + Join the ArtCraft Discord Únete al Discord de ArtCraft + Source code on GitHub Código fuente en GitHub + Report an issue Informar de un problema + MIT OR Apache-2.0. Fonts: Inter, Noto Serif and JetBrains Mono (SIL OFL 1.1). MIT OR Apache-2.0. Fuentes: Inter, Noto Serif y JetBrains Mono (SIL OFL 1.1). + just now ahora mismo + {n} s ago hace {n} s + {n} min ago hace {n} min + {n} h ago hace {n} h + All Events Todos los eventos + Warnings and Errors Advertencias y errores + Errors Only Solo errores + Clear All Borrar todo + No events. No hay eventos. + Show finished jobs Mostrar tareas terminadas + Cancelled Cancelada + Failed: {e} Error: {e} + Done Terminada + No background jobs. No hay tareas en segundo plano. + Recover Unsaved Changes Recuperar cambios no guardados + Untitled Sin título + FilmCraft was closed while “{name}” had unsaved changes. FilmCraft se cerró mientras “{name}” tenía cambios sin guardar. + FilmCraft quit unexpectedly while “{name}” had unsaved changes. FilmCraft se cerró de forma inesperada mientras “{name}” tenía cambios sin guardar. + Recover unsaved changes from {when}? ¿Recuperar los cambios no guardados de {when}? + The project had not been saved yet. El proyecto aún no se había guardado. + {n} sessions have unsaved changes: {n} sesiones tienen cambios sin guardar: + Discard Descartar + Recover Recuperar + Not Now Ahora no + Are you sure you want to discard your changes to '{file}'? ¿Seguro que quieres descartar los cambios en '{file}'? + Yes Sí + No No + Add media to your project. Drop files anywhere in the window, or choose a source below. Añade medios a tu proyecto. Suelta archivos en cualquier parte de la ventana o elige un origen abajo. + Browse files… Examinar archivos… + Movies, audio and stills from disk Películas, audio e imágenes fijas del disco + Demo footage Material de demostración + Six procedural 1080p clips with sound Seis clips procedurales 1080p con sonido + Demo project Proyecto de demostración + A cut sequence with transitions, effects and music Una secuencia montada con transiciones, efectos y música + Bars and Tone Barras y tono + SMPTE HD bars with 1 kHz reference tone Barras SMPTE HD con tono de referencia de 1 kHz + Community Comunidad + Join us on Discord Únete a nosotros en Discord + OMF Export Settings Ajustes de exportación OMF + AAF Export Settings Ajustes de exportación AAF + OMF Title: Título OMF: + Mixdown video Mezclar video + Audio: Audio: + Encapsulate Encapsular + Separate Audio Audio independiente + Embed Incrustar + Separate Files Archivos independientes + Link to Media Vincular a los medios + Audio File Format: Formato de archivo de audio: + Sample Rate: Frecuencia de muestreo: + Bits per Sample: Bits por muestra: + Rendering: Procesamiento: + Trim audio files Recortar archivos de audio + Handle Frames: Fotogramas de margen: + Render audio clip effects Procesar efectos de clips de audio + Breakout to mono Separar en mono + Exported {path} Exportado: {path} + Select a clip to view its metadata. Selecciona un clip para ver sus metadatos. + Clip: {name} Clip: {name} + Open Abrir + (no sequence) (sin secuencia) + Show or hide {color} markers Mostrar u ocultar los marcadores de color {color} + Marker Marcador + {tc} Chapter {tc} Capítulo + No markers. Press M to add one. No hay marcadores. Pulsa M para añadir uno. + All markers are hidden by the colour filter. El filtro de color oculta todos los marcadores. + Type: Tipo: + Video: Video: + Codec: Códec: + Duration: Duración: + Settings: Ajustes: + Playhead: Cabezal: + Video {n}: Video {n}: + Audio {n}: Audio {n}: + UI: Interfaz: + {fps} fps · {n} frames queued {fps} fps · {n} fotogramas en cola + {panel} — coming in a later milestone {panel}: llegará en una versión posterior + Close Panel Cerrar panel + Close Other Timeline Panels Cerrar los demás paneles de línea de tiempo + Maximize Frame Maximizar marco + Restore Workspace Restaurar espacio de trabajo + Reveal Sequence in Project Mostrar secuencia en el proyecto + Video Thumbnails Miniaturas de video + Audio Waveforms Formas de onda de audio + Delete Preset Eliminar ajuste preestablecido + Export Preset… Exportar ajuste preestablecido… + Import Presets… Importar ajustes preestablecidos… + Effect presets Ajustes preestablecidos de efectos + Save Preset Guardar ajuste preestablecido + Name: Nombre: + Type Tipo + Scale Escala + Anchor to In Point Anclar al punto de entrada + Anchor to Out Point Anclar al punto de salida + Without keyframes Sin fotogramas clave + Description: Descripción: + Saved preset “{name}” Ajuste preestablecido “{name}” guardado + Current duration {seconds} s Duración actual: {seconds} s + Remix Properties Propiedades de remezcla + Target Duration: Duración objetivo: + Segments: Segmentos: + Fewer · More Menos · Más + Variations: Variaciones: + Remixed to {seconds} s Remezclado a {seconds} s + {mode} · no clip {mode} · sin clip + {mode} · no sequence {mode} · sin secuencia + Remove Row Quitar fila + Add a timecode row Añadir una fila de código de tiempo + Panner Panoramizador + Mute Silenciar + Send {n} Level Nivel del envío {n} + Show Keyframes Mostrar fotogramas clave + Clip Keyframes Fotogramas clave del clip + Track Keyframes Fotogramas clave de la pista + Delete Eliminar + Untitled Workspace Espacio de trabajo sin título + New Workspace Nuevo espacio de trabajo + Edit Workspaces Editar espacios de trabajo + Rename Cambiar nombre + Restore Original Restaurar original + Search effects Buscar efectos + Apply to Selected Clips Aplicar a los clips seleccionados + Metadata Display Visualización de metadatos + Save As New View Preset Guardar como nuevo ajuste de vista + Manage Saved View Presets Gestionar ajustes de vista guardados + Freeform View Options Opciones de la vista de forma libre + Save Arrangement Guardar disposición + Saves the view, columns, sort, thumbnail and font size. Guarda la vista, las columnas, el orden, la miniatura y el tamaño de fuente. + Grid spacing: Espaciado de la cuadrícula: + Default clip size: Tamaño de clip predeterminado: + Snap to grid Ajustar a la cuadrícula + Show clip names Mostrar nombres de clip + Show durations Mostrar duraciones + Show: Mostrar: + Search fields Buscar campos + Project Metadata Metadatos del proyecto + Add Property… Añadir propiedad… + Column order Orden de columnas + {n}. (empty) {n}. (vacío) + Restore Restaurar + Overwrite with Current View Sobrescribir con la vista actual + Graphics text search arrives with M10.1–M10.2 La búsqueda de texto en gráficos llegará con M10.1–M10.2 + Open a sequence to work with captions Abre una secuencia para trabajar con subtítulos + Add captions Añadir subtítulos + Create a caption track or import a caption file. Crea una pista de subtítulos o importa un archivo de subtítulos. + Create new caption track Crear nueva pista de subtítulos + Import captions file… Importar archivo de subtítulos… + New {format} track Nueva pista {format} + Add caption at playhead Añadir subtítulo en el cabezal + Split caption at playhead Dividir subtítulo en el cabezal + Merge selected captions Combinar subtítulos seleccionados + Delete selected captions Eliminar subtítulos seleccionados + No captions on this track. Press + to add one at the playhead. No hay subtítulos en esta pista. Pulsa + para añadir uno en el cabezal. + No matching captions. Ningún subtítulo coincide. + Open a sequence to see its transcript Abre una secuencia para ver su transcripción + Transcribe sequence Transcribir secuencia + Speech-to-text turns the dialogue into editable text. La conversión de voz a texto convierte el diálogo en texto editable. + This build has no speech-to-text; import a transcript with transcript.set. Esta compilación no incluye conversión de voz a texto; importa una transcripción con transcript.set. + Transcribe Transcribir + Extract selected text Extraer texto seleccionado + Lift selected text Levantar texto seleccionado + Remove filler words Quitar muletillas + Create captions Crear subtítulos + Speaker Hablante + Box Recuadro + Align Alinear + Align left Alinear a la izquierda + Align center Centrar + Align right Alinear a la derecha + Position Posición + Position: top Posición: arriba + Position: middle Posición: centro + Position: bottom Posición: abajo + (gap) (hueco) + Out: {name} Salida: {name} + In: {name} Entrada: {name} + Play Around Edit (Space loops, Shift+K once) Reproducir alrededor de la edición (Espacio en bucle, Mayús+K una vez) + Trim Backward Many Recortar muchos hacia atrás + Trim Backward Recortar hacia atrás + Apply Default Transitions Aplicar transiciones predeterminadas + Apply Default Transitions to Selection (Shift+D) Aplicar transiciones predeterminadas a la selección (Mayús+D) + Trim Forward Recortar hacia delante + Trim Forward Many Recortar muchos hacia delante + Exit Trim Mode Salir del modo de recorte + Voice-over record (right-click: Voice-Over Record Settings…) Grabar voz en off (clic derecho: ajustes de grabación de voz en off…) + Voice-Over Record Settings… Ajustes de grabación de voz en off… + Recording voice-over… Grabando voz en off… + Voice-over recorded: {p} Voz en off grabada: {p} + Voice-over recording stopped (nothing recorded) Grabación de voz en off detenida (no se grabó nada) + Post-roll Margen posterior + ● Recording ● Grabando + Voice-Over Record Settings Ajustes de grabación de voz en off + Source: Origen: + Default Input Entrada predeterminada + Input: Entrada: + Channel {n} Canal {n} + Countdown Sound Cues Pitidos de cuenta atrás + Pre-roll: Margen previo: + seconds segundos + Post-roll: Margen posterior: + Playback starts the pre-roll before the playhead (or the In point); with In/Out marked, recording punches in and out there. La reproducción empieza con el margen previo antes del cabezal (o del punto de entrada); con entrada y salida marcadas, la grabación entra y sale en esos puntos. + Effect Efecto + Clip Fx Editor Editor de efectos del clip + Track Fx Editor Editor de efectos de la pista + Master Gain Ganancia maestra + Reset Restablecer + Band {n} Banda {n} + Solo Solo + Bypass Omitir + Thr Umbral + Ratio Relación + Att Ataque + Rel Liberación + Gain Ganancia + Output Gain Ganancia de salida + Limiter Limitador + Link Channels Vincular canales + Limiter Threshold Umbral del limitador + Limiter Release Liberación del limitador + Auto Gate Puerta automática + Threshold Umbral + Attack Ataque + Release Liberación + Hold Retención + Compressor Compresor + Auto Makeup Compensación automática + Makeup Compensación + Expander Expansor + Soft Clip Recorte suave + (no sequences) (sin secuencias) + (no clips) (sin clips) + Show Transport Controls Mostrar controles de transporte + Loop Bucle + Multi-Camera Audio Follows Video El audio multicámara sigue al video + Multi-Camera Record Grabación multicámara + Multi-Camera Selection Top Down Selección multicámara de arriba abajo + Show Multi-Camera Preview Monitor Mostrar monitor de previsualización multicámara + Auto-Adjust Multi-Camera Playback Quality Ajustar automáticamente la calidad de reproducción multicámara + Transmit Multi-Camera View Transmitir vista multicámara + Multi-Camera Layout Disposición multicámara + Automatic Automática + Edit Cameras… Editar cámaras… + Add Marker (M) Añadir marcador (M) + Mark In (I) Marcar entrada (I) + Mark Out (O) Marcar salida (O) + Go to In (Shift+I) Ir a la entrada (Mayús+I) + Step Back 1 Frame (Left) Retroceder 1 fotograma (Izquierda) + Play-Stop Toggle (Space) Reproducir/Detener (Espacio) + Step Forward 1 Frame (Right) Avanzar 1 fotograma (Derecha) + Go to Out (Shift+O) Ir a la salida (Mayús+O) + Insert (,) Insertar (,) + Overwrite (.) Sobrescribir (.) + Export Frame (Shift+E) Exportar fotograma (Mayús+E) + Lift (;) Levantar (;) + Extract (') Extraer (') + Export Frame: use Export mode (M6) Exportar fotograma: usa el modo Exportar (M6) + Button Editor Editor de botones + Multi-Camera Record on Grabación multicámara activada + Multi-Camera Record off Grabación multicámara desactivada + {first} - Merged {first} - Combinado + {first} Multicam {first} Multicámara + In Points Puntos de entrada + Out Points Puntos de salida + Clip Marker Marcador de clip + Audio Audio + Merge Clips Combinar clips + Create Multi-Camera Source Sequence Crear secuencia de origen multicámara + Synchronize Clips Sincronizar clips + Clip Name: Nombre del clip: + Sequence Name: Nombre de la secuencia: + {n} clip(s): {names} {n} clip(s): {names} + {n} clip(s) selected; the clip on the reference track stays put. {n} clip(s) seleccionado(s); el clip de la pista de referencia no se mueve. + Synchronize Point Punto de sincronización + Ignore Hours Ignorar horas + first marker primer marcador + Offset Audio by: Desplazar el audio: + Offset: Desplazamiento: + frames fotogramas + Reference track: Pista de referencia: + lowest (V1…) la más baja (V1…) + Remove Audio from AV Clip Quitar el audio del clip AV + Camera 1 Cámara 1 + All Cameras Todas las cámaras + Switch Audio Cambiar audio + Camera Names Nombres de cámara + Clip Names Nombres de clip + Enumerate Cameras Numerar cámaras + Camera Angle Metadata Metadatos de ángulo de cámara + Move source clips to Processed Clips bin Mover los clips de origen a la bandeja Clips procesados + Synchronized {n} clip group(s) Sincronizado(s) {n} grupo(s) de clips + Merged clip created Clip combinado creado + Multi-camera source sequence with {n} camera(s) Secuencia de origen multicámara con {n} cámara(s) + No multi-camera clip at the playhead No hay ningún clip multicámara en el cabezal + Camera {n} — {name} Cámara {n} — {name} + Page {page} of {pages} Página {page} de {pages} + Previous page Página anterior + Next page Página siguiente + Multi-Camera Record On/Off (0) Grabación multicámara activada/desactivada (0) + Record Grabar + Edit Cameras Editar cámaras + Cameras shown in the Multi-Camera view (uncheck to hide). Cámaras mostradas en la vista multicámara (desmarca para ocultar). + Recorded {n} multi-camera cut(s) Grabado(s) {n} corte(s) multicámara + Clamp Signal Limitar señal + Presets Ajustes preestablecidos + Parade ({kind}) Desfile ({kind}) + Waveform ({kind}) Forma de onda ({kind}) + Parade Type Tipo de desfile + Waveform Type Tipo de forma de onda + Vectorscope Targets Objetivos del vectorscopio + Colour Space Espacio de color + Brightness Brillo + Signal Scale Escala de la señal + Histogram Histograma + Paste Attributes Pegar atributos + Remove Attributes Quitar atributos + New Offline File Nuevo archivo sin conexión + Nested Sequence Name Nombre de la secuencia anidada + Make Subclip Crear subclip + Edit Subclip Editar subclip + Modify Clip: Audio Channels Modificar clip: canales de audio + Modify Clip: Timecode Modificar clip: código de tiempo + Frame Hold Options Opciones de fotograma congelado + Field Options Opciones de campo + Save Project Guardar proyecto + Motion Movimiento + Opacity Opacidad + Time Remapping Reasignación de tiempo + Channel Volume Volumen del canal + In Point Punto de entrada + Out Point Punto de salida + Playhead Cabezal + Source Timecode Código de tiempo de origen + Sequence Time Tiempo de la secuencia + Video Attributes Atributos de video + Audio Attributes Atributos de audio + No effects Sin efectos + Scale Attribute Times Escalar los tiempos de los atributos + File Name: Nombre de archivo: + Tape Name: Nombre de la cinta: + Contains: Contiene: + Video Video + Width: Anchura: + Height: Altura: + Frame Rate: Velocidad de fotogramas: + Media Start: Inicio del medio: + Subclip: {name} Subclip: {name} + Start: Inicio: + End: Fin: + Restrict Trims To Subclip Boundaries Limitar los recortes a los límites del subclip + Convert to Master Clip Convertir en clip maestro + Clip Channel Format: Formato de canales del clip: + Number of Audio Clips: Número de clips de audio: + Media Source Channels Canales de origen del medio + Ch. {n} Can. {n} + Clip {n} Clip {n} + Source channels the selected audio clips play: Canales de origen que reproducen los clips de audio seleccionados: + Set Timecode: Establecer código de tiempo: + Use the file's timecode Usar el código de tiempo del archivo + Hold On Congelar en + Timecode: Código de tiempo: + Hold Filters Congelar filtros + Reverse Field Dominance Invertir dominancia de campo + Processing Options Opciones de procesamiento + None Ninguno + Always Deinterlace Desentrelazar siempre + Flicker Removal Eliminar parpadeo + Save changes to “{project_name}” before closing? ¿Guardar los cambios de “{project_name}” antes de cerrar? + Don't Save No guardar + All media is online. Todos los medios están conectados. + {name} (copy) {name} (copia) + Offline Sin conexión + Missing No encontrado + Link Media Vincular medios + {n} clip(s) can't find their media. Locate them, search a folder, or leave them offline. {n} clip(s) no encuentran sus medios. Localízalos, busca en una carpeta o déjalos sin conexión. + Clip Name Nombre del clip + File Name Nombre de archivo + File Path Ruta del archivo + Status Estado + Match file properties: Propiedades de archivo que deben coincidir: + File Extension Extensión de archivo + Clip ID (fingerprint) ID del clip (huella) + Duration Duración + Media Start Inicio del medio + Frame Size / Rate Tamaño y velocidad de fotogramas + Align Timecode Alinear código de tiempo + Relink others automatically Volver a vincular los demás automáticamente + Search in: Buscar en: + Browse… Examinar… + Exact name matches only Solo coincidencias exactas de nombre + No candidates yet — Search a folder or Locate the file. Aún no hay candidatos: busca en una carpeta o localiza el archivo. + ✔ same file ✔ mismo archivo + ✔ matches ✔ coincide + ✖ different file ✖ archivo distinto + Preview Vista previa + Offline All Todo sin conexión + Locate… Localizar… + Linked {n} clip(s) {n} clip(s) vinculado(s) + Nothing found with that name. No se ha encontrado nada con ese nombre. + Make Offline Desconectar + Make {n} selected clip(s) offline. Their clips show the offline slate until you link them again. Desconectar {n} clip(s) seleccionado(s). Sus clips mostrarán la claqueta sin conexión hasta que los vuelvas a vincular. + Media files remain on disk Los archivos multimedia se conservan en el disco + Media files are deleted Los archivos multimedia se eliminan + Create Proxies Crear proxies + {n} clip(s) selected. {n} clip(s) seleccionado(s). + Format and size Formato y tamaño + Destination Destino + Next to the original media, in a Proxies folder Junto a los medios originales, en una carpeta Proxies + Proxies are made in the background and attached when done. Export always uses full-resolution media. Los proxies se crean en segundo plano y se adjuntan al terminar. La exportación siempre usa los medios a resolución completa. + Creating {n} proxy file(s)… Creando {n} archivo(s) proxy… + Project Manager Administrador de proyectos + Sequences Secuencias + Resulting Project Proyecto resultante + Collect Files and Copy to New Location Recopilar archivos y copiar en una nueva ubicación + Consolidate and Transcode Consolidar y transcodificar + Options Opciones + Exclude Unused Clips Excluir clips no utilizados + Include Handles: Incluir márgenes: + Include Proxies Incluir proxies + Include Preview Files Incluir archivos de previsualización + Destination Path Ruta de destino + Disk space: original {original} · resulting {result} ({n} files) Espacio en disco: original {original} · resultante {result} ({n} archivos) + Disk space: — Espacio en disco: — + Calculate Calcular + Project Manager: writing {path} Administrador de proyectos: escribiendo {path} + Timeline: (no sequences) Línea de tiempo: (sin secuencias) + Browse Examinar + Select audio clips, then click a preset to apply it. Selecciona clips de audio y haz clic en un ajuste preestablecido para aplicarlo. + {name} (user) {name} (usuario) + Select audio clips to edit them here Selecciona clips de audio para editarlos aquí + Multiple Clips Selected Varios clips seleccionados + The selected clips have different audio types. Assign one type to edit them together. Los clips seleccionados tienen tipos de audio distintos. Asigna un tipo para editarlos juntos. + Assign an audio type to the selection: Asigna un tipo de audio a la selección: + Clear Audio Type Borrar tipo de audio + Preset: Ajuste preestablecido: + (Custom) (Personalizado) + Preset name Nombre del ajuste preestablecido + Reduce Noise Reducir ruido + Reduce Rumble Reducir retumbo + DeHum Eliminar zumbido + DeEss Eliminar sibilancias + Reduce Reverb Reducir reverberación + Dynamics Dinámica + EQ Ecualizador + Enhance Speech Realzar voz + Low Tone Tono grave + High Tone Tono agudo + Mix Mezcla + Reverb Reverberación + Stereo Width Amplitud estéreo + Remixing music to a target duration is not available yet. Trim the clip and add a crossfade instead. Aún no se puede remezclar la música a una duración objetivo. Recorta el clip y añade un fundido cruzado. + Pan Panorámica + Auto-Match Igualar automáticamente + Not matched yet Aún sin igualar + Matches each of the {n} clips on its own. Iguala cada uno de los {n} clips por separado. + Duck against: Atenuar frente a: + Untagged Sin etiquetar + Sensitivity Sensibilidad + Reduce By Reducir en + Fades Fundidos + Generate Keyframes Generar fotogramas clave + Clip Volume Volumen del clip + (no clip selected) (ningún clip seleccionado) + Master · {name} Maestro · {name} + Add Lumetri Color to clip Añadir Color Lumetri al clip + Input LUT LUT de entrada + Color Color + Temperature Temperatura + Tint Matiz + Saturation Saturación + Exposure Exposición + Contrast Contraste + Highlights Iluminaciones + Shadows Sombras + Whites Blancos + Blacks Negros + HDR HDR + HDR White Blanco HDR + HDR Specular Especular HDR + Look Aspecto + Look LUT LUT de aspecto + Intensity Intensidad + Adjustments Ajustes + Faded Film Película desvaída + Sharpen Enfocar + Vibrance Intensidad del color + RGB Curves Curvas RGB + HDR Range Rango HDR + Hue Saturation Curves Curvas de tono y saturación + Hue vs Sat Tono frente a saturación + Hue vs Hue Tono frente a tono + Hue vs Luma Tono frente a luminancia + Luma vs Sat Luminancia frente a saturación + Sat vs Sat Saturación frente a saturación + Midtones Medios tonos + Key Clave + Hue Tono + Hue Range Rango de tono + Saturation Min Saturación mínima + Luma Min Luminancia mínima + Luma Max Luminancia máxima + Soften Suavizar + Show Mask Mostrar máscara + Off Desactivado + Color/Gray Color/Gris + Color/Black Color/Negro + White/Black Blanco/Negro + Refine Refinar + Denoise Reducir ruido + Blur Desenfoque + Correction Corrección + Hue Shift Cambio de tono + Midpoint Punto medio + Roundness Redondez + Feather Desvanecer + LUT LUT + Color Match Coincidencia de color + Reference Referencia + Face Detection (protect skin tones) Detección de caras (protege los tonos de piel) + Apply Match Aplicar coincidencia + Create ellipse mask Crear máscara de elipse + Create 4-point polygon mask Crear máscara de polígono de 4 puntos + Free draw bezier Bézier a mano alzada + Click in the Program monitor to place mask points (drag for curves); click the first point or press Enter to close Haz clic en el monitor de programa para colocar puntos de máscara (arrastra para curvas); haz clic en el primer punto o pulsa Intro para cerrar + Delete Mask Eliminar máscara + Inverted Invertida + Drag: move · Alt-click: smooth/corner · Cmd-click: delete Arrastrar: mover · Alt+clic: suave/esquina · Cmd+clic: eliminar + Track selected mask backward Seguir la máscara seleccionada hacia atrás + Track selected mask backward 1 frame Seguir la máscara seleccionada 1 fotograma hacia atrás + Track selected mask forward 1 frame Seguir la máscara seleccionada 1 fotograma hacia delante + Track selected mask forward Seguir la máscara seleccionada hacia delante + Stop tracking Detener seguimiento + Tracking method: {method} Método de seguimiento: {method} + Composite Compuesto + Comparison Comparación + Video and Waveform Video y forma de onda + System Default Predeterminado del sistema + Default location Ubicación predeterminada + Font family Familia de fuentes + Label Colors Colores de etiqueta + Decoded frames held for the monitors and thumbnails: {used} of {budget}. Fotogramas descodificados en memoria para los monitores y miniaturas: {used} de {budget}. + FilmCraft keeps the rest of the RAM free for other applications. FilmCraft deja el resto de la RAM libre para otras aplicaciones. + not available in this session no disponible en esta sesión + Currently: {loc} — {files} file(s), {size} Actualmente: {loc} — {files} archivo(s), {size} + ({ch} output channels) ({ch} canales de salida) + Programme left → device output {l}, right → device output {r}{dev}. Outputs beyond the device's channels fall back to 1 and 2. Programa izquierdo → salida {l} del dispositivo, derecho → salida {r}{dev}. Las salidas que superen los canales del dispositivo vuelven a 1 y 2. + Auto-saves go to: {dir} Los guardados automáticos van a: {dir} + Recovery copy: {dir} Copia de recuperación: {dir} + Last auto-save {a} · last recovery copy {j} Último guardado automático {a} · última copia de recuperación {j} + Last auto-save {a} Último guardado automático {a} + Last recovery copy {j} Última copia de recuperación {j} + Remove Media Cache Files Eliminar archivos de caché de medios + Deleted {n} file(s), {size}. Se eliminaron {n} archivo(s), {size}. + Reset… Restablecer… + Source · {name} Origen · {name} + Reset Effect Restablecer efecto + Save Preset… Guardar ajuste preestablecido… + Custom Setup Configuración personalizada + Edit… Editar… + Show graphs Mostrar gráficos + Toggle animation Activar/desactivar animación + Add/Remove Keyframe Añadir/quitar fotograma clave + Temporal Interpolation Interpolación temporal + Linear Lineal + Bezier Bézier + Auto Bezier Bézier automático + Continuous Bezier Bézier continuo + Ease In Suavizar entrada + Ease Out Suavizar salida + Select a clip to grade Selecciona un clip para etalonarlo + Select a clip to see its properties Selecciona un clip para ver sus propiedades + Anchor point Punto de anclaje + Rotation Rotación + Level Nivel + Speed {n}% Velocidad {n}% + Value Valor + Velocity Velocidad + Default Predeterminado + Custom ({n} points) Personalizada ({n} puntos) + Select a graphic clip, or use the Type tool (T) on the Program monitor Selecciona un clip de gráfico o usa la herramienta Texto (T) en el monitor de programa + Create new graphic Crear gráfico nuevo + New Text Layer Nueva capa de texto + New Text Texto nuevo + New Rectangle Nuevo rectángulo + New Ellipse Nueva elipse + Delete Layer Eliminar capa + Select a layer to edit its properties. Selecciona una capa para editar sus propiedades. + Align Left Alinear a la izquierda + Align Center Horizontally Centrar horizontalmente + Align Right Alinear a la derecha + Align Top Alinear arriba + Align Center Vertically Centrar verticalmente + Align Bottom Alinear abajo + Font Fuente + Font Style Estilo de fuente + Font Size Tamaño de fuente + Alignment Alineación + Left Align Text Alinear texto a la izquierda + Center Align Text Centrar texto + Right Align Text Alinear texto a la derecha + Justify Justificar + Tracking Espaciado entre letras + Leading Interlineado + Baseline Shift Desplazamiento de línea de base + Text Box Width Anchura del cuadro de texto + Style Estilo + Faux Bold Negrita simulada + Faux Italic Cursiva simulada + All Caps Todo mayúsculas + Small Caps Versalitas + Underline Subrayado + OpenType OpenType + Kerning Ajuste entre pares + Ligatures Ligaduras + Shape Forma + Size Tamaño + Corner Radius Radio de esquina + Polygon Sides Lados del polígono + Fill Relleno + Stroke Trazo + Stroke 2 Trazo 2 + Background Fondo + Opacity Opacidad + Size Tamaño + Corner Radius Radio de esquina + Shadow Sombra + Angle Ángulo + Distance Distancia + Blur Desenfoque + Search templates Buscar plantillas + All Categories Todas las categorías + Install… Instalar… + Install Motion Graphics Template (.fcgt) Instalar plantilla de animación (.fcgt) + No templates match. Ninguna plantilla coincide. + {category} · My Template {category} · Mi plantilla + Apply Aplicar + Remove Quitar + {n} editable properties {n} propiedades editables + Template: {name} Plantilla: {name} + Intro Duration Duración de la entrada + Outro Duration Duración de la salida + Roll Desplazamiento + Start Off Screen Empezar fuera de la pantalla + End Off Screen Terminar fuera de la pantalla + Preroll Margen previo + Postroll Margen posterior + Video Frame Fotograma de video + Pin To Fijar a + Pinned Edges Bordes fijados + FilmCraft Graphics Template Plantilla de gráficos de FilmCraft + {n} Font {n} Fuente + {n} Size {n} Tamaño + {n} Color {n} Color + {n} Position {n} Posición + Show {n} Mostrar {n} + My Templates Mis plantillas + Export As Motion Graphics Template Exportar como plantilla de animación + Category: Categoría: + Editable properties: Propiedades editables: + Saved as a FilmCraft graphics template (.fcgt) in your templates folder. Se guarda como plantilla de gráficos de FilmCraft (.fcgt) en tu carpeta de plantillas. + Exported graphics template to {path} Plantilla de gráficos exportada a {path} + Replace Fonts in Projects Reemplazar fuentes en proyectos + Fonts used in the project: Fuentes usadas en el proyecto: + {f} (missing) {f} (no encontrada) + Replace: Reemplazar: + Replaced {n} font uses Se reemplazaron {n} usos de fuentes + Show/Hide Effects and Sends Mostrar/ocultar efectos y envíos + Audio Track Mixer menu Menú del mezclador de pistas de audio + Show/Hide Tracks… Mostrar/ocultar pistas… + Meter Input(s) Only Medir solo entradas + Show/Hide Tracks Mostrar/ocultar pistas + Show All Mostrar todo + Post-Fader Post-fader + Remove Effect Quitar efecto + Fill the slots above first Rellena primero las ranuras de arriba + pre pre + Pre-Fader Pre-fader + Mute Send Silenciar envío + Remove Send Quitar envío + New Submix Nueva submezcla + Pan / balance (drag; double-click: centre) Panorámica / balance (arrastra; doble clic: centro) + Solo safe Protección de solo + Enable track for recording Activar la pista para grabar + Volume (drag; double-click: 0 dB) Volumen (arrastra; doble clic: 0 dB) + 5.1 panner (drag the puck; double-click: front centre) Panoramizador 5.1 (arrastra el punto; doble clic: centro delantero) + Center Centro + Go to In Point Ir al punto de entrada + Go to Out Point Ir al punto de salida + Play In to Out Reproducir de entrada a salida + Record (voice-over on the record-enabled track) Grabar (voz en off en la pista activada para grabar) + Write keyframes Escribir fotogramas clave + Something went wrong while drawing the window. Your project is still open; save it to be safe. Algo ha fallado al dibujar la ventana. Tu proyecto sigue abierto; guárdalo por si acaso. + Continue Continuar + Go Back Atrás + Go Forward Adelante + Up One Level Subir un nivel + File Types Displayed Tipos de archivo mostrados + List View Vista de lista + Thumbnail View Vista de miniaturas + Ingest Ingesta + Open Ingest Settings Abrir ajustes de ingesta + Import as Image Sequence Importar como secuencia de imágenes + {sel} of {total} items selected {sel} de {total} elementos seleccionados + {total} items {total} elementos + Cannot open {dir}: {e} No se puede abrir {dir}: {e} + Favorites Favoritos + Local Drives Unidades locales + Network Red + Recent Directories Carpetas recientes + (none) (ninguna) + Remove from Favorites Quitar de favoritos + Add to Favorites Añadir a favoritos + Clear Recent Directories Borrar carpetas recientes + Open In Source Monitor Abrir en el monitor de origen + Reveal in Finder Mostrar en el explorador de archivos + Edit Columns… Editar columnas… + Hover Scrub Arrastre al pasar el ratón + New Media Browser Panel Nuevo panel de navegador de medios + Create Folder For Imported Projects Crear carpeta para los proyectos importados + Allow Duplicate Media During Project Import Permitir medios duplicados al importar proyectos + Refresh Actualizar + Edit Columns Editar columnas + Guides Guías + (no audio) (sin audio) + Generating waveform… Generando forma de onda… + Reference {tc} Referencia {tc} + Set Fijar + Set the reference to the playhead Fijar la referencia en el cabezal + Reference: next frame Referencia: fotograma siguiente + Reference: previous frame Referencia: fotograma anterior + RGBA Channels Canales RGBA + Add Guide Añadir guía + Save Guides as Template Guardar guías como plantilla + Manage Guides Gestionar guías + Orientation: Orientación: + Vertical Vertical + Horizontal Horizontal + Position: Posición: + No saved guide templates. No hay plantillas de guías guardadas. + Project: {name} Proyecto: {name} + Bin: {name} Bandeja: {name} + Up one level Subir un nivel + Video Usage: {vu} Audio Usage: {au} Uso de video: {vu} Uso de audio: {au} + Icon View Vista de iconos + Freeform View Vista de forma libre + Sort Icons Ordenar iconos + Clear (Delete) Borrar (Supr) + New Item Nuevo elemento + New Bin Nueva bandeja + Find Buscar + Automate to Sequence Automatizar a secuencia + Select items to clear Selecciona los elementos que quieres borrar + User Order Orden del usuario + Descending Descendente + Refresh Project Actualizar proyecto + Reveal Project in Finder… Mostrar el proyecto en el explorador de archivos… + New Bin From Selection Nueva bandeja a partir de la selección + New Search Bin Nueva bandeja de búsqueda + New Project Shortcut Nuevo acceso directo al proyecto + List Lista + Icon Icono + Freeform Forma libre + Preview Area Área de previsualización + Thumbnails Miniaturas + Thumbnails show effects applied Las miniaturas muestran los efectos aplicados + Thumbnail controls for all pointing devices Controles de miniaturas para todos los dispositivos señaladores + Refresh Sort Order Actualizar el orden + Metadata Display… Visualización de metadatos… + Save Current View Preset Guardar el ajuste de vista actual + Restore View Preset Restaurar ajuste de vista + Project View Preset {n} Ajuste de vista del proyecto {n} + Freeform View Options… Opciones de la vista de forma libre… + Media missing Medio no encontrado + Unreadable Ilegible + Open in Source Monitor Abrir en el monitor de origen + Open in Timeline Abrir en la línea de tiempo + New Sequence From Clip Nueva secuencia a partir del clip + Set Poster Frame Establecer fotograma de póster + Proxy Proxy + Open in Place Abrir en el mismo lugar + Open in New Tab Abrir en una pestaña nueva + Open in New Window Abrir en una ventana nueva + {n} items {n} elementos + Stack Apilar + Unstack Desapilar + Clip Size Tamaño del clip + Larger Más grande + Smaller Más pequeño + Align to Grid Alinear a la cuadrícula + Reset to Grid Restablecer a la cuadrícula + Save Arrangement… Guardar disposición… + Restore Arrangement Restaurar disposición + Delete Arrangement Eliminar disposición + Drop media here to create sequence. Suelta medios aquí para crear una secuencia. + Double-click a sequence in the Project panel to open it here. Haz doble clic en una secuencia del panel Proyecto para abrirla aquí. + Open Demo Project Abrir proyecto de demostración + Insert and overwrite sequences as nests or individual clips Insertar y sobrescribir secuencias como anidadas o como clips individuales + Snap in Timeline (S) Ajustar en la línea de tiempo (S) + Caption track options Opciones de pista de subtítulos + Timeline Display Settings Ajustes de visualización de la línea de tiempo + Show Video Thumbnails Mostrar miniaturas de video + Show Audio Waveform Mostrar forma de onda de audio + Expand All Tracks Expandir todas las pistas + Minimize All Tracks Minimizar todas las pistas + Unrendered: should play back in real time Sin procesar: debería reproducirse en tiempo real + Unrendered: render to play back in real time Sin procesar: procésalo para reproducirlo en tiempo real + Rendered preview Previsualización procesada + ✓ Enable ✓ Activar + Unlink Desvincular + (Default) (Predeterminado) + (RGB) Gamma (RGB) Gamma + (RGB) Input Black Level (RGB) Nivel de negro de entrada + (RGB) Input White Level (RGB) Nivel de blanco de entrada + (RGB) Output Black Level (RGB) Nivel de negro de salida + (RGB) Output White Level (RGB) Nivel de blanco de salida + + Cmd + Cmd + + Opt + Opt + 10 Bit Black Point Punto negro de 10 bits + 10 Bit White Point Punto blanco de 10 bits + 105mm Prime Objetivo fijo de 105 mm + 29.97 fps Drop-Frame 29,97 fps con eliminación de fotogramas + 29.97 fps Non Drop-Frame 29,97 fps sin eliminación de fotogramas + 35mm Prime Objetivo fijo de 35 mm + 3D Roll Rodar en 3D + 3D Rotate Rotar en 3D + 3D Spin Girar en 3D + 3D Spinback Giro inverso en 3D + 4-Color Gradient Degradado de 4 colores + 5.1 Media Medios 5.1 + 5.1 Mixdown Type Tipo de mezcla 5.1 + 50-300mm Zoom Zoom de 50-300 mm + 59.94 fps Drop-Frame 59,94 fps con eliminación de fotogramas + 59.94 fps Non Drop-Frame 59,94 fps sin eliminación de fotogramas + ASC CDL ASC CDL + Accessible color contrast Contraste de color accesible + Action-Safe Margin Margen seguro de acción + Active Monitor Monitor activo + Add Añadir + Add tracks automatically when editing source clips onto the Timeline Añadir pistas automáticamente al editar clips de origen en la línea de tiempo + Additional Scale Escala adicional + Additive Dissolve Disolución aditiva + Additive Dissolve (Legacy) Disolución aditiva (heredada) + Adjust Ajustar + Advanced Avanzado + Aggressive Agresivo + Align and Transform Alinear y transformar + All lines Todas las líneas + Allow Selection tool to choose Roll and Ripple trims without modifier key Permitir que la herramienta de selección elija recortes de rodillo y de rizo sin tecla modificadora + Allow current tool to change trim type of previously selected edit point Permitir que la herramienta actual cambie el tipo de recorte del punto de edición seleccionado + Allow duplicate media during project import Permitir medios duplicados al importar proyectos + Alpha Adjust Ajuste alfa + Alpha Blurriness Desenfoque alfa + Alpha Channel Canal alfa + Alpha Glow Resplandor alfa + Always import embedded CEA-708 closed captions with wide aspect. Importar siempre los subtítulos CEA-708 incrustados con aspecto panorámico. + Always override audio tags Sustituir siempre las etiquetas de audio + Ambience Ambiente + Ambience Intensity Intensidad del ambiente + Ambience Light Color Color de la luz ambiente + Amount of Noise Cantidad de ruido + Amount to Decolor Cantidad de decoloración + Amount to Tint Cantidad de tinte + Amplifier Amplificador + Amplify Amplificar + Amplitude Amplitud + Amplitude and Compression Amplitud y compresión + Analog Analógico + Analog Delay Retardo analógico + Analyze all imported media to search for visuals or audio Analizar todos los medios importados para buscar imágenes o audio + Anamorphic Anamórfico + Anchor Ancla + Anchor Point Punto de anclaje + Angle Ángulo + Animation Animación + Anti-aliasing Quality Calidad del suavizado + Anti-flicker Filter Filtro antiparpadeo + Antialiasing for Best Quality Suavizado para la mejor calidad + Asymmetry Asimetría + At Startup Al iniciar + At playback end, return to beginning when restarting playback Al terminar la reproducción, volver al principio al reiniciarla + Attempt to force hardware to document sample rate Intentar forzar el hardware a la frecuencia de muestreo del documento + Audio Codec Códec de audio + Audio Duration Duración del audio + Audio Effects Efectos de audio + Audio Hardware Hardware de audio + Audio In Point Punto de entrada de audio + Audio Info Información de audio + Audio Out Point Punto de salida de audio + Audio Samples Muestras de audio + Audio Time Units Unidades de tiempo de audio + Audio Transition Default Duration Duración predeterminada de las transiciones de audio + Audio Transitions Transiciones de audio + Audio Usage Uso de audio + Auto Automático + Auto Align Alineación automática + Auto Reframe Reencuadre automático + Auto Save Guardado automático + Auto Save also saves the current project(s) El guardado automático también guarda los proyectos actuales + Auto-tag audio types in the timeline Etiquetar automáticamente los tipos de audio en la línea de tiempo + Auto-transcribe all imported clips Transcribir automáticamente todos los clips importados + Auto-transcribe only clips in sequences Transcribir automáticamente solo los clips de secuencias + Automatch Time Tiempo de igualación automática + Automatic Click Remover Eliminador automático de clics + Automatically Hide Dependent Clips Ocultar automáticamente los clips dependientes + Automatically Save Every Guardar automáticamente cada + Automatically delete cache files older than Eliminar automáticamente los archivos de caché con más de + Automatically delete oldest cache files when cache exceeds Eliminar automáticamente los archivos de caché más antiguos cuando la caché supere + Automatically refresh growing files Actualizar automáticamente los archivos en crecimiento + Automatically resume playback for growing files in Source Monitor Reanudar automáticamente la reproducción de archivos en crecimiento en el monitor de origen + Automatically save projects Guardar proyectos automáticamente + Automatically transcribe clips Transcribir clips automáticamente + Automation Keyframe Optimization Optimización de fotogramas clave de automatización + Background Color Color de fondo + Background Corner Radius Radio de esquina del fondo + Background Opacity Opacidad del fondo + Background Size Tamaño del fondo + Background fill Relleno de fondo + Balance Balance + Balanced Voice Voz equilibrada + Band 1 Banda 1 + Band 2 Banda 2 + Band 3 Banda 3 + Band 4 Banda 4 + Band 5 Banda 5 + Band Pass Paso de banda + Band Slide Deslizamiento de bandas + Band Stop Rechazo de banda + Bandpass Paso de banda + Barn Doors Puertas de granero + Basic Básico + Basic 3D 3D básico + Basic Correction Corrección básica + Bass Graves + Behind Detrás + Bessel Bessel + Bevel Alpha Bisel alfa + Bevel join Unión biselada + Binauralizer - Ambisonics Binauralizador - Ambisonics + Bins Bandejas + Black Negro + Black & White Blanco y negro + Black Input Level Nivel de entrada de negro + Black and white Blanco y negro + Black and white with crushed blacks and strong contrast Blanco y negro con negros aplastados y contraste fuerte + Bleach Bypass Blanqueo omitido + Bleach-bypass style: desaturated with hard contrast Estilo blanqueo omitido: desaturado y con contraste duro + Blend Mezclar + Blend Mode Modo de fusión + Blend With Original Mezclar con el original + Blending Mode Modo de fusión + Block Dissolve Disolución en bloques + Block Height Altura del bloque + Block Motion Movimiento de bloques + Block Size Tamaño del bloque + Block Width Anchura del bloque + Blocks Bloques + Blue Blurriness Desenfoque del azul + Blue Curve Curva del azul + Blue Offset Desplazamiento del azul + Blue Power Potencia del azul + Blue Slope Pendiente del azul + Blue-Blue Azul-azul + Blue-Const Azul-constante + Blue-Green Azul-verde + Blue-Red Azul-rojo + Blur & Sharpen Desenfocar y enfocar + Blur Amount Cantidad de desenfoque + Blur Angle Ángulo de desenfoque + Blur Dimensions Dimensiones de desenfoque + Blur Dissolve Disolución con desenfoque + Blur Layer Capa de desenfoque + Blur Length Longitud del desenfoque + Blurriness Desenfoque + Bokeh Blur Desenfoque bokeh + Boost Realce + Border Borde + Border Color Color del borde + Border Width Anchura del borde + Borders Bordes + Bottom Center Abajo en el centro + Bottom Left Abajo a la izquierda + Bottom Right Abajo a la derecha + Bounce Rebote + Branching Ramificación + Bright Brillante + Bright Air Aire brillante + Bright Studio Estudio brillante + Brightness & Contrast Brillo y contraste + British Stack Pila británica + Brush Hardness Dureza del pincel + Brush Opacity Opacidad del pincel + Brush Position Posición del pincel + Brush Size Tamaño del pincel + Brush Spacing (secs) Espaciado del pincel (s) + Brush Strokes Trazos de pincel + Bubbles Burbujas + Bulge Abultamiento + Bulge Smoother Suavizado del abultamiento + Bump Height Altura del relieve + Burn Alpha Quemar alfa + Burn Chroma Quemar croma + Burn Color Quemar color + Butt cap Extremo cuadrado + Butterworth Butterworth + CEA-608 CEA-608 + CEA-708 CEA-708 + Cache analysis results for re-use Guardar en caché los resultados del análisis para reutilizarlos + Camera Angle Ángulo de cámara + Camera Blur Desenfoque de cámara + Camera Shake Movimiento de cámara + Capitalisation Uso de mayúsculas + Cathedral Catedral + Cell Pattern Patrón de celdas + Center Channel Pan Panorámica del canal central + Center Gradient Degradado central + Center Input Entrada central + Center Out Salida central + Center Point (Latitude) Punto central (latitud) + Center Point (Longitude) Punto central (longitud) + Center Split División central + Center of Sphere Centro de la esfera + Cents Centésimas + Change to Color Cambiar a color + Changes are copied in the background a moment after each edit. If FilmCraft quits unexpectedly, they are offered the next time it starts. Los cambios se copian en segundo plano poco después de cada edición. Si FilmCraft se cierra de forma inesperada, se ofrecen la próxima vez que se inicie. + Channel Canal + Channel Blur Desenfoque de canal + Channel Mix Mezcla de canales + Channel Mixer Mezclador de canales + Chaos Caos + Chebyshev Chebyshev + Checker Wipe Barrido en damero + Checkerboard Tablero de damas + Chinese Chino + Choke Estrechar + Chorus Chorus + Chorus/Flanger Chorus/Flanger + Chroma Croma + Chroma Leak Fuga de croma + Chromatic Aberration (Blue) Aberración cromática (azul) + Chromatic Aberration (Green) Aberración cromática (verde) + Chromatic Aberration (Red) Aberración cromática (rojo) + Cineon Converter Conversor Cineon + Circle Círculo + Clarity Claridad + Clean Combo Combo limpio + Clean Up Noisy Dialogue Limpiar diálogo con ruido + Clear Muddiness Eliminar turbiedad + Client Cliente + Clip Clip + Clip Level Nivel del clip + Clipping Recorte + Clock Wipe Barrido de reloj + Clock Wipe (Legacy) Barrido de reloj (heredado) + Clockwise En el sentido de las agujas del reloj + Clone Clonar + Closed Captions Subtítulos opcionales + Color & Alpha Color y alfa + Color 1 Color 1 + Color 2 Color 2 + Color 3 Color 3 + Color 4 Color 4 + Color Balance Equilibrio de color + Color Burn Subexponer color + Color Channel Canal de color + Color Correction Corrección de color + Color Distortion Distorsión de color + Color Dodge Sobreexponer color + Color Emboss Relieve en color + Color Key Incrustación de color + Color Pass Paso de color + Color Replace Sustitución de color + Color Space Espacio de color + Color Theme Tema de color + Color To Leave Color que se conserva + Color Tolerance Tolerancia de color + Color Wheels & Match Ruedas de color y coincidencia + Colorize Colorear + Columns Columnas + Comment Comentario + Complexity Complejidad + Composite In Back Componer detrás + Composite In Front Componer delante + Composite Using Componer usando + Composite on Original Componer sobre el original + Compound Blur Desenfoque compuesto + Compression Before Clipping Compresión antes del recorte + Constant Gain Ganancia constante + Constant Power Potencia constante + Conversion Type Tipo de conversión + Convolution Reverb Reverberación por convolución + Cool Blue Azul frío + Cool, desaturated and punchy Frío, desaturado y contundente + Core Width Anchura del núcleo + Corner Esquina + Corner Pin Anclaje de esquinas + Count Recuento + Counter-clockwise En sentido contrario a las agujas del reloj + Crash Recovery Recuperación tras un fallo + Crawl Left Desplazar a la izquierda + Crawl Right Desplazar a la derecha + Create folder for imported projects Crear carpeta para los proyectos importados + Creative Creativo + Crop Recortar + Crop Less <-> Smooth More Recortar menos <-> Suavizar más + Cross Displacement Desplazamiento cruzado + Cross Dissolve Disolución cruzada + Cross Dissolve (Legacy) Disolución cruzada (heredada) + Cross Zoom Zoom cruzado + Cross Zoom (Legacy) Zoom cruzado (heredado) + Crossfade Fundido cruzado + Crossovers Frecuencias de cruce + Crystallize Cristalizar + Crystals Cristales + Cube Spin Giro de cubo + Curl Radius Radio de curvatura + Current Time Tiempo actual + Cursor Cursor + Curvature Curvatura + Curve Curva + Curves Curvas + Custom Personalizado + Custom Color Color personalizado + Cutoff Corte + Cyan-teal shadows and warm highlights with a little more contrast Sombras cian verdosas e iluminaciones cálidas con algo más de contraste + Cycle (in Revolutions) Ciclo (en revoluciones) + Cycle Evolution Evolución cíclica + Damping Amortiguación + Damping HF Amortiguación de agudos + Damping LF Amortiguación de graves + Dark Oscuro + Darken Oscurecer + Darker Color Color más oscuro + Date Created Fecha de creación + Date Modified Fecha de modificación + Day for night: darker, cooler and less saturated Noche americana: más oscuro, más frío y menos saturado + DeEsser Eliminador de sibilancias + DeHummer Eliminador de zumbido + DeNoise Reductor de ruido + DeReverb Reductor de reverberación + Decay Caída + Decay Time (RT60) Tiempo de caída (RT60) + Default Audio Tracks Pistas de audio predeterminadas + Default Emoji font Fuente de emoji predeterminada + Default Media Scaling Escalado de medios predeterminado + Default Output Salida predeterminada + Default closed caption font Fuente predeterminada de subtítulos opcionales + Default language Idioma predeterminado + Default paragraph direction Dirección de párrafo predeterminada + Default subtitle and text font Fuente predeterminada de subtítulos y texto + Delay Retardo + Delay 1 Retardo 1 + Delay 2 Retardo 2 + Delay 3 Retardo 3 + Delay 4 Retardo 4 + Delay and Echo Retardo y eco + Delete files older than Eliminar archivos con más de + Delete oldest files when the cache exceeds Eliminar los archivos más antiguos cuando la caché supere + Delete… Eliminar… + Depth Profundidad + Desaturate Desaturar + Description Descripción + Detail Amplitude Amplitud del detalle + Detail Level Nivel de detalle + Detailed Analysis Análisis detallado + Device Class Clase de dispositivo + Dialogue Diálogo + Diamond Diamante + Difference Diferencia + Dimensions Dimensiones + Dimmed Atenuado + Dip to Black Fundido a negro + Dip to Black (Legacy) Fundido a negro (heredado) + Dip to Color Fundido a color + Dip to White Fundido a blanco + Dip to White (Legacy) Fundido a blanco (heredado) + Direction Dirección + Directional Direccional + Directional Blur Desenfoque direccional + Directional Blur (Legacy) Desenfoque direccional (heredado) + Disable video output when in the background Desactivar la salida de video en segundo plano + Disperse Dispersar + Displacement Desplazamiento + Display Color Management Gestión del color de la pantalla + Display Color Settings Ajustes de color de la pantalla + Display out of sync indicators for unlinked clips Mostrar indicadores de desincronización en clips desvinculados + Dissolve Disolución + Dissolve Blocks Bloques de disolución + Distance Distancia + Distance to Image Distancia a la imagen + Distort Distorsionar + Distortion Distorsión + Distortion Complexity Complejidad de la distorsión + Distortion Rate Velocidad de la distorsión + Distortion Type Tipo de distorsión + Divide Dividir + Do not delete cache files automatically No eliminar automáticamente los archivos de caché + Don't label speakers No etiquetar hablantes + Double-click Doble clic + Draft decoding at reduced playback resolution (H.264: faster, some frames less filtered) Descodificación de borrador a resolución de reproducción reducida (H.264: más rápida, algunos fotogramas menos filtrados) + Drive Saturación + Drop Shadow Sombra paralela + Dry Seco + Dry Out Salida seca + Dry Up a Roomy Voice Secar una voz con mucha sala + Duck Deep Under Dialogue Atenuar mucho bajo el diálogo + Duck Under Dialogue Atenuar bajo el diálogo + Duck Under Voice and Effects Atenuar bajo la voz y los efectos + Ducking Atenuación + Dutch Holandés + Dynamic Link Dynamic Link + Dynamics Processing Procesamiento de dinámica + Early Reflections Reflexiones tempranas + Earthquake Terremoto + Ease In and Out Suavizar entrada y salida + Easing Suavizado + Echo Eco + Echo Glow Resplandor de eco + Echo Operator Operador de eco + Echo Time (seconds) Tiempo de eco (segundos) + Echoes Ecos + Edge Color Color del borde + Edge Feather Desvanecimiento del borde + Edge Glow Resplandor del borde + Edge Sharpness Nitidez del borde + Edge Softness Suavidad del borde + Edge Thickness Grosor del borde + Edge Thin Adelgazar borde + Edge Threshold Umbral del borde + Edge Type Tipo de borde + Edge Width Anchura del borde + Elliptical Elíptico + Emboss Relieve + Enable HSL Secondary Activar HSL secundaria + Enable Mercury Transmit Activar Mercury Transmit + Enable ProRes hardware accelerated encoding, if available Activar la codificación ProRes acelerada por hardware, si está disponible + Enable clip and XMP metadata linking Activar la vinculación de metadatos de clip y XMP + Enable developer mode Activar el modo de desarrollador + Enable hardware accelerated decoding (requires restart) Activar la descodificación acelerada por hardware (requiere reiniciar) + Enable language auto-detection Activar la detección automática del idioma + Enable proxies Activar proxies + End Color Color final + End Point Punto final + End of Ramp Final de la rampa + Error Error + Evolution Evolución + Evolution Options Opciones de evolución + Exciter Amount Cantidad de excitador + Exciter Mode Modo del excitador + Exclusion Exclusión + Exponential Fade Fundido exponencial + Extended Dynamic Range Monitoring Monitorización de rango dinámico ampliado + FFT Filter Filtro FFT + Fade Fundido + Fade Out Fundido de salida + Faded Matte Mate desvaído + Falloff Atenuación + Far Away Lejano + Fast Rápido + Faster Motion Movimiento más rápido + Feedback Realimentación + Feedback 1 Realimentación 1 + Feedback 2 Realimentación 2 + Feedback 3 Realimentación 3 + Feedback 4 Realimentación 4 + Feet + Frames 16mm Pies + fotogramas 16 mm + Feet + Frames 35mm Pies + fotogramas 35 mm + Female Femenina + File Size Tamaño del archivo + Fill Background Rellenar fondo + Fill Color Color de relleno + Fill Left with Right Rellenar izquierda con derecha + Fill Point Punto de relleno + Fill Right with Left Rellenar derecha con izquierda + Fill Selector Selector de relleno + Film Dissolve Disolución de película + Film Dissolve (Legacy) Disolución de película (heredada) + Film Roll Rollo de película + Filter Filtro + Filter Frequency Frecuencia del filtro + Filter Resonance Resonancia del filtro + Filter and EQ Filtro y ecualizador + Final Delay Retardo final + Find Edges Buscar bordes + Flanger Flanger + Flare Destello + Flare Brightness Brillo del destello + Flare Center Centro del destello + Flare Color Color del destello + Flash Destello + Flicker Parpadeo + Flickers Parpadeos + Flip Motion Movimiento de volteo + Flip Over Voltear + Float Coma flotante + Focus Foco + Focus Blur Desenfoque de foco + Focus Point Punto de enfoque + Focus Shape Forma del foco + Focus Size Tamaño del foco + Fold Motion Movimiento de plegado + Foldback Retorno + For color management parameters, go to the Settings tab in the Lumetri Color panel. Para los parámetros de gestión del color, ve a la pestaña Ajustes del panel Color Lumetri. + Format Formato + Four Scopes (RGB, 8 bit) Cuatro ámbitos (RGB, 8 bits) + Four Scopes (YUV, float, no clamp) Cuatro ámbitos (YUV, coma flotante, sin limitar) + Fractal Influence Influencia fractal + Fractal Type Tipo de fractal + Frame Fotograma + Frame Color Color del marco + Frame Count Número de fotogramas + Frame Layout Disposición del fotograma + Frame Rate Velocidad de fotogramas + Frame Width Anchura del marco + Frame cache for monitors and thumbnails Caché de fotogramas para monitores y miniaturas + Frames Fotogramas + Framing Encuadre + French Francés + Frequency Frecuencia + From Desde + From East Desde el este + From North Desde el norte + From South Desde el sur + From West Desde el oeste + From the Left Desde la izquierda + From the Right Desde la derecha + Front + LFE Frontales + LFE + Front + Rear Frontales + traseros + Front + Rear + LFE Frontales + traseros + LFE + Front Only Solo frontales + Fuzz Fuzz + GB GB + Gamma Gamma + Gamma Correction Corrección de gamma + Gamut Warning Aviso de gama + Gamut Warning Color Color del aviso de gama + Gap Hueco + Gap Color Color del hueco + Gaussian Blur Desenfoque gaussiano + Gaussian Blur (Legacy) Desenfoque gaussiano (heredado) + General General + Generate Generar + Generate Timecode Generar código de tiempo + Generate waveforms and conform audio using multithreading Generar formas de onda y conformar audio con varios hilos + Generate waveforms automatically during import Generar formas de onda automáticamente al importar + German Alemán + Ghosts Fantasmas + Glass Cristal + Glint Destello + Glitch Glitch + Glow Resplandor + Glow Brightness Brillo del resplandor + Glow Color Color del resplandor + Glow Only Solo resplandor + Glow Radius Radio del resplandor + Glow Saturation Saturación del resplandor + Glow Width Anchura del resplandor + Golden Hour Hora dorada + Good Buena + Gradient Degradado + Gradient End Final del degradado + Gradient Layer Capa de degradado + Gradient Placement Colocación del degradado + Gradient Scatter Dispersión del degradado + Gradient Shape Forma del degradado + Gradient Start Inicio del degradado + Gradient Wipe Barrido con degradado + Gradient Wipe (Legacy) Barrido con degradado (heredado) + Grain Size Tamaño del grano + Graphic Equalizer (10 Bands) Ecualizador gráfico (10 bandas) + Graphic Equalizer (20 Bands) Ecualizador gráfico (20 bandas) + Graphic Equalizer (30 Bands) Ecualizador gráfico (30 bandas) + Graphics Gráficos + Green Blurriness Desenfoque del verde + Green Curve Curva del verde + Green Offset Desplazamiento del verde + Green Power Potencia del verde + Green Slope Pendiente del verde + Green-Blue Verde-azul + Green-Const Verde-constante + Green-Green Verde-verde + Green-Red Verde-rojo + Grid Cuadrícula + Grow Crecer + Growing Files Archivos en crecimiento + Grunge Grunge + Grunge & Distort Grunge y distorsión + GuitarSuite GuitarSuite + HDR Graphics White (Nits) Blanco de gráficos HDR (nits) + HLS Vectorscope + Histogram Vectorscopio HLS + histograma + HSL Secondary HSL secundaria + Hard Duro + Hard Clip Recorte duro + Hard Light Luz fuerte + Hard Limiter Limitador duro + Hard Mix Mezcla definida + Hardware decoding Descodificación por hardware + Hardware decoding: Auto uses the system's video decoder (VideoToolbox on macOS) for the H.264 and HEVC streams it supports, and FilmCraft's own decoder for everything else or if the hardware fails. Media that is already open keeps its decoder until it is reopened. Descodificación por hardware: Automático usa el descodificador de video del sistema (VideoToolbox en macOS) para los flujos H.264 y HEVC que admite, y el descodificador propio de FilmCraft para todo lo demás o si falla el hardware. Los medios ya abiertos mantienen su descodificador hasta que se vuelven a abrir. + Head Size Tamaño de la cabeza + Heart Corazón + Height Altura + Hexagon Hexágono + Hide Back Face Ocultar la cara posterior + High Alta + High Cutoff Corte de agudos + High Frequency Cut Corte de altas frecuencias + High Frequency Damping Amortiguación de altas frecuencias + High Pass Paso alto + High Shelf Estante de agudos + High Shelf Gain Ganancia del estante de agudos + High contrast with held highlights and deep shadows Alto contraste con iluminaciones contenidas y sombras profundas + Highlight Iluminación + Highlight Blue Balance Balance de azul en iluminaciones + Highlight Brightness Brillo de las iluminaciones + Highlight Color Color de resaltado + Highlight Green Balance Balance de verde en iluminaciones + Highlight Red Balance Balance de rojo en iluminaciones + Highlight Rolloff Atenuación de iluminaciones + Highlight Threshold Umbral de iluminaciones + Highlight Tint Matiz de iluminaciones + Highlights Lightness Luminosidad de iluminaciones + Highpass Paso alto + Hindi Hindi + Hindi digits Dígitos hindi + Horizontal 16:9 Horizontal 16:9 + Horizontal Alignment Alineación horizontal + Horizontal Blocks Bloques horizontales + Horizontal Decentering Descentrado horizontal + Horizontal Displacement Desplazamiento horizontal + Horizontal Flip Volteo horizontal + Horizontal Margin Margen horizontal + Horizontal and Vertical Horizontal y vertical + Hue Center Centro de tono + Hue Tolerance Tolerancia de tono + I/O Buffer Size Tamaño del búfer de E/S + Ignore Alpha Ignorar alfa + Image Control Control de imagen + Immersive Video Video inmersivo + Import numbered stills as image sequences Importar imágenes fijas numeradas como secuencias de imágenes + Impulse Impulso + In a Room En una sala + In the Media Cache En la caché de medios + In the project En el proyecto + In/Out Duration Duración de entrada a salida + Include captions on import Incluir subtítulos al importar + Incoming Entrante + Indeterminate Media Timebase Base de tiempo de medios indeterminada + Initial Delay Retardo inicial + Inner Interior + Inner Radius Radio interior + Input Boost Realce de entrada + Inset Recuadro + Inside Dentro + Inside Color Color interior + Inter Inter + Interior Room Tone Tono de sala interior + Internal Black Point Punto negro interno + Internal White Point Punto blanco interno + Interpolation Interpolación + Intersect Intersecar + Inverse Falloff Atenuación inversa + Invert Invertir + Invert Alpha Invertir alfa + Invert Blur Invertir desenfoque + Invert Fill Invertir relleno + Invert Gradient Invertir degradado + Invert Left Invertir izquierda + Invert Right Invertir derecha + Iris Iris + Iris Box Iris en caja + Iris Cross Iris en cruz + Iris Diamond Iris en diamante + Iris Radius Radio del iris + Iris Rotation Rotación del iris + Iris Round Iris redondo + Iris Shape Forma del iris + Italian Italiano + Japanese Japonés + JetBrains Mono JetBrains Mono + Join Through Edits Unir ediciones pasantes + Kaleidoscope Caleidoscopio + Keep a recovery copy of unsaved changes Mantener una copia de recuperación de los cambios sin guardar + Key Color Color clave + Keying Incrustación + Korean Coreano + Label Defaults Etiquetas predeterminadas + Label speakers Etiquetar hablantes + Labels Etiquetas + Large Hall Sala grande + Large Trim Offset Desplazamiento de recorte grande + Large Volume Adjustment Ajuste de volumen grande + Latch Retener + Layer Name Nombre de la capa + Layers Capas + Leave Color Conservar color + Left - Left Izquierda - izquierda + Left - Right Izquierda - derecha + Left channel to device output Canal izquierdo a la salida del dispositivo + Left to Right De izquierda a derecha + Left to right De izquierda a derecha + Legacy Heredados + Length Longitud + Lens Blur Desenfoque de lente + Lens Distortion Distorsión de lente + Lens Flare Destello de lente + Lens Type Tipo de lente + Level 1 Nivel 1 + Level 2 Nivel 2 + Level 3 Nivel 3 + Level 4 Nivel 4 + Levels Niveles + Lifted blacks, softer contrast, muted colour Negros elevados, contraste más suave y color apagado + Light 1 Luz 1 + Light 2 Luz 2 + Light 3 Luz 3 + Light 4 Luz 4 + Light 5 Luz 5 + Light Angle Ángulo de la luz + Light Color Color de la luz + Light Intensity Intensidad de la luz + Light Leak Fuga de luz + Light Leaks Fugas de luz + Light Sweep Barrido de luz + Light Type Tipo de luz + Lighten Aclarar + Lighter Color Color más claro + Lighting Effects Efectos de iluminación + Lightning Rayo + Lights & Blurs Luces y desenfoques + Lights & Glows Luces y resplandores + Line Thickness Grosor de línea + Linear Burn Subexposición lineal + Linear Dodge (Add) Sobreexposición lineal (añadir) + Linear Light Luz lineal + Linear Ramp Rampa lineal + Linear Wipe Barrido lineal + Linear Wipe (Legacy) Barrido lineal (heredado) + Linear keyframe thinning Reducción lineal de fotogramas clave + Linear to Log De lineal a logarítmico + Liquid Distortion Distorsión líquida + Local Projects Proyectos locales + Location Ubicación + Log Note Nota de registro + Log to Linear De logarítmico a lineal + Log to Log De logarítmico a logarítmico + Logo Cutout Recorte de logotipo + Long Shadow Sombra alargada + Look-Ahead Time Tiempo de anticipación + Loudness Sonoridad + Loudness Maximizer Maximizador de sonoridad + Loudness Meter Medidor de sonoridad + Louver Persiana + Low Baja + Low Frequency Cut Corte de bajas frecuencias + Low Pass Paso bajo + Low Shelf Estante de graves + Low Shelf Gain Ganancia del estante de graves + Lower Left Abajo a la izquierda + Lower Right Abajo a la derecha + Lowpass Paso bajo + Luma Luminancia + Luma Curve Curva de luminancia + Luma Fade Fundido por luminancia + Luma Key Incrustación de luminancia + Luma Source Origen de luminancia + Luma and Chroma Luminancia y croma + Lumetri Presets Ajustes preestablecidos de Lumetri + Luminance Luminancia + Luminance Threshold Umbral de luminancia + Luminosity Luminosidad + MB MB + Magenta Magenta + Magnify Ampliar + Magnify (Legacy) Ampliar (heredado) + Maintain pitch while shuttling Mantener el tono al desplazarse con lanzadera + Major Radius Radio mayor + Male Masculina + Mango Mango + Map Black To Asignar negro a + Map White To Asignar blanco a + Maps full-range levels into 16–235 (video range) with a luma curve Convierte niveles de rango completo a 16–235 (rango de video) con una curva de luminancia + Mask Only Solo máscara + Master Amplitude Amplitud maestra + Mastering Masterización + Match frame sets in point El fotograma coincidente establece el punto de entrada + Matte Mate + Matte Alpha Mate alfa + Matte Cleanup Limpieza del mate + Matte Generation Generación del mate + Matte Luma Mate de luminancia + Maximum Máximo + Maximum Amplitude Amplitud máxima + Maximum Block Size Tamaño máximo de bloque + Maximum Blur Desenfoque máximo + Maximum Project Versions Número máximo de versiones del proyecto + Maximum Reduction Reducción máxima + Maximum Scale Escala máxima + Media Medios + Media Analysis Análisis de medios + Media Analysis & Transcription Análisis y transcripción de medios + Media Cache Caché de medios + Media Cache Database Base de datos de la caché de medios + Media Cache Files Archivos de caché de medios + Media Cache Management Gestión de la caché de medios + Media Duration Duración del medio + Media End Fin del medio + Media Time Tiempo del medio + Media Timecode Código de tiempo del medio + Media Type Tipo de medio + Media analysis and transcriptions will be cached in the Media Cache folder. Reanalysis won't be required if the media is imported into another project on this computer. Los análisis y transcripciones de medios se guardarán en la carpeta de caché de medios. No hará falta volver a analizar si los medios se importan en otro proyecto de este ordenador. + Median Mediana + Medium Room Sala mediana + Memory Memoria + Metadata & Timecode Burn-in Impresión de metadatos y código de tiempo + Method Método + Metrics Kerning Ajuste entre pares métrico + Mid Medios + Mid Point Punto medio + Midtone Blue Balance Balance de azul en medios tonos + Midtone Green Balance Balance de verde en medios tonos + Midtone Red Balance Balance de rojo en medios tonos + Midtones Lightness Luminosidad de medios tonos + Minimum Mínimo + Minimum time Tiempo mínimo + Minimum time interval thinning Reducción por intervalo de tiempo mínimo + Minor Radius Radio menor + Mirror Espejo + Mirror Alternate Espejo alterno + Missing font replacement Sustitución de fuentes que faltan + Miter join Unión en inglete + Miter limit Límite de inglete + Mixed Crystals Cristales mezclados + Mode Modo + Modern High Gain Ganancia alta moderna + Modulation Modulación + Modulation Rate Velocidad de modulación + Mono Media Medios mono + Monochrome Monocromo + Monochrome toned cyan-blue Monocromo virado a azul cian + Monochrome toned warm brown Monocromo virado a marrón cálido + Monoscopic Monoscópico + Monospace only Solo monoespaciadas + More colour in the less saturated areas Más color en las zonas menos saturadas + Morph Cut Corte de transformación + Mosaic Mosaico + Mosaic (Legacy) Mosaico (heredado) + Motion Blur Desenfoque de movimiento + Motion Camera Cámara en movimiento + Motion Preset Ajuste preestablecido de movimiento + Motion Tween Interpolación de movimiento + Move Mover + Movie Película + Movie (audio and video) Película (audio y video) + Multiband Compressor Compresor multibanda + Multichannel Mono Media Medios mono multicanal + Multiply Multiplicar + Multitap Delay Retardo multitoma + Music Música + Mute input during timeline recording Silenciar la entrada al grabar en la línea de tiempo + Name Nombre + Narrow Estrecho + Neon Color Color neón + Neon Wipe Barrido neón + Next to original media files Junto a los archivos multimedia originales + Night Noche + No Motion Sin movimiento + No Scroll Sin desplazamiento + No plugins are installed. Changes will take effect the next time you start FilmCraft. No hay complementos instalados. Los cambios se aplicarán la próxima vez que inicies FilmCraft. + Noise Ruido + Noise & Grain Ruido y grano + Noise (Legacy) Ruido (heredado) + Noise Level Nivel de ruido + Noise Reduction/Restoration Reducción de ruido/restauración + Noise Type Tipo de ruido + Non-Additive Dissolve Disolución no aditiva + Normal Normal + Notch 1 Muesca 1 + Notch 2 Muesca 2 + Notch 3 Muesca 3 + Notch 4 Muesca 4 + Notch 5 Muesca 5 + Notch 6 Muesca 6 + Notch Filter Filtro de muesca + Notch Width Anchura de la muesca + Number Of Echoes Número de ecos + Number of Harmonics Número de armónicos + Obsolete Obsoletos + Octagon Octágono + Offset Desplazamiento + Offset (Turbulence) Desplazamiento (turbulencia) + Offset From Desplazar desde + Offset To Desplazar hasta + Omni Omni + On Activado + On Original Image Sobre la imagen original + On Transparent Sobre transparente + Open Air Aire libre + Open Most Recent Abrir el más reciente + Open in new window Abrir en una ventana nueva + Open in place Abrir en el mismo lugar + Open new tab Abrir una pestaña nueva + Opens up the shadows Abre las sombras + Order Orden + Orientation Orientación + Outer Exterior + Outgoing Saliente + Output Salida + Output Mapping Asignación de salidas + Outside Fuera + Outside Color Color exterior + Overflow Desbordamiento + Overlay Superponer + Overshoot Rebasamiento + Page Peel Despegar página + Page Scroll Desplazar página + Page Turn Pasar página + Paint Bucket Bote de pintura + Paint On Black Pintar sobre negro + Paint On Original Image Pintar sobre la imagen original + Paint On Transparent Pintar sobre transparente + Paint On White Pintar sobre blanco + Paint Style Estilo de pintura + Paint Surface Superficie de pintura + Pan (Y Axis) Panorámica (eje Y) + Panel Wipe Barrido de paneles + Panels Paneles + Panner - Ambisonics Panoramizador - Ambisonics + Parade Desfile + Parade (RGB) Desfile (RGB) + Parametric Equalizer Ecualizador paramétrico + Passband Ripple Ondulación de la banda de paso + Path Trazado + Path Points Puntos del trazado + Pause Media Encoder queue during playback Pausar la cola de codificación durante la reproducción + Peak Frequency Frecuencia de pico + Peak Gain Ganancia de pico + Pedestal Pedestal + Pentagon Pentágono + Per line Por línea + Percent Blur Porcentaje de desenfoque + Perspective Perspectiva + Phase Difference Diferencia de fase + Phaser Phaser + Phone Call Llamada telefónica + Phosphor Fósforo + Phosphor Color Color del fósforo + Photocopy Fotocopia + Photocopy Color Color de fotocopia + Pillow Almohada + Pin All Fijar todo + Pin Horizontal Fijar horizontal + Pin Light Luz puntual + Pin Vertical Fijar vertical + Pinning Fijación + Pitch Shifter Cambiador de tono + Plate Placa + Plateau Wipe Barrido en mesetas + Plateaus Mesetas + Plates Placas + Play after rendering previews Reproducir después de procesar las previsualizaciones + Play audio while scrubbing in Source and Program Monitors Reproducir audio al arrastrar en los monitores de origen y de programa + Playback Reproducción + Playhead position determines trim monitor loop playback La posición del cabezal determina la reproducción en bucle del monitor de recorte + Plugins Complementos + Podcast Voice Voz de pódcast + Point 1 Punto 1 + Point 1 Color Color del punto 1 + Point 1 Latitude Latitud del punto 1 + Point 1 Longitude Longitud del punto 1 + Point 2 Punto 2 + Point 2 Color Color del punto 2 + Point 2 Latitude Latitud del punto 2 + Point 2 Longitude Longitud del punto 2 + Point 3 Punto 3 + Point 3 Color Color del punto 3 + Point 3 Latitude Latitud del punto 3 + Point 3 Longitude Longitud del punto 3 + Point 4 Punto 4 + Point 5 Punto 5 + Point 6 Punto 6 + Point 7 Punto 7 + Point 8 Punto 8 + Points Puntos + Policy Política + Pop Motion Movimiento emergente + Portuguese Portugués + Position & Rotation Posición y rotación + Position Amount Cantidad de posición + Position, Scale & Rotation Posición, escala y rotación + Position, Scale, Rotation Posición, escala, rotación + Posterize Posterizar + Posterize Time Posterizar tiempo + Pre-Delay Pre-retardo + Prefix Prefijo + Presence Lift Realce de presencia + Preserve Luminosity Conservar luminosidad + Preserve Scale Conservar escala + ProcAmp ProcAmp + Projecting cap Extremo proyectado + Projects Proyectos + Pull Motion Movimiento de arrastre + Pulls highlights and whites down Baja las iluminaciones y los blancos + Push Empujar + Push (Legacy) Empujar (heredado) + Q Q + RAM reserved for other applications RAM reservada para otras aplicaciones + RGB RGB + RGB Split División RGB + RGB-White RGB-blanco + Radial Radial + Radial Blur Desenfoque radial + Radial Ramp Rampa radial + Radial Wipe Barrido radial + Radial Wipe (Legacy) Barrido radial (heredado) + Radio Announcer Locutor de radio + Radio Voice Voz de radio + Radius Radio + Ramp Rampa + Ramp Shape Forma de la rampa + Random Aleatorio + Random Seed Semilla aleatoria + Range Rango + Ray Rayo + Ray Color Color del rayo + Ray Length Longitud del rayo + Rays Rayos + Rays Only Solo rayos + Read Lectura + Rec. 2100 Rec. 2100 + Rec. 601 Rec. 601 + Rec. 709 Rec. 709 + Red Blurriness Desenfoque del rojo + Red Curve Curva del rojo + Red Offset Desplazamiento del rojo + Red Power Potencia del rojo + Red Slope Pendiente del rojo + Red-Blue Rojo-azul + Red-Const Rojo-constante + Red-Green Rojo-verde + Red-Red Rojo-rojo + Reduce Axis Reducir eje + Reduce Interlace Flicker Reducir parpadeo de entrelazado + Reflected Reflejado + Reflection Angle Ángulo de reflexión + Reflection Center Centro de reflexión + Refraction Refracción + Reframe Offset Desplazamiento del reencuadre + Reframe Zoom Zoom del reencuadre + Refresh growing Files Every Actualizar los archivos en crecimiento cada + Relaxed Relajado + Release Time Tiempo de liberación + Relief Relieve + Remaining Restante + Remove Fringe Eliminar halo + Render Edit in Audition files to: Procesar los archivos editados en Audition en: + Render audio when rendering video Procesar el audio al procesar el video + Repair Reparar + Repeat Edge Pixels Repetir píxeles del borde + Replace Color Sustituir color + Replace With Clip From Source Monitor Reemplazar con el clip del monitor de origen + Replicate Replicar + Responsive Design - Position Diseño adaptable - posición + Responsive Design - Time Diseño adaptable - tiempo + Restore open sequences when opening projects Restaurar las secuencias abiertas al abrir proyectos + Result Resultado + Retro Retro + Reveal Nested Sequence Mostrar secuencia anidada + Reveal Original Image Mostrar la imagen original + Reveal in Project Mostrar en el proyecto + Reverb Amount Cantidad de reverberación + Reverse Invertir + Right - Left Derecha - izquierda + Right - Right Derecha - derecha + Right channel to device output Canal derecho a la salida del dispositivo + Right to left De derecha a izquierda + Ripple trim adds edits to keep both sides of trim in sync El recorte de rizo añade ediciones para mantener sincronizados ambos lados + Roll (Z Axis) Alabeo (eje Z) + Room Size Tamaño de la sala + Rotation Amount Cantidad de rotación + Roughen Rugosidad + Roughen Color Color rugoso + Roughen Edges Bordes rugosos + Round cap Extremo redondeado + Round join Unión redondeada + Rounded Crop Recorte redondeado + Rows Filas + Russian Ruso + Rusty Oxidado + Rusty Color Color oxidado + SFX Efectos de sonido + Sample Rate Frecuencia de muestreo + Saturation at 50 % Saturación al 50 % + Save .cfa and .pek media cache files next to original media files when possible Guardar los archivos de caché .cfa y .pek junto a los medios originales cuando sea posible + Scale (Degrees) Escala (grados) + Scale Amount Cantidad de escala + Scale From Escalar desde + Scale Height Escalar altura + Scale Match Igualar escala + Scale To Escalar a + Scale Width Escalar anchura + Scale to frame size Escalar al tamaño del fotograma + Scan Lines Líneas de exploración + Scene Escena + Scientific Filter Filtro científico + Scratch disk location for Captured Audio Ubicación del disco de memoria virtual para el audio capturado + Screen Trama + Seconds Segundos + Segments Segmentos + Semi-tones Semitonos + Sequence Name Nombre de la secuencia + Sequence Timecode Código de tiempo de la secuencia + Set focus on the Timeline when performing Insert/Overwrite edits Enfocar la línea de tiempo al insertar o sobrescribir + Set to frame size Ajustar al tamaño del fotograma + Setting Ajuste + Shadow Angle Ángulo de la sombra + Shadow Blue Balance Balance de azul en sombras + Shadow Blur Desenfoque de la sombra + Shadow Color Color de la sombra + Shadow Distance Distancia de la sombra + Shadow Green Balance Balance de verde en sombras + Shadow Only Solo sombra + Shadow Opacity Opacidad de la sombra + Shadow Red Balance Balance de rojo en sombras + Shadow Size Tamaño de la sombra + Shadow Tint Matiz de sombras + Shadows Lightness Luminosidad de sombras + Shake Sacudida + Shape Dissolve Disolución con forma + Shape Flow Flujo de formas + Shape Size Tamaño de la forma + Shapes Formas + Shard Size Tamaño de los fragmentos + Sharp Colors Colores nítidos + Sharpen Amount Cantidad de enfoque + Shift Center To Desplazar el centro a + Shift clips that overlap trim point during ripple trimming Desplazar los clips que se solapan con el punto de recorte en el recorte de rizo + Shot Plano + Show "Project:" and "Bin:" in Project panel tab Mostrar "Proyecto:" y "Bandeja:" en la pestaña del panel Proyecto + Show Clip Mismatch Warning dialog Mostrar el aviso de discrepancia de clip + Show Event Indicator Mostrar el indicador de eventos + Show Focus Area Mostrar zona de enfoque + Show Home Mostrar Inicio + Show Mask Tracker Preview Mostrar la previsualización del seguimiento de máscara + Show Only Noise Mostrar solo el ruido + Show Open Dialog Mostrar el diálogo Abrir + Show Project Load Error Dialog Mostrar el diálogo de error al cargar proyectos + Show Rich Tool Tips Mostrar sugerencias detalladas + Show Tool Tips Mostrar sugerencias + Show Workspace Reset Warning dialog on double click Mostrar el aviso de restablecer el espacio de trabajo al hacer doble clic + Show system compatibility issues at startup Mostrar problemas de compatibilidad del sistema al iniciar + Shrink Encoger + Shutter Angle Ángulo de obturación + Similarity Similitud + Simple Notch Filter Filtro de muesca simple + Simple Parametric EQ Ecualizador paramétrico simple + Simple Text Texto simple + Single-band Compressor Compresor de una banda + Sinusoidal Sinusoidal + Skew Sesgo + Skew Axis Eje del sesgo + Slats Listones + Slice Corte + Slices Cortes + Slide Deslizar + Slide (Legacy) Deslizar (heredado) + Slope Pendiente + Slow Lento + Slower Motion Movimiento más lento + Small Room Sala pequeña + Smart Limit Límite inteligente + Smart Tools Herramientas inteligentes + Smart quotes Comillas tipográficas + Smooth Suave + Smooth Motion Movimiento suave + Smooth Scroll Desplazamiento suave + Smoothness Suavidad + Snap playhead in Timeline when Snap is enabled Ajustar el cabezal en la línea de tiempo cuando el ajuste está activado + Soft Suave + Soft Clamp Limitación suave + Soft Edges (Best Quality) Bordes suaves (mejor calidad) + Soft Light Luz suave + Soft Wipe Barrido suave + Soften Harshness Suavizar la aspereza + Softness Suavidad + Solarize Solarizar + Solid Colors Colores sólidos + Source Point Punto de origen + Source Text Texto de origen + Spacer Espaciador + Spanish Español + Speaker Angle Ángulo del altavoz + Speaker Labeling Etiquetado de hablantes + Special Especial + Special Effects Efectos especiales + Speech model Modelo de voz + Speed Velocidad + Spherize Esferizar + Spiky Puntiagudo + Spill Derrame + Spill Suppression Supresión de derrame + Spin Girar + Spin Direction Dirección del giro + Spin Motion Movimiento de giro + Split Dividir + Split (Legacy) Dividir (heredado) + Spotlight Foco + Spread Extensión + Spring Motion Movimiento elástico + Square Cuadrado + Square (Width Only) Cuadrado (solo anchura) + Square 1:1 Cuadrado 1:1 + Stabilization Estabilización + Stabilize Only Solo estabilizar + Stabilize, Crop Estabilizar, recortar + Stabilize, Crop, Auto-scale Estabilizar, recortar, escalar automáticamente + Stabilize, Synthesize Edges Estabilizar, sintetizar bordes + Stages Etapas + Star Estrella + Star Wipe Barrido en estrella + Start Angle Ángulo inicial + Start Color Color inicial + Start Point Punto inicial + Start at 0 Empezar en 0 + Start at 1 Empezar en 1 + Start of Ramp Inicio de la rampa + Start with an Empty Project Empezar con un proyecto vacío + Starting Intensity Intensidad inicial + Static Plates Placas estáticas + Step forward/back many Avanzar/retroceder muchos + Stereo Expand Expansión estéreo + Stereo Expander Expansor estéreo + Stereo Imagery Imagen estéreo + Stereo Media Medios estéreo + Stereo Phasing Fase estéreo + Stereoscopic - Over/Under Estereoscópico - arriba/abajo + Stereoscopic - Side by Side Estereoscópico - lado a lado + Still Imagen fija + Still Image Default Duration Duración predeterminada de imágenes fijas + Stone Church Iglesia de piedra + Stopband Attenuation Atenuación de la banda de rechazo + Straight Color Color directo + Strength Intensidad + Stretch Estirar + Stretch Gradient to Fit Estirar degradado para ajustar + Stretch Map to Fit Estirar mapa para ajustar + Stretch Width or Height Estirar anchura o altura + Stretch Wipe Barrido estirado + Stretch to Fit Frame Estirar para ajustar al fotograma + Stripe Franja + Stripes Franjas + Strobe Color Color estroboscópico + Strobe Duration (secs) Duración del estroboscopio (s) + Strobe Light Luz estroboscópica + Strobe Period (secs) Periodo del estroboscopio (s) + Stroke 2 Color Color del trazo 2 + Stroke 2 Type Tipo del trazo 2 + Stroke 2 Width Anchura del trazo 2 + Stroke Angle Ángulo del trazo + Stroke Color Color del trazo + Stroke Density Densidad del trazo + Stroke Length Longitud del trazo + Stroke Length (secs) Longitud del trazo (s) + Stroke Only Solo trazo + Stroke Randomness Aleatoriedad del trazo + Stroke Type Tipo de trazo + Stroke Width Anchura del trazo + Stroke line cap Extremo de línea del trazo + Stroke line join Unión de línea del trazo + Studio Reverb Reverberación de estudio + Stylize Estilizar + Subclip End Fin del subclip + Subclip Start Inicio del subclip + Subspace Warp Deformación de subespacio + Subtitle Subtítulo + Subtract Restar + Sum multichannel outputs to mono in Source Monitor Sumar las salidas multicanal en mono en el monitor de origen + Super Narrow Superestrecho + Surface Gloss Brillo de la superficie + Surface Material Material de la superficie + Surround Reverb Reverberación envolvente + Swap Intercambiar + Swap Channels Intercambiar canales + Swivel Pivotar + Synthetic Sintético + TV Power Encendido de TV + Tape Cinta + Tape Name Nombre de la cinta + Tape/Tube Cinta/válvula + Target Aspect Aspecto de destino + Target Color Color de destino + Target Field of View Campo de visión de destino + Target Loudness Sonoridad de destino + Teal & Orange Verde azulado y naranja + Telephone Teléfono + Teletext Teletexto + Template Properties Propiedades de la plantilla + Text Animator Animador de texto + Text Color Color del texto + The media cache holds render previews of unsaved projects and other files FilmCraft can recreate. Deleting them is always safe; FilmCraft rebuilds them as needed. La caché de medios guarda previsualizaciones procesadas de proyectos sin guardar y otros archivos que FilmCraft puede volver a crear. Eliminarlos siempre es seguro; FilmCraft los regenera cuando hace falta. + Thickness Grosor + Tile Gradient Degradado en mosaico + Tilt Inclinación + Tilt (X Axis) Inclinación (eje X) + Time Tiempo + Time and Pitch Tiempo y tono + Timecode Conversion Conversión de código de tiempo + Timeline Mouse Scrolling Desplazamiento con el ratón en la línea de tiempo + Timeline Playback Auto-Scrolling Desplazamiento automático de la línea de tiempo al reproducir + Tint Color Color del matiz + To Hasta + Tolerance Tolerancia + Tone Tono + Top Center Arriba en el centro + Top Left Arriba a la izquierda + Top Right Arriba a la derecha + Touch Tocar + Track Matte Key Incrustación de mate de pista + Transcription Transcripción + Transcription preferences Preferencias de transcripción + Transform Transformar + Transformers Transformadores + Transience Transitoriedad + Transition Completion Finalización de la transición + Transition Softness Suavidad de la transición + Transparency Transparencia + Trash Papelera + Travel Motion Movimiento de viaje + Treble Agudos + Triangle Triángulo + Trim Recortar + Tube Válvula + Tube-modeled Compressor Compresor de modelado de válvulas + Tubular Tubular + Turbulent Turbulento + Turbulent Displace Desplazamiento turbulento + Turbulent Sharp Turbulento nítido + Turbulent Smooth Turbulento suave + Turbulent Smoother Turbulento más suave + Tweed Tweed + Twirl Remolino + Twirl (Legacy) Remolino (heredado) + Twirl Center Centro del remolino + Twirl Radius Radio del remolino + Twist Torsión + Twist Smoother Suavizado de la torsión + Typewriter Máquina de escribir + Ultra Key Ultra Key + Uniform Scale Escala uniforme + Uniform Spacing Espaciado uniforme + Unsharp Mask Máscara de enfoque + Update it at least every Actualizarla al menos cada + Update timeline dynamically during ripple edits. Actualizar la línea de tiempo dinámicamente durante las ediciones de rizo. + Upper Frequency Frecuencia superior + Upper Left Arriba a la izquierda + Upper Right Arriba a la derecha + Use Color Noise Usar ruido de color + Use End Color Usar color final + Use File Usar archivo + Use Glow Color Usar color de resplandor + Use Media Source Usar el origen del medio + Use Tint Color Usar color de matiz + Utility Utilidad + VHS Damage Daño de VHS + VR Blur Desenfoque VR + VR Chroma Leaks Fugas de croma VR + VR Chromatic Aberrations Aberraciones cromáticas VR + VR Color Gradients Degradados de color VR + VR De-Noise Reducción de ruido VR + VR Digital Glitch Fallo digital VR + VR Fractal Noise Ruido fractal VR + VR Glow Resplandor VR + VR Gradient Wipe Barrido con degradado VR + VR Iris Wipe Barrido de iris VR + VR Light Leaks Fugas de luz VR + VR Light Rays Rayos de luz VR + VR Mobius Zoom Zoom de Möbius VR + VR Plane to Sphere Plano a esfera VR + VR Projection Proyección VR + VR Random Blocks Bloques aleatorios VR + VR Rotate Sphere Rotar esfera VR + VR Sharpen Enfocar VR + VR Spherical Blur Desenfoque esférico VR + Validate Content Credentials on import Validar las credenciales de contenido al importar + Vectorscope (YUV) Vectorscopio (YUV) + Vectorscope + Waveform YC Vectorscopio + forma de onda YC + Vectorscope HLS Vectorscopio HLS + Vectorscope YUV Vectorscopio YUV + Venetian Blinds Persianas venecianas + Vertical 4:5 Vertical 4:5 + Vertical 9:16 Vertical 9:16 + Vertical Alignment Alineación vertical + Vertical Blocks Bloques verticales + Vertical Decentering Descentrado vertical + Vertical Displacement Desplazamiento vertical + Vertical Flip Volteo vertical + Vertical Margin Margen vertical + Very Narrow Muy estrecho + Video 1 Video 1 + Video 2 Video 2 + Video 3 Video 3 + Video 4 Video 4 + Video 5 Video 5 + Video 6 Video 6 + Video 7 Video 7 + Video 8 Video 8 + Video Codec Códec de video + Video Duration Duración del video + Video Effects Efectos de video + Video In Point Punto de entrada de video + Video Info Información de video + Video Limiter Limitador de video + Video Out Point Punto de salida de video + Video Transition Default Duration Duración predeterminada de las transiciones de video + Video Transitions Transiciones de video + Video Usage Uso de video + Vignette Viñeta + Vivid Light Luz intensa + Vocal Booth Cabina vocal + Vocal Enhancer Realzador vocal + Voice Voz + Voice in a Hall Voz en una sala + Volumetric Rays Rayos volumétricos + Warm Film Película cálida + Warm Room Sala cálida + Warm Voice Voz cálida + Warm print stock with gentle roll-off Copia de película cálida con atenuación suave + Warm, soft late-afternoon light Luz cálida y suave de última hora de la tarde + Warning Advertencia + Warp Stabilizer Estabilizador de deformación + Wave Onda + Wave Height Altura de la onda + Wave Speed Velocidad de la onda + Wave Warp Deformación de onda + Wave Width Anchura de la onda + Waveform Forma de onda + Waveform (Luma) Forma de onda (luminancia) + Waveform (RGB) Forma de onda (RGB) + Wavelength Longitud de onda + Wet Húmedo + Wet Out Salida húmeda + When Opening a Project Al abrir un proyecto + Whip Latigazo + Whip (Legacy) Latigazo (heredado) + Whisper base (balanced) Whisper base (equilibrado) + Whisper small (most accurate) Whisper small (más preciso) + Whisper tiny (fastest) Whisper tiny (más rápido) + White Blanco + White Input Level Nivel de entrada de blanco + White Is High El blanco es alto + Wide Outdoor Exterior amplio + Widener Ensanchador + Width Anchura + Wiggle Oscilación + Wipe Barrido + Wipe Angle Ángulo del barrido + Wonder Glow Resplandor maravilloso + Wrap Back Envolver hacia atrás + Write Escritura + Write XMP ID to files on import Escribir el ID XMP en los archivos al importar + Write clip markers to XMP Escribir los marcadores de clip en XMP + Write-on Escritura progresiva + X Rotation Rotación X + Y Rotation Rotación Y + YC YC + YC no Chroma YC sin croma + YUV YUV + Yellow-green highlights and blue-lifted shadows from crossed channel curves Iluminaciones amarillo verdosas y sombras elevadas hacia el azul mediante curvas de canal cruzadas + Z Position Posición Z + Z Rotation Rotación Z + Zoom Zoom + Zoom Amount Cantidad de zoom + Zoom Blur Desenfoque de zoom + Zoom Strength Intensidad del zoom + days días + frames fotogramas + milliseconds milisegundos + minute(s) minuto(s) + second(s) segundo(s) + seconds segundos + AAF… AAF… + Audio… Audio… + Avid Log Exchange… Avid Log Exchange… + Color… Color… + EDL… EDL… + Edit Workspaces… Editar espacios de trabajo… + FCPXML… FCPXML… + Final Cut Pro XML… Final Cut Pro XML… + General… General… + OMF… OMF… + OpenTimelineIO… OpenTimelineIO… + Save Changes to this Workspace Guardar los cambios en este espacio de trabajo + Save as New Workspace… Guardar como nuevo espacio de trabajo… + Create Search Bin Crear bandeja de búsqueda + Edit Offline File Editar archivo sin conexión + Source Settings Ajustes de origen + Update Metadata Actualizar metadatos + Automate To Sequence Automatizar a secuencia + Scene Edit Detection Detección de cambios de escena + Analyze Analizar + Normalize Mix Track Normalizar pista de mezcla + Simplify Sequence Simplificar secuencia + Simplify Simplificar + Transcribe Sequence Transcribir secuencia + Save as Template Guardar como plantilla + Flash Cue Marker Marcador de referencia Flash + System Compatibility Report Informe de compatibilidad del sistema + no GPU adapter: the CPU compositor is used no hay adaptador de GPU: se usa el compositor de CPU + Avid Log Exchange Avid Log Exchange + FilmCraft Project Proyecto de FilmCraft + {name} Selection.fcproj {name} Selección.fcproj + {name} (Simplified) {name} (simplificada) + Find in: Buscar en: + Find: Buscar: + Search: Buscar: + Clip Names and Markers Nombres de clip y marcadores + Match: Coincidir: + All Todos + Any Cualquiera + Case Sensitive Distinguir mayúsculas y minúsculas + Scratch Disks Discos de memoria virtual + Ingest Settings Ajustes de ingesta + Captured and Generated: Capturado y generado: + Video Previews: Previsualizaciones de video: + Audio Previews: Previsualizaciones de audio: + Project Auto Save: Guardado automático del proyecto: + Same as Project Igual que el proyecto + Path: {path} Ruta: {path} + Transcode Transcodificar + Copy and Create Proxies Copiar y crear proxies + Action: Acción: + Destination: Destino: + Next to the media Junto a los medios + Video Rendering and Playback Procesamiento y reproducción de video + Renderer: Procesador: + Display Format: Formato de visualización: + Milliseconds Milisegundos + Capture Captura + Capture Format: Formato de captura: + Action and Title Safe Areas Zonas seguras de acción y título + Title Safe Area Zona segura de título + Action Safe Area Zona segura de acción + Total Duration Duración total + Average Data Rate Velocidad de datos media + Colour Color + {codec} · {rate} Hz · {n} channel(s) {codec} · {rate} Hz · {n} canal(es) + Media Name: Nombre del medio: + Scene: Escena: + Shot/Take: Plano/toma: + Log Note: Nota de registro: + Codec: {codec} Códec: {codec} + Write the metadata of the selected clip(s) ({n}) to XMP files next to their media? ¿Escribir los metadatos de los clips seleccionados ({n}) en archivos XMP junto a sus medios? + Existing XMP files written by other applications are left unchanged. Los archivos XMP existentes escritos por otras aplicaciones no se modifican. + To {name} A {name} + Ordering: Orden: + Sort Order Orden de clasificación + Selection Order Orden de selección + Placement: Ubicación: + Sequentially Secuencialmente + At Unnumbered Markers En marcadores sin numerar + Method: Método: + Insert Edit Edición de inserción + Overwrite Edit Edición de sobrescritura + Clip Overlap: Solapamiento de clips: + Frames per Still: Fotogramas por imagen fija: + Transitions Transiciones + Apply Default Audio Transition Aplicar la transición de audio predeterminada + Apply Default Video Transition Aplicar la transición de video predeterminada + Ignore Options Opciones de omisión + Ignore Audio Ignorar audio + Ignore Video Ignorar video + Apply a cut at each detected cut point Aplicar un corte en cada punto de corte detectado + Create a subclip for each detected cut point Crear un subclip para cada punto de corte detectado + Generate clip markers at each detected cut point Generar marcadores de clip en cada punto de corte detectado + Sensitivity: Sensibilidad: + Normalize Mix Track to: Normalizar pista de mezcla a: + Disabled clips Clips desactivados + Empty tracks Pistas vacías + Gaps Huecos + Video effects Efectos de video + Audio effects Efectos de audio + Text (graphics and captions) Texto (gráficos y subtítulos) + Clips Clips + Move clips to the lowest track possible Mover los clips a la pista más baja posible + Keep: Conservar: + Video and Audio Video y audio + Video Only Solo video + Audio Only Solo audio + Language: Idioma: + Audio on track {t} Audio de la pista {t} + Audio analysis: Análisis de audio: + Recognize when different speakers are talking Reconocer cuándo hablan personas distintas + Template Name: Nombre de la plantilla: + Comments: Comentarios: + (unavailable) (no disponible) + Decoders Descodificadores + Containers Contenedores + Export formats Formatos de exportación + Match {n} of {total} Coincidencia {n} de {total} + Saved template {path} Plantilla guardada en {path} + Scene Edit Detection: analysing in the background (see Progress) Detección de cambios de escena: analizando en segundo plano (ver Progreso) + Delete Search Bin Eliminar bandeja de búsqueda + (removed from {list}) (quitado de {list}) + {key} can't be used as a shortcut {key} no se puede usar como atajo + Keyboard Shortcuts Atajos de teclado + {active} (modified) {active} (modificado) + Loaded “{name}” Cargado “{name}” + My Shortcuts Mis atajos + Deleted preset “{active}” Ajuste preestablecido “{active}” eliminado + Export… Exportar… + Exported to {path} Exportado a {path} + Export: no file chosen (agents: shortcuts.export {path}) Exportar: no se eligió ningún archivo (agentes: shortcuts.export {path}) + Imported “{name}” Importado “{name}” + Import: no file chosen (agents: shortcuts.import {path}) Importar: no se eligió ningún archivo (agentes: shortcuts.import {path}) + Keyboard: US (macOS) Teclado: EE. UU. (macOS) + Keyboard: US Teclado: EE. UU. + Save preset as: Guardar ajuste preestablecido como: + Commands: Comandos: + {panel} Panel Panel {panel} + Application shortcuts work whatever panel has focus Los atajos de la aplicación funcionan con cualquier panel enfocado + Panel shortcuts override application shortcuts while that panel has focus Los atajos de panel sustituyen a los de la aplicación mientras ese panel está enfocado + Modifier held / selected Modificador pulsado / seleccionado + Search commands or keys Buscar comandos o teclas + Selected: {command} Seleccionado: {command} + Command Comando + Shortcut Atajo + Type a shortcut… (Esc cancels) Pulsa un atajo… (Esc cancela) + Add another shortcut Añadir otro atajo + No matching commands Ningún comando coincide + Key: Tecla: + Modifiers Modificadores + Nothing is assigned to this key No hay nada asignado a esta tecla + Click a key on the keyboard to see its shortcuts Haz clic en una tecla del teclado para ver sus atajos + Assign {key} to {command} Asignar {key} a {command} + Conflict: {key} is assigned to {commands} ({ctx}) Conflicto: {key} está asignada a {commands} ({ctx}) + and y + Click a command's Shortcut cell and press keys to assign it; + adds another shortcut. Haz clic en la celda Atajo de un comando y pulsa las teclas para asignarlo; + añade otro atajo. + Cleared {n} shortcut(s) of {command} Se borraron {n} atajo(s) de {command} + No export preset named `{name}` No hay ningún ajuste preestablecido de exportación llamado `{name}` + Open a sequence to export Abre una secuencia para exportar + Destinations Destinos + Media File Archivo multimedia + Queue ({n}) Cola ({n}) + Send exports here with Send to Queue, then start the queue. Envía exportaciones aquí con Enviar a la cola y después inicia la cola. + Ready Lista + Encoding {n}% Codificando {n}% + Start Queue Iniciar cola + Stop Detener + Clear Finished Borrar terminadas + Not supported by FilmCraft's encoders yet Los codificadores de FilmCraft aún no lo admiten + Sequence name Nombre de la secuencia + Folder Carpeta + Preset Ajuste preestablecido + More presets… Más ajustes preestablecidos… + Multiplexer Multiplexor + The sequence has no caption track. La secuencia no tiene pista de subtítulos. + Export Options Opciones de exportación + Burn Captions Into Video Grabar los subtítulos en el video + Create Sidecar File Crear archivo adjunto + File Format Formato de archivo + SubRip Subtitle (.srt) Subtítulo SubRip (.srt) + WebVTT (.vtt) WebVTT (.vtt) + Title Título + Creator Autor + Copyright Copyright + Image Imagen + Frame Size Tamaño del fotograma + Match Source Igual que el origen + Field Order Orden de campos + Aspect Aspecto + Profile Perfil + Bitrate Encoding Codificación de velocidad de bits + Target Bitrate Velocidad de bits objetivo + Adaptive ({bpp} bits per pixel) Adaptable ({bpp} bits por píxel) + Maximum Bitrate Velocidad de bits máxima + Key Frame Distance Distancia entre fotogramas clave + Quality Calidad + Render at Maximum Depth Procesar a la máxima profundidad + Use Maximum Render Quality Usar la máxima calidad de procesamiento + Export Audio Exportar audio + Audio Format Formato de audio + Uncompressed (PCM) Sin comprimir (PCM) + Match Source ({seq_sr} Hz) Igual que el origen ({seq_sr} Hz) + Channels Canales + Audio Bitrate Velocidad de bits de audio + Sample Size Tamaño de muestra + 16 bit 16 bits + 24 bit 24 bits + Size / Opacity Tamaño / opacidad + Image Overlay Superposición de imagen + PNG or JPEG file Archivo PNG o JPEG + Name Overlay Superposición de nombre + Timecode Overlay Superposición de código de tiempo + Luma Min / Max Luminancia mín. / máx. + Loudness Normalization Normalización de sonoridad + True Peak Limit Límite de pico real + Audio only Solo audio + Scaling Escalado + Scale To Fit Escalar para ajustar + Scale To Fill Escalar para rellenar + Stretch To Fill Estirar para rellenar + Summary Resumen + Sequence, {w}x{h}, {fps} fps, {rate} Hz Secuencia, {w}x{h}, {fps} fps, {rate} Hz + Estimated file size: {size} Tamaño de archivo estimado: {size} + Send to Queue Enviar a la cola + Exporting {path} Exportando {path} + Added to the export queue Añadido a la cola de exportación + Preset Manager Administrador de ajustes preestablecidos + Search presets Buscar ajustes preestablecidos + Favorites only Solo favoritos + Show in the Preset menu Mostrar en el menú de ajustes preestablecidos + User Usuario + Save current settings as: Guardar los ajustes actuales como: + Export presets Ajustes preestablecidos de exportación + Import: no file chosen (agents: export.presets.import {path}) Importar: no se eligió ningún archivo (agentes: export.presets.import {path}) + Export: no file chosen (agents: export.presets.export {path, names}) Exportar: no se eligió ningún archivo (agentes: export.presets.export {path, names}) + Quick Export: open a sequence first Exportación rápida: abre antes una secuencia + File Name & Location Nombre y ubicación del archivo + Quick Export: {path} Exportación rápida: {path} + Graphics problem: switched to software compositing Problema de gráficos: se ha cambiado a la composición por software + Exporting Exportando + Click to select, or click in empty space and drag to marquee select. Use Shift, Opt, and Cmd for other options. Haz clic para seleccionar, o haz clic en un espacio vacío y arrastra para seleccionar con un marco. Usa Mayús, Opción y Cmd para otras opciones. + Click to select all clips to the right in all tracks. Shift-click for a single track. Haz clic para seleccionar todos los clips a la derecha en todas las pistas. Mayús+clic para una sola pista. + Click to select all clips to the left in all tracks. Shift-click for a single track. Haz clic para seleccionar todos los clips a la izquierda en todas las pistas. Mayús+clic para una sola pista. + Drag an edit point to ripple trim; later clips move to keep the gap closed. Arrastra un punto de edición para hacer un recorte de rizo; los clips posteriores se mueven para no dejar hueco. + Drag an edit point to roll it: the out of one clip and the in of the next move together. Arrastra un punto de edición para rodarlo: la salida de un clip y la entrada del siguiente se mueven juntas. + Drag an edge to change the clip's speed so it fills the new duration. Arrastra un borde para cambiar la velocidad del clip y que ocupe la nueva duración. + Drag the edge of a music clip to remix it to the new duration at musically matching beats. Arrastra el borde de un clip de música para remezclarlo a la nueva duración en tiempos que encajen musicalmente. + Click to split a clip. Shift-click to split all tracks. Haz clic para dividir un clip. Mayús+clic para dividir todas las pistas. + Drag a clip to slip its source in/out without moving it. Arrastra un clip para desplazar su entrada y salida de origen sin moverlo. + Drag a clip to slide it between its neighbours. Arrastra un clip para deslizarlo entre sus vecinos. + Drag to scroll the timeline. Arrastra para desplazar la línea de tiempo. + Click to zoom in; Opt-click to zoom out. Haz clic para acercar; Opción+clic para alejar. + FilmCraft hit an error FilmCraft ha encontrado un error + Details: {path} Detalles: {path} + Opened {url} Abierto {url} + (empty) (vacío) + Step Back 1 Frame Retroceder 1 fotograma + Step Forward 1 Frame Avanzar 1 fotograma + Match Playhead Igualar cabezal + Gang to Program Vincular al programa + AIFF AIFF + AIFF 48 kHz 16-bit AIFF 48 kHz 16 bits + AIFF, stereo 48 kHz 16-bit PCM AIFF, estéreo, PCM de 48 kHz y 16 bits + APV APV + APV 422-10 APV 422-10 + APV 422-12 APV 422-12 + Anamorphic 2:1 (2.0) Anamórfico 2:1 (2.0) + Animated GIF GIF animado + Animated GIF 640×360 GIF animado 640×360 + Apple ProRes Apple ProRes + Apple ProRes 422 Apple ProRes 422 + Apple ProRes 422 HQ Apple ProRes 422 HQ + Apple ProRes 422 LT Apple ProRes 422 LT + Apple ProRes 422 Proxy Apple ProRes 422 Proxy + Application Aplicación + Apply Guide Template Aplicar plantilla de guías + Auto Save Now Guardar automáticamente ahora + Auto-Match Loudness Igualar sonoridad automáticamente + Avid DNxHR Avid DNxHR + Avid DNxHR HQ Avid DNxHR HQ + Avid DNxHR LB Avid DNxHR LB + Avid DNxHR SQ Avid DNxHR SQ + Avid-style OP-Atom: DNxHR HQ picture file plus one 24-bit 48 kHz PCM file per channel (_A1, _A2…) OP-Atom al estilo de Avid: archivo de imagen DNxHR HQ más un archivo PCM de 24 bits y 48 kHz por canal (_A1, _A2…) + BMP BMP + BMP Sequence Secuencia BMP + Baseline Baseline + Begin Text Editing for a Graphic Layer Empezar a editar el texto de una capa de gráfico + Begins With Empieza por + Blur In Entrada con desenfoque + Blur Out Salida con desenfoque + Brightens inside a feathered circular mask Aclara el interior de una máscara circular desvanecida + Broadcast Difusión + CBR CBR + Cancel Dynamic Trim Cancelar recorte dinámico + Cancel Queued Export Cancelar exportación en cola + Center Left Centro a la izquierda + Center Right Centro a la derecha + Center align text Centrar texto + Change Mask Cambiar máscara + Clear All Events Borrar todos los eventos + Clear Edit Point Selection Borrar la selección de puntos de edición + Clear Finished Exports Borrar exportaciones terminadas + Clear Poster Frame Borrar fotograma de póster + Close Bin Cerrar bandeja + Close Sequence Cerrar secuencia + Contains Contiene + Cut to Camera 1 Cortar a la cámara 1 + Cut to Camera 2 Cortar a la cámara 2 + Cut to Camera 3 Cortar a la cámara 3 + Cut to Camera 4 Cortar a la cámara 4 + Cut to Camera 5 Cortar a la cámara 5 + Cut to Camera 6 Cortar a la cámara 6 + Cut to Camera 7 Cortar a la cámara 7 + Cut to Camera 8 Cortar a la cámara 8 + Cut to Camera 9 Cortar a la cámara 9 + D1/DV NTSC (0.9091) D1/DV NTSC (0.9091) + D1/DV PAL (1.0940) D1/DV PAL (1.0940) + DVCPRO HD (1.5) DVCPRO HD (1.5) + Darkens outside a feathered oval mask Oscurece el exterior de una máscara ovalada desvanecida + Decrease Audio Tracks Height Reducir la altura de las pistas de audio + Decrease Clip Volume Bajar el volumen del clip + Decrease Clip Volume Many Bajar mucho el volumen del clip + Decrease Font Size by Five Units Reducir el tamaño de fuente cinco unidades + Decrease Font Size by One Unit Reducir el tamaño de fuente una unidad + Decrease Leading by Five Units Reducir el interlineado cinco unidades + Decrease Leading by One Unit Reducir el interlineado una unidad + Decrease Video Tracks Height Reducir la altura de las pistas de video + Delete Captions Eliminar subtítulos + Delete Graphic Layer Eliminar capa de gráfico + Delete Guide Template Eliminar plantilla de guías + Delete Media Cache Files Eliminar archivos de caché de medios + Discard Unsaved Changes Descartar cambios no guardados + Does Not Contain No contiene + Download Speech Model Descargar modelo de voz + Dynamic Trim Stop Detener recorte dinámico + Edit Caption Text Editar el texto del subtítulo + Ellipse Tool Herramienta Elipse + Ends With Termina en + Entire Source Todo el origen + EssentialSound Sonido esencial + Export Frame Exportar fotograma + Extend Next Edit To Playhead Extender la siguiente edición hasta el cabezal + Extend Previous Edit To Playhead Extender la edición anterior hasta el cabezal + Extend Selected Edit to Playhead Extender la edición seleccionada hasta el cabezal + Extend Selection Down Extender la selección hacia abajo + Extend Selection Left Extender la selección hacia la izquierda + Extend Selection Right Extender la selección hacia la derecha + Extend Selection Up Extender la selección hacia arriba + Fade In Fundido de entrada + Gaussian Blur from 0 to 60 over the last second Desenfoque gaussiano de 0 a 60 durante el último segundo + Gaussian Blur from 60 to 0 over the first second Desenfoque gaussiano de 60 a 0 durante el primer segundo + Generate Ducking Keyframes Generar fotogramas clave de atenuación + Go to Next Edit Point Ir al siguiente punto de edición + Go to Next Edit Point on Any Track Ir al siguiente punto de edición en cualquier pista + Go to Previous Edit Point Ir al punto de edición anterior + Go to Previous Edit Point on Any Track Ir al punto de edición anterior en cualquier pista + Go to Selected Clip End Ir al final del clip seleccionado + Go to Selected Clip Start Ir al inicio del clip seleccionado + Go to Sequence End Ir al final de la secuencia + Go to Sequence Start Ir al inicio de la secuencia + H.264 H.264 + H.264 1920×1080 for video sharing sites, VBR 1 pass, 16 Mbps H.264 1920×1080 para sitios de video, VBR 1 pasada, 16 Mbps + H.264 1920×1080, VBR 1 pass, 20 Mbps target, AAC 320 kbps H.264 1920×1080, VBR 1 pasada, 20 Mbps objetivo, AAC 320 kbps + H.264 1920×1080, VBR 2 pass, 18 Mbps H.264 1920×1080, VBR 2 pasadas, 18 Mbps + H.264 3840×2160 for video sharing sites, VBR 1 pass, 45 Mbps H.264 3840×2160 para sitios de video, VBR 1 pasada, 45 Mbps + H.264 3840×2160, VBR 1 pass, 60 Mbps target, AAC 320 kbps H.264 3840×2160, VBR 1 pasada, 60 Mbps objetivo, AAC 320 kbps + H.264 3840×2160, VBR 2 pass, 50 Mbps H.264 3840×2160, VBR 2 pasadas, 50 Mbps + H.264 9:16 portrait for short-form social video, scaled to fill, 12 Mbps H.264 9:16 vertical para videos cortos en redes sociales, escalado para rellenar, 12 Mbps + H.264 at the sequence's frame size and rate; 0.05 bits per pixel H.264 con el tamaño y la velocidad de fotogramas de la secuencia; 0,05 bits por píxel + H.264 at the sequence's frame size and rate; 0.1 bits per pixel H.264 con el tamaño y la velocidad de fotogramas de la secuencia; 0,1 bits por píxel + H.264 at the sequence's frame size and rate; bitrate adapts to the frame size (0.2 bits per pixel) H.264 con el tamaño y la velocidad de fotogramas de la secuencia; la velocidad de bits se adapta al tamaño (0,2 bits por píxel) + HD Anamorphic 1080 (1.333) HD anamórfico 1080 (1.333) + Hand Tool Herramienta Mano + High Quality 1080p HD Alta calidad 1080p HD + High Quality 2160p 4K Alta calidad 2160p 4K + Image Sequence Secuencia de imágenes + Increase Audio Tracks Height Aumentar la altura de las pistas de audio + Increase Clip Volume Subir el volumen del clip + Increase Clip Volume Many Subir mucho el volumen del clip + Increase Font Size by Five Units Aumentar el tamaño de fuente cinco unidades + Increase Font Size by One Unit Aumentar el tamaño de fuente una unidad + Increase Leading by Five Units Aumentar el interlineado cinco unidades + Increase Leading by One Unit Aumentar el interlineado una unidad + Increase Video Tracks Height Aumentar la altura de las pistas de video + Large blocks resolving to the picture over the first second Bloques grandes que se resuelven en la imagen durante el primer segundo + Left align text Alinear texto a la izquierda + Looping GIF, 640×360 at 15 fps GIF en bucle, 640×360 a 15 fps + Lower First Inferior primero + Lumetri Lumetri + MXF OP-Atom MXF OP-Atom + MXF OP-Atom DNxHR (Avid) MXF OP-Atom DNxHR (Avid) + MXF OP1a MXF OP1a + MXF OP1a DNxHR HQ MXF OP1a DNxHR HQ + MXF OP1a H.264 MXF OP1a H.264 + MXF OP1a ProRes 422 HQ MXF OP1a ProRes 422 HQ + MXF OP1a, DNxHR HQ (8-bit 4:2:2), 24-bit 48 kHz PCM, start timecode MXF OP1a, DNxHR HQ (8 bits 4:2:2), PCM de 24 bits y 48 kHz, código de tiempo inicial + MXF OP1a, H.264 High long GOP (0.3 bits per pixel), 24-bit 48 kHz PCM MXF OP1a, H.264 High GOP largo (0,3 bits por píxel), PCM de 24 bits y 48 kHz + MXF OP1a, ProRes 422 HQ, 24-bit 48 kHz PCM, start timecode MXF OP1a, ProRes 422 HQ, PCM de 24 bits y 48 kHz, código de tiempo inicial + Main Main + Mark In (Hover Scrub) Marcar entrada (arrastre al pasar el ratón) + Mark Out (Hover Scrub) Marcar salida (arrastre al pasar el ratón) + Masks Máscaras + Match Source – Adaptive High Bitrate Igual que el origen – velocidad de bits adaptable alta + Match Source – Adaptive Low Bitrate Igual que el origen – velocidad de bits adaptable baja + Match Source – Adaptive Medium Bitrate Igual que el origen – velocidad de bits adaptable media + Matches Coincide con + Maximize or Restore Active Frame Maximizar o restaurar el marco activo + Maximize or Restore Frame Under Cursor Maximizar o restaurar el marco bajo el cursor + Media Browser Settings Ajustes del navegador de medios + MediaBrowser Navegador de medios + MediaCache Caché de medios + Merge Captions Combinar subtítulos + Mixer Mezclador + Mosaic In Entrada en mosaico + Move All Audio Targets Down Bajar todos los destinos de audio + Move All Audio Targets Up Subir todos los destinos de audio + Move All Video Targets Down Bajar todos los destinos de video + Move All Video Targets Up Subir todos los destinos de video + Move Selection Down Mover la selección hacia abajo + Move Selection End Mover la selección al final + Move Selection Home Mover la selección al inicio + Move Selection Left Mover la selección a la izquierda + Move Selection Page Down Mover la selección una página abajo + Move Selection Page Up Mover la selección una página arriba + Move Selection Right Mover la selección a la derecha + Move Selection Up Mover la selección hacia arriba + Multi-Camera Record On/Off Toggle Activar/desactivar la grabación multicámara + Multicam Multicámara + Navigate to End of Segment Ir al final del segmento + Navigate to Next Line Ir a la línea siguiente + Navigate to Next Word Ir a la palabra siguiente + Navigate to Previous Line Ir a la línea anterior + Navigate to Previous Word Ir a la palabra anterior + Navigate to Start of Segment Ir al inicio del segmento + Navigation Navegación + Next Multi-Camera Page Siguiente página multicámara + Nudge Clip Selection Down Desplazar la selección de clips hacia abajo + Nudge Clip Selection Left Five Frames Desplazar la selección de clips cinco fotogramas a la izquierda + Nudge Clip Selection Left One Frame Desplazar la selección de clips un fotograma a la izquierda + Nudge Clip Selection Right Five Frames Desplazar la selección de clips cinco fotogramas a la derecha + Nudge Clip Selection Right One Frame Desplazar la selección de clips un fotograma a la derecha + Nudge Clip Selection Up Desplazar la selección de clips hacia arriba + Nudge Selected Object down by five Desplazar el objeto seleccionado cinco hacia abajo + Nudge Selected Object down by one Desplazar el objeto seleccionado uno hacia abajo + Nudge Selected Object to left by five Desplazar el objeto seleccionado cinco a la izquierda + Nudge Selected Object to left by one Desplazar el objeto seleccionado uno a la izquierda + Nudge Selected Object to right by five Desplazar el objeto seleccionado cinco a la derecha + Nudge Selected Object to right by one Desplazar el objeto seleccionado uno a la derecha + Nudge Selected Object up by five Desplazar el objeto seleccionado cinco hacia arriba + Nudge Selected Object up by one Desplazar el objeto seleccionado uno hacia arriba + Nudge Volume +1dB Ajustar volumen +1 dB + Nudge Volume +3dB Ajustar volumen +3 dB + Nudge Volume -1dB Ajustar volumen -1 dB + Nudge Volume -3dB Ajustar volumen -3 dB + Numbered BMP stills (24-bit) Imágenes fijas BMP numeradas (24 bits) + Numbered PNG stills at the sequence's frame size Imágenes fijas PNG numeradas al tamaño de fotograma de la secuencia + Numbered TIFF stills (RGB, 8-bit) Imágenes fijas TIFF numeradas (RGB, 8 bits) + Opacity 0 → 100 % over the first second Opacidad de 0 a 100 % durante el primer segundo + Opacity 100 → 0 % over the last second Opacidad de 100 a 0 % durante el último segundo + Open Bin Abrir bandeja + Open Search Abrir búsqueda + PNG PNG + PNG Sequence Secuencia PNG + Panel Panel + Pen Tool Herramienta Pluma + PiP 35% Upper Right Imagen en imagen al 35 % arriba a la derecha + Picture-in-picture: 35 % scale in the upper right Imagen en imagen: escala del 35 % arriba a la derecha + Play Around Reproducir alrededor + Play In to Out with Preroll/Postroll Reproducir de entrada a salida con márgenes previo y posterior + Play from Playhead to Out Point Reproducir del cabezal al punto de salida + Play/Stop Reproducir/Detener + Previous Multi-Camera Page Página multicámara anterior + Progressive Progresivo + ProjectPanel Panel Proyecto + QuickTime (Motion JPEG) QuickTime (Motion JPEG) + QuickTime, APV 422-10 (10-bit 4:2:2), 24-bit 48 kHz PCM QuickTime, APV 422-10 (10 bits 4:2:2), PCM de 24 bits y 48 kHz + QuickTime, APV 422-12 (12-bit 4:2:2), 24-bit 48 kHz PCM QuickTime, APV 422-12 (12 bits 4:2:2), PCM de 24 bits y 48 kHz + QuickTime, DNxHR HQ (8-bit 4:2:2), 24-bit 48 kHz PCM QuickTime, DNxHR HQ (8 bits 4:2:2), PCM de 24 bits y 48 kHz + QuickTime, DNxHR LB (8-bit 4:2:2), 24-bit 48 kHz PCM QuickTime, DNxHR LB (8 bits 4:2:2), PCM de 24 bits y 48 kHz + QuickTime, DNxHR SQ (8-bit 4:2:2), 24-bit 48 kHz PCM QuickTime, DNxHR SQ (8 bits 4:2:2), PCM de 24 bits y 48 kHz + QuickTime, ProRes 422 HQ, 24-bit 48 kHz PCM QuickTime, ProRes 422 HQ, PCM de 24 bits y 48 kHz + QuickTime, ProRes 422 LT, 24-bit 48 kHz PCM QuickTime, ProRes 422 LT, PCM de 24 bits y 48 kHz + QuickTime, ProRes 422 Proxy, 24-bit 48 kHz PCM QuickTime, ProRes 422 Proxy, PCM de 24 bits y 48 kHz + QuickTime, ProRes 422, 24-bit 48 kHz PCM QuickTime, ProRes 422, PCM de 24 bits y 48 kHz + Quit FilmCraft Salir de FilmCraft + Rate Stretch Tool Herramienta Ajuste de velocidad + Razor Tool Herramienta Cuchilla + Rectangle Tool Herramienta Rectángulo + Remix Tool Herramienta Remezcla + Right align text Alinear texto a la derecha + Ripple Edit Tool Herramienta Edición de rizo + Ripple Trim Next Edit to Playhead Recorte de rizo de la siguiente edición hasta el cabezal + Ripple Trim Previous Edit to Playhead Recorte de rizo de la edición anterior hasta el cabezal + Rolling Edit Tool Herramienta Edición de rodillo + Select Camera 1 Seleccionar cámara 1 + Select Camera 2 Seleccionar cámara 2 + Select Camera 3 Seleccionar cámara 3 + Select Camera 4 Seleccionar cámara 4 + Select Camera 5 Seleccionar cámara 5 + Select Camera 6 Seleccionar cámara 6 + Select Camera 7 Seleccionar cámara 7 + Select Camera 8 Seleccionar cámara 8 + Select Camera 9 Seleccionar cámara 9 + Select Clip at Playhead Seleccionar el clip en el cabezal + Select Find Box Seleccionar el cuadro de búsqueda + Select Next Clip Seleccionar el clip siguiente + Select Next Panel Seleccionar el panel siguiente + Select Previous Clip Seleccionar el clip anterior + Select Previous Panel Seleccionar el panel anterior + Select to Next Line Seleccionar hasta la línea siguiente + Select to Next Word Seleccionar hasta la palabra siguiente + Select to Previous Line Seleccionar hasta la línea anterior + Select to Previous Word Seleccionar hasta la palabra anterior + Select to Segment End Seleccionar hasta el final del segmento + Select to Segment Start Seleccionar hasta el inicio del segmento + Selection Tool Herramienta Selección + Set Comparison Reference Fijar la referencia de comparación + Set Creative Look Fijar el aspecto creativo + Set Input LUT Fijar la LUT de entrada + Set Project Panel View Fijar la vista del panel Proyecto + Show Next Screen Mostrar la pantalla siguiente + Show Previous Screen Mostrar la pantalla anterior + Show Program Transcript Mostrar la transcripción del programa + Shuttle Left Lanzadera a la izquierda + Shuttle Right Lanzadera a la derecha + Shuttle Slow Left Lanzadera lenta a la izquierda + Shuttle Slow Right Lanzadera lenta a la derecha + Shuttle Stop Detener lanzadera + Slide Clip Selection Left Five Frames Deslizar la selección de clips cinco fotogramas a la izquierda + Slide Clip Selection Left One Frame Deslizar la selección de clips un fotograma a la izquierda + Slide Clip Selection Right Five Frames Deslizar la selección de clips cinco fotogramas a la derecha + Slide Clip Selection Right One Frame Deslizar la selección de clips un fotograma a la derecha + Slide Tool Herramienta Deslizar + Slip Clip Selection Left Five Frames Desplazar el contenido de la selección de clips cinco fotogramas a la izquierda + Slip Clip Selection Left One Frame Desplazar el contenido de la selección de clips un fotograma a la izquierda + Slip Clip Selection Right Five Frames Desplazar el contenido de la selección de clips cinco fotogramas a la derecha + Slip Clip Selection Right One Frame Desplazar el contenido de la selección de clips un fotograma a la derecha + Slip Tool Herramienta Desplazar contenido + Social Vertical 1080×1920 Vertical para redes sociales 1080×1920 + Soft Vignette Viñeta suave + Source In/Out Entrada/salida del origen + Split Caption Dividir subtítulo + Square Pixels (1.0) Píxeles cuadrados (1.0) + Stack Clips Apilar clips + Start Export Queue Iniciar la cola de exportación + Start Voice-over Recording Iniciar la grabación de voz en off + Step Back Five Frames Retroceder cinco fotogramas + Step Back One Frame Retroceder un fotograma + Step Forward Five Frames Avanzar cinco fotogramas + Step Forward One Frame Avanzar un fotograma + Stop Export Queue Detener la cola de exportación + Stop Multi-Camera Recording Detener la grabación multicámara + Stop Voice-over Recording Detener la grabación de voz en off + Sync Voice-over Capture Sincronizar la captura de voz en off + TIFF TIFF + TIFF Sequence Secuencia TIFF + TextPanel Panel Texto + Thumbnail Size Next Siguiente tamaño de miniatura + Thumbnail Size Previous Tamaño de miniatura anterior + Toggle All Audio Targets Alternar todos los destinos de audio + Toggle All Source Audio Alternar todo el audio de origen + Toggle All Source Video Alternar todo el video de origen + Toggle All Video Targets Alternar todos los destinos de video + Toggle Audio During Scrubbing Alternar audio al arrastrar + Toggle Full Screen Alternar pantalla completa + Toggle Mute for All Targeted Audio Tracks Alternar silencio en todas las pistas de audio de destino + Toggle Solo for All Targeted Audio Tracks Alternar solo en todas las pistas de audio de destino + Toggle Source/Program Monitor Focus Alternar el foco entre los monitores de origen y programa + Toggle Target Audio 1 Alternar destino de audio 1 + Toggle Target Audio 2 Alternar destino de audio 2 + Toggle Target Audio 3 Alternar destino de audio 3 + Toggle Target Audio 4 Alternar destino de audio 4 + Toggle Target Audio 5 Alternar destino de audio 5 + Toggle Target Audio 6 Alternar destino de audio 6 + Toggle Target Audio 7 Alternar destino de audio 7 + Toggle Target Audio 8 Alternar destino de audio 8 + Toggle Target Video 1 Alternar destino de video 1 + Toggle Target Video 2 Alternar destino de video 2 + Toggle Target Video 3 Alternar destino de video 3 + Toggle Target Video 4 Alternar destino de video 4 + Toggle Target Video 5 Alternar destino de video 5 + Toggle Target Video 6 Alternar destino de video 6 + Toggle Target Video 7 Alternar destino de video 7 + Toggle Target Video 8 Alternar destino de video 8 + Toggle Track Output for All Targeted Video Tracks Alternar la salida de todas las pistas de video de destino + Toggle Trim Type Alternar tipo de recorte + Toggle View Alternar vista + Track Select Backward Tool Herramienta Seleccionar pista hacia atrás + Track Select Forward Tool Herramienta Seleccionar pista hacia delante + Track Selected Mask Seguir la máscara seleccionada + Trim Next Edit to Playhead Recortar la siguiente edición hasta el cabezal + Trim Previous Edit to Playhead Recortar la edición anterior hasta el cabezal + Trimming Recorte + Type Tool Herramienta Texto + Unstack Clips Desapilar clips + Upper First Superior primero + VBR, 1 pass VBR, 1 pasada + VBR, 2 pass VBR, 2 pasadas + Vimeo 1080p Full HD Vimeo 1080p Full HD + Vimeo 2160p 4K Ultra HD Vimeo 2160p 4K Ultra HD + Voice-over Record Grabación de voz en off + Voiceover Voz en off + WAV, stereo 48 kHz 16-bit PCM WAV, estéreo, PCM de 48 kHz y 16 bits + WAV, stereo 48 kHz 24-bit PCM WAV, estéreo, PCM de 48 kHz y 24 bits + Warm Tint Matiz cálido + Warm highlights, brown shadows Iluminaciones cálidas, sombras marrones + Waveform Audio Waveform Audio + Waveform Audio 48 kHz 16-bit Waveform Audio 48 kHz 16 bits + Waveform Audio 48 kHz 24-bit Waveform Audio 48 kHz 24 bits + Web & Social Web y redes sociales + Work Area Área de trabajo + Write Automation Escribir automatización + YouTube 1080p Full HD YouTube 1080p Full HD + YouTube 2160p 4K Ultra HD YouTube 2160p 4K Ultra HD + Zoom Program Monitor to 100% Ampliar el monitor de programa al 100% + Zoom Program Monitor to Fit Ajustar el monitor de programa a la ventana + Zoom Source Monitor to 100% Ampliar el monitor de origen al 100% + Zoom Source Monitor to Fit Ajustar el monitor de origen a la ventana + Zoom Tool Herramienta Zoom + with: por: + Interface Language Idioma de la interfaz + Cinematic Cinematográfico + Film Emulation Emulación de película + Technical Técnico + Teal Shadows, Warm Skin Sombras turquesa, piel cálida + Night Exterior Exterior nocturno + Golden Dusk Atardecer dorado + Steel Blue Azul acero + Hard Sun Sol intenso + Faded Print Copia desvanecida + Silver Retention Retención de plata + Cross Processed Proceso cruzado + Warm Negative Negativo cálido + Neutral Mono Monocromo neutro + Hard Mono Monocromo intenso + Sepia Tone Tono sepia + Cold Mono Monocromo frío + Lift Shadows Levantar sombras + Protect Highlights Proteger luces + Half Saturation Saturación al 50 % + Boost Vibrance Aumentar intensidad + Legal Range Squeeze Compresión al rango de vídeo + A boxed label with a round pointer; move the label and the pointer follows. Una etiqueta enmarcada con un indicador redondo; al mover la etiqueta, el indicador la sigue. + A rounded tag that fits its text, with a status dot. Una etiqueta redondeada que se ajusta al texto, con un punto de estado. + A title on a rounded card with a soft shadow. Un título en una tarjeta redondeada con una sombra suave. + Accent Color Color de acento + Bar Color Color de la barra + Box Color Color del recuadro + Box Opacity Opacidad del recuadro + Callout – Pointer Llamada – Indicador + Callout – Tag Llamada – Etiqueta + Callouts Llamadas + Credits Créditos + Dot Color Color del punto + Heading Encabezado + Large centred title, accent rule and subtitle. Un título grande centrado, una línea de acento y un subtítulo. + Lower Third – Rule Rótulo inferior – Línea + Lower Third – Slab Rótulo inferior – Bloque + Lower Thirds Rótulos inferiores + Name Color Color del nombre + Name Size Tamaño del nombre + Name and role on a coloured slab that widens with the name. Nombre y cargo sobre un bloque de color que se ensancha con el nombre. + Name over a thin rule that always matches the name's width. Nombre sobre una línea fina que siempre se ajusta a su anchura. + Pointer Color Color del indicador + Role Cargo + Rule Color Color de la línea + Show Dot Mostrar punto + Show Role Mostrar cargo + Slab Color Color del bloque + Tag Color Color de la etiqueta + Title Color Color del título + Title Size Tamaño del título + Title – Boxed Título – Enmarcado + Title – Centered Título – Centrado + Titles Títulos + Select an item to preview it Selecciona un elemento para previsualizarlo + End Credits Créditos finales + End Credits – Roll Créditos finales – Desplazamiento vertical + Ticker – Crawl Rótulo informativo – Desplazamiento horizontal + Centred credits that roll up from below the frame and leave at the top. Créditos centrados que suben desde debajo del fotograma y salen por la parte superior. + A news-style line that crawls in from the right and leaves at the left. Una línea de estilo informativo que entra desde la derecha y sale por la izquierda. + out salida + in entrada + The language changed but could not be saved: {e} El idioma ha cambiado, pero no se ha podido guardar: {e} + no Japanese font is installed on this system; the interface stays in English no hay ninguna fuente japonesa instalada en este sistema; la interfaz sigue en inglés diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv new file mode 100644 index 00000000..469fd6f6 --- /dev/null +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -0,0 +1,93 @@ +# Japanese (ja) catalog. Same format as `es.tsv`: context source translation. +# +# Clean-room: every entry was written from the plain meaning of the English label using ordinary +# Japanese computing vocabulary. No proprietary localisation resource, string table or product UI +# was consulted or copied. This catalog covers the menus only; anything else is shown in English. + + Clip クリップ + Sequence シーケンス + Markers マーカー + Graphics and Titles グラフィックスとタイトル + Settings 環境設定 + Object オブジェクト + Effect 効果 + Settings… 環境設定… + Image 画像 + Layer レイヤー + Type 書式 + Select 選択 + Filter フィルター + Window ウィンドウ + Language 表示言語 + Save As… 別名で保存… + Exit 終了 + New… 新規… + New 新規 + Horizontal 横書き + Vertical 縦書き + Orientation 組み方向 + Layers レイヤー + History 履歴 + Properties プロパティ + Color カラー + Brush Settings ブラシ設定 + Tools ツール + Options オプション + Zoom In ズームイン + Zoom Out ズームアウト + Fit on Screen 画面に合わせる + Copy コピー + Cut 切り取り + Paste 貼り付け + Select All すべて選択 + Deselect 選択を解除 + Export 書き出し + Export As… 形式を指定して書き出し… + Search… 検索… + Theme テーマ + Menu メニュー + File ファイル + Edit 編集 + Pages ページ + View 表示 + Help ヘルプ + Preferences 環境設定 + Preferences… 環境設定… + Interface language 表示言語 + Open… 開く… + New blank PDF 空白の PDF を作成 + Create PDF from file… ファイルから PDF を作成… + Create PDF from images… 画像から PDF を作成… + Create PDF from clipboard クリップボードから PDF を作成 + Combine files… ファイルを結合… + Save 保存 + Save as… 別名で保存… + Close file ファイルを閉じる + Close all すべて閉じる + Revert 保存済みの状態に戻す + Print… 印刷… + Document properties… 文書のプロパティ… + Undo 取り消し + Redo やり直し + Find… 検索… + Advanced search… 高度な検索… + Copy pages ページをコピー + Cut pages ページを切り取り + Paste pages ページを貼り付け + Fit visible 表示範囲に合わせる + Marquee zoom 範囲指定ズーム + Take a snapshot スナップショットを作成 + Full screen mode 全画面表示 + Read mode 閲覧モード + Switch light / dark theme 明るい/暗いテーマを切り替え + Comments panel コメントパネル + Form fields panel フォームフィールドパネル + Clear form フォームをクリア + Find tools and commands… ツールとコマンドを検索… + Zoom ズーム + Actual size 実際のサイズ + Zoom to page level ページ全体を表示 + Fit to width 幅に合わせる + Display theme 表示テーマ + Side panels サイドパネル + OK OK diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs new file mode 100644 index 00000000..73493c02 --- /dev/null +++ b/crates/ui-egui/src/i18n/mod.rs @@ -0,0 +1,619 @@ +//! Interface translations. Strings in code stay English and are the lookup keys; one catalog per +//! language (`i18n/.tsv`, format in the header of `es.tsv`) maps them to display text at draw +//! time. Untranslated strings are shown in English, so coverage can grow incrementally. Command ids, +//! automation ids, preference values, the control channel, the CLI and MCP stay English (an +//! automation element's label is the text shown, so it follows the interface language). +//! +//! # Looking strings up +//! - `tl!` with a string literal: that UI string in the language the UI is drawn in (see +//! [`set_current`]). Use it for every label, button, heading, tooltip and hint; the tests check +//! that each `tl!` literal has a Spanish translation. +//! - [`t`]: the same for a string that is not a literal (a name from a registry or a list). +//! - [`tr_ctx`]: when one English word needs different translations (the Edit menu, the Edit button). +//! - [`fmt`]: fill `{name}` placeholders after translating a template; translators may reorder them. +//! +//! Japanese text uses the Japanese craft-fonts when FilmCraft was built with them (`CRAFT_FONTS_DIR`; +//! `theme::install` already puts them in every font family, see [`craft_japanese_font`]), otherwise +//! a font already installed on the system ([`system_japanese_font`]); with neither, switching to +//! Japanese is refused with a message. + +mod catalog; + +use std::cell::Cell; +use std::sync::{Arc, OnceLock}; + +use catalog::Catalog; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Language { + #[default] + En, + Ja, + Es, +} + +static JAPANESE: OnceLock = OnceLock::new(); +static SPANISH: OnceLock = OnceLock::new(); + +impl Language { + pub const ALL: [Self; 3] = [Self::En, Self::Ja, Self::Es]; + + pub fn name(self) -> &'static str { + match self { + Self::En => "English", + Self::Ja => "日本語", + Self::Es => "Español", + } + } + + pub fn parse(code: &str) -> Option { + match code { + "en" => Some(Self::En), + "ja" => Some(Self::Ja), + "es" => Some(Self::Es), + _ => None, + } + } + + pub fn code(self) -> &'static str { + match self { + Self::En => "en", + Self::Ja => "ja", + Self::Es => "es", + } + } + + /// The language's catalog (none for English, the source language). + fn catalog(self) -> Option<&'static Catalog> { + match self { + Self::En => None, + Self::Ja => Some(JAPANESE.get_or_init(|| Catalog::parse(include_str!("ja.tsv")))), + Self::Es => Some(SPANISH.get_or_init(|| Catalog::parse(include_str!("es.tsv")))), + } + } + + /// Translate an English UI string; unknown strings come back unchanged. + pub fn tr(self, text: &str) -> &str { + self.catalog().and_then(|c| c.plain(text)).unwrap_or(text) + } + + /// Does this language's catalog translate `text`? (English never does: it is the source.) + pub fn has(self, text: &str) -> bool { + self.catalog().and_then(|c| c.plain(text)).is_some() + } +} + +thread_local! { + /// The language the UI is drawn in. Per thread: the UI is drawn on one thread, and tests that + /// switch language don't leak it into tests running on other threads. + static CURRENT: Cell = const { Cell::new(Language::En) }; +} + +/// Set the language for drawing. `FilmcraftApp::frame` calls this at the start of every frame from +/// the UI state, so widgets translate without every call site carrying the language around. +pub fn set_current(lang: Language) { + CURRENT.with(|c| c.set(lang)); +} + +/// The language the UI is drawn in. +pub fn current() -> Language { + CURRENT.with(Cell::get) +} + +/// Translate an English UI string into the current language (the `tl!` macro, for literals). +pub fn t(text: &str) -> &str { + current().tr(text) +} + +/// Match a lowercased search query against both the source and the displayed label. +pub fn matches_query(text: &str, query: &str) -> bool { + text.to_lowercase().contains(query) || t(text).to_lowercase().contains(query) +} + +/// Like [`t`], for an English string that needs a disambiguating `context` (a catalog row whose +/// first column is that context); falls back to the context-free translation. +pub fn tr_ctx<'a>(context: &str, text: &'a str) -> &'a str { + let lang = current(); + lang.catalog().and_then(|c| c.contextual(context, text)).unwrap_or_else(|| lang.tr(text)) +} + +/// Fill `{name}` placeholders. Unknown placeholders are left as written. +pub fn fmt(template: &str, args: &[(&str, &str)]) -> String { + let mut out = String::new(); + let mut rest = template; + while let Some((prefix, after)) = rest.split_once('{') { + out.push_str(prefix); + let Some((name, tail)) = after.split_once('}') else { + out.push('{'); + out.push_str(after); + return out; + }; + if let Some((_, value)) = args.iter().find(|(key, _)| *key == name) { + out.push_str(value); + } else { + out.push('{'); + out.push_str(name); + out.push('}'); + } + rest = tail; + } + out.push_str(rest); + out +} + +/// Text every Japanese interface font must cover (menus use kanji, hiragana and katakana). +const JAPANESE_SAMPLE: &str = "日本語ファイル編集あア"; + +/// Installed families preferred for Japanese interface text, best first (Gothic / sans-serif faces +/// read best at menu sizes). Any other installed face that covers [`JAPANESE_SAMPLE`] is used if +/// none of these is present. +const PREFERRED_JAPANESE: &[&str] = &[ + "Hiragino Sans", + "Hiragino Kaku Gothic ProN", + "Hiragino Kaku Gothic Pro", + "Yu Gothic UI", + "Yu Gothic", + "Meiryo UI", + "Meiryo", + "Noto Sans CJK JP", + "Noto Sans JP", + "Source Han Sans JP", + "Source Han Sans", + "IPAexGothic", + "IPAGothic", + "TakaoGothic", + "VL Gothic", +]; + +const JAPANESE_FONT: &str = "system-japanese"; + +/// A Japanese font already installed on this system, for the interface (none is bundled). Looked up +/// once per process: the system font folders are scanned on first use (name tables only), then the +/// chosen face's file is read. `None` on the web and on systems without a Japanese font. +pub fn system_japanese_font() -> Option> { + static FONT: OnceLock>> = OnceLock::new(); + FONT.get_or_init(|| { + filmcraft_text::fonts::scan_system(); + let faces: Vec<_> = filmcraft_text::fonts::all_faces().into_iter().filter(|f| f.info.origin == "system" && !f.info.italic).collect(); + let covers = |f: &filmcraft_text::fonts::Face| JAPANESE_SAMPLE.chars().all(|c| f.has_char(c)); + // within a family, the face closest to regular weight + let by_weight = |f: &&Arc| f.info.weight.abs_diff(400); + let preferred = + PREFERRED_JAPANESE.iter().find_map(|name| faces.iter().filter(|f| f.info.family.eq_ignore_ascii_case(name) && covers(f)).min_by_key(by_weight)); + let face = preferred.or_else(|| faces.iter().filter(|f| covers(f)).min_by_key(by_weight))?; + // kept for the life of the process (one font, read once) so installing it again after a + // theme change shares the bytes instead of copying the whole file + let bytes: &'static [u8] = Box::leak(face.data()?.into_boxed_slice()); + Some(Arc::new(egui::FontData { font: std::borrow::Cow::Borrowed(bytes), index: face.info.index, tweak: Default::default() })) + }) + .clone() +} + +/// Whether the craft-fonts build input (empty unless built with `CRAFT_FONTS_DIR`) supplies a +/// Japanese interface font: some craft-fonts face covers [`JAPANESE_SAMPLE`]. `theme::install` adds +/// these faces to every font family, so nothing else needs installing (and no system scan runs). +pub fn craft_japanese_font() -> bool { + filmcraft_text::fonts::craft_japanese().next().is_some() + && filmcraft_text::fonts::all_faces() + .iter() + .any(|f| f.info.origin == filmcraft_text::fonts::CRAFT_ORIGIN && JAPANESE_SAMPLE.chars().all(|c| f.has_char(c))) +} + +/// Japanese for the interface: true when built with the craft-fonts (already installed by +/// `theme::install`). Otherwise add the system's Japanese font as the last fallback of every theme font family, from the next +/// pass on. Returns false (and changes nothing) when no Japanese font is installed. Call it again +/// after `theme::install`, which replaces the font definitions. +pub fn install_japanese_font(ctx: &egui::Context) -> bool { + if craft_japanese_font() { + return true; + } + let Some(font) = system_japanese_font() else { return false }; + let families = crate::theme::font_families() + .into_iter() + .map(|family| egui::epaint::text::InsertFontFamily { family, priority: egui::epaint::text::FontPriority::Lowest }) + .collect(); + // queued for the next pass (works before the first frame); a no-op when already installed + ctx.add_font(egui::epaint::text::FontInsert { name: JAPANESE_FONT.into(), data: (*font).clone(), families }); + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn catalogs_are_well_formed() { + for (code, text) in [("es", include_str!("es.tsv")), ("ja", include_str!("ja.tsv"))] { + let (entries, errors) = catalog::parse_entries(text); + assert!(errors.is_empty(), "{code}: {errors:?}"); + for (i, (ctx, en, tr)) in entries.iter().enumerate() { + assert!(entries.iter().take(i).all(|(c, e, _)| (c, e) != (ctx, en)), "{code}: duplicate entry {ctx:?} {en:?}"); + let (mut a, mut b) = (catalog::placeholders(en), catalog::placeholders(tr)); + a.sort_unstable(); + b.sort_unstable(); + assert_eq!(a, b, "{code}: placeholders differ in {en:?} → {tr:?}"); + assert_eq!(en.ends_with('…'), tr.ends_with('…'), "{code}: ellipsis differs in {en:?} → {tr:?}"); + } + } + assert_eq!(Language::Es.tr("File"), "Archivo"); + assert_eq!(Language::Ja.tr("File"), "ファイル"); + assert_eq!(Language::En.tr("File"), "File"); + assert_eq!(Language::Es.tr("mi video.mp4"), "mi video.mp4"); + assert_eq!(Language::Ja.tr("日本語の文書.pdf"), "日本語の文書.pdf"); + assert_eq!(Language::parse("es"), Some(Language::Es)); + assert_eq!(Language::parse("xx"), None); + assert_eq!(fmt("{b} y {a}", &[("a", "1"), ("b", "2")]), "2 y 1"); + assert_eq!(fmt("{a} {zz}", &[("a", "1")]), "1 {zz}"); + } + + #[test] + fn the_current_language_is_per_thread() { + set_current(Language::Es); + assert_eq!((current(), t("File")), (Language::Es, "Archivo")); + // another thread (another test) still draws in English + assert_eq!(std::thread::spawn(|| t("File").to_string()).join().ok().as_deref(), Some("File")); + set_current(Language::En); + assert_eq!(t("File"), "File"); + } + + #[test] + fn template_values_are_not_interpreted_as_placeholders() { + assert_eq!(fmt("{name}: {n}", &[("name", "Vídeo {n}.mp4"), ("n", "3")]), "Vídeo {n}.mp4: 3"); + assert_eq!(fmt("{n} {name} {n}", &[("name", "{n}"), ("n", "日本語")]), "日本語 {n} 日本語"); + assert_eq!(fmt("{missing} y {unfinished", &[]), "{missing} y {unfinished"); + } + + #[test] + fn searches_match_spanish_labels_and_english_names() { + set_current(Language::Es); + assert!(matches_query("Gaussian Blur", "desenfoque")); + assert!(matches_query("Gaussian Blur", "gaussian")); + assert!(matches_query("Selection Tool", &"SELECCIÓN".to_lowercase())); + assert!(!matches_query("Gaussian Blur", "selección")); + set_current(Language::En); + } + + #[test] + fn spanish_translates_every_menu_label() { + let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); + let mut missing: Vec = Vec::new(); + let mut need = |text: &str| { + if translatable(text) && !Language::Es.has(text) && !missing.iter().any(|m| m == text) { + missing.push(text.to_string()); + } + }; + for top in crate::menus::MENUS { + need(top); + } + for it in crate::menus::menu_items(&app) { + need(&it.label); + for p in &it.path { + need(p); + } + } + missing.sort(); + assert!(missing.is_empty(), "untranslated menu labels: {missing:#?}"); + } + + /// Every `tl!`/`tlf!` literal outside test modules has a Spanish translation. + #[test] + fn spanish_translates_every_tl_literal() { + let mut missing = Vec::new(); + for (path, text) in sources() { + for lit in tl_literals(&text) { + if !Language::Es.has(&lit) && !missing.contains(&lit) { + missing.push(lit); + } + } + assert!(!text.contains("tl!(r") && !text.contains("tlf!(r"), "{path}: use a plain string literal in tl! and tlf!"); + } + missing.sort(); + assert!(missing.is_empty(), "untranslated tl! strings: {missing:#?}"); + } + + /// The crate's sources (`src/**/*.rs`), each cut at its test module. + fn sources() -> Vec<(String, String)> { + let mut out = Vec::new(); + let mut dirs = vec![std::path::PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/src"))]; + while let Some(d) = dirs.pop() { + for entry in std::fs::read_dir(&d).into_iter().flatten().flatten() { + let p = entry.path(); + if p.is_dir() { + dirs.push(p); + } else if p.extension().is_some_and(|e| e == "rs") { + let text = std::fs::read_to_string(&p).unwrap_or_default(); + let cut = text.find("#[cfg(test)]\nmod tests").unwrap_or(text.len()); + out.push((p.display().to_string(), text[..cut].to_string())); + } + } + } + out + } + + /// The (unescaped) string literals passed to `tl!` and `tlf!` in a source file. + fn tl_literals(text: &str) -> Vec { + let mut out = Vec::new(); + let mut rest = text; + loop { + let next = ["tl!(\"", "tlf!(\""].into_iter().filter_map(|p| Some((rest.find(p)?, p.len()))).min(); + let Some((at, len)) = next else { break }; + rest = &rest[at + len..]; + let mut lit = String::new(); + let mut chars = rest.char_indices(); + let mut end = rest.len(); + while let Some((i, c)) = chars.next() { + match c { + '"' => { + end = i + 1; + break; + } + '\\' => match chars.next().map(|(_, e)| e) { + Some('n') => lit.push('\n'), + Some('t') => lit.push('\t'), + Some('u') => { + // \u{201c} + let hex: String = chars.by_ref().map(|(_, h)| h).take_while(|h| *h != '}').filter(|h| *h != '{').collect(); + lit.extend(u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32)); + } + Some(e) => lit.push(e), + None => {} + }, + c => lit.push(c), + } + } + rest = &rest[end..]; + out.push(lit); + } + out + } + + /// Does `s` hold words to translate? Numbers with units ("1.25 kHz", "24 fps", "8 Bit", "100 IRE") + /// and the language names (always shown in their own language) don't. + fn translatable(s: &str) -> bool { + const UNITS: [&str; 14] = ["Hz", "kHz", "dB", "oct", "fps", "IRE", "mm", "px", "ms", "s", "x", "Bit", "HLG", "PQ"]; + !Language::ALL.iter().any(|l| l.name() == s) && s.split(|c: char| !c.is_alphabetic()).any(|w| !w.is_empty() && !UNITS.contains(&w)) + } + + fn push(out: &mut Vec, s: &str) { + if translatable(s) && !out.iter().any(|o| o == s) { + out.push(s.to_string()); + } + } + + fn settings_rows(list: &[filmcraft_engine::settings::Row], out: &mut Vec) { + use filmcraft_engine::settings::{Kind, Row}; + for r in list { + match r { + Row::Field(f) => { + push(out, f.label); + match f.kind { + Kind::Int { unit, .. } | Kind::Float { unit, .. } => push(out, unit), + Kind::Choice(opts) => opts.iter().for_each(|(_, l)| push(out, l)), + _ => {} + } + } + Row::Group(title, inner) => { + push(out, title); + settings_rows(inner, out); + } + Row::Note(text) => push(out, text), + Row::Button { label, .. } => push(out, label), + Row::Custom(_) => {} + } + } + } + + /// Names the interface shows through `i18n::t` rather than as `tl!` literals: registries and + /// schemas from the engine and the other crates, and the UI's own enums. + fn dynamic_names() -> Vec { + let mut out = Vec::new(); + for c in filmcraft_engine::settings::categories() { + push(&mut out, c.title); + settings_rows(c.rows, &mut out); + } + filmcraft_engine::settings::DURATION_UNITS.iter().for_each(|(_, l)| push(&mut out, l)); + for top in filmcraft_project::vtransition::EFFECT_TOP_FOLDERS { + push(&mut out, top); + } + for d in filmcraft_project::effect_defs() { + push(&mut out, d.name); + d.category.iter().for_each(|c| push(&mut out, c)); + for p in &d.params { + push(&mut out, p.label); + p.group.iter().for_each(|g| push(&mut out, g)); + if let filmcraft_project::ParamKind::Choice(opts) = p.kind { + opts.iter().for_each(|o| push(&mut out, o)); + } + } + } + crate::panels::timeline::CLIP_MENU.iter().flat_map(|g| g.iter()).for_each(|(l, _)| push(&mut out, l)); + crate::panels::project::NEW_ITEMS.iter().for_each(|(l, _)| push(&mut out, l)); + // section headers keyed by their English name (collapsed state), translated when drawn + let sections = [ + // Lumetri Color + "Basic Correction", + "Creative", + "Curves", + "Color Wheels & Match", + "HSL Secondary", + "Vignette", + // Properties + "Transform", + "Crop", + // Essential Graphics + "Layers", + "Template Properties", + "Responsive Design - Time", + "Responsive Design - Position", + "Align and Transform", + "Text", + "Shape", + "Appearance", + ]; + sections.iter().for_each(|s| push(&mut out, s)); + for p in filmcraft_render::lumetri_presets::presets() { + push(&mut out, p.name); + push(&mut out, p.folder); + push(&mut out, p.description); + } + crate::dock::PanelKind::ALL.iter().for_each(|p| push(&mut out, p.title())); + crate::dock::WORKSPACES.iter().for_each(|w| push(&mut out, w)); + filmcraft_project::Label::ALL.iter().for_each(|l| push(&mut out, l.name())); + crate::panels::panel_state::TcMode::ALL.iter().for_each(|m| push(&mut out, m.label())); + crate::panels::panel_state::TcSource::ALL.iter().for_each(|s| push(&mut out, s.label())); + crate::panels::panel_state::SCOPE_PRESETS.iter().for_each(|p| push(&mut out, p.0)); + filmcraft_time::TimeDisplay::ALL.iter().for_each(|d| push(&mut out, d.label())); + use filmcraft_engine::panels::Level; + [Level::Info, Level::Warning, Level::Error].iter().for_each(|l| push(&mut out, l.label())); + use filmcraft_scopes as sc; + sc::ScopeKind::ALL.iter().for_each(|k| push(&mut out, k.label())); + sc::WaveformType::ALL.iter().for_each(|k| push(&mut out, k.label())); + sc::ParadeType::ALL.iter().for_each(|k| push(&mut out, k.label())); + sc::ColorSpace::ALL.iter().for_each(|k| push(&mut out, k.label())); + sc::Scale::ALL.iter().for_each(|k| push(&mut out, k.label())); + sc::Brightness::ALL.iter().for_each(|k| push(&mut out, k.label())); + [sc::Targets::Percent75, sc::Targets::Percent100].iter().for_each(|k| push(&mut out, k.label())); + crate::state::PlaybackRes::ALL.iter().for_each(|r| push(&mut out, r.label())); + crate::state::Tool::ALL.iter().for_each(|t| push(&mut out, t.label())); + filmcraft_project::CaptionFormat::ALL.iter().for_each(|f| push(&mut out, f.label())); + filmcraft_project::MaskMode::ALL.iter().for_each(|m| push(&mut out, m.label())); + use filmcraft_project::essential as es; + for kind in es::AudioType::ALL { + push(&mut out, kind.label()); + kind.sections().iter().for_each(|s| push(&mut out, s.label())); + } + es::EQ_PRESETS.iter().for_each(|p| push(&mut out, p.name)); + es::REVERB_PRESETS.iter().for_each(|p| push(&mut out, p.name)); + filmcraft_engine::essential_sound::presets(&filmcraft_engine::Session::default()).iter().for_each(|p| push(&mut out, &p.name)); + filmcraft_project::TrackMethod::ALL.iter().for_each(|m| push(&mut out, m.label())); + filmcraft_project::AutomationMode::ALL.iter().for_each(|m| push(&mut out, m.label())); + filmcraft_project::graphic_design::RollMode::ALL.iter().for_each(|m| push(&mut out, m.label())); + filmcraft_project::InputMap::ALL.iter().for_each(|m| push(&mut out, m.label())); + filmcraft_engine::panels::LOG_FIELDS.iter().for_each(|f| push(&mut out, f)); + filmcraft_engine::project_panel::BUILTIN_COLUMNS.iter().for_each(|c| push(&mut out, c)); + filmcraft_project::find::COLUMNS.iter().for_each(|c| push(&mut out, c)); + filmcraft_project::FindOp::ALL.iter().for_each(|o| push(&mut out, o.label())); + filmcraft_engine::media_browser::ALL_COLUMNS.iter().for_each(|c| push(&mut out, c)); + // export: presets, settings choices, ranges and pixel aspects + use filmcraft_engine::export as ex; + for p in ex::builtin_presets() { + push(&mut out, &p.name); + push(&mut out, &p.category); + push(&mut out, &p.description); + } + ex::Format::ALL.iter().for_each(|f| push(&mut out, f.label())); + ex::MxfVideoCodec::ALL.iter().for_each(|c| push(&mut out, c.label())); + ex::Placement::ALL.iter().for_each(|p| push(&mut out, p.label())); + [ex::FieldOrder::Progressive, ex::FieldOrder::UpperFirst, ex::FieldOrder::LowerFirst].iter().for_each(|f| push(&mut out, f.label())); + [ex::BitrateMode::Cbr, ex::BitrateMode::Vbr1Pass, ex::BitrateMode::Vbr2Pass].iter().for_each(|m| push(&mut out, m.label())); + [ex::H264Profile::Baseline, ex::H264Profile::Main, ex::H264Profile::High].iter().for_each(|p| push(&mut out, p.label())); + crate::panels::export_mode::RANGES.iter().for_each(|(_, l)| push(&mut out, l)); + crate::panels::export_mode::PARS.iter().for_each(|(l, _)| push(&mut out, l)); + for template in filmcraft_project::gtemplate::builtin_templates() { + push(&mut out, &template.name); + push(&mut out, &template.category); + push(&mut out, &template.description); + template.controls.iter().for_each(|c| push(&mut out, &c.name)); + } + // effect presets (Effects panel ▸ Presets) + for p in filmcraft_engine::presets::builtin_presets() { + push(&mut out, &p.name); + push(&mut out, &p.description); + } + // every command (Keyboard Shortcuts dialog, History panel) and the shortcut contexts + let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); + for c in app.session.shortcuts.commands() { + push(&mut out, &c.label); + push(&mut out, &c.category); + } + push(&mut out, filmcraft_engine::shortcuts::APPLICATION); + filmcraft_engine::shortcuts::PANELS.iter().for_each(|p| push(&mut out, p)); + // metadata rows, item types and history entries of the demo project + let mut s = filmcraft_engine::Session::default(); + let _ = s.execute("file.openDemoProject", serde_json::json!({})); + for it in s.project.items.values() { + push(&mut out, it.type_label()); + for f in filmcraft_engine::panels::metadata_fields(&s.project, it.id) { + push(&mut out, f.section); + push(&mut out, &f.name); + } + } + out + } + + #[test] + fn spanish_translates_dynamic_names() { + let mut missing: Vec = dynamic_names().into_iter().filter(|n| !Language::Es.has(n)).collect(); + missing.sort(); + assert!(missing.is_empty(), "untranslated names: {missing:#?}"); + } + + #[test] + fn tl_literals_are_found_and_unescaped() { + let src = "a(tl!(\"Save\")); b(tl!(\"Say \\\"hi\\\"\\n\")); tl!(x); tlf!(\"{n} file(s)\", n);"; + assert_eq!(tl_literals(src), ["Save", "Say \"hi\"\n", "{n} file(s)"]); + } + + #[test] + fn language_commands_switch_and_persist_without_a_document() { + let mut app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); + let ctx = egui::Context::default(); + crate::menus::invoke(&mut app, &ctx, "app.language.japanese", serde_json::json!({})).unwrap(); + assert_eq!(app.ui.language, Language::Ja); + let saved = serde_json::to_string(&app.ui).unwrap(); + let restored: crate::state::UiState = serde_json::from_str(&saved).unwrap(); + assert_eq!(restored.language, Language::Ja); + crate::menus::invoke(&mut app, &ctx, "app.language.spanish", serde_json::json!({})).unwrap(); + assert_eq!(app.ui.language, Language::Es); + let prefs = serde_json::to_string(&app.session.prefs).unwrap(); + let mut restarted = filmcraft_engine::Session::default(); + restarted.prefs = serde_json::from_str(&prefs).unwrap(); + assert_eq!(crate::FilmcraftApp::new(restarted).ui.language, Language::Es); + assert!(crate::menus::menu_items(&app).iter().any(|it| it.id == "app.language.spanish" && it.checked == Some(true))); + crate::menus::invoke(&mut app, &ctx, "app.language.english", serde_json::json!({})).unwrap(); + assert_eq!(app.ui.language, Language::En); + set_current(Language::En); + } + + #[test] + fn japanese_needs_an_installed_font() { + let mut app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &crate::theme::Tokens::for_kind(crate::theme::ThemeKind::default())); + let r = crate::menus::invoke(&mut app, &ctx, "app.language.japanese", serde_json::json!({})); + let craft = craft_japanese_font(); + if !craft && system_japanese_font().is_none() { + // no craft-fonts and no system font: refused, and the interface stays English + assert!(r.is_err(), "{r:?}"); + assert_eq!(app.ui.language, Language::En); + return; + } + assert!(r.is_ok(), "{r:?}"); + let mut output = ctx.run_ui(egui::RawInput::default(), |_| {}); + output.textures_delta.clear(); + ctx.fonts_mut(|fonts| { + // every theme family falls back to the craft-fonts (when built with them; no system + // font is added then) or to the system Japanese font + for family in crate::theme::font_families() { + let stack = fonts.definitions().families.get(&family).cloned().unwrap_or_default(); + if craft { + assert!(stack.last().is_some_and(|n| n.starts_with("craft:")), "{family:?}: {stack:?}"); + assert!(!stack.iter().any(|n| n == JAPANESE_FONT), "{family:?}: {stack:?}"); + } else { + assert_eq!(stack.last().map(String::as_str), Some(JAPANESE_FONT), "{family:?}: {stack:?}"); + } + } + // and the glyphs resolve. (Only families whose replacement-box face is another font: + // egui's `has_glyph` reports false for any character served by the face it also uses + // for the replacement box, which in the Inter-only "medium"/"semibold" stacks is the + // Japanese font itself.) + for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] { + let font = egui::FontId::new(13.0, family); + for ch in JAPANESE_SAMPLE.chars() { + assert!(fonts.has_glyph(&font, ch), "missing {ch} in {font:?}"); + } + } + }); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e7779a90..b7f0093d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -6,6 +6,29 @@ #![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))] +/// A literal UI string in the language the interface is drawn in (`i18n::t`). Only literals: the +/// i18n tests read every `tl!` literal in the crate and require a Spanish translation for it. +macro_rules! tl { + ($s:literal) => { + $crate::i18n::t($s) + }; +} + +/// A translated template with `{name}` placeholders filled in, for text that `format!` would build. +/// After the template literal come the values: `n = count`, or just `gain` for a variable of the +/// same name. Values are formatted with `Display`; the translation may reorder placeholders. +macro_rules! tlf { + (@v $k:ident = $v:expr) => { + $v + }; + (@v $k:ident) => { + $k + }; + ($s:literal $(, $k:ident $(= $v:expr)?)* $(,)?) => { + $crate::i18n::fmt($crate::i18n::t($s), &[$((stringify!($k), ToString::to_string(&tlf!(@v $k $(= $v)?)).as_str())),*]) + }; +} + pub mod automation; pub mod brand; pub mod control; @@ -297,7 +320,7 @@ impl FilmcraftApp { { g.render_state.renderer.write().free_texture(&id); } - self.ui.status = "Graphics problem: switched to software compositing".into(); + self.ui.status = tl!("Graphics problem: switched to software compositing").into(); } /// Composite a plan on the GPU and return the egui texture showing it. @@ -349,9 +372,10 @@ impl FilmcraftApp { let frames = Arc::new(FrameServer::new(session.media.clone(), session.services.clone(), session.previews.clone(), FrameServer::default_workers())); let workspaces = session.prefs_path.as_ref().and_then(|p| p.parent()).map(|d| dock::WorkspacePrefs::load(&d.join(dock::WORKSPACES_FILE))).unwrap_or_default(); + let language = i18n::Language::parse(&session.prefs.general.interface_language).unwrap_or_default(); Self { session, - ui: UiState::default(), + ui: UiState { language, ..UiState::default() }, tokens: Tokens::for_kind(ThemeKind::Dark), frames, playback: Playback { speed: 1.0, ..Default::default() }, @@ -451,6 +475,20 @@ impl FilmcraftApp { if prev.as_ref().is_none_or(|q| q.appearance != p.appearance || q.general.show_tool_tips != p.general.show_tool_tips) { self.set_theme(ctx, ThemeKind::from_pref(&p.appearance.color_theme)); } + if prev.as_ref().is_none_or(|q| q.general.interface_language != p.general.interface_language) { + let language = i18n::Language::parse(&p.general.interface_language).unwrap_or_default(); + if language == i18n::Language::Ja && !i18n::install_japanese_font(ctx) { + self.ui.language = i18n::Language::En; + self.ui.status = tl!("no Japanese font is installed on this system; the interface stays in English").into(); + } else { + self.ui.language = language; + } + i18n::set_current(self.ui.language); + let items = menus::menu_items(self); + if let Some(hook) = self.hooks.shortcuts_changed.as_mut() { + hook(&items); + } + } self.frames.set_cache_budget(p.memory.frame_cache_mb as usize * (1 << 20)); self.ui.play_after_render = p.timeline.play_after_rendering; if prev.as_ref().is_none_or(|q| q.audio_hardware != p.audio_hardware) { @@ -1127,6 +1165,7 @@ impl FilmcraftApp { // ---------------------------------------------------------------- frame fn frame(&mut self, ui: &mut egui::Ui) { + i18n::set_current(self.ui.language); if std::mem::take(&mut self.panic_next_frame) { crash::injected_fault("injected UI fault"); } @@ -1234,7 +1273,7 @@ impl FilmcraftApp { let p = ui.painter(); p.rect_filled(bar, 3.0, t.separator); p.rect_filled(egui::Rect::from_min_size(bar.min, egui::vec2(bar.width() * f, bar.height())), 3.0, t.accent); - let verb = if job.label.starts_with("Rendering") { job.label.clone() } else { "Exporting".to_string() }; + let verb = if job.label.starts_with("Rendering") { crate::i18n::t(&job.label).to_string() } else { tl!("Exporting").to_string() }; p.text(egui::pos2(bar.min.x - 8.0, sb.center().y), egui::Align2::RIGHT_CENTER, format!("{verb}… {:.0}%", f * 100.0), Tokens::ui(11.0), t.text_dim); let resp = ui.interact(cancel, egui::Id::new(("job-cancel", job.id)), egui::Sense::click()); let c = if resp.hovered() { t.hot_text } else { t.text_dim }; @@ -1243,7 +1282,7 @@ impl FilmcraftApp { p.line_segment([cancel.center() + egui::vec2(-k, k), cancel.center() + egui::vec2(k, -k)], egui::Stroke::new(1.4, c)); self.auto.add("status.job.cancel", cancel, &format!("Cancel {}", job.label)); self.auto.add("status.job.progress", bar, &format!("{:.0}%", f * 100.0)); - if resp.on_hover_text("Cancel").clicked() { + if resp.on_hover_text(tl!("Cancel")).clicked() { job.progress.cancel.store(true, Ordering::Relaxed); self.watched_render = None; } @@ -1252,18 +1291,18 @@ impl FilmcraftApp { /// Contextual hint for the status bar (Premiere shows tool/gesture hints here). fn hint_text(&self) -> String { match self.ui.tool { - state::Tool::Selection => "Click to select, or click in empty space and drag to marquee select. Use Shift, Opt, and Cmd for other options.", - state::Tool::TrackSelectForward => "Click to select all clips to the right in all tracks. Shift-click for a single track.", - state::Tool::TrackSelectBackward => "Click to select all clips to the left in all tracks. Shift-click for a single track.", - state::Tool::Ripple => "Drag an edit point to ripple trim; later clips move to keep the gap closed.", - state::Tool::Rolling => "Drag an edit point to roll it: the out of one clip and the in of the next move together.", - state::Tool::RateStretch => "Drag an edge to change the clip's speed so it fills the new duration.", - state::Tool::Remix => "Drag the edge of a music clip to remix it to the new duration at musically matching beats.", - state::Tool::Razor => "Click to split a clip. Shift-click to split all tracks.", - state::Tool::Slip => "Drag a clip to slip its source in/out without moving it.", - state::Tool::Slide => "Drag a clip to slide it between its neighbours.", - state::Tool::Hand => "Drag to scroll the timeline.", - state::Tool::Zoom => "Click to zoom in; Opt-click to zoom out.", + state::Tool::Selection => tl!("Click to select, or click in empty space and drag to marquee select. Use Shift, Opt, and Cmd for other options."), + state::Tool::TrackSelectForward => tl!("Click to select all clips to the right in all tracks. Shift-click for a single track."), + state::Tool::TrackSelectBackward => tl!("Click to select all clips to the left in all tracks. Shift-click for a single track."), + state::Tool::Ripple => tl!("Drag an edit point to ripple trim; later clips move to keep the gap closed."), + state::Tool::Rolling => tl!("Drag an edit point to roll it: the out of one clip and the in of the next move together."), + state::Tool::RateStretch => tl!("Drag an edge to change the clip's speed so it fills the new duration."), + state::Tool::Remix => tl!("Drag the edge of a music clip to remix it to the new duration at musically matching beats."), + state::Tool::Razor => tl!("Click to split a clip. Shift-click to split all tracks."), + state::Tool::Slip => tl!("Drag a clip to slip its source in/out without moving it."), + state::Tool::Slide => tl!("Drag a clip to slide it between its neighbours."), + state::Tool::Hand => tl!("Drag to scroll the timeline."), + state::Tool::Zoom => tl!("Click to zoom in; Opt-click to zoom out."), _ => "", } .to_string() @@ -1349,26 +1388,31 @@ impl FilmcraftApp { fn error_window(&mut self, ctx: &egui::Context) { let Some(msg) = self.ui_error.clone() else { return }; let mut close = false; - egui::Window::new("FilmCraft hit an error").collapsible(false).resizable(false).anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { - ui.set_max_width(460.0); - ui.label("Something went wrong while drawing the window. Your project is still open; save it to be safe."); - ui.add_space(6.0); - ui.label(egui::RichText::new(&msg).monospace().small()); - if let Some(p) = crash::log_path() { - ui.label(egui::RichText::new(format!("Details: {}", p.display())).small()); - } - ui.add_space(8.0); - ui.horizontal(|ui| { - let s = ui.button("Save Project"); - self.auto.add("error.save", s.rect, "Save Project"); - if s.clicked() { - let _ = crate::menus::invoke(self, ctx, "file.save", serde_json::json!({})); + egui::Window::new(tl!("FilmCraft hit an error")) + .id(egui::Id::new("FilmCraft hit an error")) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.set_max_width(460.0); + ui.label(tl!("Something went wrong while drawing the window. Your project is still open; save it to be safe.")); + ui.add_space(6.0); + ui.label(egui::RichText::new(&msg).monospace().small()); + if let Some(p) = crash::log_path() { + ui.label(egui::RichText::new(tlf!("Details: {path}", path = p.display())).small()); } - let d = ui.button("Continue"); - self.auto.add("error.dismiss", d.rect, "Continue"); - close |= d.clicked(); + ui.add_space(8.0); + ui.horizontal(|ui| { + let s = ui.button(tl!("Save Project")); + self.auto.add("error.save", s.rect, "Save Project"); + if s.clicked() { + let _ = crate::menus::invoke(self, ctx, "file.save", serde_json::json!({})); + } + let d = ui.button(tl!("Continue")); + self.auto.add("error.dismiss", d.rect, "Continue"); + close |= d.clicked(); + }); }); - }); if close { self.ui_error = None; } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 620aaea4..df475907 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -155,11 +155,19 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: Val if id == "app.language.japanese" && !crate::i18n::install_japanese_font(ctx) { return Err("no Japanese font is installed on this system (for example Noto Sans CJK JP); the interface stays in English".into()); } - app.ui.language = match id { + let language = match id { "app.language.japanese" => crate::i18n::Language::Ja, "app.language.spanish" => crate::i18n::Language::Es, _ => crate::i18n::Language::En, }; + // The preference is updated in memory before it is written, so a failed write (read-only + // or full disk) still switches the interface; it only can't be remembered for next time. + let saved = app.session.execute("prefs.set", json!({"key": "general.interfaceLanguage", "value": language.code()})); + app.ui.language = language; + crate::i18n::set_current(language); + if let Err(e) = saved { + app.ui.status = tlf!("The language changed but could not be saved: {e}", e); + } let items = menu_items(app); if let Some(hook) = app.hooks.shortcuts_changed.as_mut() { hook(&items); @@ -295,7 +303,7 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: Val id if crate::links::url_for(id).is_some() => { let url = crate::links::url_for(id).unwrap_or_default(); crate::links::open(ctx, url); - app.ui.status = format!("Opened {url}"); + app.ui.status = tlf!("Opened {url}", url); return Ok(json!({"url": url})); } "help.shortcuts" | "app.keyboardShortcuts" => { @@ -567,7 +575,7 @@ pub fn menu_bar(app: &mut FilmcraftApp, ui: &mut egui::Ui) { ui.menu_button(app.ui.language.tr(top), |ui| { ui.set_min_width(260.0); if mine.is_empty() { - ui.add_enabled(false, egui::Button::new("(empty)")); + ui.add_enabled(false, egui::Button::new(tl!("(empty)"))); } menu_level(ui, &mine, 1, &mut clicked); }); diff --git a/crates/ui-egui/src/panels/audio_fx_editor.rs b/crates/ui-egui/src/panels/audio_fx_editor.rs index b5a7c705..fbbde839 100644 --- a/crates/ui-egui/src/panels/audio_fx_editor.rs +++ b/crates/ui-egui/src/panels/audio_fx_editor.rs @@ -172,8 +172,8 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { close.push(target); continue; } - let name = inst.def().map(|d| d.name).unwrap_or("Effect"); - let kind = if matches!(target, FxTarget::Clip { .. }) { "Clip Fx Editor" } else { "Track Fx Editor" }; + let name = inst.def().map_or(tl!("Effect"), |d| crate::i18n::t(d.name)); + let kind = if matches!(target, FxTarget::Clip { .. }) { tl!("Clip Fx Editor") } else { tl!("Track Fx Editor") }; let title = format!("{kind} - {name}: {place}"); let k = key(&target); let drafts_id = egui::Id::new(("fx-editor-drafts", &k)); @@ -191,7 +191,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { } ui.separator(); ui.horizontal(|ui| { - let r = ui.button("Close"); + let r = ui.button(tl!("Close")); ed.auto(format!("fxEditor.{}.close", ed.fx), r.rect, "Close"); if r.clicked() { closed = true; @@ -349,7 +349,7 @@ fn parametric(ui: &mut egui::Ui, ed: &mut Ed) { ed.toggle(ui, &format!("{pre}_on"), label); } }); - ed.slider(ui, "master_gain", "Master Gain", false); + ed.slider(ui, "master_gain", tl!("Master Gain"), false); } // --------------------------------------------------------------------------- graphic EQ @@ -401,8 +401,8 @@ fn graphic(ui: &mut egui::Ui, ed: &mut Ed) { } } ui.horizontal(|ui| { - ed.slider(ui, "gain", "Master Gain", false); - let r = ui.button("Reset"); + ed.slider(ui, "gain", tl!("Master Gain"), false); + let r = ui.button(tl!("Reset")); ed.auto(format!("fxEditor.{}.reset", ed.fx), r.rect, "Reset"); if r.clicked() { for i in 0..n { @@ -437,8 +437,8 @@ fn transfer_plot(ui: &mut egui::Ui, ed: &mut Ed, size: f32, band: usize, auto: S .collect(); p.add(egui::Shape::line(pts, Stroke::new(1.5, Color32::from_rgb(0xff, 0xb3, 0x47)))); } - p.text(r.left_top() + vec2(3.0, 2.0), Align2::LEFT_TOP, "out", Tokens::ui(8.5), ed.t.text_faint); - p.text(r.right_bottom() - vec2(3.0, 2.0), Align2::RIGHT_BOTTOM, "in", Tokens::ui(8.5), ed.t.text_faint); + p.text(r.left_top() + vec2(3.0, 2.0), Align2::LEFT_TOP, tl!("out"), Tokens::ui(8.5), ed.t.text_faint); + p.text(r.right_bottom() - vec2(3.0, 2.0), Align2::RIGHT_BOTTOM, tl!("in"), Tokens::ui(8.5), ed.t.text_faint); ed.auto(auto, r, "Transfer curve"); } @@ -455,7 +455,7 @@ fn multiband(ui: &mut egui::Ui, ed: &mut Ed) { for b in 0..4 { let br = Rect::from_min_max(pos2(edges[b], r.min.y), pos2(edges[b + 1], r.max.y)); ui.painter().rect_filled(br.shrink2(vec2(0.0, 8.0)), 0.0, cols[b].gamma_multiply(0.35)); - ui.painter().text(br.center(), Align2::CENTER_CENTER, format!("Band {}", b + 1), Tokens::ui(10.0), ed.t.text); + ui.painter().text(br.center(), Align2::CENTER_CENTER, tlf!("Band {n}", n = b + 1), Tokens::ui(10.0), ed.t.text); } for (k, pid) in ["xo1", "xo2", "xo3"].iter().enumerate() { let x = edges[k + 1]; @@ -480,29 +480,29 @@ fn multiband(ui: &mut egui::Ui, ed: &mut Ed) { for b in 1..=4 { ui.vertical(|ui| { ui.set_width(150.0); - ui.label(egui::RichText::new(format!("Band {b}")).strong()); + ui.label(egui::RichText::new(tlf!("Band {n}", n = b)).strong()); transfer_plot(ui, ed, 110.0, b - 1, format!("fxEditor.{}.curve.{b}", ed.fx)); ui.horizontal(|ui| { - ed.toggle(ui, &format!("b{b}_solo"), "Solo"); - ed.toggle(ui, &format!("b{b}_bypass"), "Bypass"); + ed.toggle(ui, &format!("b{b}_solo"), tl!("Solo")); + ed.toggle(ui, &format!("b{b}_bypass"), tl!("Bypass")); }); - ed.slider(ui, &format!("b{b}_threshold"), "Thr", false); - ed.slider(ui, &format!("b{b}_ratio"), "Ratio", false); - ed.slider(ui, &format!("b{b}_attack"), "Att", true); - ed.slider(ui, &format!("b{b}_release"), "Rel", true); - ed.slider(ui, &format!("b{b}_gain"), "Gain", false); + ed.slider(ui, &format!("b{b}_threshold"), tl!("Thr"), false); + ed.slider(ui, &format!("b{b}_ratio"), tl!("Ratio"), false); + ed.slider(ui, &format!("b{b}_attack"), tl!("Att"), true); + ed.slider(ui, &format!("b{b}_release"), tl!("Rel"), true); + ed.slider(ui, &format!("b{b}_gain"), tl!("Gain"), false); }); } }); ui.separator(); ui.horizontal(|ui| { - ed.slider(ui, "output", "Output Gain", false); - ed.toggle(ui, "lim_on", "Limiter"); - ed.toggle(ui, "link", "Link Channels"); + ed.slider(ui, "output", tl!("Output Gain"), false); + ed.toggle(ui, "lim_on", tl!("Limiter")); + ed.toggle(ui, "link", tl!("Link Channels")); }); ui.horizontal(|ui| { - ed.slider(ui, "lim_threshold", "Limiter Threshold", false); - ed.slider(ui, "lim_release", "Limiter Release", true); + ed.slider(ui, "lim_threshold", tl!("Limiter Threshold"), false); + ed.slider(ui, "lim_release", tl!("Limiter Release"), true); }); } @@ -513,34 +513,34 @@ fn dynamics(ui: &mut egui::Ui, ed: &mut Ed) { transfer_plot(ui, ed, 220.0, 0, format!("fxEditor.{}.curve", ed.fx)); ui.vertical(|ui| { ui.set_width(330.0); - ed.toggle(ui, "gate_on", "Auto Gate"); - ed.slider(ui, "gate_threshold", "Threshold", false); - ed.slider(ui, "gate_attack", "Attack", true); - ed.slider(ui, "gate_release", "Release", true); - ed.slider(ui, "gate_hold", "Hold", false); + ed.toggle(ui, "gate_on", tl!("Auto Gate")); + ed.slider(ui, "gate_threshold", tl!("Threshold"), false); + ed.slider(ui, "gate_attack", tl!("Attack"), true); + ed.slider(ui, "gate_release", tl!("Release"), true); + ed.slider(ui, "gate_hold", tl!("Hold"), false); ui.separator(); - ed.toggle(ui, "comp_on", "Compressor"); - ed.slider(ui, "comp_threshold", "Threshold", false); - ed.slider(ui, "comp_ratio", "Ratio", false); - ed.slider(ui, "comp_attack", "Attack", true); - ed.slider(ui, "comp_release", "Release", true); + ed.toggle(ui, "comp_on", tl!("Compressor")); + ed.slider(ui, "comp_threshold", tl!("Threshold"), false); + ed.slider(ui, "comp_ratio", tl!("Ratio"), false); + ed.slider(ui, "comp_attack", tl!("Attack"), true); + ed.slider(ui, "comp_release", tl!("Release"), true); ui.horizontal(|ui| { - ed.toggle(ui, "comp_auto", "Auto Makeup"); + ed.toggle(ui, "comp_auto", tl!("Auto Makeup")); }); - ed.slider(ui, "comp_makeup", "Makeup", false); + ed.slider(ui, "comp_makeup", tl!("Makeup"), false); }); ui.vertical(|ui| { ui.set_width(330.0); - ed.toggle(ui, "exp_on", "Expander"); - ed.slider(ui, "exp_threshold", "Threshold", false); - ed.slider(ui, "exp_ratio", "Ratio", false); + ed.toggle(ui, "exp_on", tl!("Expander")); + ed.slider(ui, "exp_threshold", tl!("Threshold"), false); + ed.slider(ui, "exp_ratio", tl!("Ratio"), false); ui.separator(); - ed.toggle(ui, "lim_on", "Limiter"); - ed.slider(ui, "lim_threshold", "Threshold", false); - ed.slider(ui, "lim_release", "Release", true); - ed.toggle(ui, "soft_clip", "Soft Clip"); + ed.toggle(ui, "lim_on", tl!("Limiter")); + ed.slider(ui, "lim_threshold", tl!("Threshold"), false); + ed.slider(ui, "lim_release", tl!("Release"), true); + ed.toggle(ui, "soft_clip", tl!("Soft Clip")); ui.separator(); - ed.slider(ui, "output", "Output Gain", false); + ed.slider(ui, "output", tl!("Output Gain"), false); }); }); } diff --git a/crates/ui-egui/src/panels/clip_dialogs.rs b/crates/ui-egui/src/panels/clip_dialogs.rs index cbdb5e1d..f9221b42 100644 --- a/crates/ui-egui/src/panels/clip_dialogs.rs +++ b/crates/ui-egui/src/panels/clip_dialogs.rs @@ -41,26 +41,39 @@ fn push(elems: &mut Elems, id: impl Into, r: &egui::Response, label: imp /// Dialog title and automation prefix of a command's dialog. fn meta(command: &str) -> Option<(&'static str, &'static str)> { Some(match command { - "edit.pasteAttributes" => ("Paste Attributes", "pasteAttributes"), - "edit.removeAttributes" => ("Remove Attributes", "removeAttributes"), - "file.newOfflineFile" => ("New Offline File", "offlineFile"), - "clip.nest" => ("Nested Sequence Name", "nest"), - "clip.makeSubclip" => ("Make Subclip", "makeSubclip"), - "clip.editSubclip" => ("Edit Subclip", "editSubclip"), - "clip.audioChannels" => ("Modify Clip: Audio Channels", "audioChannels"), - "clip.modifyTimecode" => ("Modify Clip: Timecode", "timecode"), - "clip.frameHoldOptions" => ("Frame Hold Options", "frameHold"), - "clip.fieldOptions" => ("Field Options", "fieldOptions"), - "file.closeProject" => ("Save Project", "closeProject"), + "edit.pasteAttributes" => (tl!("Paste Attributes"), "pasteAttributes"), + "edit.removeAttributes" => (tl!("Remove Attributes"), "removeAttributes"), + "file.newOfflineFile" => (tl!("New Offline File"), "offlineFile"), + "clip.nest" => (tl!("Nested Sequence Name"), "nest"), + "clip.makeSubclip" => (tl!("Make Subclip"), "makeSubclip"), + "clip.editSubclip" => (tl!("Edit Subclip"), "editSubclip"), + "clip.audioChannels" => (tl!("Modify Clip: Audio Channels"), "audioChannels"), + "clip.modifyTimecode" => (tl!("Modify Clip: Timecode"), "timecode"), + "clip.frameHoldOptions" => (tl!("Frame Hold Options"), "frameHold"), + "clip.fieldOptions" => (tl!("Field Options"), "fieldOptions"), + "file.closeProject" => (tl!("Save Project"), "closeProject"), _ => return None, }) } -const INTRINSIC_VIDEO: [(&str, &str); 3] = [("motion", "Motion"), ("opacity", "Opacity"), ("timeRemapping", "Time Remapping")]; -const INTRINSIC_AUDIO: [(&str, &str); 3] = [("volume", "Volume"), ("channelVolume", "Channel Volume"), ("panner", "Panner")]; -const FORMATS: [(&str, &str); 4] = [("mono", "Mono"), ("stereo", "Stereo"), ("5.1", "5.1"), ("adaptive", "Adaptive")]; -const HOLD_ON: [(&str, &str); 5] = - [("in", "In Point"), ("out", "Out Point"), ("playhead", "Playhead"), ("sourceTimecode", "Source Timecode"), ("sequenceTime", "Sequence Time")]; +fn intrinsic_video() -> [(&'static str, &'static str); 3] { + [("motion", tl!("Motion")), ("opacity", tl!("Opacity")), ("timeRemapping", tl!("Time Remapping"))] +} +fn intrinsic_audio() -> [(&'static str, &'static str); 3] { + [("volume", tl!("Volume")), ("channelVolume", tl!("Channel Volume")), ("panner", tl!("Panner"))] +} +fn formats() -> [(&'static str, &'static str); 4] { + [("mono", tl!("Mono")), ("stereo", tl!("Stereo")), ("5.1", "5.1"), ("adaptive", tl!("Adaptive"))] +} +fn hold_on() -> [(&'static str, &'static str); 5] { + [ + ("in", tl!("In Point")), + ("out", tl!("Out Point")), + ("playhead", tl!("Playhead")), + ("sourceTimecode", tl!("Source Timecode")), + ("sequenceTime", tl!("Sequence Time")), + ] +} /// Menu / shortcut entry points without params open a dialog. Returns None for other ids. pub fn route(app: &mut FilmcraftApp, id: &str, params: &Value) -> Option> { @@ -262,141 +275,168 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut action: Option<&'static str> = None; let project_name = app.session.project.name.clone(); let can_save = app.session.path.is_some(); - egui::Window::new(title).collapsible(false).resizable(false).default_width(360.0).anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { - let p = &mut d.params; - match d.command.as_str() { - "edit.pasteAttributes" | "edit.removeAttributes" => { - let (v, a) = (d.info["video"].as_bool().unwrap_or(true), d.info["audio"].as_bool().unwrap_or(true)); - ui.label(RichText::new("Video Attributes").strong()); - for (k, l) in INTRINSIC_VIDEO { - check(ui, &mut elems, pre, p, k, l, v); - } - ui.label(RichText::new("Audio Attributes").strong()); - for (k, l) in INTRINSIC_AUDIO { - check(ui, &mut elems, pre, p, k, l, a); - } - let effects: Vec<(String, String)> = d.info["effects"] - .as_array() - .map(|x| x.iter().filter_map(|e| Some((e[0].as_str()?.to_string(), e[1].as_str()?.to_string()))).collect()) - .unwrap_or_default(); - ui.label(RichText::new("Effects").strong()); - if effects.is_empty() { - ui.label(RichText::new("No effects").weak()); - } - for (id, name) in &effects { - let mut on = p["effects"].as_array().is_some_and(|x| x.iter().any(|e| e.as_str() == Some(id))); - let r = ui.checkbox(&mut on, name); - push(&mut elems, format!("{pre}.effect.{id}"), &r, name.as_str()); - if r.changed() { - let mut list: Vec = p["effects"].as_array().cloned().unwrap_or_default(); - list.retain(|e| e.as_str() != Some(id)); - if on { - list.push(json!(id)); - } - p["effects"] = Value::Array(list); + egui::Window::new(title) + .id(egui::Id::new(("clip-dialog", pre))) + .collapsible(false) + .resizable(false) + .default_width(360.0) + .anchor(Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + let p = &mut d.params; + match d.command.as_str() { + "edit.pasteAttributes" | "edit.removeAttributes" => { + let (v, a) = (d.info["video"].as_bool().unwrap_or(true), d.info["audio"].as_bool().unwrap_or(true)); + ui.label(RichText::new(tl!("Video Attributes")).strong()); + for (k, l) in intrinsic_video() { + check(ui, &mut elems, pre, p, k, l, v); } - } - if d.command == "edit.pasteAttributes" { - ui.separator(); - check(ui, &mut elems, pre, p, "scaleTimes", "Scale Attribute Times", true); - } - } - "file.newOfflineFile" => { - text(ui, &mut elems, pre, p, "fileName", "File Name:", 220.0); - text(ui, &mut elems, pre, p, "name", "Clip Name:", 220.0); - text(ui, &mut elems, pre, p, "tapeName", "Tape Name:", 220.0); - text(ui, &mut elems, pre, p, "description", "Description:", 220.0); - ui.horizontal(|ui| { - ui.label("Contains:"); - check(ui, &mut elems, pre, p, "video", "Video", true); - check(ui, &mut elems, pre, p, "audio", "Audio", true); - }); - let has_v = p["video"].as_bool().unwrap_or(true); - ui.add_enabled_ui(has_v, |ui| { - number(ui, &mut elems, pre, p, "width", "Width:", 16.0..=16384.0, " px"); - number(ui, &mut elems, pre, p, "height", "Height:", 16.0..=16384.0, " px"); - number(ui, &mut elems, pre, p, "fps", "Frame Rate:", 1.0..=240.0, " fps"); - }); - text(ui, &mut elems, pre, p, "timecode", "Media Start:", 120.0); - number(ui, &mut elems, pre, p, "seconds", "Duration:", 0.05..=86_400.0, " s"); - } - "clip.nest" => { - // the name is ready to type over, and Enter accepts it - let mut v = p["name"].as_str().unwrap_or_default().to_string(); - ui.horizontal(|ui| { - ui.label("Name:"); - let id = egui::Id::new("nest-name"); - let r = ui.add(egui::TextEdit::singleline(&mut v).desired_width(240.0).id(id)); - push(&mut elems, format!("{pre}.name"), &r, "Name:"); - if r.changed() { - p["name"] = json!(v); + ui.label(RichText::new(tl!("Audio Attributes")).strong()); + for (k, l) in intrinsic_audio() { + check(ui, &mut elems, pre, p, k, l, a); } - if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { - action = Some("ok"); + let effects: Vec<(String, String)> = d.info["effects"] + .as_array() + .map(|x| x.iter().filter_map(|e| Some((e[0].as_str()?.to_string(), e[1].as_str()?.to_string()))).collect()) + .unwrap_or_default(); + ui.label(RichText::new(tl!("Effects")).strong()); + if effects.is_empty() { + ui.label(RichText::new(tl!("No effects")).weak()); } - let first = egui::Id::new("nest-name-focused"); - if !ui.data(|d| d.get_temp::(first).unwrap_or(false)) { - ui.data_mut(|d| d.insert_temp(first, true)); - r.request_focus(); - } - }); - } - "clip.makeSubclip" | "clip.editSubclip" => { - let fps = d.info["fps"].as_f64().unwrap_or(24.0); - if d.command == "clip.makeSubclip" { - text(ui, &mut elems, pre, p, "name", "Name:", 240.0); - } else { - ui.label(format!("Subclip: {}", d.info["name"].as_str().unwrap_or_default())); - } - frames(ui, &mut elems, pre, p, "startFrame", "Start:", fps); - frames(ui, &mut elems, pre, p, "endFrame", "End:", fps); - check(ui, &mut elems, pre, p, "restrictTrims", "Restrict Trims To Subclip Boundaries", true); - if d.command == "clip.editSubclip" { - check(ui, &mut elems, pre, p, "convertToMaster", "Convert to Master Clip", true); - } - } - "clip.audioChannels" => { - let n = d.info["channels"].as_u64().unwrap_or(2).max(1) as u16; - if d.info["items"].as_bool().unwrap_or(false) { - ui.horizontal(|ui| { - ui.label("Clip Channel Format:"); - for (k, l) in FORMATS { - let r = ui.radio(p["format"].as_str() == Some(k), l); - push(&mut elems, format!("{pre}.format.{k}"), &r, l); - if r.clicked() { - p["format"] = json!(k); - p["clips"] = json!(AudioChannelMap::for_format(format_of(k), n).clips); - } - } - }); - let mut clips: Vec> = serde_json::from_value(p["clips"].clone()).unwrap_or_default(); - ui.horizontal(|ui| { - ui.label("Number of Audio Clips:"); - let mut count = clips.len() as u32; - let r = ui.add(egui::DragValue::new(&mut count).range(1..=n as u32)); - push(&mut elems, format!("{pre}.count"), &r, "Number of Audio Clips"); + for (id, name) in &effects { + let mut on = p["effects"].as_array().is_some_and(|x| x.iter().any(|e| e.as_str() == Some(id))); + let r = ui.checkbox(&mut on, crate::i18n::t(name)); + push(&mut elems, format!("{pre}.effect.{id}"), &r, name.as_str()); if r.changed() { - let count = count.max(1) as usize; - while clips.len() < count { - let c = (clips.len() as u16).min(n - 1); - clips.push(vec![c]); + let mut list: Vec = p["effects"].as_array().cloned().unwrap_or_default(); + list.retain(|e| e.as_str() != Some(id)); + if on { + list.push(json!(id)); } - clips.truncate(count); + p["effects"] = Value::Array(list); + } + } + if d.command == "edit.pasteAttributes" { + ui.separator(); + check(ui, &mut elems, pre, p, "scaleTimes", tl!("Scale Attribute Times"), true); + } + } + "file.newOfflineFile" => { + text(ui, &mut elems, pre, p, "fileName", tl!("File Name:"), 220.0); + text(ui, &mut elems, pre, p, "name", tl!("Clip Name:"), 220.0); + text(ui, &mut elems, pre, p, "tapeName", tl!("Tape Name:"), 220.0); + text(ui, &mut elems, pre, p, "description", tl!("Description:"), 220.0); + ui.horizontal(|ui| { + ui.label(tl!("Contains:")); + check(ui, &mut elems, pre, p, "video", tl!("Video"), true); + check(ui, &mut elems, pre, p, "audio", tl!("Audio"), true); + }); + let has_v = p["video"].as_bool().unwrap_or(true); + ui.add_enabled_ui(has_v, |ui| { + number(ui, &mut elems, pre, p, "width", tl!("Width:"), 16.0..=16384.0, " px"); + number(ui, &mut elems, pre, p, "height", tl!("Height:"), 16.0..=16384.0, " px"); + number(ui, &mut elems, pre, p, "fps", tl!("Frame Rate:"), 1.0..=240.0, " fps"); + }); + text(ui, &mut elems, pre, p, "timecode", tl!("Media Start:"), 120.0); + number(ui, &mut elems, pre, p, "seconds", tl!("Duration:"), 0.05..=86_400.0, " s"); + } + "clip.nest" => { + // the name is ready to type over, and Enter accepts it + let mut v = p["name"].as_str().unwrap_or_default().to_string(); + ui.horizontal(|ui| { + ui.label(tl!("Name:")); + let id = egui::Id::new("nest-name"); + let r = ui.add(egui::TextEdit::singleline(&mut v).desired_width(240.0).id(id)); + push(&mut elems, format!("{pre}.name"), &r, "Name:"); + if r.changed() { + p["name"] = json!(v); + } + if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + action = Some("ok"); + } + let first = egui::Id::new("nest-name-focused"); + if !ui.data(|d| d.get_temp::(first).unwrap_or(false)) { + ui.data_mut(|d| d.insert_temp(first, true)); + r.request_focus(); } }); - ui.label(RichText::new("Media Source Channels").strong()); - egui::Grid::new("audio-channel-matrix").striped(true).show(ui, |ui| { - ui.label(""); - for c in 0..n { - ui.label(format!("Ch. {}", c + 1)); - } - ui.end_row(); - for (ci, chans) in clips.iter_mut().enumerate() { - ui.label(format!("Clip {}", ci + 1)); + } + "clip.makeSubclip" | "clip.editSubclip" => { + let fps = d.info["fps"].as_f64().unwrap_or(24.0); + if d.command == "clip.makeSubclip" { + text(ui, &mut elems, pre, p, "name", tl!("Name:"), 240.0); + } else { + ui.label(tlf!("Subclip: {name}", name = d.info["name"].as_str().unwrap_or_default())); + } + frames(ui, &mut elems, pre, p, "startFrame", tl!("Start:"), fps); + frames(ui, &mut elems, pre, p, "endFrame", tl!("End:"), fps); + check(ui, &mut elems, pre, p, "restrictTrims", tl!("Restrict Trims To Subclip Boundaries"), true); + if d.command == "clip.editSubclip" { + check(ui, &mut elems, pre, p, "convertToMaster", tl!("Convert to Master Clip"), true); + } + } + "clip.audioChannels" => { + let n = d.info["channels"].as_u64().unwrap_or(2).max(1) as u16; + if d.info["items"].as_bool().unwrap_or(false) { + ui.horizontal(|ui| { + ui.label(tl!("Clip Channel Format:")); + for (k, l) in formats() { + let r = ui.radio(p["format"].as_str() == Some(k), l); + push(&mut elems, format!("{pre}.format.{k}"), &r, l); + if r.clicked() { + p["format"] = json!(k); + p["clips"] = json!(AudioChannelMap::for_format(format_of(k), n).clips); + } + } + }); + let mut clips: Vec> = serde_json::from_value(p["clips"].clone()).unwrap_or_default(); + ui.horizontal(|ui| { + ui.label(tl!("Number of Audio Clips:")); + let mut count = clips.len() as u32; + let r = ui.add(egui::DragValue::new(&mut count).range(1..=n as u32)); + push(&mut elems, format!("{pre}.count"), &r, "Number of Audio Clips"); + if r.changed() { + let count = count.max(1) as usize; + while clips.len() < count { + let c = (clips.len() as u16).min(n - 1); + clips.push(vec![c]); + } + clips.truncate(count); + } + }); + ui.label(RichText::new(tl!("Media Source Channels")).strong()); + egui::Grid::new("audio-channel-matrix").striped(true).show(ui, |ui| { + ui.label(""); for c in 0..n { + ui.label(tlf!("Ch. {n}", n = c + 1)); + } + ui.end_row(); + for (ci, chans) in clips.iter_mut().enumerate() { + ui.label(tlf!("Clip {n}", n = ci + 1)); + for c in 0..n { + let mut on = chans.contains(&c); + let r = ui.checkbox(&mut on, ""); + push(&mut elems, format!("{pre}.clip.{ci}.ch.{c}"), &r, format!("Clip {} Ch. {}", ci + 1, c + 1)); + if r.changed() { + if on { + chans.push(c); + chans.sort_unstable(); + } else { + chans.retain(|x| *x != c); + } + } + } + ui.end_row(); + } + }); + p["clips"] = json!(clips); + } else { + ui.label(tl!("Source channels the selected audio clips play:")); + let mut chans: Vec = serde_json::from_value(p["channels"].clone()).unwrap_or_default(); + ui.horizontal(|ui| { + for c in 0..n.max(2) { let mut on = chans.contains(&c); - let r = ui.checkbox(&mut on, ""); - push(&mut elems, format!("{pre}.clip.{ci}.ch.{c}"), &r, format!("Clip {} Ch. {}", ci + 1, c + 1)); + let r = ui.checkbox(&mut on, tlf!("Ch. {n}", n = c + 1)); + push(&mut elems, format!("{pre}.clip.0.ch.{c}"), &r, format!("Ch. {}", c + 1)); if r.changed() { if on { chans.push(c); @@ -406,105 +446,84 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { } } } - ui.end_row(); - } - }); - p["clips"] = json!(clips); - } else { - ui.label("Source channels the selected audio clips play:"); - let mut chans: Vec = serde_json::from_value(p["channels"].clone()).unwrap_or_default(); - ui.horizontal(|ui| { - for c in 0..n.max(2) { - let mut on = chans.contains(&c); - let r = ui.checkbox(&mut on, format!("Ch. {}", c + 1)); - push(&mut elems, format!("{pre}.clip.0.ch.{c}"), &r, format!("Ch. {}", c + 1)); - if r.changed() { - if on { - chans.push(c); - chans.sort_unstable(); - } else { - chans.retain(|x| *x != c); - } + }); + p["channels"] = json!(chans); + } + } + "clip.modifyTimecode" => { + text(ui, &mut elems, pre, p, "timecode", tl!("Set Timecode:"), 120.0); + text(ui, &mut elems, pre, p, "tapeName", tl!("Tape Name:"), 200.0); + check(ui, &mut elems, pre, p, "reset", tl!("Use the file's timecode"), true); + } + "clip.frameHoldOptions" => { + check(ui, &mut elems, pre, p, "enabled", tl!("Hold On"), true); + let on = p["enabled"].as_bool().unwrap_or(true); + ui.add_enabled_ui(on, |ui| { + for (k, l) in hold_on() { + let r = ui.radio(p["holdOn"].as_str() == Some(k), l); + push(&mut elems, format!("{pre}.holdOn.{k}"), &r, l); + if r.clicked() { + p["holdOn"] = json!(k); } } + if matches!(p["holdOn"].as_str(), Some("sourceTimecode" | "sequenceTime")) { + text(ui, &mut elems, pre, p, "timecode", tl!("Timecode:"), 120.0); + } + check(ui, &mut elems, pre, p, "holdFilters", tl!("Hold Filters"), true); }); - p["channels"] = json!(chans); } - } - "clip.modifyTimecode" => { - text(ui, &mut elems, pre, p, "timecode", "Set Timecode:", 120.0); - text(ui, &mut elems, pre, p, "tapeName", "Tape Name:", 200.0); - check(ui, &mut elems, pre, p, "reset", "Use the file's timecode", true); - } - "clip.frameHoldOptions" => { - check(ui, &mut elems, pre, p, "enabled", "Hold On", true); - let on = p["enabled"].as_bool().unwrap_or(true); - ui.add_enabled_ui(on, |ui| { - for (k, l) in HOLD_ON { - let r = ui.radio(p["holdOn"].as_str() == Some(k), l); - push(&mut elems, format!("{pre}.holdOn.{k}"), &r, l); + "clip.fieldOptions" => { + check(ui, &mut elems, pre, p, "reverseFieldDominance", tl!("Reverse Field Dominance"), true); + ui.label(RichText::new(tl!("Processing Options")).strong()); + for (k, l) in [("none", tl!("None")), ("alwaysDeinterlace", tl!("Always Deinterlace")), ("flickerRemoval", tl!("Flicker Removal"))] { + let r = ui.radio(p["processing"].as_str() == Some(k), l); + push(&mut elems, format!("{pre}.processing.{k}"), &r, l); if r.clicked() { - p["holdOn"] = json!(k); + p["processing"] = json!(k); } } - if matches!(p["holdOn"].as_str(), Some("sourceTimecode" | "sequenceTime")) { - text(ui, &mut elems, pre, p, "timecode", "Timecode:", 120.0); - } - check(ui, &mut elems, pre, p, "holdFilters", "Hold Filters", true); - }); - } - "clip.fieldOptions" => { - check(ui, &mut elems, pre, p, "reverseFieldDominance", "Reverse Field Dominance", true); - ui.label(RichText::new("Processing Options").strong()); - for (k, l) in [("none", "None"), ("alwaysDeinterlace", "Always Deinterlace"), ("flickerRemoval", "Flicker Removal")] { - let r = ui.radio(p["processing"].as_str() == Some(k), l); - push(&mut elems, format!("{pre}.processing.{k}"), &r, l); - if r.clicked() { - p["processing"] = json!(k); - } } + "file.closeProject" => { + ui.label(tlf!("Save changes to “{project_name}” before closing?", project_name)); + ui.add_space(8.0); + ui.horizontal(|ui| { + let r = ui.button(tl!("Cancel")); + push(&mut elems, format!("{pre}.cancel"), &r, "Cancel"); + if r.clicked() { + action = Some("cancel"); + } + let r = ui.button(tl!("Don't Save")); + push(&mut elems, format!("{pre}.dontSave"), &r, "Don't Save"); + if r.clicked() { + action = Some("dontSave"); + } + let r = ui.add_enabled(can_save, egui::Button::new(tl!("Save"))); + push(&mut elems, format!("{pre}.save"), &r, "Save"); + if r.clicked() { + action = Some("save"); + } + }); + return; + } + _ => {} } - "file.closeProject" => { - ui.label(format!("Save changes to “{project_name}” before closing?")); - ui.add_space(8.0); - ui.horizontal(|ui| { - let r = ui.button("Cancel"); - push(&mut elems, format!("{pre}.cancel"), &r, "Cancel"); - if r.clicked() { - action = Some("cancel"); - } - let r = ui.button("Don't Save"); - push(&mut elems, format!("{pre}.dontSave"), &r, "Don't Save"); - if r.clicked() { - action = Some("dontSave"); - } - let r = ui.add_enabled(can_save, egui::Button::new("Save")); - push(&mut elems, format!("{pre}.save"), &r, "Save"); - if r.clicked() { - action = Some("save"); - } - }); - return; - } - _ => {} - } - if !d.error.is_empty() { - ui.colored_label(egui::Color32::from_rgb(0xe0, 0x60, 0x60), &d.error); - } - ui.add_space(6.0); - ui.horizontal(|ui| { - let r = ui.button("Cancel"); - push(&mut elems, format!("{pre}.cancel"), &r, "Cancel"); - if r.clicked() { - action = Some("cancel"); - } - let r = ui.button("OK"); - push(&mut elems, format!("{pre}.ok"), &r, "OK"); - if r.clicked() { - action = Some("ok"); + if !d.error.is_empty() { + ui.colored_label(egui::Color32::from_rgb(0xe0, 0x60, 0x60), &d.error); } + ui.add_space(6.0); + ui.horizontal(|ui| { + let r = ui.button(tl!("Cancel")); + push(&mut elems, format!("{pre}.cancel"), &r, "Cancel"); + if r.clicked() { + action = Some("cancel"); + } + let r = ui.button(tl!("OK")); + push(&mut elems, format!("{pre}.ok"), &r, "OK"); + if r.clicked() { + action = Some("ok"); + } + }); }); - }); for (id, r, l) in elems { app.auto.add(&id, r, &l); } diff --git a/crates/ui-egui/src/panels/color_dialogs.rs b/crates/ui-egui/src/panels/color_dialogs.rs index 671653a4..9ee1c809 100644 --- a/crates/ui-egui/src/panels/color_dialogs.rs +++ b/crates/ui-egui/src/panels/color_dialogs.rs @@ -48,9 +48,9 @@ fn buttons(app: &mut FilmcraftApp, ui: &mut egui::Ui) -> (bool, bool) { let mut out = (false, false); ui.add_space(6.0); ui.horizontal(|ui| { - let c = ui.button("Cancel"); + let c = ui.button(tl!("Cancel")); app.auto.add("colorDialog.cancel", c.rect, "Cancel"); - let o = ui.button("OK"); + let o = ui.button(tl!("OK")); app.auto.add("colorDialog.ok", o.rect, "OK"); out = (o.clicked(), c.clicked()); }); @@ -70,47 +70,62 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { .and_then(|v| v["detectedLabel"].as_str().map(str::to_string)) .unwrap_or_else(|| "—".into()); let n = items.len(); - egui::Window::new("Interpret Footage").open(&mut open).collapsible(false).resizable(false).default_width(380.0).show(ctx, |ui| { - ui.label(egui::RichText::new(format!("Color Management · {n} item{}", if n == 1 { "" } else { "s" })).strong()); - ui.label(format!("Media colour space (from file metadata): {detected}")); - ui.add_space(4.0); - ui.label("Override media colour space:"); - if radio(app, ui, "colorDialog.space.auto", color_space == "auto", "Use file metadata") { - *color_space = "auto".into(); - } - egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { - for cs in ColorSpace::ALL { - if radio(app, ui, &format!("colorDialog.space.{}", cs.id()), color_space == cs.id(), cs.label()) { - *color_space = cs.id().into(); - } + egui::Window::new(tl!("Interpret Footage")) + .id(egui::Id::new("Interpret Footage")) + .open(&mut open) + .collapsible(false) + .resizable(false) + .default_width(380.0) + .show(ctx, |ui| { + ui.label( + egui::RichText::new(if n == 1 { tlf!("Color Management · {n} item", n) } else { tlf!("Color Management · {n} items", n) }).strong(), + ); + ui.label(tlf!("Media colour space (from file metadata): {detected}", detected)); + ui.add_space(4.0); + ui.label(tl!("Override media colour space:")); + if radio(app, ui, "colorDialog.space.auto", color_space == "auto", tl!("Use file metadata")) { + *color_space = "auto".into(); } + egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { + for cs in ColorSpace::ALL { + if radio(app, ui, &format!("colorDialog.space.{}", cs.id()), color_space == cs.id(), cs.label()) { + *color_space = cs.id().into(); + } + } + }); + let (ok, cancel) = buttons(app, ui); + if ok { + apply = Some(("clip.interpretFootage", json!({"items": items, "colorSpace": color_space}))); + } + close = ok || cancel; }); - let (ok, cancel) = buttons(app, ui); - if ok { - apply = Some(("clip.interpretFootage", json!({"items": items, "colorSpace": color_space}))); - } - close = ok || cancel; - }); } ColorDialog::Sequence { working_space, wide_gamut, auto_tone_map } => { - egui::Window::new("Sequence Color Management").open(&mut open).collapsible(false).resizable(false).default_width(340.0).show(ctx, |ui| { - ui.label(egui::RichText::new("Working Color Space").strong()); - for w in WorkingSpace::ALL { - if radio(app, ui, &format!("colorDialog.working.{}", w.id()), working_space == w.id(), w.label()) { - *working_space = w.id().into(); + egui::Window::new(tl!("Sequence Color Management")) + .id(egui::Id::new("Sequence Color Management")) + .open(&mut open) + .collapsible(false) + .resizable(false) + .default_width(340.0) + .show(ctx, |ui| { + ui.label(egui::RichText::new(tl!("Working Color Space")).strong()); + for w in WorkingSpace::ALL { + if radio(app, ui, &format!("colorDialog.working.{}", w.id()), working_space == w.id(), w.label()) { + *working_space = w.id().into(); + } } - } - ui.add_space(4.0); - let r = ui.checkbox(wide_gamut, "Wide gamut color (composite in BT.2020)"); - app.auto.add("colorDialog.wideGamut", r.rect, "Wide gamut"); - let r = ui.checkbox(auto_tone_map, "Auto tone map media (HDR and log into SDR)"); - app.auto.add("colorDialog.autoToneMap", r.rect, "Auto tone map"); - let (ok, cancel) = buttons(app, ui); - if ok { - apply = Some(("sequence.colorSettings", json!({"workingSpace": working_space, "wideGamut": *wide_gamut, "autoToneMap": *auto_tone_map}))); - } - close = ok || cancel; - }); + ui.add_space(4.0); + let r = ui.checkbox(wide_gamut, tl!("Wide gamut color (composite in BT.2020)")); + app.auto.add("colorDialog.wideGamut", r.rect, "Wide gamut"); + let r = ui.checkbox(auto_tone_map, tl!("Auto tone map media (HDR and log into SDR)")); + app.auto.add("colorDialog.autoToneMap", r.rect, "Auto tone map"); + let (ok, cancel) = buttons(app, ui); + if ok { + apply = + Some(("sequence.colorSettings", json!({"workingSpace": working_space, "wideGamut": *wide_gamut, "autoToneMap": *auto_tone_map}))); + } + close = ok || cancel; + }); } } app.ui.color_dialog = if close || !open { None } else { Some(d) }; diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index f3390701..c979296d 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -30,7 +30,8 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut open = true; match d { Dialog::About => { - egui::Window::new("About FilmCraft") + egui::Window::new(tl!("About FilmCraft")) + .id(egui::Id::new("About FilmCraft")) .open(&mut open) .collapsible(false) .resizable(false) @@ -92,44 +93,49 @@ fn audio_gain(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { let mut keep = true; let mut apply = false; let mut elems: Vec<(String, egui::Rect, String)> = Vec::new(); - egui::Window::new("Audio Gain").collapsible(false).resizable(false).anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { - ui.add_space(6.0); - egui::Grid::new("audio-gain").num_columns(2).spacing([12.0, 10.0]).show(ui, |ui| { - let rows: [(&str, &str, &mut f64); 4] = [ - ("set", "Set Gain to:", &mut draft.set_db), - ("adjust", "Adjust Gain by:", &mut draft.adjust_db), - ("normalizeMax", "Normalize Max Peak to:", &mut draft.max_peak_db), - ("normalizeAll", "Normalize All Peaks to:", &mut draft.all_peaks_db), - ]; - for (id, label, v) in rows { - let r = ui.radio(draft.mode == id, label); - elems.push((format!("audioGain.{id}"), r.rect, label.to_string())); - if r.clicked() { - draft.mode = id.to_string(); + egui::Window::new(tl!("Audio Gain")) + .id(egui::Id::new("Audio Gain")) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.add_space(6.0); + egui::Grid::new("audio-gain").num_columns(2).spacing([12.0, 10.0]).show(ui, |ui| { + let rows: [(&str, &str, &mut f64); 4] = [ + ("set", tl!("Set Gain to:"), &mut draft.set_db), + ("adjust", tl!("Adjust Gain by:"), &mut draft.adjust_db), + ("normalizeMax", tl!("Normalize Max Peak to:"), &mut draft.max_peak_db), + ("normalizeAll", tl!("Normalize All Peaks to:"), &mut draft.all_peaks_db), + ]; + for (id, label, v) in rows { + let r = ui.radio(draft.mode == id, label); + elems.push((format!("audioGain.{id}"), r.rect, label.to_string())); + if r.clicked() { + draft.mode = id.to_string(); + } + let f = ui.add_enabled(draft.mode == id, egui::DragValue::new(v).speed(0.1).range(-96.0..=96.0).suffix(" dB")); + elems.push((format!("audioGain.{id}.value"), f.rect, format!("{v:.1} dB"))); + ui.end_row(); } - let f = ui.add_enabled(draft.mode == id, egui::DragValue::new(v).speed(0.1).range(-96.0..=96.0).suffix(" dB")); - elems.push((format!("audioGain.{id}.value"), f.rect, format!("{v:.1} dB"))); - ui.end_row(); - } + }); + ui.add_space(8.0); + let pk_text = tlf!("Peak Amplitude: {peak}", peak = peak.map(|p| format!("{p:.1} dB")).unwrap_or_else(|| "—".into())); + let pk = ui.label(&pk_text); + elems.push(("audioGain.peak".into(), pk.rect, pk_text)); + ui.add_space(8.0); + ui.horizontal(|ui| { + let c = ui.button(tl!("Cancel")); + elems.push(("audioGain.cancel".into(), c.rect, "Cancel".into())); + if c.clicked() { + keep = false; + } + let o = ui.add(egui::Button::new(egui::RichText::new(tl!("OK")).color(egui::Color32::WHITE)).fill(app.tokens.accent)); + elems.push(("audioGain.ok".into(), o.rect, "OK".into())); + if o.clicked() { + apply = true; + } + }); }); - ui.add_space(8.0); - let pk_text = format!("Peak Amplitude: {}", peak.map(|p| format!("{p:.1} dB")).unwrap_or_else(|| "—".into())); - let pk = ui.label(&pk_text); - elems.push(("audioGain.peak".into(), pk.rect, pk_text)); - ui.add_space(8.0); - ui.horizontal(|ui| { - let c = ui.button("Cancel"); - elems.push(("audioGain.cancel".into(), c.rect, "Cancel".into())); - if c.clicked() { - keep = false; - } - let o = ui.add(egui::Button::new(egui::RichText::new("OK").color(egui::Color32::WHITE)).fill(app.tokens.accent)); - elems.push(("audioGain.ok".into(), o.rect, "OK".into())); - if o.clicked() { - apply = true; - } - }); - }); for (id, r, l) in elems { app.auto.add(&id, r, &l); } @@ -171,88 +177,94 @@ fn add_tracks(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { let mut keep = true; let mut apply = false; let mut elems: Vec<(String, egui::Rect, String)> = Vec::new(); - const AUDIO_TYPES: [(&str, &str); 4] = [("standard", "Standard"), ("5.1", "5.1"), ("adaptive", "Adaptive"), ("mono", "Mono")]; - const SUBMIX_TYPES: [(&str, &str); 4] = [("stereo", "Stereo"), ("5.1", "5.1"), ("adaptive", "Adaptive"), ("mono", "Mono")]; - egui::Window::new("Add Tracks").collapsible(false).resizable(false).default_width(320.0).anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { - // everything spans the width the window has: no empty band beside the fields - ui.set_width(ui.available_width()); - // the label column is as wide as its widest label; the lists take the rest of the row, - // up to the group's right edge (as in Premiere's dialog) - let font = egui::TextStyle::Body.resolve(ui.style()); - let label_w = ["Amount", "Placement", "Track type"] - .iter() - .map(|l| ui.painter().layout_no_wrap(l.to_string(), font.clone(), egui::Color32::WHITE).size().x) - .fold(0.0, f32::max); - let row_h = ui.spacing().interact_size.y; - let label = |ui: &mut egui::Ui, text: &str| { - ui.allocate_ui_with_layout(egui::vec2(label_w, row_h), egui::Layout::right_to_left(egui::Align::Center), |ui| ui.label(text)); - }; - let groups: [(&str, &str, &mut u32, &mut usize, &[String], Option<(&mut String, &[(&str, &str); 4])>); 3] = [ - ("video", "Add video tracks", &mut d.video, &mut d.video_after, &vnames, None), - ("audio", "Add audio tracks", &mut d.audio, &mut d.audio_after, &anames, Some((&mut d.audio_type, &AUDIO_TYPES))), - ("submix", "Add audio submix tracks", &mut d.submix, &mut d.submix_after, &snames, Some((&mut d.submix_type, &SUBMIX_TYPES))), - ]; - for (kind, title, amount, after, tracks, track_type) in groups { - ui.add_space(4.0); - ui.label(egui::RichText::new(title).strong()); - ui.group(|ui| { - ui.set_width(ui.available_width()); - ui.horizontal(|ui| { - label(ui, "Amount"); - let r = ui.add(egui::DragValue::new(amount).range(0..=99).speed(0.1)); - elems.push((format!("addTracks.{kind}.amount"), r.rect, amount.to_string())); - }); - ui.horizontal(|ui| { - label(ui, "Placement"); - *after = (*after).min(tracks.len()); - let place = |n: usize| match n.checked_sub(1).and_then(|i| tracks.get(i)) { - Some(name) => format!("After {name}"), - None => "Before First Track".to_string(), - }; - let shown = place(*after); - let list_w = ui.available_width(); - // nowhere to choose from while the sequence has no track of the kind - ui.add_enabled_ui(!tracks.is_empty(), |ui| { - let r = egui::ComboBox::from_id_salt(("add-tracks-placement", kind)).selected_text(&shown).width(list_w).show_ui(ui, |ui| { - for n in 0..=tracks.len() { - let label = place(n); - let o = ui.selectable_value(after, n, &label); - elems.push((format!("addTracks.{kind}.placement.option.{n}"), o.rect, label)); - } - }); - elems.push((format!("addTracks.{kind}.placement"), r.response.rect, shown.clone())); - }); - }); - if let Some((chosen, types)) = track_type { + let audio_types: [(&str, &str); 4] = [("standard", tl!("Standard")), ("5.1", "5.1"), ("adaptive", tl!("Adaptive")), ("mono", tl!("Mono"))]; + let submix_types: [(&str, &str); 4] = [("stereo", tl!("Stereo")), ("5.1", "5.1"), ("adaptive", tl!("Adaptive")), ("mono", tl!("Mono"))]; + egui::Window::new(tl!("Add Tracks")) + .id(egui::Id::new("Add Tracks")) + .collapsible(false) + .resizable(false) + .default_width(320.0) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + // everything spans the width the window has: no empty band beside the fields + ui.set_width(ui.available_width()); + // the label column is as wide as its widest label; the lists take the rest of the row, + // up to the group's right edge (as in Premiere's dialog) + let font = egui::TextStyle::Body.resolve(ui.style()); + let label_w = [tl!("Amount"), tl!("Placement"), tl!("Track type")] + .iter() + .map(|l| ui.painter().layout_no_wrap(l.to_string(), font.clone(), egui::Color32::WHITE).size().x) + .fold(0.0, f32::max); + let row_h = ui.spacing().interact_size.y; + let label = |ui: &mut egui::Ui, text: &str| { + ui.allocate_ui_with_layout(egui::vec2(label_w, row_h), egui::Layout::right_to_left(egui::Align::Center), |ui| ui.label(text)); + }; + let groups: [(&str, &str, &mut u32, &mut usize, &[String], Option<(&mut String, &[(&str, &str); 4])>); 3] = [ + ("video", tl!("Add video tracks"), &mut d.video, &mut d.video_after, &vnames, None), + ("audio", tl!("Add audio tracks"), &mut d.audio, &mut d.audio_after, &anames, Some((&mut d.audio_type, &audio_types))), + ("submix", tl!("Add audio submix tracks"), &mut d.submix, &mut d.submix_after, &snames, Some((&mut d.submix_type, &submix_types))), + ]; + for (kind, title, amount, after, tracks, track_type) in groups { + ui.add_space(4.0); + ui.label(egui::RichText::new(title).strong()); + ui.group(|ui| { + ui.set_width(ui.available_width()); ui.horizontal(|ui| { - label(ui, "Track type"); - let shown = types.iter().find(|(id, _)| *id == chosen.as_str()).map_or(types[0].1, |(_, label)| label).to_string(); - let list_w = ui.available_width(); - let r = egui::ComboBox::from_id_salt(("add-tracks-type", kind)).selected_text(&shown).width(list_w).show_ui(ui, |ui| { - for (id, label) in types { - let o = ui.selectable_value(chosen, id.to_string(), *label); - elems.push((format!("addTracks.{kind}.type.option.{id}"), o.rect, label.to_string())); - } - }); - elems.push((format!("addTracks.{kind}.type"), r.response.rect, shown)); + label(ui, tl!("Amount")); + let r = ui.add(egui::DragValue::new(amount).range(0..=99).speed(0.1)); + elems.push((format!("addTracks.{kind}.amount"), r.rect, amount.to_string())); }); + ui.horizontal(|ui| { + label(ui, tl!("Placement")); + *after = (*after).min(tracks.len()); + let place = |n: usize| match n.checked_sub(1).and_then(|i| tracks.get(i)) { + Some(name) => tlf!("After {name}", name), + None => tl!("Before First Track").to_string(), + }; + let shown = place(*after); + let list_w = ui.available_width(); + // nowhere to choose from while the sequence has no track of the kind + ui.add_enabled_ui(!tracks.is_empty(), |ui| { + let r = egui::ComboBox::from_id_salt(("add-tracks-placement", kind)).selected_text(&shown).width(list_w).show_ui(ui, |ui| { + for n in 0..=tracks.len() { + let label = place(n); + let o = ui.selectable_value(after, n, &label); + elems.push((format!("addTracks.{kind}.placement.option.{n}"), o.rect, label)); + } + }); + elems.push((format!("addTracks.{kind}.placement"), r.response.rect, shown.clone())); + }); + }); + if let Some((chosen, types)) = track_type { + ui.horizontal(|ui| { + label(ui, tl!("Track type")); + let shown = types.iter().find(|(id, _)| *id == chosen.as_str()).map_or(types[0].1, |(_, label)| label).to_string(); + let list_w = ui.available_width(); + let r = egui::ComboBox::from_id_salt(("add-tracks-type", kind)).selected_text(&shown).width(list_w).show_ui(ui, |ui| { + for (id, label) in types { + let o = ui.selectable_value(chosen, id.to_string(), *label); + elems.push((format!("addTracks.{kind}.type.option.{id}"), o.rect, label.to_string())); + } + }); + elems.push((format!("addTracks.{kind}.type"), r.response.rect, shown)); + }); + } + }); + } + ui.add_space(10.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let o = ui.add(egui::Button::new(egui::RichText::new(tl!("OK")).color(egui::Color32::WHITE)).fill(app.tokens.accent)); + elems.push(("addTracks.ok".into(), o.rect, "OK".into())); + if o.clicked() { + apply = true; + } + let c = ui.button(tl!("Cancel")); + elems.push(("addTracks.cancel".into(), c.rect, "Cancel".into())); + if c.clicked() { + keep = false; } }); - } - ui.add_space(10.0); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let o = ui.add(egui::Button::new(egui::RichText::new("OK").color(egui::Color32::WHITE)).fill(app.tokens.accent)); - elems.push(("addTracks.ok".into(), o.rect, "OK".into())); - if o.clicked() { - apply = true; - } - let c = ui.button("Cancel"); - elems.push(("addTracks.cancel".into(), c.rect, "Cancel".into())); - if c.clicked() { - keep = false; - } }); - }); for (id, r, l) in elems { app.auto.add(&id, r, &l); } @@ -296,48 +308,55 @@ fn delete_tracks(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { let mut keep = true; let mut apply = false; let mut elems: Vec<(String, egui::Rect, String)> = Vec::new(); - egui::Window::new("Delete Tracks").collapsible(false).resizable(false).anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { - ui.set_min_width(300.0); - for (kind, title, on, target, list) in [ - ("video", "Video Tracks", &mut draft.video, &mut draft.video_target, &vnames), - ("audio", "Audio Tracks", &mut draft.audio, &mut draft.audio_target, &anames), - ] { - ui.add_space(4.0); - ui.label(egui::RichText::new(title).strong()); - let label = format!("Delete {title}"); - let c = ui.checkbox(on, &label); - elems.push((format!("deleteTracks.{kind}"), c.rect, label)); - ui.horizontal(|ui| { - ui.add_space(22.0); - let shown = if target == "empty" { "All Empty Tracks".to_string() } else { target.clone() }; - ui.add_enabled_ui(*on, |ui| { - let r = egui::ComboBox::from_id_salt(("delete-tracks", kind)).selected_text(&shown).width(170.0).show_ui(ui, |ui| { - let e = ui.selectable_value(target, "empty".to_string(), "All Empty Tracks"); - elems.push((format!("deleteTracks.{kind}.option.empty"), e.rect, "All Empty Tracks".into())); - for n in list { - let r = ui.selectable_value(target, n.clone(), n); - elems.push((format!("deleteTracks.{kind}.option.{n}"), r.rect, n.clone())); - } + egui::Window::new(tl!("Delete Tracks")) + .id(egui::Id::new("Delete Tracks")) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.set_min_width(300.0); + for (kind, title, delete, on, target, list) in [ + ("video", tl!("Video Tracks"), tl!("Delete Video Tracks"), &mut draft.video, &mut draft.video_target, &vnames), + ("audio", tl!("Audio Tracks"), tl!("Delete Audio Tracks"), &mut draft.audio, &mut draft.audio_target, &anames), + ] { + ui.add_space(4.0); + ui.label(egui::RichText::new(title).strong()); + let label = delete.to_string(); + let c = ui.checkbox(on, &label); + elems.push((format!("deleteTracks.{kind}"), c.rect, label)); + ui.horizontal(|ui| { + ui.add_space(22.0); + let shown = if target == "empty" { tl!("All Empty Tracks").to_string() } else { target.clone() }; + ui.add_enabled_ui(*on, |ui| { + let r = egui::ComboBox::from_id_salt(("delete-tracks", kind)).selected_text(&shown).width(170.0).show_ui(ui, |ui| { + let e = ui.selectable_value(target, "empty".to_string(), tl!("All Empty Tracks")); + elems.push((format!("deleteTracks.{kind}.option.empty"), e.rect, "All Empty Tracks".into())); + for n in list { + let r = ui.selectable_value(target, n.clone(), n); + elems.push((format!("deleteTracks.{kind}.option.{n}"), r.rect, n.clone())); + } + }); + elems.push((format!("deleteTracks.{kind}.target"), r.response.rect, shown)); }); - elems.push((format!("deleteTracks.{kind}.target"), r.response.rect, shown)); }); + } + ui.add_space(10.0); + ui.horizontal(|ui| { + let c = ui.button(tl!("Cancel")); + elems.push(("deleteTracks.cancel".into(), c.rect, "Cancel".into())); + if c.clicked() { + keep = false; + } + let o = ui.add_enabled( + draft.video || draft.audio, + egui::Button::new(egui::RichText::new(tl!("OK")).color(egui::Color32::WHITE)).fill(app.tokens.accent), + ); + elems.push(("deleteTracks.ok".into(), o.rect, "OK".into())); + if o.clicked() { + apply = true; + } }); - } - ui.add_space(10.0); - ui.horizontal(|ui| { - let c = ui.button("Cancel"); - elems.push(("deleteTracks.cancel".into(), c.rect, "Cancel".into())); - if c.clicked() { - keep = false; - } - let o = - ui.add_enabled(draft.video || draft.audio, egui::Button::new(egui::RichText::new("OK").color(egui::Color32::WHITE)).fill(app.tokens.accent)); - elems.push(("deleteTracks.ok".into(), o.rect, "OK".into())); - if o.clicked() { - apply = true; - } }); - }); for (id, r, l) in elems { app.auto.add(&id, r, &l); } @@ -376,8 +395,8 @@ fn about(app: &mut FilmcraftApp, ui: &mut egui::Ui) { crate::brand::paint_wordmark(ui, egui::pos2(r.min.x, r.center().y), 20.0, app.ui.dark); ui.add_space(6.0); ui.heading("FilmCraft"); - ui.label(format!("Version {}", env!("CARGO_PKG_VERSION"))); - ui.label("A clean-room, pure-Rust non-linear video editor. Part of the ArtCraft family."); + ui.label(tlf!("Version {version}", version = env!("CARGO_PKG_VERSION"))); + ui.label(tl!("A clean-room, pure-Rust non-linear video editor. Part of the ArtCraft family.")); ui.add_space(10.0); let mut link = |ui: &mut egui::Ui, id: &str, icon: Icon, label: &str, url: &str, primary: bool| { let size = egui::vec2(ui.available_width(), if primary { 36.0 } else { 28.0 }); @@ -406,12 +425,12 @@ fn about(app: &mut FilmcraftApp, ui: &mut egui::Ui) { links::open(ui.ctx(), url); } }; - link(ui, "discord", Icon::Chat, "Join the ArtCraft Discord", links::DISCORD, true); + link(ui, "discord", Icon::Chat, tl!("Join the ArtCraft Discord"), links::DISCORD, true); ui.add_space(6.0); link(ui, "website", Icon::Globe, "getartcraft.com", links::WEBSITE, false); - link(ui, "appPage", Icon::Globe, "FilmCraft on getartcraft.com", links::APP_PAGE, false); - link(ui, "github", Icon::Code, "Source code on GitHub", links::GITHUB, false); - link(ui, "reportIssue", Icon::Code, "Report an issue", links::ISSUES, false); + link(ui, "appPage", Icon::Globe, tl!("FilmCraft on getartcraft.com"), links::APP_PAGE, false); + link(ui, "github", Icon::Code, tl!("Source code on GitHub"), links::GITHUB, false); + link(ui, "reportIssue", Icon::Code, tl!("Report an issue"), links::ISSUES, false); ui.add_space(10.0); - ui.label(egui::RichText::new("MIT OR Apache-2.0. Fonts: Inter, Noto Serif and JetBrains Mono (SIL OFL 1.1).").small().color(t.text_dim)); + ui.label(egui::RichText::new(tl!("MIT OR Apache-2.0. Fonts: Inter, Noto Serif and JetBrains Mono (SIL OFL 1.1).")).small().color(t.text_dim)); } diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 0397d3ad..7d24fa97 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -31,11 +31,11 @@ fn selected_clip(app: &FilmcraftApp) -> Option<(ClipId, TrackItem, filmcraft_pro pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let Some((clip, it, kind)) = selected_clip(app) else { - crate::dock::placeholder(ui, rect, &t, "(no clip selected)"); + crate::dock::placeholder(ui, rect, &t, tl!("(no clip selected)")); return; }; let Some(seq) = app.session.active_sequence().cloned() else { - crate::dock::placeholder(ui, rect, &t, "(no sequences)"); + crate::dock::placeholder(ui, rect, &t, tl!("(no sequences)")); return; }; let split = rect.min.x + (rect.width() * 0.58).max(260.0).min(rect.width() - 60.0); @@ -50,7 +50,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { cp.text(pos2(r.min.x + 8.0, r.center().y), Align2::LEFT_CENTER, text, Tokens::ui(11.5), if active { t.text } else { t.text_dim }); }; let half = (head.width() - 6.0) / 2.0; - pill(ui, Rect::from_min_size(head.min, vec2(half, 24.0)), &format!("Source · {}", it.name), false); + pill(ui, Rect::from_min_size(head.min, vec2(half, 24.0)), &tlf!("Source · {name}", name = it.name), false); pill(ui, Rect::from_min_size(head.min + vec2(half + 6.0, 0.0), vec2(half, 24.0)), &format!("{} · {}", seq_name(app), it.name), true); // keyframe lane header: mini ruler over the clip's duration let lane = Rect::from_min_max(pos2(split + 4.0, rect.min.y + 4.0), pos2(rect.max.x - 6.0, rect.max.y - 26.0)); @@ -76,7 +76,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut bui = ui.new_child(egui::UiBuilder::new().max_rect(body).id_salt("ec-body")); bui.set_clip_rect(Rect::from_min_max(body.min, pos2(rect.max.x, body.max.y))); let mt_now = it.source_time_at(ph.clamp(it.start, it.end() - Tick(1))); - let heading = if kind == filmcraft_project::TrackKind::Video { "Video" } else { "Audio" }; + let heading = if kind == filmcraft_project::TrackKind::Video { tl!("Video") } else { tl!("Audio") }; bui.painter().text(pos2(body.min.x + 8.0, body.min.y + 8.0), Align2::LEFT_CENTER, heading, Tokens::semibold(11.5), t.text_dim); bui.add_space(18.0); // Premiere lists the fixed effects (Motion, Opacity, Time Remapping / Volume…) first. @@ -108,10 +108,10 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if fxresp.clicked() { actions.push(("effects.toggleEnabled".into(), json!({"clip": clip.0, "index": idx}))); } - bui.painter().text(pos2(r.min.x + 42.0, r.center().y), Align2::LEFT_CENTER, def.name, Tokens::ui(12.0), t.text); + bui.painter().text(pos2(r.min.x + 42.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(def.name), Tokens::ui(12.0), t.text); // reset button let rr = Rect::from_center_size(pos2(r.max.x - 14.0, r.center().y), vec2(16.0, 16.0)); - let rresp = bui.interact(rr, egui::Id::new(("fxreset", clip.0, idx)), Sense::click()).on_hover_text("Reset Effect"); + let rresp = bui.interact(rr, egui::Id::new(("fxreset", clip.0, idx)), Sense::click()).on_hover_text(tl!("Reset Effect")); icons::paint(bui.painter(), rr.shrink(2.0), Icon::Reset, if rresp.hovered() { t.text } else { t.text_dim }); if rresp.clicked() { actions.push(("effects.reset".into(), json!({"clip": clip.0, "index": idx}))); @@ -127,13 +127,13 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut save_preset = false; let fx_id = e.effect.clone(); resp.context_menu(|ui| { - let sp = ui.button("Save Preset…"); + let sp = ui.button(tl!("Save Preset…")); app.auto.add(&format!("effectControls.effect.{fx_id}.savePreset"), sp.rect, "Save Preset…"); if sp.clicked() { save_preset = true; ui.close(); } - if !def.intrinsic && ui.button("Clear").clicked() { + if !def.intrinsic && ui.button(tl!("Clear")).clicked() { actions.push(("effects.remove".into(), json!({"clip": clip.0, "index": idx}))); ui.close(); } @@ -180,12 +180,12 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { fn custom_setup_row(app: &mut FilmcraftApp, ui: &mut egui::Ui, body: Rect, clip: ClipId, idx: usize, effect: &str) { let t = app.tokens; let (r, _) = ui.allocate_exact_size(vec2(body.width(), ROW_H), Sense::hover()); - ui.painter().text(pos2(r.min.x + 42.0, r.center().y), Align2::LEFT_CENTER, "Custom Setup", Tokens::ui(12.0), t.text_dim); + ui.painter().text(pos2(r.min.x + 42.0, r.center().y), Align2::LEFT_CENTER, tl!("Custom Setup"), Tokens::ui(12.0), t.text_dim); let br = Rect::from_min_size(pos2(r.min.x + 160.0, r.min.y + 2.0), vec2(60.0, ROW_H - 4.0)); let resp = ui.interact(br, egui::Id::new(("fx-custom-setup", clip.0, idx)), Sense::click()); ui.painter().rect_filled(br, 3.0, if resp.hovered() { t.hover } else { t.field_bg }); ui.painter().rect_stroke(br, 3.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); - ui.painter().text(br.center(), Align2::CENTER_CENTER, "Edit…", Tokens::ui(11.5), t.text); + ui.painter().text(br.center(), Align2::CENTER_CENTER, tl!("Edit…"), Tokens::ui(11.5), t.text); app.auto.add(&format!("effectControls.effect.{effect}.edit"), br, "Edit…"); if resp.clicked() { crate::panels::audio_fx_editor::open(app, crate::panels::audio_fx_editor::FxTarget::Clip { clip: clip.0, index: idx }); @@ -238,7 +238,7 @@ pub(crate) fn param_row( let key = graph_key(clip, idx, pkey); let open = app.ui.expanded_fx.contains(&key); let tw = Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(12.0, 12.0)); - let tresp = ui.interact(tw.expand(2.0), egui::Id::new(("twirl", clip.0, idx, pkey)), Sense::click()).on_hover_text("Show graphs"); + let tresp = ui.interact(tw.expand(2.0), egui::Id::new(("twirl", clip.0, idx, pkey)), Sense::click()).on_hover_text(tl!("Show graphs")); icons::paint(ui.painter(), tw, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim); app.auto.add(&format!("effectControls.{}.{}.graphs", e.effect, pkey), tw, "Show graphs"); if tresp.clicked() { @@ -251,7 +251,7 @@ pub(crate) fn param_row( } if pd.animatable { let sw = Rect::from_center_size(pos2(x, r.center().y), vec2(14.0, 14.0)); - let resp = ui.interact(sw, egui::Id::new(("sw", clip.0, idx, pkey)), Sense::click()).on_hover_text("Toggle animation"); + let resp = ui.interact(sw, egui::Id::new(("sw", clip.0, idx, pkey)), Sense::click()).on_hover_text(tl!("Toggle animation")); icons::paint(ui.painter(), sw, Icon::Stopwatch, if param.is_animated() { t.accent } else { t.text_dim }); if resp.clicked() { actions.push(("effects.toggleAnimation".into(), with_mask(json!({"clip": clip.0, "effect": idx, "param": pd.id})))); @@ -259,7 +259,7 @@ pub(crate) fn param_row( app.auto.add(&format!("effectControls.{}.{}.stopwatch", e.effect, pkey), sw, "Toggle animation"); } x += 14.0; - ui.painter().text(pos2(x, r.center().y), Align2::LEFT_CENTER, pd.label, Tokens::ui(12.0), t.text); + ui.painter().text(pos2(x, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(pd.label), Tokens::ui(12.0), t.text); let vx = r.min.x + (r.width() * 0.5).max(150.0); let value = param.value_at(mt); let mut vui = ui.new_child( @@ -298,9 +298,9 @@ pub(crate) fn param_row( } (ParamKind::Choice(opts), ParamValue::Choice(c)) => { let mut sel = *c as usize; - egui::ComboBox::from_id_salt(id).selected_text(opts.get(sel).copied().unwrap_or("")).width(130.0).show_ui(&mut vui, |ui| { + egui::ComboBox::from_id_salt(id).selected_text(opts.get(sel).map_or("", |o| crate::i18n::t(o))).width(130.0).show_ui(&mut vui, |ui| { for (i, o) in opts.iter().enumerate() { - if ui.selectable_value(&mut sel, i, *o).changed() { + if ui.selectable_value(&mut sel, i, crate::i18n::t(o)).changed() { set = Some(json!(i)); } } @@ -372,7 +372,7 @@ pub(crate) fn param_row( { actions.push(("playhead.set".into(), json!({"time": to_tl(k).0}))); } - if ui.interact(kr, egui::Id::new(("kadd", clip.0, idx, pkey)), Sense::click()).on_hover_text("Add/Remove Keyframe").clicked() { + if ui.interact(kr, egui::Id::new(("kadd", clip.0, idx, pkey)), Sense::click()).on_hover_text(tl!("Add/Remove Keyframe")).clicked() { actions.push(("effects.addKeyframe".into(), with_mask(json!({"clip": clip.0, "effect": eff_json, "param": pd.id})))); } app.auto.add(&format!("effectControls.{}.{}.addKeyframe", e.effect, pkey), kr, "Add/Remove Keyframe"); @@ -426,15 +426,15 @@ pub(crate) fn param_row( actions.push(("playhead.set".into(), json!({"time": tl.0}))); } resp.context_menu(|ui| { - ui.label(egui::RichText::new("Temporal Interpolation").color(t.text_dim)); + ui.label(egui::RichText::new(tl!("Temporal Interpolation")).color(t.text_dim)); for (label, key) in [ - ("Linear", "linear"), - ("Bezier", "bezier"), - ("Auto Bezier", "autoBezier"), - ("Continuous Bezier", "continuousBezier"), - ("Hold", "hold"), - ("Ease In", "easeIn"), - ("Ease Out", "easeOut"), + (tl!("Linear"), "linear"), + (tl!("Bezier"), "bezier"), + (tl!("Auto Bezier"), "autoBezier"), + (tl!("Continuous Bezier"), "continuousBezier"), + (tl!("Hold"), "hold"), + (tl!("Ease In"), "easeIn"), + (tl!("Ease Out"), "easeOut"), ] { if ui.button(label).clicked() { actions.push(( @@ -445,7 +445,7 @@ pub(crate) fn param_row( } } ui.separator(); - if ui.button("Clear").clicked() { + if ui.button(tl!("Clear")).clicked() { actions .push(("effects.deleteKeyframe".into(), with_mask(json!({"clip": clip.0, "effect": eff_json, "param": pd.id, "mediaTime": k.time.0})))); ui.close(); @@ -459,14 +459,14 @@ pub(crate) fn param_row( pub fn lumetri_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let Some((clip, it, _)) = selected_clip(app) else { - crate::dock::placeholder(ui, rect, &t, "Select a clip to grade"); + crate::dock::placeholder(ui, rect, &t, tl!("Select a clip to grade")); return; }; let idx = it.effects.iter().position(|e| e.effect == "lumetri"); let mut bui = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink(6.0)).id_salt("lumetri")); - bui.label(egui::RichText::new(format!("Master · {}", it.name)).color(t.text_dim)); + bui.label(egui::RichText::new(tlf!("Master · {name}", name = it.name)).color(t.text_dim)); let Some(idx) = idx else { - if bui.button("Add Lumetri Color to clip").clicked() { + if bui.button(tl!("Add Lumetri Color to clip")).clicked() { let _ = app.session.execute("effects.apply", json!({"clips": [clip.0], "effect": "lumetri"})); } return; @@ -488,7 +488,7 @@ pub fn lumetri_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { for g in groups { let key = format!("lumetri:{g}"); let open = !app.ui.collapsed_fx.contains(&key); - let (resp, now_open) = crate::widgets::section_header(ui, egui::Id::new(&key), g, open, &t, true); + let (resp, now_open) = crate::widgets::section_header(ui, egui::Id::new(&key), crate::i18n::t(g), open, &t, true); app.auto.add(&format!("lumetri.section.{g}"), resp.rect, g); if now_open != open { if now_open { @@ -504,7 +504,7 @@ pub fn lumetri_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let v = e.params.get(pd.id).map(|p| p.value_at(mt)).unwrap_or(pd.default.clone()); ui.horizontal(|ui| { ui.add_space(18.0); - ui.add_sized(vec2(110.0, 18.0), egui::Label::new(egui::RichText::new(pd.label).size(12.0))); + ui.add_sized(vec2(110.0, 18.0), egui::Label::new(egui::RichText::new(crate::i18n::t(pd.label)).size(12.0))); if let (ParamKind::Float { min, max, soft_min, soft_max, .. }, ParamValue::Float(x)) = (&pd.kind, &v) { let mut val = *x; let s = ui.add(egui::Slider::new(&mut val, *soft_min..=*soft_max).show_value(false)); @@ -539,7 +539,7 @@ pub fn lumetri_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let Some((clip, it, kind)) = selected_clip(app) else { - crate::dock::placeholder(ui, rect, &t, "Select a clip to see its properties"); + crate::dock::placeholder(ui, rect, &t, tl!("Select a clip to see its properties")); return; }; let ph = app.session.playhead(); @@ -569,7 +569,7 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim, ); - ui.painter().text(pos2(r.min.x + 18.0, r.center().y), Align2::LEFT_CENTER, name, Tokens::semibold(13.0), t.text); + ui.painter().text(pos2(r.min.x + 18.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(name), Tokens::semibold(13.0), t.text); if let Some((idx, c)) = reset { let rr = Rect::from_center_size(pos2(r.max.x - 10.0, r.center().y), vec2(14.0, 14.0)); icons::paint(ui.painter(), rr, Icon::Reset, t.text_dim); @@ -605,10 +605,10 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if kind == filmcraft_project::TrackKind::Video { if section(&mut bui, app, "Transform", eff_idx("motion").map(|i| (i, clip.0)), &mut actions) { for (label, p, unit, speed, range) in [ - ("Position", "position", "", 1.0, (-100_000.0, 100_000.0)), - ("Anchor point", "anchor", "", 1.0, (-100_000.0, 100_000.0)), - ("Scale", "scale", " %", 0.5, (0.0, 10000.0)), - ("Rotation", "rotation", " °", 0.5, (-36000.0, 36000.0)), + (tl!("Position"), "position", "", 1.0, (-100_000.0, 100_000.0)), + (tl!("Anchor point"), "anchor", "", 1.0, (-100_000.0, 100_000.0)), + (tl!("Scale"), "scale", " %", 0.5, (0.0, 10000.0)), + (tl!("Rotation"), "rotation", " °", 0.5, (-36000.0, 36000.0)), ] { let r = row(&mut bui, label); let mut vui = bui.new_child( @@ -641,7 +641,7 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(("effects.toggleAnimation".into(), json!({"clip": clip.0, "effect": "motion", "param": p}))); } } - let r = row(&mut bui, "Opacity"); + let r = row(&mut bui, tl!("Opacity")); let mut vui = bui.new_child( egui::UiBuilder::new() .max_rect(Rect::from_min_max(pos2(r.min.x + 150.0, r.min.y + 4.0), pos2(r.max.x - 24.0, r.max.y - 4.0))) @@ -659,7 +659,7 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } let crop = eff_idx("crop"); if section(&mut bui, app, "Crop", crop.map(|i| (i, clip.0)), &mut actions) { - for (label, p) in [("Left", "left"), ("Top", "top"), ("Right", "right"), ("Bottom", "bottom")] { + for (label, p) in [(tl!("Left"), "left"), (tl!("Top"), "top"), (tl!("Right"), "right"), (tl!("Bottom"), "bottom")] { let r = row(&mut bui, label); let mut vui = bui.new_child( egui::UiBuilder::new() @@ -678,7 +678,7 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } } } else if section(&mut bui, app, "Audio", eff_idx("volume").map(|i| (i, clip.0)), &mut actions) { - let r = row(&mut bui, "Level"); + let r = row(&mut bui, tl!("Level")); let mut vui = bui.new_child( egui::UiBuilder::new() .max_rect(Rect::from_min_max(pos2(r.min.x + 150.0, r.min.y + 4.0), pos2(r.max.x - 24.0, r.max.y - 4.0))) @@ -692,7 +692,7 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if diamond(&mut bui, r, anim("volume", "level"), egui::Id::new(("pd-vol", clip.0))) { actions.push(("effects.toggleAnimation".into(), json!({"clip": clip.0, "effect": "volume", "param": "level"}))); } - let r = row(&mut bui, "Pan"); + let r = row(&mut bui, tl!("Pan")); let mut vui = bui.new_child( egui::UiBuilder::new() .max_rect(Rect::from_min_max(pos2(r.min.x + 150.0, r.min.y + 4.0), pos2(r.max.x - 24.0, r.max.y - 4.0))) @@ -709,7 +709,7 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let (br, bresp) = bui.allocate_exact_size(vec2(118.0, 26.0), Sense::click()); bui.painter().rect_filled(br, 4.0, if bresp.hovered() { t.hover } else { t.panel_bg }); bui.painter().rect_stroke(br, 4.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Inside); - bui.painter().text(br.center(), Align2::CENTER_CENTER, format!("Speed {:.0}%", it.speed * 100.0), Tokens::ui(12.0), t.text); + bui.painter().text(br.center(), Align2::CENTER_CENTER, tlf!("Speed {n}%", n = format!("{:.0}", it.speed * 100.0)), Tokens::ui(12.0), t.text); for (cmd, p) in actions { if let Err(e) = app.session.execute(&cmd, p) { app.ui.status = e.to_string(); @@ -784,7 +784,7 @@ pub(crate) fn graph_rows( let dec = if let ParamKind::Float { decimals, .. } = pd.kind { decimals as usize } else { 1 }; p.text(pos2(vr.max.x - 30.0, area.min.y + 6.0), Align2::RIGHT_CENTER, format!("{hi:.dec$}"), Tokens::ui(10.0), t.text_dim); p.text(pos2(vr.max.x - 30.0, area.max.y - 6.0), Align2::RIGHT_CENTER, format!("{lo:.dec$}"), Tokens::ui(10.0), t.text_dim); - p.text(pos2(vr.min.x + 44.0, area.center().y), Align2::LEFT_CENTER, "Value", Tokens::ui(11.0), t.text_dim); + p.text(pos2(vr.min.x + 44.0, area.center().y), Align2::LEFT_CENTER, tl!("Value"), Tokens::ui(11.0), t.text_dim); let line: Vec = vals.iter().enumerate().map(|(i, v)| pos2(x_of(i as f32 / n as f32), y_of(*v))).collect(); p.add(egui::Shape::line(line, Stroke::new(1.5, t.accent))); // velocity (units per second, derivative of the sampled value) @@ -797,7 +797,7 @@ pub(crate) fn graph_rows( p.line_segment([pos2(varea.min.x, varea.center().y), pos2(varea.max.x, varea.center().y)], Stroke::new(1.0, Color32::from_rgb(0x33, 0x33, 0x33))); let vline: Vec = vel.iter().enumerate().map(|(i, v)| pos2(x_of((i as f32 + 0.5) / n as f32), vy(*v))).collect(); p.add(egui::Shape::line(vline, Stroke::new(1.2, Color32::from_rgb(0xd0, 0xa0, 0x40)))); - p.text(pos2(velr.min.x + 44.0, varea.center().y), Align2::LEFT_CENTER, "Velocity", Tokens::ui(11.0), t.text_dim); + p.text(pos2(velr.min.x + 44.0, varea.center().y), Align2::LEFT_CENTER, tl!("Velocity"), Tokens::ui(11.0), t.text_dim); p.text(pos2(velr.max.x - 30.0, varea.min.y + 6.0), Align2::RIGHT_CENTER, format!("{vmax:.1}/s"), Tokens::ui(10.0), t.text_dim); let p = p.clone(); // keyframes + handles @@ -860,10 +860,10 @@ pub(crate) fn graph_rows( fn param_text(project: &filmcraft_project::Project, id: &str, value: &ParamValue) -> String { match value { ParamValue::Text(s) if id.ends_with("_lut") => filmcraft_render::luts::label(Some(project), s), - ParamValue::Text(s) if s.is_empty() => "None".into(), + ParamValue::Text(s) if s.is_empty() => tl!("None").into(), ParamValue::Text(s) => s.clone(), - ParamValue::Curve(pts) if pts.is_empty() || *pts == [[0.0, 0.0], [1.0, 1.0]] => "Default".into(), - ParamValue::Curve(pts) => format!("Custom ({} points)", pts.len()), + ParamValue::Curve(pts) if pts.is_empty() || *pts == [[0.0, 0.0], [1.0, 1.0]] => tl!("Default").into(), + ParamValue::Curve(pts) => tlf!("Custom ({n} points)", n = pts.len()), other => format!("{other:?}"), } } diff --git a/crates/ui-egui/src/panels/effects.rs b/crates/ui-egui/src/panels/effects.rs index 75b25e34..ef99ad1c 100644 --- a/crates/ui-egui/src/panels/effects.rs +++ b/crates/ui-egui/src/panels/effects.rs @@ -20,7 +20,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let sr = Rect::from_min_size(rect.min + vec2(8.0, 6.0), vec2(rect.width() - 16.0, 22.0)); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(sr)); let mut q = app.ui.effects_search.clone(); - crate::widgets::search_field(&mut child, &mut q, "Search effects", sr.width(), &t); + crate::widgets::search_field(&mut child, &mut q, tl!("Search effects"), sr.width(), &t); app.ui.effects_search = q.clone(); let body = Rect::from_min_max(pos2(rect.min.x, sr.max.y + 6.0), rect.max); // wide panel: the tree on the left, the Lumetri Presets thumbnail grid on the right @@ -31,7 +31,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { }; let mut bui = ui.new_child(egui::UiBuilder::new().max_rect(body).id_salt("fx-body")); bui.set_clip_rect(body); - let filter = q.to_ascii_lowercase(); + let filter = q.to_lowercase(); let defs = filmcraft_project::effect_defs(); // folder tree: top-level categories in Premiere's order let tops = filmcraft_project::vtransition::EFFECT_TOP_FOLDERS; @@ -41,7 +41,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { egui::ScrollArea::vertical().id_salt("fx-scroll").auto_shrink([false, false]).show(&mut bui, |ui| { for &top in tops { if top == "Presets" { - let any = filter.is_empty() || app.session.presets.all().iter().any(|p| p.name.to_ascii_lowercase().contains(&filter)); + let any = app.session.presets.all().iter().any(|p| crate::i18n::matches_query(&p.name, &filter)); if any && folder_row(app, ui, top, 0, &filter) { preset_action = crate::panels::presets::folder_rows(app, ui, &filter).or(preset_action.take()); } @@ -53,11 +53,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } continue; } - let mut items: Vec<_> = defs - .iter() - .filter(|d| d.category.first() == Some(&top)) - .filter(|d| filter.is_empty() || d.name.to_ascii_lowercase().contains(&filter)) - .collect(); + let mut items: Vec<_> = defs.iter().filter(|d| d.category.first() == Some(&top)).filter(|d| crate::i18n::matches_query(d.name, &filter)).collect(); // Premiere lists effect folders and effects alphabetically (obsolete ones last) if top == "Video Effects" || top == "Legacy" { items.sort_by_key(|d| (d.category.get(1) == Some(&"Obsolete"), d.category.get(1).copied().unwrap_or(""), d.name.to_ascii_lowercase())); @@ -99,7 +95,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } else { ui.painter().text(icon_r.center(), Align2::CENTER_CENTER, "fx", Tokens::ui(8.0), t.text_dim); } - ui.painter().text(pos2(x, r.center().y), Align2::LEFT_CENTER, d.name, Tokens::ui(12.0), t.text); + ui.painter().text(pos2(x, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(d.name), Tokens::ui(12.0), t.text); // badges: accelerated / 32-bit / YUV let mut bx = r.max.x - 8.0; for (on, label) in [(d.yuv, "YUV"), (d.float32, "32"), (d.accelerated, "⚡")] { @@ -158,7 +154,7 @@ fn folder_row(app: &mut FilmcraftApp, ui: &mut egui::Ui, key: &str, depth: usize ); icons::paint(ui.painter(), Rect::from_center_size(pos2(x + 18.0, r.center().y), vec2(13.0, 13.0)), Icon::Folder, t.text_dim); let name = key.rsplit('/').next().unwrap_or(key); - ui.painter().text(pos2(x + 30.0, r.center().y), Align2::LEFT_CENTER, name, Tokens::ui(12.0), t.text); + ui.painter().text(pos2(x + 30.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(name), Tokens::ui(12.0), t.text); app.auto.add(&format!("effects.folder.{key}"), r, name); if resp.clicked() { if open { @@ -175,7 +171,7 @@ fn folder_row(app: &mut FilmcraftApp, ui: &mut egui::Ui, key: &str, depth: usize fn apply_menu(app: &mut FilmcraftApp, resp: &egui::Response) -> bool { let mut chosen = false; resp.context_menu(|ui| { - let b = ui.button("Apply to Selected Clips"); + let b = ui.button(tl!("Apply to Selected Clips")); app.auto.add("effects.presetMenu.apply", b.rect, "Apply to Selected Clips"); if b.clicked() { chosen = true; @@ -210,7 +206,7 @@ fn lumetri_rows(app: &mut FilmcraftApp, ui: &mut egui::Ui, filter: &str) -> Opti use filmcraft_render::lumetri_presets as lp; let t = app.tokens; let top = "Lumetri Presets"; - let presets: Vec = lp::presets().into_iter().filter(|p| filter.is_empty() || p.name.to_ascii_lowercase().contains(filter)).collect(); + let presets: Vec = lp::presets().into_iter().filter(|p| crate::i18n::matches_query(p.name, filter)).collect(); if !filter.is_empty() && presets.is_empty() { return None; } @@ -243,9 +239,9 @@ fn lumetri_rows(app: &mut FilmcraftApp, ui: &mut egui::Ui, filter: &str) -> Opti let tex = preset_texture(app, ui.ctx(), p, 64); let tr = Rect::from_min_size(pos2(r.min.x + 40.0, r.min.y + 3.0), vec2(32.0, 18.0)); ui.painter().image(tex, tr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); - ui.painter().text(pos2(tr.max.x + 8.0, r.center().y), Align2::LEFT_CENTER, p.name, Tokens::ui(12.0), t.text); + ui.painter().text(pos2(tr.max.x + 8.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(p.name), Tokens::ui(12.0), t.text); app.auto.add(&format!("effects.lumetriPreset.{}", p.name), r, p.name); - let resp = resp.on_hover_text(p.description); + let resp = resp.on_hover_text(crate::i18n::t(p.description)); if apply_menu(app, &resp) { apply = Some(p.name.to_string()); } @@ -277,10 +273,10 @@ fn preset_grid(app: &mut FilmcraftApp, ui: &mut egui::Ui, area: Rect) -> Option< } let pic = Rect::from_min_size(cell.min, vec2(cw, ch)); let tex = preset_texture(app, ui.ctx(), p, 160); - let resp = ui.interact(cell, egui::Id::new(("lumetri-grid", p.name)), Sense::click()).on_hover_text(p.description); + let resp = ui.interact(cell, egui::Id::new(("lumetri-grid", p.name)), Sense::click()).on_hover_text(crate::i18n::t(p.description)); ui.painter().rect_filled(cell, 3.0, if resp.hovered() { t.hover } else { t.field_bg }); ui.painter().image(tex, pic.shrink(2.0), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); - let label = ui.painter().layout(p.name.to_string(), Tokens::ui(11.0), t.text, cw - 6.0); + let label = ui.painter().layout(crate::i18n::t(p.name).to_string(), Tokens::ui(11.0), t.text, cw - 6.0); ui.painter().galley(pos2(cell.min.x + 4.0, pic.max.y + 2.0), label, t.text); app.auto.add(&format!("effects.presetGrid.{}", p.name), cell, p.name); if apply_menu(app, &resp) { diff --git a/crates/ui-egui/src/panels/essential_sound.rs b/crates/ui-egui/src/panels/essential_sound.rs index 2a64b572..934a638b 100644 --- a/crates/ui-egui/src/panels/essential_sound.rs +++ b/crates/ui-egui/src/panels/essential_sound.rs @@ -61,11 +61,12 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let ty = rect.min.y + 16.0; let mut new_tab = None; for name in ["Browse", "Edit"] { - let g = painter.layout_no_wrap(name.to_string(), Tokens::ui(12.5), t.text); + let shown = if name == "Browse" { tl!("Browse") } else { tl!("Edit") }; + let g = painter.layout_no_wrap(shown.to_string(), Tokens::ui(12.5), t.text); let r = Rect::from_min_size(pos2(x, ty - 9.0), vec2(g.size().x, 20.0)); let resp = ui.interact(r, egui::Id::new(("es-tab", name)), Sense::click()); let col = if name == tab || resp.hovered() { t.text } else { t.text_dim }; - painter.text(pos2(x, ty), Align2::LEFT_CENTER, name, Tokens::ui(12.5), col); + painter.text(pos2(x, ty), Align2::LEFT_CENTER, shown, Tokens::ui(12.5), col); if name == tab { painter.line_segment([pos2(x, ty + 10.0), pos2(x + g.size().x, ty + 10.0)], Stroke::new(1.5, t.text)); } @@ -111,19 +112,19 @@ fn browse(ui: &mut egui::Ui, cx: &mut Ctx, rect: Rect, presets: &[es::Preset], i egui::ScrollArea::vertical().auto_shrink([false, false]).show(&mut child, |ui| { ui.set_max_width(ui.available_width() - 10.0); if items.is_empty() { - ui.label(egui::RichText::new("Select audio clips, then click a preset to apply it.").color(t.text_faint).size(11.5)); + ui.label(egui::RichText::new(tl!("Select audio clips, then click a preset to apply it.")).color(t.text_faint).size(11.5)); } for kind in AudioType::ALL { ui.add_space(8.0); let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::hover()); type_icon(ui.painter(), Rect::from_center_size(pos2(r.min.x + 8.0, r.center().y), vec2(16.0, 16.0)), kind, t.text); - ui.painter().text(pos2(r.min.x + 24.0, r.center().y), Align2::LEFT_CENTER, kind.label(), Tokens::semibold(12.5), t.text); + ui.painter().text(pos2(r.min.x + 24.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(kind.label()), Tokens::semibold(12.5), t.text); for p in presets.iter().filter(|p| p.kind == kind) { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); if resp.hovered() { ui.painter().rect_filled(r, 2.0, t.hover); } - let name = if p.builtin { p.name.clone() } else { format!("{} (user)", p.name) }; + let name = if p.builtin { crate::i18n::t(&p.name).to_string() } else { tlf!("{name} (user)", name = p.name) }; ui.painter().text( pos2(r.min.x + 24.0, r.center().y), Align2::LEFT_CENTER, @@ -145,12 +146,12 @@ fn browse(ui: &mut egui::Ui, cx: &mut Ctx, rect: Rect, presets: &[es::Preset], i fn edit(ui: &mut egui::Ui, cx: &mut Ctx, rect: Rect, items: &[TrackItem], presets: &[es::Preset], collapsed: Vec, expanded: Vec) { let t = cx.t; if items.is_empty() { - crate::dock::placeholder(ui, rect, &t, "Select audio clips to edit them here"); + crate::dock::placeholder(ui, rect, &t, tl!("Select audio clips to edit them here")); return; } let mut kinds: Vec> = items.iter().map(|i| i.essential.as_ref().map(|e| e.kind)).collect(); kinds.dedup(); - let title = if items.len() > 1 { "Multiple Clips Selected".to_string() } else { items[0].name.clone() }; + let title = if items.len() > 1 { tl!("Multiple Clips Selected").to_string() } else { items[0].name.clone() }; let body = Rect::from_min_max(rect.min, pos2(rect.max.x, rect.max.y - FOOTER_H)); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_max(body.min + vec2(PAD, 2.0), body.max - vec2(PAD - 2.0, 2.0))).id_salt("es-edit")); @@ -164,12 +165,12 @@ fn edit(ui: &mut egui::Ui, cx: &mut Ctx, rect: Rect, items: &[TrackItem], preset _ => { if kinds.len() > 1 { ui.label( - egui::RichText::new("The selected clips have different audio types. Assign one type to edit them together.") + egui::RichText::new(tl!("The selected clips have different audio types. Assign one type to edit them together.")) .color(t.text_dim) .size(11.5), ); } else { - ui.label(egui::RichText::new("Assign an audio type to the selection:").color(t.text_dim).size(12.0)); + ui.label(egui::RichText::new(tl!("Assign an audio type to the selection:")).color(t.text_dim).size(12.0)); } ui.add_space(6.0); for kind in AudioType::ALL { @@ -178,7 +179,7 @@ fn edit(ui: &mut egui::Ui, cx: &mut Ctx, rect: Rect, items: &[TrackItem], preset p.rect_filled(r.shrink(2.0), 4.0, if resp.hovered() { t.hover } else { t.field_bg }); p.rect_stroke(r.shrink(2.0), 4.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); type_icon(p, Rect::from_center_size(pos2(r.min.x + 26.0, r.center().y), vec2(22.0, 22.0)), kind, t.text); - p.text(pos2(r.min.x + 50.0, r.center().y), Align2::LEFT_CENTER, kind.label(), Tokens::semibold(13.0), t.text); + p.text(pos2(r.min.x + 50.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(kind.label()), Tokens::semibold(13.0), t.text); cx.auto.add(&format!("essentialSound.type.{}", kind.label()), r, kind.label()); if resp.clicked() { cx.run("essentialSound.setType", json!({"type": kind.id()})); @@ -198,16 +199,16 @@ fn typed_body(ui: &mut egui::Ui, cx: &mut Ctx, st: &EssentialSound, items: &[Tra // type badge + Clear Audio Type let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::hover()); type_icon(ui.painter(), Rect::from_center_size(pos2(r.min.x + 10.0, r.center().y), vec2(18.0, 18.0)), kind, t.text); - ui.painter().text(pos2(r.min.x + 28.0, r.center().y), Align2::LEFT_CENTER, kind.label(), Tokens::ui(12.5), t.text); + ui.painter().text(pos2(r.min.x + 28.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(kind.label()), Tokens::ui(12.5), t.text); let br = Rect::from_min_max(pos2(r.max.x - 122.0, r.min.y + 3.0), pos2(r.max.x, r.max.y - 3.0)); - if button(ui, cx, br, "Clear Audio Type", "essentialSound.clearType") { + if button(ui, cx, br, tl!("Clear Audio Type"), "essentialSound.clearType") { cx.run("essentialSound.clearType", json!({})); } ui.add_space(6.0); // Preset: [▾] save delete - ui.label(egui::RichText::new("Preset:").color(t.text_dim).size(12.0)); + ui.label(egui::RichText::new(tl!("Preset:")).color(t.text_dim).size(12.0)); let mine: Vec<&es::Preset> = presets.iter().filter(|p| p.kind == kind).collect(); - let current = if st.preset.is_empty() { "(Custom)".to_string() } else { st.preset.clone() }; + let current = if st.preset.is_empty() { tl!("(Custom)").to_string() } else { crate::i18n::t(&st.preset).to_string() }; let mut picked = None; let draft_id = egui::Id::new("es-preset-draft"); let mut draft: Option = ui.data(|d| d.get_temp(draft_id)); @@ -241,9 +242,9 @@ fn typed_body(ui: &mut egui::Ui, cx: &mut Ctx, st: &EssentialSound, items: &[Tra if let Some(mut d) = draft.take() { let mut done = false; ui.horizontal(|ui| { - let r = ui.add(egui::TextEdit::singleline(&mut d).hint_text("Preset name").desired_width(ui.available_width() - 70.0)); + let r = ui.add(egui::TextEdit::singleline(&mut d).hint_text(tl!("Preset name")).desired_width(ui.available_width() - 70.0)); cx.auto.add("essentialSound.preset.name", r.rect, "Preset name"); - let ok = ui.button("Save"); + let ok = ui.button(tl!("Save")); cx.auto.add("essentialSound.preset.ok", ok.rect, "Save"); if (ok.clicked() || (r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)))) && !d.trim().is_empty() { cx.run("essentialSound.savePreset", json!({"name": d.trim()})); @@ -275,46 +276,52 @@ fn typed_body(ui: &mut egui::Ui, cx: &mut Ctx, st: &EssentialSound, items: &[Tra match sec { Section::Loudness => loudness(ui, cx, st, items), Section::Repair => { - slot(ui, cx, "Reduce Noise", "repair.noise", st.repair.noise, on); - slot(ui, cx, "Reduce Rumble", "repair.rumble", st.repair.rumble, on); - slot(ui, cx, "DeHum", "repair.dehum", st.repair.dehum, on); + slot(ui, cx, tl!("Reduce Noise"), "repair.noise", st.repair.noise, on); + slot(ui, cx, tl!("Reduce Rumble"), "repair.rumble", st.repair.rumble, on); + slot(ui, cx, tl!("DeHum"), "repair.dehum", st.repair.dehum, on); choice_row(ui, cx, "repair.humHz", &["50 Hz", "60 Hz"], usize::from(st.repair.hum_hz == 60), on && st.repair.dehum.on, |i| { json!(if i == 1 { 60 } else { 50 }) }); - slot(ui, cx, "DeEss", "repair.deess", st.repair.deess, on); - slot(ui, cx, "Reduce Reverb", "repair.reverb", st.repair.reverb, on); + slot(ui, cx, tl!("DeEss"), "repair.deess", st.repair.deess, on); + slot(ui, cx, tl!("Reduce Reverb"), "repair.reverb", st.repair.reverb, on); } Section::Clarity => { - slot(ui, cx, "Dynamics", "clarity.dynamics", st.clarity.dynamics, on); - switch_row(ui, cx, "EQ", "clarity.eq.on", st.clarity.eq.on, on); + slot(ui, cx, tl!("Dynamics"), "clarity.dynamics", st.clarity.dynamics, on); + switch_row(ui, cx, tl!("EQ"), "clarity.eq.on", st.clarity.eq.on, on); let names: Vec<&str> = es::EQ_PRESETS.iter().map(|p| p.name).collect(); combo(ui, cx, "clarity.eqPreset", &names, &st.clarity.eq_preset, on && st.clarity.eq.on); - slider(ui, cx, "Amount", "clarity.eq.amount", st.clarity.eq.amount, (0.0, 10.0), 1, "", on && st.clarity.eq.on); - switch_row(ui, cx, "Enhance Speech", "clarity.enhance.on", st.clarity.enhance.on, on); - choice_row(ui, cx, "clarity.enhanceTone", &["Low Tone", "High Tone"], st.clarity.enhance_tone as usize, on && st.clarity.enhance.on, |i| { - json!(i) - }); - slider(ui, cx, "Mix", "clarity.enhance.amount", st.clarity.enhance.amount, (0.0, 10.0), 1, "", on && st.clarity.enhance.on); + slider(ui, cx, tl!("Amount"), "clarity.eq.amount", st.clarity.eq.amount, (0.0, 10.0), 1, "", on && st.clarity.eq.on); + switch_row(ui, cx, tl!("Enhance Speech"), "clarity.enhance.on", st.clarity.enhance.on, on); + choice_row( + ui, + cx, + "clarity.enhanceTone", + &[tl!("Low Tone"), tl!("High Tone")], + st.clarity.enhance_tone as usize, + on && st.clarity.enhance.on, + |i| json!(i), + ); + slider(ui, cx, tl!("Mix"), "clarity.enhance.amount", st.clarity.enhance.amount, (0.0, 10.0), 1, "", on && st.clarity.enhance.on); } Section::Creative => { - switch_row(ui, cx, "Reverb", "creative.reverb.on", st.creative.reverb.on, on); - ui.label(egui::RichText::new("Preset:").color(t.text_dim).size(12.0)); + switch_row(ui, cx, tl!("Reverb"), "creative.reverb.on", st.creative.reverb.on, on); + ui.label(egui::RichText::new(tl!("Preset:")).color(t.text_dim).size(12.0)); let names: Vec<&str> = es::REVERB_PRESETS.iter().map(|p| p.name).collect(); combo(ui, cx, "creative.reverbPreset", &names, &st.creative.reverb_preset, on && st.creative.reverb.on); - slider(ui, cx, "Amount", "creative.reverb.amount", st.creative.reverb.amount, (0.0, 10.0), 1, "", on && st.creative.reverb.on); + slider(ui, cx, tl!("Amount"), "creative.reverb.amount", st.creative.reverb.amount, (0.0, 10.0), 1, "", on && st.creative.reverb.on); if kind == AudioType::Ambience { - slot(ui, cx, "Stereo Width", "creative.width", st.creative.width, on); + slot(ui, cx, tl!("Stereo Width"), "creative.width", st.creative.width, on); } } Section::Ducking => ducking(ui, cx, st, on), Section::Duration => { ui.label( - egui::RichText::new("Remixing music to a target duration is not available yet. Trim the clip and add a crossfade instead.") + egui::RichText::new(tl!("Remixing music to a target duration is not available yet. Trim the clip and add a crossfade instead.")) .color(t.text_faint) .size(11.5), ); } - Section::Pan => slider(ui, cx, "Pan", "pan.value", st.pan.value, (-100.0, 100.0), 1, "", on), + Section::Pan => slider(ui, cx, tl!("Pan"), "pan.value", st.pan.value, (-100.0, 100.0), 1, "", on), } ui.add_space(8.0); } @@ -327,22 +334,22 @@ fn loudness(ui: &mut egui::Ui, cx: &mut Ctx, st: &EssentialSound, items: &[Track let t = cx.t; let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 28.0), Sense::hover()); let br = Rect::from_min_size(r.min + vec2(0.0, 2.0), vec2(104.0, 24.0)); - if button(ui, cx, br, "Auto-Match", "essentialSound.autoMatch") { + if button(ui, cx, br, tl!("Auto-Match"), "essentialSound.autoMatch") { cx.run("essentialSound.autoMatch", json!({})); } let text = match (st.loudness.measured_lufs, st.loudness.target_lufs) { (Some(m), Some(tg)) => format!("{m:.1} → {tg:.1} LUFS ({:+.1} dB)", st.loudness.gain_db), - _ => "Not matched yet".to_string(), + _ => tl!("Not matched yet").to_string(), }; ui.painter().text(pos2(br.max.x + 10.0, r.center().y), Align2::LEFT_CENTER, text, Tokens::ui(11.5), t.text_dim); if items.len() > 1 { - ui.label(egui::RichText::new(format!("Matches each of the {} clips on its own.", items.len())).color(t.text_faint).size(11.0)); + ui.label(egui::RichText::new(tlf!("Matches each of the {n} clips on its own.", n = items.len())).color(t.text_faint).size(11.0)); } } fn ducking(ui: &mut egui::Ui, cx: &mut Ctx, st: &EssentialSound, on: bool) { let t = cx.t; - ui.label(egui::RichText::new("Duck against:").color(t.text_dim).size(12.0)); + ui.label(egui::RichText::new(tl!("Duck against:")).color(t.text_dim).size(12.0)); let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::hover()); let mut x = r.min.x; for kind in AudioType::ALL { @@ -387,18 +394,18 @@ fn ducking(ui: &mut egui::Ui, cx: &mut Ctx, st: &EssentialSound, on: bool) { Color32::TRANSPARENT }, ); - ui.painter().text(br.center(), Align2::CENTER_CENTER, "Untagged", Tokens::ui(11.5), if sel { t.text } else { t.text_faint }); + ui.painter().text(br.center(), Align2::CENTER_CENTER, tl!("Untagged"), Tokens::ui(11.5), if sel { t.text } else { t.text_faint }); cx.auto.add("essentialSound.ducking.against.Untagged", br, "Untagged"); if resp.clicked() && on { cx.set("ducking.againstUntyped", json!(!sel), true); } - slider(ui, cx, "Sensitivity", "ducking.sensitivity", st.ducking.sensitivity, (0.0, 10.0), 1, "", on); - slider(ui, cx, "Reduce By", "ducking.reduceDb", st.ducking.reduce_db, (-40.0, 0.0), 1, " dB", on); - slider(ui, cx, "Fades", "ducking.fadeS", st.ducking.fade_s * 1000.0, (0.0, 5000.0), 0, " ms", on); + slider(ui, cx, tl!("Sensitivity"), "ducking.sensitivity", st.ducking.sensitivity, (0.0, 10.0), 1, "", on); + slider(ui, cx, tl!("Reduce By"), "ducking.reduceDb", st.ducking.reduce_db, (-40.0, 0.0), 1, " dB", on); + slider(ui, cx, tl!("Fades"), "ducking.fadeS", st.ducking.fade_s * 1000.0, (0.0, 5000.0), 0, " ms", on); ui.add_space(4.0); let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::hover()); let br = Rect::from_min_size(r.min + vec2(0.0, 2.0), vec2(150.0, 24.0)); - if button(ui, cx, br, "Generate Keyframes", "essentialSound.generateDucking") && on { + if button(ui, cx, br, tl!("Generate Keyframes"), "essentialSound.generateDucking") && on { cx.run("essentialSound.generateDucking", json!({})); } } @@ -413,7 +420,7 @@ fn footer(ui: &mut egui::Ui, cx: &mut Ctx, rect: Rect, st: Option<&EssentialSoun if switch(ui, cx, Rect::from_min_size(row.min + vec2(0.0, 4.0), vec2(28.0, 14.0)), st.volume.on, "essentialSound.volume.on", "Clip Volume") { cx.set("volume.on", json!(!st.volume.on), true); } - ui.painter().text(pos2(row.min.x + 38.0, row.center().y), Align2::LEFT_CENTER, "Clip Volume", Tokens::ui(12.5), t.text); + ui.painter().text(pos2(row.min.x + 38.0, row.center().y), Align2::LEFT_CENTER, tl!("Clip Volume"), Tokens::ui(12.5), t.text); let vr = Rect::from_min_max(pos2(row.max.x - 70.0, row.min.y), row.max); ui.painter().text(pos2(vr.max.x - 18.0, vr.center().y), Align2::RIGHT_CENTER, format!("{:.1}", st.volume.level_db), Tokens::ui(12.0), t.hot_text); ui.painter().text(pos2(vr.max.x, vr.center().y), Align2::RIGHT_CENTER, "dB", Tokens::ui(12.0), t.text_dim); @@ -423,7 +430,7 @@ fn footer(ui: &mut egui::Ui, cx: &mut Ctx, rect: Rect, st: Option<&EssentialSoun if switch(ui, cx, mr, st.mute, "essentialSound.mute", "Mute") { cx.set("mute", json!(!st.mute), true); } - ui.painter().text(pos2(mr.max.x + 10.0, mr.center().y), Align2::LEFT_CENTER, "Mute", Tokens::ui(12.5), t.text); + ui.painter().text(pos2(mr.max.x + 10.0, mr.center().y), Align2::LEFT_CENTER, tl!("Mute"), Tokens::ui(12.5), t.text); } // ------------------------------------------------------------------------------------- widgets @@ -439,7 +446,7 @@ fn section_header(ui: &mut egui::Ui, cx: &mut Ctx, sec: Section, open: bool, ena if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text, ); - ui.painter().text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, sec.label(), Tokens::semibold(13.0), t.text); + ui.painter().text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(sec.label()), Tokens::semibold(13.0), t.text); cx.auto.add(&format!("essentialSound.section.{}", sec.label()), r, sec.label()); if let (Some(on), Some(k)) = (enabled, sec.key()) { let sw = Rect::from_center_size(pos2(r.max.x - 16.0, r.center().y), vec2(30.0, 15.0)); @@ -553,9 +560,9 @@ fn slider_track(ui: &mut egui::Ui, cx: &mut Ctx, track: Rect, key: &str, v: f64, fn combo(ui: &mut egui::Ui, cx: &mut Ctx, key: &str, names: &[&str], current: &str, active: bool) { let mut picked = None; ui.add_enabled_ui(active, |ui| { - let cb = egui::ComboBox::from_id_salt(("es-combo", key)).selected_text(current).width(ui.available_width() - 4.0).show_ui(ui, |ui| { + let cb = egui::ComboBox::from_id_salt(("es-combo", key)).selected_text(crate::i18n::t(current)).width(ui.available_width() - 4.0).show_ui(ui, |ui| { for n in names { - if ui.selectable_label(*n == current, *n).clicked() { + if ui.selectable_label(*n == current, crate::i18n::t(n)).clicked() { picked = Some(n.to_string()); } } diff --git a/crates/ui-egui/src/panels/events.rs b/crates/ui-egui/src/panels/events.rs index 50df75eb..60512ef5 100644 --- a/crates/ui-egui/src/panels/events.rs +++ b/crates/ui-egui/src/panels/events.rs @@ -42,10 +42,10 @@ fn ago(ms: u64) -> String { let now = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(ms); let s = now.saturating_sub(ms) / 1000; match s { - 0..=4 => "just now".into(), - 5..=59 => format!("{s} s ago"), - 60..=3599 => format!("{} min ago", s / 60), - _ => format!("{} h ago", s / 3600), + 0..=4 => tl!("just now").into(), + 5..=59 => tlf!("{n} s ago", n = s), + 60..=3599 => tlf!("{n} min ago", n = s / 60), + _ => tlf!("{n} h ago", n = s / 3600), } } @@ -63,9 +63,9 @@ pub fn events(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { // toolbar: [All ▾] counts … [Clear All] ui.horizontal(|ui| { let label = |l: Level| match l { - Level::Info => "All Events", - Level::Warning => "Warnings and Errors", - Level::Error => "Errors Only", + Level::Info => tl!("All Events"), + Level::Warning => tl!("Warnings and Errors"), + Level::Error => tl!("Errors Only"), }; let r = egui::ComboBox::from_id_salt("events-filter").selected_text(label(min)).width(150.0).show_ui(ui, |ui| { for l in [Level::Info, Level::Warning, Level::Error] { @@ -84,7 +84,7 @@ pub fn events(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.label(RichText::new(log.count(l).to_string()).color(t.text_dim)); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let r = ui.button("Clear All"); + let r = ui.button(tl!("Clear All")); elems.push(("events.clearAll".into(), r.rect, "Clear All".into())); clear = r.clicked(); }); @@ -94,7 +94,7 @@ pub fn events(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let list_h = (ui.available_height() - details_h).max(20.0); egui::ScrollArea::vertical().max_height(list_h).auto_shrink([false, false]).show(ui, |ui| { if entries.is_empty() { - ui.label(RichText::new("No events.").color(t.text_faint)); + ui.label(RichText::new(tl!("No events.")).color(t.text_faint)); } for (n, e) in entries.iter().enumerate() { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); @@ -121,7 +121,7 @@ pub fn events(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if let Some(sel) = st.selected.and_then(|id| entries.iter().find(|e| e.id == id)) { let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), details_h - 4.0), Sense::hover()); ui.painter().rect_filled(r, 3.0, t.field_bg); - let text = format!("{} · {} · {}\n{}", sel.level.label(), sel.source, ago(sel.time_ms), sel.message); + let text = format!("{} · {} · {}\n{}", crate::i18n::t(sel.level.label()), sel.source, ago(sel.time_ms), sel.message); let g = ui.painter().layout(text, Tokens::ui(11.5), t.text, r.width() - 12.0); ui.painter().with_clip_rect(r).galley(r.min + vec2(6.0, 4.0), g, t.text); elems.push(("events.details".into(), r, sel.message.clone())); @@ -153,7 +153,7 @@ pub fn progress(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut elems: Vec<(String, Rect, String)> = Vec::new(); let mut cancel = None; let mut finished = show_finished; - let r = ui.checkbox(&mut finished, "Show finished jobs"); + let r = ui.checkbox(&mut finished, tl!("Show finished jobs")); elems.push(("progress.showFinished".into(), r.rect, "Show finished jobs".into())); ui.add_space(4.0); let active = jobs.iter().filter(|j| j["finished"] != json!(true)).count(); @@ -166,16 +166,16 @@ pub fn progress(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } shown += 1; let id = j["id"].as_u64().unwrap_or(0); - let label = j["label"].as_str().unwrap_or("Job"); + let label = crate::i18n::t(j["label"].as_str().unwrap_or("Job")); let frac = j["progress"].as_f64().unwrap_or(0.0).clamp(0.0, 1.0) as f32; let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::hover()); ui.painter().rect_filled(r.shrink(1.0), 3.0, t.row_alt); ui.painter().text(r.min + vec2(8.0, 6.0), Align2::LEFT_TOP, label, Tokens::semibold(12.0), t.text); let err = j["result"]["error"].as_str(); let status = match (done, err) { - (true, Some(e)) if e.to_ascii_lowercase().contains("cancel") => "Cancelled".to_string(), - (true, Some(e)) => format!("Failed: {e}"), - (true, None) => "Done".to_string(), + (true, Some(e)) if e.to_ascii_lowercase().contains("cancel") => tl!("Cancelled").to_string(), + (true, Some(e)) => tlf!("Failed: {e}", e), + (true, None) => tl!("Done").to_string(), (false, _) => { let s = j["status"].as_str().unwrap_or(""); let n = format!("{} / {}", j["done"].as_u64().unwrap_or(0), j["total"].as_u64().unwrap_or(0)); @@ -203,13 +203,13 @@ pub fn progress(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.painter().line_segment([cr.center() + vec2(-5.0, -5.0), cr.center() + vec2(5.0, 5.0)], s); ui.painter().line_segment([cr.center() + vec2(5.0, -5.0), cr.center() + vec2(-5.0, 5.0)], s); elems.push((format!("progress.cancel.{id}"), cr, format!("Cancel {label}"))); - if resp.on_hover_text("Cancel").clicked() { + if resp.on_hover_text(tl!("Cancel")).clicked() { cancel = Some(id); } } } if shown == 0 { - ui.label(RichText::new(if active == 0 { "No background jobs." } else { "" }).color(t.text_faint)); + ui.label(RichText::new(if active == 0 { tl!("No background jobs.") } else { "" }).color(t.text_faint)); } }); for (id, r, l) in elems { diff --git a/crates/ui-egui/src/panels/export_mode.rs b/crates/ui-egui/src/panels/export_mode.rs index c9446a7f..74cb7315 100644 --- a/crates/ui-egui/src/panels/export_mode.rs +++ b/crates/ui-egui/src/panels/export_mode.rs @@ -120,7 +120,7 @@ pub fn apply_preset(app: &mut FilmcraftApp, name: &str) -> bool { true } None => { - app.ui.status = format!("No export preset named `{name}`"); + app.ui.status = tlf!("No export preset named `{name}`", name); false } } @@ -165,7 +165,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.painter().rect_filled(r, t.radius, t.panel_bg); } let Some(seq_id) = app.session.state.active_sequence else { - crate::dock::placeholder(ui, mid, &t, "Open a sequence to export"); + crate::dock::placeholder(ui, mid, &t, tl!("Open a sequence to export")); queue_column(app, ui, left); return; }; @@ -195,21 +195,21 @@ fn queue_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let mut reg = Reg::default(); let p = ui.painter(); - p.text(rect.min + vec2(14.0, 20.0), Align2::LEFT_CENTER, "Destinations", Tokens::semibold(13.0), t.text); + p.text(rect.min + vec2(14.0, 20.0), Align2::LEFT_CENTER, tl!("Destinations"), Tokens::semibold(13.0), t.text); let mf = Rect::from_min_size(rect.min + vec2(8.0, 38.0), vec2(rect.width() - 16.0, 26.0)); p.rect_filled(mf, 4.0, t.row_selected); - p.text(pos2(mf.min.x + 10.0, mf.center().y), Align2::LEFT_CENTER, "Media File", Tokens::ui(12.5), t.text); + p.text(pos2(mf.min.x + 10.0, mf.center().y), Align2::LEFT_CENTER, tl!("Media File"), Tokens::ui(12.5), t.text); reg.add("export.destination.mediaFile", mf, "Media File"); let qtop = mf.max.y + 18.0; let n = app.session.export_queue.items.len(); - p.text(pos2(rect.min.x + 14.0, qtop), Align2::LEFT_CENTER, format!("Queue ({n})"), Tokens::semibold(13.0), t.text); + p.text(pos2(rect.min.x + 14.0, qtop), Align2::LEFT_CENTER, tlf!("Queue ({n})", n), Tokens::semibold(13.0), t.text); let body = Rect::from_min_max(pos2(rect.min.x + 8.0, qtop + 14.0), pos2(rect.max.x - 8.0, rect.max.y - 40.0)); let items: Vec = app.session.execute("export.queue.list", json!({})).ok().and_then(|v| v["items"].as_array().cloned()).unwrap_or_default(); let mut action: Option<(&'static str, Value)> = None; let mut child = ui.new_child(egui::UiBuilder::new().max_rect(body).id_salt("export-queue")); egui::ScrollArea::vertical().id_salt("export-queue-scroll").auto_shrink([false, false]).show(&mut child, |ui| { if items.is_empty() { - ui.label(egui::RichText::new("Send exports here with Send to Queue, then start the queue.").color(t.text_dim).size(11.5)); + ui.label(egui::RichText::new(tl!("Send exports here with Send to Queue, then start the queue.")).color(t.text_dim).size(11.5)); } for (i, it) in items.iter().enumerate() { let id = it["id"].as_u64().unwrap_or(0); @@ -222,13 +222,13 @@ fn queue_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.label(egui::RichText::new(format!("{} → {file}", it["sequenceName"].as_str().unwrap_or(""))).size(12.0).color(t.text)); let preset = it["preset"].as_str().filter(|s| !s.is_empty()).unwrap_or(CUSTOM); let (label, col) = match status { - "ready" => ("Ready".to_string(), t.text_dim), - "encoding" => (format!("Encoding {:.0}%", it["progress"].as_f64().unwrap_or(0.0) * 100.0), t.accent), - "done" => ("Done".to_string(), t.render_green), - "failed" => (format!("Failed: {}", it["error"].as_str().unwrap_or("")), t.danger), - _ => ("Cancelled".to_string(), t.text_faint), + "ready" => (tl!("Ready").to_string(), t.text_dim), + "encoding" => (tlf!("Encoding {n}%", n = format!("{:.0}", it["progress"].as_f64().unwrap_or(0.0) * 100.0)), t.accent), + "done" => (tl!("Done").to_string(), t.render_green), + "failed" => (tlf!("Failed: {e}", e = it["error"].as_str().unwrap_or("")), t.danger), + _ => (tl!("Cancelled").to_string(), t.text_faint), }; - ui.label(egui::RichText::new(format!("{preset} · {label}")).size(11.0).color(col)); + ui.label(egui::RichText::new(format!("{} · {label}", crate::i18n::t(preset))).size(11.0).color(col)); if status == "encoding" { ui.add(egui::ProgressBar::new(it["progress"].as_f64().unwrap_or(0.0) as f32).desired_height(6.0)); } @@ -259,17 +259,18 @@ fn queue_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let has_ready = items.iter().any(|i| i["status"] == "ready"); let foot = Rect::from_min_max(pos2(rect.min.x + 8.0, rect.max.y - 34.0), pos2(rect.max.x - 8.0, rect.max.y - 6.0)); let mut f = ui.new_child(egui::UiBuilder::new().max_rect(foot).layout(egui::Layout::left_to_right(egui::Align::Center))); - let r = f.add_enabled(!running && has_ready, egui::Button::new("Start Queue")); + let r = f.add_enabled(!running && has_ready, egui::Button::new(tl!("Start Queue"))); reg.add("export.queue.start", r.rect, "Start Queue"); if r.clicked() { action = Some(("export.queue.start", json!({}))); } - let r = f.add_enabled(running, egui::Button::new("Stop")); + let r = f.add_enabled(running, egui::Button::new(tl!("Stop"))); reg.add("export.queue.stop", r.rect, "Stop"); if r.clicked() { action = Some(("export.queue.stop", json!({}))); } - let r = f.add_enabled(items.iter().any(|i| matches!(i["status"].as_str(), Some("done" | "failed" | "cancelled"))), egui::Button::new("Clear Finished")); + let r = + f.add_enabled(items.iter().any(|i| matches!(i["status"].as_str(), Some("done" | "failed" | "cancelled"))), egui::Button::new(tl!("Clear Finished"))); reg.add("export.queue.clear", r.rect, "Clear Finished"); if r.clicked() { action = Some(("export.queue.clear", json!({}))); @@ -300,10 +301,10 @@ fn row(ui: &mut egui::Ui, t: &Tokens, label: &str, add: impl FnOnce(&mut egui /// A combo box over `options` (label, enabled); returns the chosen index. fn combo(ui: &mut egui::Ui, reg: &mut Reg, id: &str, selected: &str, options: &[(String, bool)], width: f32) -> Option { let mut chosen = None; - let r = egui::ComboBox::from_id_salt(id).selected_text(selected).width(width).show_ui(ui, |ui| { + let r = egui::ComboBox::from_id_salt(id).selected_text(crate::i18n::t(selected)).width(width).show_ui(ui, |ui| { for (i, (label, enabled)) in options.iter().enumerate() { - let resp = ui.add_enabled(*enabled, egui::Button::selectable(label == selected, label)); - let resp = if *enabled { resp } else { resp.on_disabled_hover_text("Not supported by FilmCraft's encoders yet") }; + let resp = ui.add_enabled(*enabled, egui::Button::selectable(label == selected, crate::i18n::t(label))); + let resp = if *enabled { resp } else { resp.on_disabled_hover_text(tl!("Not supported by FilmCraft's encoders yet")) }; reg.add(format!("{id}.option.{i}"), resp.rect, label.clone()); if resp.clicked() { chosen = Some(i); @@ -363,7 +364,7 @@ const RATES: [(&str, FrameRate); 11] = [ ("60", FrameRate::FPS_60), ]; -const PARS: [(&str, Option<(u32, u32)>); 6] = [ +pub(crate) const PARS: [(&str, Option<(u32, u32)>); 6] = [ ("Square Pixels (1.0)", None), ("D1/DV NTSC (0.9091)", Some((10, 11))), ("D1/DV PAL (1.0940)", Some((59, 54))), @@ -390,53 +391,54 @@ fn settings_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(14.0, 10.0))).id_salt("export-settings")); let ex = &mut app.ui.export; egui::ScrollArea::vertical().id_salt("export-settings-scroll").auto_shrink([false, false]).show(&mut child, |ui| { - ui.label(egui::RichText::new("Media File").strong().size(14.0)); + ui.label(egui::RichText::new(tl!("Media File")).strong().size(14.0)); ui.add_space(6.0); - row(ui, &t, "File Name", |ui| text(ui, &mut reg, "export.fileName", &mut ex.file_name, "Sequence name")); - row(ui, &t, "Location", |ui| { + row(ui, &t, tl!("File Name"), |ui| text(ui, &mut reg, "export.fileName", &mut ex.file_name, tl!("Sequence name"))); + row(ui, &t, tl!("Location"), |ui| { let r = ui.small_button("…"); reg.add("export.location.browse", r.rect, "Choose folder"); pick_folder = r.clicked(); - text(ui, &mut reg, "export.location", &mut ex.location, "Folder"); + text(ui, &mut reg, "export.location", &mut ex.location, tl!("Folder")); }); // Preset: favourites, then every preset by category, then More presets… - row(ui, &t, "Preset", |ui| { - let r = egui::ComboBox::from_id_salt("export.preset").selected_text(&ex.preset).width(ui.available_width() - 4.0).show_ui(ui, |ui| { - if !favs.is_empty() { - ui.label(egui::RichText::new("Favorites").color(t.text_dim).size(11.0)); - for f in &favs { - if ui.selectable_label(*f == ex.preset, f).clicked() { - chosen_preset = Some(f.clone()); + row(ui, &t, tl!("Preset"), |ui| { + let r = + egui::ComboBox::from_id_salt("export.preset").selected_text(crate::i18n::t(&ex.preset)).width(ui.available_width() - 4.0).show_ui(ui, |ui| { + if !favs.is_empty() { + ui.label(egui::RichText::new(tl!("Favorites")).color(t.text_dim).size(11.0)); + for f in &favs { + if ui.selectable_label(*f == ex.preset, crate::i18n::t(f)).clicked() { + chosen_preset = Some(f.clone()); + } + } + ui.separator(); + } + let mut cat = String::new(); + for p in &all_presets { + if p.category != cat { + cat = p.category.clone(); + ui.label(egui::RichText::new(crate::i18n::t(&cat)).color(t.text_dim).size(11.0)); + } + if ui.selectable_label(p.name == ex.preset, crate::i18n::t(&p.name)).clicked() { + chosen_preset = Some(p.name.clone()); } } ui.separator(); - } - let mut cat = String::new(); - for p in &all_presets { - if p.category != cat { - cat = p.category.clone(); - ui.label(egui::RichText::new(&cat).color(t.text_dim).size(11.0)); + if ui.button(tl!("More presets…")).clicked() { + open_manager = true; } - if ui.selectable_label(p.name == ex.preset, &p.name).clicked() { - chosen_preset = Some(p.name.clone()); - } - } - ui.separator(); - if ui.button("More presets…").clicked() { - open_manager = true; - } - }); + }); reg.add("export.preset", r.response.rect, ex.preset.clone()); }); ui.horizontal(|ui| { ui.add_space(LABEL_W + 8.0); - let r = ui.link("More presets…"); + let r = ui.link(tl!("More presets…")); reg.add("export.preset.more", r.rect, "More presets…"); if r.clicked() { open_manager = true; } }); - row(ui, &t, "Format", |ui| { + row(ui, &t, tl!("Format"), |ui| { let labels: Vec<(String, bool)> = Format::ALL.iter().map(|f| (f.label().to_string(), filmcraft_engine::export::available(*f))).collect(); if let Some(i) = combo(ui, &mut reg, "export.format", ex.settings.format.label(), &labels, ui.available_width() - 4.0) { let f = Format::ALL[i]; @@ -456,7 +458,7 @@ fn settings_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { }); ui.add_space(8.0); let s = &mut ex.settings; - if s.has_video() && section(ui, &mut reg, &mut ex.open_sections, "video", "Video", &t) { + if s.has_video() && section(ui, &mut reg, &mut ex.open_sections, "video", tl!("Video"), &t) { video_section(ui, &mut reg, s, &t, seq_w, seq_h); } if (s.has_audio() @@ -466,57 +468,59 @@ fn settings_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { || s.format.is_mxf()) && !s.is_image_sequence() && s.format != Format::Gif - && section(ui, &mut reg, &mut ex.open_sections, "audio", "Audio", &t) + && section(ui, &mut reg, &mut ex.open_sections, "audio", tl!("Audio"), &t) { audio_section(ui, &mut reg, s, &t, seq_sr); } - if s.format == Format::H264 && section(ui, &mut reg, &mut ex.open_sections, "multiplexer", "Multiplexer", &t) { - row(ui, &t, "Multiplexer", |ui| { + if s.format == Format::H264 && section(ui, &mut reg, &mut ex.open_sections, "multiplexer", tl!("Multiplexer"), &t) { + row(ui, &t, tl!("Multiplexer"), |ui| { let cur = if s.multiplexer == Multiplexer::Mp4 { "MP4" } else { "QuickTime" }; if let Some(i) = combo(ui, &mut reg, "export.multiplexer", cur, &opts(&["MP4", "QuickTime"]), 160.0) { s.multiplexer = if i == 0 { Multiplexer::Mp4 } else { Multiplexer::Mov }; } }); } - if s.has_video() && section(ui, &mut reg, &mut ex.open_sections, "captions", "Captions", &t) { + if s.has_video() && section(ui, &mut reg, &mut ex.open_sections, "captions", tl!("Captions"), &t) { if !has_captions { - ui.label(egui::RichText::new("The sequence has no caption track.").color(t.text_dim).size(11.5)); + ui.label(egui::RichText::new(tl!("The sequence has no caption track.")).color(t.text_dim).size(11.5)); } - row(ui, &t, "Export Options", |ui| { + row(ui, &t, tl!("Export Options"), |ui| { + let modes = [tl!("None"), tl!("Burn Captions Into Video"), tl!("Create Sidecar File")]; let cur = if s.burn_captions { - "Burn Captions Into Video" + modes[1] } else if s.caption_sidecar.is_some() { - "Create Sidecar File" + modes[2] } else { - "None" + modes[0] }; - if let Some(i) = combo(ui, &mut reg, "export.captions.mode", cur, &opts(&["None", "Burn Captions Into Video", "Create Sidecar File"]), 220.0) { + if let Some(i) = combo(ui, &mut reg, "export.captions.mode", cur, &opts(&modes), 220.0) { s.burn_captions = i == 1; s.caption_sidecar = (i == 2).then(|| s.caption_sidecar.clone().unwrap_or_else(|| "srt".into())); } }); if let Some(kind) = s.caption_sidecar.clone() { - row(ui, &t, "File Format", |ui| { - let cur = if kind == "vtt" { "WebVTT (.vtt)" } else { "SubRip Subtitle (.srt)" }; - if let Some(i) = combo(ui, &mut reg, "export.captions.format", cur, &opts(&["SubRip Subtitle (.srt)", "WebVTT (.vtt)"]), 220.0) { + row(ui, &t, tl!("File Format"), |ui| { + let formats = [tl!("SubRip Subtitle (.srt)"), tl!("WebVTT (.vtt)")]; + let cur = if kind == "vtt" { formats[1] } else { formats[0] }; + if let Some(i) = combo(ui, &mut reg, "export.captions.format", cur, &opts(&formats), 220.0) { s.caption_sidecar = Some(if i == 0 { "srt" } else { "vtt" }.into()); } }); } } - if section(ui, &mut reg, &mut ex.open_sections, "effects", "Effects", &t) { + if section(ui, &mut reg, &mut ex.open_sections, "effects", tl!("Effects"), &t) { pick_overlay = effects_section(ui, &mut reg, s, &t); } if matches!(s.format, Format::H264 | Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg) - && section(ui, &mut reg, &mut ex.open_sections, "metadata", "Metadata", &t) + && section(ui, &mut reg, &mut ex.open_sections, "metadata", tl!("Metadata"), &t) { let m = &mut s.metadata; for (key, label, v) in [ - ("title", "Title", &mut m.title), - ("creator", "Creator", &mut m.creator), - ("copyright", "Copyright", &mut m.copyright), - ("description", "Description", &mut m.description), - ("comment", "Comment", &mut m.comment), + ("title", tl!("Title"), &mut m.title), + ("creator", tl!("Creator"), &mut m.creator), + ("copyright", tl!("Copyright"), &mut m.copyright), + ("description", tl!("Description"), &mut m.description), + ("comment", tl!("Comment"), &mut m.comment), ] { row(ui, &t, label, |ui| text(ui, &mut reg, &format!("export.metadata.{key}"), v, "")); } @@ -532,7 +536,7 @@ fn settings_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if pick_folder && let Some(d) = app.hooks.pick_folder.as_mut().and_then(|f| f()) { app.ui.export.location = d; } - if pick_overlay && let Some(f) = app.hooks.pick_open_file.as_mut().and_then(|f| f("Image", &["png", "jpg", "jpeg"])) { + if pick_overlay && let Some(f) = app.hooks.pick_open_file.as_mut().and_then(|f| f(tl!("Image"), &["png", "jpg", "jpeg"])) { app.ui.export.settings.effects.image_overlay.path = f; app.ui.export.settings.effects.image_overlay.enabled = true; } @@ -542,8 +546,8 @@ fn settings_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &Tokens, seq_w: u32, seq_h: u32) { // frame size let mut match_size = s.frame_size.is_none(); - row(ui, t, "Frame Size", |ui| { - if check(ui, reg, "export.video.matchSize", &mut match_size, "Match Source") { + row(ui, t, tl!("Frame Size"), |ui| { + if check(ui, reg, "export.video.matchSize", &mut match_size, tl!("Match Source")) { s.frame_size = if match_size { None } else { Some((seq_w, seq_h)) }; } }); @@ -558,8 +562,8 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T } // frame rate let mut match_rate = s.frame_rate.is_none(); - row(ui, t, "Frame Rate", |ui| { - if check(ui, reg, "export.video.matchRate", &mut match_rate, "Match Source") { + row(ui, t, tl!("Frame Rate"), |ui| { + if check(ui, reg, "export.video.matchRate", &mut match_rate, tl!("Match Source")) { s.frame_rate = if match_rate { None } else { Some(FrameRate::FPS_29_97) }; } if let Some(r) = s.frame_rate { @@ -570,22 +574,22 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T } } }); - row(ui, t, "Field Order", |ui| { + row(ui, t, tl!("Field Order"), |ui| { let o = [FieldOrder::Progressive, FieldOrder::UpperFirst, FieldOrder::LowerFirst]; let labels: Vec<(String, bool)> = o.iter().map(|f| (f.label().to_string(), *f == FieldOrder::Progressive)).collect(); if let Some(i) = combo(ui, reg, "export.video.fieldOrder", s.field_order.label(), &labels, 160.0) { s.field_order = o[i]; } }); - row(ui, t, "Aspect", |ui| { - let cur = PARS.iter().find(|(_, p)| *p == s.pixel_aspect).map(|(l, _)| l.to_string()).unwrap_or_else(|| "Custom".into()); + row(ui, t, tl!("Aspect"), |ui| { + let cur = PARS.iter().find(|(_, p)| *p == s.pixel_aspect).map(|(l, _)| l.to_string()).unwrap_or_else(|| tl!("Custom").into()); let labels: Vec<(String, bool)> = PARS.iter().map(|(l, _)| (l.to_string(), true)).collect(); if let Some(i) = combo(ui, reg, "export.video.aspect", &cur, &labels, 220.0) { s.pixel_aspect = PARS[i].1; } }); if s.format.is_mxf() { - row(ui, t, "Video Codec", |ui| { + row(ui, t, tl!("Video Codec"), |ui| { let labels: Vec<(String, bool)> = MxfVideoCodec::ALL.iter().map(|c| (c.label().to_string(), true)).collect(); if let Some(i) = combo(ui, reg, "export.video.mxfCodec", s.mxf_video_codec.label(), &labels, 180.0) { s.mxf_video_codec = MxfVideoCodec::ALL[i]; @@ -594,23 +598,23 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T } match s.video_format() { Format::H264 => { - row(ui, t, "Profile", |ui| { + row(ui, t, tl!("Profile"), |ui| { let o = [H264Profile::Baseline, H264Profile::Main, H264Profile::High]; let labels: Vec<(String, bool)> = o.iter().map(|p| (p.label().to_string(), true)).collect(); if let Some(i) = combo(ui, reg, "export.video.profile", s.h264_profile.label(), &labels, 120.0) { s.h264_profile = o[i]; } }); - row(ui, t, "Level", |ui| { + row(ui, t, tl!("Level"), |ui| { let levels: [Option; 13] = [None, Some(30), Some(31), Some(32), Some(40), Some(41), Some(42), Some(50), Some(51), Some(52), Some(60), Some(61), Some(62)]; - let lab = |l: &Option| l.map(|l| format!("{}.{}", l / 10, l % 10)).unwrap_or_else(|| "Auto".into()); + let lab = |l: &Option| l.map(|l| format!("{}.{}", l / 10, l % 10)).unwrap_or_else(|| tl!("Auto").into()); let labels: Vec<(String, bool)> = levels.iter().map(|l| (lab(l), true)).collect(); if let Some(i) = combo(ui, reg, "export.video.level", &lab(&s.h264_level), &labels, 120.0) { s.h264_level = levels[i]; } }); - row(ui, t, "Bitrate Encoding", |ui| { + row(ui, t, tl!("Bitrate Encoding"), |ui| { let o = [BitrateMode::Cbr, BitrateMode::Vbr1Pass, BitrateMode::Vbr2Pass]; let labels: Vec<(String, bool)> = o.iter().map(|m| (m.label().to_string(), true)).collect(); if let Some(i) = combo(ui, reg, "export.video.bitrateMode", s.bitrate_mode.label(), &labels, 140.0) { @@ -618,23 +622,23 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T } }); if let Some(bpp) = s.adaptive_bitrate { - row(ui, t, "Target Bitrate", |ui| { - ui.label(egui::RichText::new(format!("Adaptive ({bpp} bits per pixel)")).size(12.0)); - let r = ui.small_button("Set"); + row(ui, t, tl!("Target Bitrate"), |ui| { + ui.label(egui::RichText::new(tlf!("Adaptive ({bpp} bits per pixel)", bpp)).size(12.0)); + let r = ui.small_button(tl!("Set")); reg.add("export.video.fixedBitrate", r.rect, "Set a fixed bitrate"); if r.clicked() { s.adaptive_bitrate = None; } }); } else { - row(ui, t, "Target Bitrate", |ui| { + row(ui, t, tl!("Target Bitrate"), |ui| { let mut mbps = s.bitrate_kbps as f64 / 1000.0; if drag(ui, reg, "export.video.target", &mut mbps, 0.1..=800.0, 0.1, " Mbps", 1) { s.bitrate_kbps = (mbps * 1000.0).round() as u32; } }); if s.bitrate_mode != BitrateMode::Cbr { - row(ui, t, "Maximum Bitrate", |ui| { + row(ui, t, tl!("Maximum Bitrate"), |ui| { let mut mbps = s.max_bitrate_kbps.unwrap_or(s.bitrate_kbps * 3 / 2) as f64 / 1000.0; if drag(ui, reg, "export.video.max", &mut mbps, 0.1..=1000.0, 0.1, " Mbps", 1) { s.max_bitrate_kbps = Some((mbps * 1000.0).round() as u32); @@ -642,7 +646,7 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T }); } } - row(ui, t, "Key Frame Distance", |ui| { + row(ui, t, tl!("Key Frame Distance"), |ui| { let mut on = s.keyframe_distance.is_some(); if check(ui, reg, "export.video.keyframeOn", &mut on, "") { s.keyframe_distance = on.then_some(48); @@ -655,7 +659,7 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T }); } Format::ProRes => { - row(ui, t, "Profile", |ui| { + row(ui, t, tl!("Profile"), |ui| { let keys = ["proxy", "lt", "standard", "hq"]; let labels = ["Apple ProRes 422 Proxy", "Apple ProRes 422 LT", "Apple ProRes 422", "Apple ProRes 422 HQ"]; let cur = keys.iter().position(|k| *k == s.prores_profile).unwrap_or(3); @@ -665,7 +669,7 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T }); } Format::DnxHr => { - row(ui, t, "Profile", |ui| { + row(ui, t, tl!("Profile"), |ui| { let keys = ["lb", "sq", "hq", "hqx"]; let labels = ["DNxHR LB", "DNxHR SQ", "DNxHR HQ", "DNxHR HQX (10-bit)"]; let cur = keys.iter().position(|k| *k == s.dnx_profile).unwrap_or(2); @@ -675,7 +679,7 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T }); } Format::Apv => { - row(ui, t, "Profile", |ui| { + row(ui, t, tl!("Profile"), |ui| { let keys = ["422-10", "422-12", "444-10", "444-12"]; let labels = ["APV 422-10", "APV 422-12", "APV 444-10", "APV 444-12"]; let cur = keys.iter().position(|k| *k == s.apv_profile).unwrap_or(0); @@ -685,7 +689,7 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T }); } Format::Mjpeg => { - row(ui, t, "Quality", |ui| { + row(ui, t, tl!("Quality"), |ui| { let mut q = s.quality as f64; drag(ui, reg, "export.video.quality", &mut q, 1.0..=100.0, 1.0, "", 0); s.quality = q as u8; @@ -693,52 +697,53 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T } _ => {} } - row(ui, t, "", |ui| check(ui, reg, "export.video.maxDepth", &mut s.render_at_max_depth, "Render at Maximum Depth")); - row(ui, t, "", |ui| check(ui, reg, "export.video.maxQuality", &mut s.max_render_quality, "Use Maximum Render Quality")); + row(ui, t, "", |ui| check(ui, reg, "export.video.maxDepth", &mut s.render_at_max_depth, tl!("Render at Maximum Depth"))); + row(ui, t, "", |ui| check(ui, reg, "export.video.maxQuality", &mut s.max_render_quality, tl!("Use Maximum Render Quality"))); } fn audio_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &Tokens, seq_sr: u32) { let audio_only = !s.has_video(); if !audio_only { - row(ui, t, "", |ui| check(ui, reg, "export.audio.include", &mut s.include_audio, "Export Audio")); + row(ui, t, "", |ui| check(ui, reg, "export.audio.include", &mut s.include_audio, tl!("Export Audio"))); if !s.include_audio { return; } } - row(ui, t, "Audio Format", |ui| { + row(ui, t, tl!("Audio Format"), |ui| { let fixed = s.format == Format::H264 && s.multiplexer == Multiplexer::Mp4 || audio_only || s.format.is_mxf(); let cur = match s.audio_codec() { AudioCodec::Aac => "AAC", - _ => "Uncompressed (PCM)", + _ => tl!("Uncompressed (PCM)"), }; let labels = - vec![("AAC".to_string(), !audio_only), ("Uncompressed (PCM)".to_string(), !(s.format == Format::H264 && s.multiplexer == Multiplexer::Mp4))]; + vec![("AAC".to_string(), !audio_only), (tl!("Uncompressed (PCM)").to_string(), !(s.format == Format::H264 && s.multiplexer == Multiplexer::Mp4))]; if fixed { ui.label(cur); } else if let Some(i) = combo(ui, reg, "export.audio.codec", cur, &labels, 180.0) { s.audio.codec = if i == 0 { AudioCodec::Aac } else { AudioCodec::Pcm }; } }); - row(ui, t, "Sample Rate", |ui| { + row(ui, t, tl!("Sample Rate"), |ui| { let rates: [Option; 5] = [None, Some(32_000), Some(44_100), Some(48_000), Some(96_000)]; - let lab = |r: &Option| r.map(|r| format!("{r} Hz")).unwrap_or_else(|| format!("Match Source ({seq_sr} Hz)")); + let lab = |r: &Option| r.map(|r| format!("{r} Hz")).unwrap_or_else(|| tlf!("Match Source ({seq_sr} Hz)", seq_sr)); let labels: Vec<(String, bool)> = rates.iter().map(|r| (lab(r), true)).collect(); if let Some(i) = combo(ui, reg, "export.audio.sampleRate", &lab(&s.audio.sample_rate), &labels, 180.0) { s.audio.sample_rate = rates[i]; } }); - row(ui, t, "Channels", |ui| { + row(ui, t, tl!("Channels"), |ui| { + let names = [tl!("Mono"), tl!("Stereo"), "5.1"]; let cur = match s.audio.channels { - 1 => "Mono", - 6 => "5.1", - _ => "Stereo", + 1 => names[0], + 6 => names[2], + _ => names[1], }; - if let Some(i) = combo(ui, reg, "export.audio.channels", cur, &opts(&["Mono", "Stereo", "5.1"]), 120.0) { + if let Some(i) = combo(ui, reg, "export.audio.channels", cur, &opts(&names), 120.0) { s.audio.channels = [1, 2, 6][i.min(2)]; } }); if s.audio_codec() == AudioCodec::Aac { - row(ui, t, "Audio Bitrate", |ui| { + row(ui, t, tl!("Audio Bitrate"), |ui| { let rates = [128u32, 160, 192, 256, 320]; let labels: Vec<(String, bool)> = rates.iter().map(|r| (format!("{r} kbps"), true)).collect(); if let Some(i) = combo(ui, reg, "export.audio.bitrate", &format!("{} kbps", s.audio.bitrate_kbps), &labels, 120.0) { @@ -746,9 +751,10 @@ fn audio_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T } }); } else { - row(ui, t, "Sample Size", |ui| { - let cur = if s.audio.bits >= 24 { "24 bit" } else { "16 bit" }; - if let Some(i) = combo(ui, reg, "export.audio.bits", cur, &opts(&["16 bit", "24 bit"]), 120.0) { + row(ui, t, tl!("Sample Size"), |ui| { + let sizes = [tl!("16 bit"), tl!("24 bit")]; + let cur = if s.audio.bits >= 24 { sizes[1] } else { sizes[0] }; + if let Some(i) = combo(ui, reg, "export.audio.bits", cur, &opts(&sizes), 120.0) { s.audio.bits = if i == 0 { 16 } else { 24 }; } }); @@ -764,8 +770,8 @@ fn placement_combo(ui: &mut egui::Ui, reg: &mut Reg, id: &str, p: &mut Placement fn text_overlay(ui: &mut egui::Ui, reg: &mut Reg, key: &str, o: &mut TextOverlay, t: &Tokens, text_label: &str) { row(ui, t, text_label, |ui| text(ui, reg, &format!("export.effects.{key}.text"), &mut o.text, "")); - row(ui, t, "Position", |ui| placement_combo(ui, reg, &format!("export.effects.{key}.position"), &mut o.placement)); - row(ui, t, "Size / Opacity", |ui| { + row(ui, t, tl!("Position"), |ui| placement_combo(ui, reg, &format!("export.effects.{key}.position"), &mut o.placement)); + row(ui, t, tl!("Size / Opacity"), |ui| { let (mut sz, mut op) = (o.size_percent as f64, o.opacity as f64); drag(ui, reg, &format!("export.effects.{key}.size"), &mut sz, 1.0..=50.0, 0.2, " %", 1); drag(ui, reg, &format!("export.effects.{key}.opacity"), &mut op, 0.0..=100.0, 1.0, " %", 0); @@ -780,34 +786,34 @@ fn effects_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: let video = s.has_video(); let fx = &mut s.effects; if video { - check(ui, reg, "export.effects.imageOverlay", &mut fx.image_overlay.enabled, "Image Overlay"); + check(ui, reg, "export.effects.imageOverlay", &mut fx.image_overlay.enabled, tl!("Image Overlay")); if fx.image_overlay.enabled { let o = &mut fx.image_overlay; - row(ui, t, "Image", |ui| { - let r = ui.small_button("Browse…"); + row(ui, t, tl!("Image"), |ui| { + let r = ui.small_button(tl!("Browse…")); reg.add("export.effects.imageOverlay.browse", r.rect, "Browse…"); browse = r.clicked(); - text(ui, reg, "export.effects.imageOverlay.path", &mut o.path, "PNG or JPEG file"); + text(ui, reg, "export.effects.imageOverlay.path", &mut o.path, tl!("PNG or JPEG file")); }); - row(ui, t, "Position", |ui| placement_combo(ui, reg, "export.effects.imageOverlay.position", &mut o.placement)); - row(ui, t, "Size / Opacity", |ui| { + row(ui, t, tl!("Position"), |ui| placement_combo(ui, reg, "export.effects.imageOverlay.position", &mut o.placement)); + row(ui, t, tl!("Size / Opacity"), |ui| { let (mut sz, mut op) = (o.size_percent as f64, o.opacity as f64); drag(ui, reg, "export.effects.imageOverlay.size", &mut sz, 1.0..=100.0, 0.5, " %", 1); drag(ui, reg, "export.effects.imageOverlay.opacity", &mut op, 0.0..=100.0, 1.0, " %", 0); (o.size_percent, o.opacity) = (sz as f32, op as f32); }); } - check(ui, reg, "export.effects.nameOverlay", &mut fx.name_overlay.enabled, "Name Overlay"); + check(ui, reg, "export.effects.nameOverlay", &mut fx.name_overlay.enabled, tl!("Name Overlay")); if fx.name_overlay.enabled { - text_overlay(ui, reg, "nameOverlay", &mut fx.name_overlay, t, "Name"); + text_overlay(ui, reg, "nameOverlay", &mut fx.name_overlay, t, tl!("Name")); } - check(ui, reg, "export.effects.timecodeOverlay", &mut fx.timecode_overlay.enabled, "Timecode Overlay"); + check(ui, reg, "export.effects.timecodeOverlay", &mut fx.timecode_overlay.enabled, tl!("Timecode Overlay")); if fx.timecode_overlay.enabled { - text_overlay(ui, reg, "timecodeOverlay", &mut fx.timecode_overlay, t, "Prefix"); + text_overlay(ui, reg, "timecodeOverlay", &mut fx.timecode_overlay, t, tl!("Prefix")); } - check(ui, reg, "export.effects.videoLimiter", &mut fx.video_limiter.enabled, "Video Limiter"); + check(ui, reg, "export.effects.videoLimiter", &mut fx.video_limiter.enabled, tl!("Video Limiter")); if fx.video_limiter.enabled { - row(ui, t, "Luma Min / Max", |ui| { + row(ui, t, tl!("Luma Min / Max"), |ui| { let (mut lo, mut hi) = (fx.video_limiter.min_percent as f64, fx.video_limiter.max_percent as f64); drag(ui, reg, "export.effects.videoLimiter.min", &mut lo, 0.0..=50.0, 0.5, " %", 1); drag(ui, reg, "export.effects.videoLimiter.max", &mut hi, 50.0..=100.0, 0.5, " %", 1); @@ -816,12 +822,12 @@ fn effects_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: } } if audio { - check(ui, reg, "export.effects.loudness", &mut fx.loudness.enabled, "Loudness Normalization"); + check(ui, reg, "export.effects.loudness", &mut fx.loudness.enabled, tl!("Loudness Normalization")); if fx.loudness.enabled { - row(ui, t, "Target Loudness", |ui| { + row(ui, t, tl!("Target Loudness"), |ui| { drag(ui, reg, "export.effects.loudness.target", &mut fx.loudness.target_lufs, -40.0..=-5.0, 0.1, " LUFS", 1); }); - row(ui, t, "True Peak Limit", |ui| { + row(ui, t, tl!("True Peak Limit"), |ui| { drag(ui, reg, "export.effects.loudness.truePeak", &mut fx.loudness.true_peak_dbtp, -9.0..=0.0, 0.1, " dBTP", 1); }); } @@ -833,7 +839,7 @@ fn effects_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: // right: preview, range, summary, buttons // --------------------------------------------------------------------------------------------- -const RANGES: [(&str, &str); 4] = [("entire", "Entire Source"), ("inOut", "Source In/Out"), ("workArea", "Work Area"), ("custom", "Custom")]; +pub(crate) const RANGES: [(&str, &str); 4] = [("entire", "Entire Source"), ("inOut", "Source In/Out"), ("workArea", "Work Area"), ("custom", "Custom")]; fn preview_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq_id: filmcraft_engine::project::ItemId) { let t = app.tokens; @@ -864,7 +870,7 @@ fn preview_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq_id: ui.painter().with_clip_rect(pic).image(tex, inner, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } } else { - ui.painter().text(pic.center(), Align2::CENTER_CENTER, "Audio only", Tokens::ui(13.0), t.text_dim); + ui.painter().text(pic.center(), Align2::CENTER_CENTER, tl!("Audio only"), Tokens::ui(13.0), t.text_dim); } reg.add("export.preview", pic, "Preview"); // range + scaling @@ -876,7 +882,7 @@ fn preview_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq_id: let ex = &mut app.ui.export; let has_work_area = q.work_area.is_some(); child.horizontal(|ui| { - ui.label(egui::RichText::new("Range").color(t.text_dim)); + ui.label(egui::RichText::new(tl!("Range")).color(t.text_dim)); let cur = RANGES.iter().find(|(k, _)| *k == ex.range).map(|x| x.1).unwrap_or("Entire Source"); let labels: Vec<(String, bool)> = RANGES.iter().map(|(k, l)| (l.to_string(), *k != "workArea" || has_work_area)).collect(); if let Some(i) = combo(ui, &mut reg, "export.range", cur, &labels, 140.0) { @@ -889,9 +895,10 @@ fn preview_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq_id: }); if ex.settings.has_video() { child.horizontal(|ui| { - ui.label(egui::RichText::new("Scaling").color(t.text_dim)); - let o = [(Scaling::ScaleToFit, "Scale To Fit"), (Scaling::ScaleToFill, "Scale To Fill"), (Scaling::StretchToFill, "Stretch To Fill")]; - let cur = o.iter().find(|(x, _)| *x == ex.settings.scaling).map(|x| x.1).unwrap_or("Scale To Fit"); + ui.label(egui::RichText::new(tl!("Scaling")).color(t.text_dim)); + let o = + [(Scaling::ScaleToFit, tl!("Scale To Fit")), (Scaling::ScaleToFill, tl!("Scale To Fill")), (Scaling::StretchToFill, tl!("Stretch To Fill"))]; + let cur = o.iter().find(|(x, _)| *x == ex.settings.scaling).map_or(o[0].1, |x| x.1); let labels: Vec<(String, bool)> = o.iter().map(|(_, l)| (l.to_string(), true)).collect(); if let Some(i) = combo(ui, &mut reg, "export.scaling", cur, &labels, 140.0) { ex.settings.scaling = o[i].0; @@ -908,10 +915,10 @@ fn preview_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq_id: .inner_margin(egui::Margin::same(10)) .show(&mut child, |ui| { ui.set_width(ui.available_width()); - ui.label(egui::RichText::new("Summary").strong()); + ui.label(egui::RichText::new(tl!("Summary")).strong()); egui::Grid::new("export-summary-grid").num_columns(2).spacing(vec2(10.0, 3.0)).show(ui, |ui| { let line = |ui: &mut egui::Ui, k: &str, v: &str| { - ui.label(egui::RichText::new(k).color(t.text_dim).size(11.5)); + ui.label(egui::RichText::new(crate::i18n::t(k)).color(t.text_dim).size(11.5)); ui.add(egui::Label::new(egui::RichText::new(v).size(11.5)).wrap_mode(egui::TextWrapMode::Wrap).halign(egui::Align::Min)); ui.end_row(); }; @@ -919,35 +926,49 @@ fn preview_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq_id: line(ui, "", &sum.format); line(ui, "", &sum.video); line(ui, "", &sum.audio); - line(ui, "Source", &format!("Sequence, {}x{}, {} fps, {} Hz", q.settings.width, q.settings.height, rate.label(), q.settings.sample_rate)); + line( + ui, + "Source", + &tlf!( + "Sequence, {w}x{h}, {fps} fps, {rate} Hz", + w = q.settings.width, + h = q.settings.height, + fps = rate.label(), + rate = q.settings.sample_rate + ), + ); line( ui, "Range", - &format!("{} ({:.2} s)", RANGES.iter().find(|(k, _)| *k == ex.range).map(|x| x.1).unwrap_or(""), (range.1 - range.0).seconds()), + &format!( + "{} ({:.2} s)", + crate::i18n::t(RANGES.iter().find(|(k, _)| *k == ex.range).map(|x| x.1).unwrap_or("")), + (range.1 - range.0).seconds() + ), ); }); }) .response; reg.add("export.summary", resp.rect, sum.video.clone()); child.add_space(4.0); - let est = child.label(egui::RichText::new(format!("Estimated file size: {}", format_bytes(sum.estimated_bytes))).color(t.text_dim)); + let est = child.label(egui::RichText::new(tlf!("Estimated file size: {size}", size = format_bytes(sum.estimated_bytes))).color(t.text_dim)); reg.add("export.estimate", est.rect, sum.estimated_size.clone()); // buttons let go = Rect::from_min_size(pos2(rect.max.x - 120.0, rect.max.y - 44.0), vec2(104.0, 30.0)); let queue = Rect::from_min_size(pos2(go.min.x - 132.0, go.min.y), vec2(122.0, 30.0)); let qresp = ui.interact(queue, egui::Id::new("export-queue-send"), Sense::click()); ui.painter().rect_stroke(queue, 15.0, egui::Stroke::new(1.0, if qresp.hovered() { t.text } else { t.text_dim }), egui::StrokeKind::Inside); - ui.painter().text(queue.center(), Align2::CENTER_CENTER, "Send to Queue", Tokens::semibold(12.5), t.text); + ui.painter().text(queue.center(), Align2::CENTER_CENTER, tl!("Send to Queue"), Tokens::semibold(12.5), t.text); reg.add("export.sendToQueue", queue, "Send to Queue"); let resp = ui.interact(go, egui::Id::new("export-go"), Sense::click()); ui.painter().rect_filled(go, 15.0, if resp.hovered() { t.accent_hover } else { t.accent }); - ui.painter().text(go.center(), Align2::CENTER_CENTER, "Export", Tokens::semibold(13.0), Color32::WHITE); + ui.painter().text(go.center(), Align2::CENTER_CENTER, tl!("Export"), Tokens::semibold(13.0), Color32::WHITE); reg.add("export.button", go, "Export"); reg.flush(app); if resp.clicked() { let r = app.session.execute("file.exportMedia", export_params(app)); match r { - Ok(v) => app.ui.status = format!("Exporting {}", v["path"].as_str().unwrap_or("")), + Ok(v) => app.ui.status = tlf!("Exporting {path}", path = v["path"].as_str().unwrap_or("")), Err(e) => app.ui.status = e.to_string(), } } @@ -958,7 +979,7 @@ fn preview_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq_id: p["preset"] = json!(app.ui.export.preset); } match app.session.execute("export.queue.add", p) { - Ok(_) => app.ui.status = "Added to the export queue".into(), + Ok(_) => app.ui.status = tl!("Added to the export queue").into(), Err(e) => app.ui.status = e.to_string(), } } @@ -994,8 +1015,9 @@ fn preset_manager(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut pick: Option<&'static str> = None; let all = app.session.export_presets.all(); let favorite = |n: &str| app.session.export_presets.is_favorite(n); - let q = m.query.to_ascii_lowercase(); - egui::Window::new("Preset Manager") + let q = m.query.to_lowercase(); + egui::Window::new(tl!("Preset Manager")) + .id(egui::Id::new("Preset Manager")) .id(egui::Id::new("preset-manager")) .open(&mut open) .collapsible(false) @@ -1005,35 +1027,32 @@ fn preset_manager(app: &mut FilmcraftApp, ctx: &egui::Context) { .default_pos(ctx.content_rect().center()) .show(ctx, |ui| { ui.horizontal(|ui| { - let r = crate::widgets::search_field(ui, &mut m.query, "Search presets", 300.0, &t); + let r = crate::widgets::search_field(ui, &mut m.query, tl!("Search presets"), 300.0, &t); reg.add("presetManager.search", r.rect, "Search"); - check(ui, &mut reg, "presetManager.favoritesOnly", &mut m.favorites_only, "Favorites only"); + check(ui, &mut reg, "presetManager.favoritesOnly", &mut m.favorites_only, tl!("Favorites only")); }); ui.separator(); egui::ScrollArea::vertical().max_height(340.0).auto_shrink([false, false]).show(ui, |ui| { let mut cat = String::new(); for p in all.iter().filter(|p| { - (q.is_empty() - || p.name.to_ascii_lowercase().contains(&q) - || p.description.to_ascii_lowercase().contains(&q) - || p.category.to_ascii_lowercase().contains(&q)) + (crate::i18n::matches_query(&p.name, &q) || crate::i18n::matches_query(&p.description, &q) || crate::i18n::matches_query(&p.category, &q)) && (!m.favorites_only || favorite(&p.name)) }) { if p.category != cat { cat = p.category.clone(); ui.add_space(4.0); - ui.label(egui::RichText::new(&cat).strong().color(t.text_dim).size(11.5)); + ui.label(egui::RichText::new(crate::i18n::t(&cat)).strong().color(t.text_dim).size(11.5)); } ui.horizontal(|ui| { let fav = favorite(&p.name); let (sr, sresp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click()); paint_star(ui.painter(), sr.shrink(2.0), fav, if fav { Color32::from_rgb(0xf5, 0xc2, 0x42) } else { t.text_faint }); reg.add(format!("presetManager.favorite.{}", p.name), sr, if fav { "Remove from favorites" } else { "Add to favorites" }); - if sresp.on_hover_text("Show in the Preset menu").clicked() { + if sresp.on_hover_text(tl!("Show in the Preset menu")).clicked() { cmd = Some(("export.presets.favorite", json!({"name": p.name}))); } let sel = preset_key(&m.selected) == preset_key(&p.name); - let r = list_row(ui, &t, sel, &p.name, 300.0); + let r = list_row(ui, &t, sel, crate::i18n::t(&p.name), 300.0); reg.add(format!("presetManager.item.{}", p.name), r.rect, p.name.clone()); if r.clicked() { m.selected = p.name.clone(); @@ -1041,11 +1060,11 @@ fn preset_manager(app: &mut FilmcraftApp, ctx: &egui::Context) { if r.double_clicked() { apply = Some(p.name.clone()); } - let r = r.on_hover_text(&p.description); + let r = r.on_hover_text(crate::i18n::t(&p.description)); let _ = r; ui.label(egui::RichText::new(p.settings.format.label()).color(t.text_dim).size(11.0)); if !p.builtin { - ui.label(egui::RichText::new("User").color(t.accent).size(10.5)); + ui.label(egui::RichText::new(tl!("User")).color(t.accent).size(10.5)); } }); } @@ -1053,13 +1072,13 @@ fn preset_manager(app: &mut FilmcraftApp, ctx: &egui::Context) { ui.separator(); let sel = all.iter().find(|p| preset_key(&p.name) == preset_key(&m.selected)).cloned(); if let Some(p) = &sel { - ui.label(egui::RichText::new(&p.description).color(t.text_dim).size(11.5)); + ui.label(egui::RichText::new(crate::i18n::t(&p.description)).color(t.text_dim).size(11.5)); } ui.horizontal(|ui| { - ui.label("Save current settings as:"); - let r = ui.add(egui::TextEdit::singleline(&mut m.save_name).hint_text("Preset name").desired_width(200.0)); + ui.label(tl!("Save current settings as:")); + let r = ui.add(egui::TextEdit::singleline(&mut m.save_name).hint_text(tl!("Preset name")).desired_width(200.0)); reg.add("presetManager.saveName", r.rect, "Preset name"); - let r = ui.add_enabled(!m.save_name.trim().is_empty(), egui::Button::new("Save")); + let r = ui.add_enabled(!m.save_name.trim().is_empty(), egui::Button::new(tl!("Save"))); reg.add("presetManager.save", r.rect, "Save"); if r.clicked() { cmd = Some(("export.presets.save", json!({"name": m.save_name.trim(), "settings": settings_json(&app.ui.export.settings)}))); @@ -1067,28 +1086,28 @@ fn preset_manager(app: &mut FilmcraftApp, ctx: &egui::Context) { } }); ui.horizontal(|ui| { - let r = ui.button("Import…"); + let r = ui.button(tl!("Import…")); reg.add("presetManager.import", r.rect, "Import…"); if r.clicked() { pick = Some("import"); } - let r = ui.add_enabled(sel.is_some(), egui::Button::new("Export…")); + let r = ui.add_enabled(sel.is_some(), egui::Button::new(tl!("Export…"))); reg.add("presetManager.export", r.rect, "Export…"); if r.clicked() { pick = Some("export"); } - let r = ui.add_enabled(sel.as_ref().is_some_and(|p| !p.builtin), egui::Button::new("Delete")); + let r = ui.add_enabled(sel.as_ref().is_some_and(|p| !p.builtin), egui::Button::new(tl!("Delete"))); reg.add("presetManager.delete", r.rect, "Delete"); if r.clicked() { cmd = Some(("export.presets.delete", json!({"name": m.selected}))); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let r = ui.add_enabled(sel.is_some(), egui::Button::new("OK")); + let r = ui.add_enabled(sel.is_some(), egui::Button::new(tl!("OK"))); reg.add("presetManager.ok", r.rect, "OK"); if r.clicked() { apply = Some(m.selected.clone()); } - let r = ui.button("Cancel"); + let r = ui.button(tl!("Cancel")); reg.add("presetManager.cancel", r.rect, "Cancel"); close |= r.clicked(); }); @@ -1101,21 +1120,21 @@ fn preset_manager(app: &mut FilmcraftApp, ctx: &egui::Context) { app.ui.status = e.to_string(); } match pick { - Some("import") => match app.hooks.pick_open_file.as_mut().and_then(|f| f("Export presets", &["json"])) { + Some("import") => match app.hooks.pick_open_file.as_mut().and_then(|f| f(tl!("Export presets"), &["json"])) { Some(path) => { if let Err(e) = app.session.execute("export.presets.import", json!({"path": path})) { app.ui.status = e.to_string(); } } - None => app.ui.status = "Import: no file chosen (agents: export.presets.import {path})".into(), + None => app.ui.status = tl!("Import: no file chosen (agents: export.presets.import {path})").into(), }, - Some(_) => match app.hooks.pick_save_as.as_mut().and_then(|f| f("Export presets", &["json"], &format!("{}.json", m.selected))) { + Some(_) => match app.hooks.pick_save_as.as_mut().and_then(|f| f(tl!("Export presets"), &["json"], &format!("{}.json", m.selected))) { Some(path) => { if let Err(e) = app.session.execute("export.presets.export", json!({"path": path, "names": [m.selected]})) { app.ui.status = e.to_string(); } } - None => app.ui.status = "Export: no file chosen (agents: export.presets.export {path, names})".into(), + None => app.ui.status = tl!("Export: no file chosen (agents: export.presets.export {path, names})").into(), }, None => {} } @@ -1171,7 +1190,7 @@ pub fn quick_export(app: &mut FilmcraftApp, ctx: &egui::Context, anchor: egui::P let t = app.tokens; let Some(seq) = app.session.state.active_sequence else { app.ui.export.quick_open = false; - app.ui.status = "Quick Export: open a sequence first".into(); + app.ui.status = tl!("Quick Export: open a sequence first").into(); return; }; if app.ui.export.quick_preset.is_empty() { @@ -1201,21 +1220,21 @@ pub fn quick_export(app: &mut FilmcraftApp, ctx: &egui::Context, anchor: egui::P egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_width(340.0); ui.horizontal(|ui| { - ui.label(egui::RichText::new("Quick Export").strong().size(14.0)); + ui.label(egui::RichText::new(tl!("Quick Export")).strong().size(14.0)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let r = ui.small_button("Close"); + let r = ui.small_button(tl!("Close")); reg.add("quickExport.close", r.rect, "Close"); close = r.clicked(); }); }); - ui.label(egui::RichText::new("File Name & Location").color(t.text_dim).size(11.5)); + ui.label(egui::RichText::new(tl!("File Name & Location")).color(t.text_dim).size(11.5)); let r = ui.add(egui::TextEdit::singleline(&mut q.quick_path).desired_width(f32::INFINITY)); reg.add("quickExport.path", r.rect, "File Name & Location"); ui.add_space(4.0); - ui.label(egui::RichText::new("Preset").color(t.text_dim).size(11.5)); + ui.label(egui::RichText::new(tl!("Preset")).color(t.text_dim).size(11.5)); egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { for name in &presets { - let r = list_row(ui, &t, preset_key(name) == preset_key(&q.quick_preset), name, 320.0); + let r = list_row(ui, &t, preset_key(name) == preset_key(&q.quick_preset), crate::i18n::t(name), 320.0); reg.add(format!("quickExport.preset.{name}"), r.rect, name.clone()); if r.clicked() { q.quick_preset = name.clone(); @@ -1223,10 +1242,11 @@ pub fn quick_export(app: &mut FilmcraftApp, ctx: &egui::Context, anchor: egui::P } }); ui.add_space(4.0); - ui.label(egui::RichText::new(format!("Estimated file size: {}", est.as_deref().unwrap_or("–"))).color(t.text_dim).size(11.5)); + ui.label(egui::RichText::new(tlf!("Estimated file size: {size}", size = est.as_deref().unwrap_or("–"))).color(t.text_dim).size(11.5)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let r = ui - .add(egui::Button::new(egui::RichText::new("Export").color(Color32::WHITE)).fill(t.accent).corner_radius(12.0).min_size(vec2(90.0, 26.0))); + let r = ui.add( + egui::Button::new(egui::RichText::new(tl!("Export")).color(Color32::WHITE)).fill(t.accent).corner_radius(12.0).min_size(vec2(90.0, 26.0)), + ); reg.add("quickExport.go", r.rect, "Export"); go = r.clicked(); }); @@ -1239,7 +1259,7 @@ pub fn quick_export(app: &mut FilmcraftApp, ctx: &egui::Context, anchor: egui::P if go { let p = json!({"preset": app.ui.export.quick_preset, "path": app.ui.export.quick_path}); match app.session.execute("export.quick", p) { - Ok(v) => app.ui.status = format!("Quick Export: {}", v["path"].as_str().unwrap_or("")), + Ok(v) => app.ui.status = tlf!("Quick Export: {path}", path = v["path"].as_str().unwrap_or("")), Err(e) => app.ui.status = e.to_string(), } close = true; diff --git a/crates/ui-egui/src/panels/file_dialogs.rs b/crates/ui-egui/src/panels/file_dialogs.rs index b9009b5b..6266b59f 100644 --- a/crates/ui-egui/src/panels/file_dialogs.rs +++ b/crates/ui-egui/src/panels/file_dialogs.rs @@ -45,31 +45,31 @@ pub fn show_recovery(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { egui::Modal::new(egui::Id::new("recovery-modal")).frame(modal_frame(&t)).show(ctx, |ui| { ui.set_width(520.0); Frame::new().inner_margin(Margin { left: 28, right: 28, top: 24, bottom: 20 }).show(ui, |ui| { - ui.label(RichText::new("Recover Unsaved Changes").size(18.0).strong().color(t.text)); + ui.label(RichText::new(tl!("Recover Unsaved Changes")).size(18.0).strong().color(t.text)); ui.add_space(6.0); ui.separator(); ui.add_space(6.0); let c = &items[choice]; - let name = c["projectName"].as_str().unwrap_or("Untitled"); + let name = c["projectName"].as_str().unwrap_or(tl!("Untitled")); let when = c["savedAt"].as_str().unwrap_or(""); let why = if c["cleanExit"].as_bool() == Some(true) { - format!("FilmCraft was closed while “{name}” had unsaved changes.") + tlf!("FilmCraft was closed while “{name}” had unsaved changes.", name) } else { - format!("FilmCraft quit unexpectedly while “{name}” had unsaved changes.") + tlf!("FilmCraft quit unexpectedly while “{name}” had unsaved changes.", name) }; ui.label(RichText::new(why).size(13.5).color(t.text)); ui.add_space(4.0); - ui.label(RichText::new(format!("Recover unsaved changes from {when}?")).size(13.5).color(t.text)); + ui.label(RichText::new(tlf!("Recover unsaved changes from {when}?", when)).size(13.5).color(t.text)); if let Some(p) = c["projectPath"].as_str() { ui.label(RichText::new(p).size(11.5).color(t.text_dim)); } else { - ui.label(RichText::new("The project had not been saved yet.").size(11.5).color(t.text_dim)); + ui.label(RichText::new(tl!("The project had not been saved yet.")).size(11.5).color(t.text_dim)); } if items.len() > 1 { ui.add_space(8.0); - ui.label(RichText::new(format!("{} sessions have unsaved changes:", items.len())).size(12.0).color(t.text_dim)); + ui.label(RichText::new(tlf!("{n} sessions have unsaved changes:", n = items.len())).size(12.0).color(t.text_dim)); for (i, it) in items.iter().enumerate() { - let label = format!("{} — {}", it["projectName"].as_str().unwrap_or("Untitled"), it["savedAt"].as_str().unwrap_or("")); + let label = format!("{} — {}", it["projectName"].as_str().unwrap_or(tl!("Untitled")), it["savedAt"].as_str().unwrap_or("")); let r = ui.radio(choice == i, RichText::new(&label).size(12.5)); app.auto.add(&format!("recovery.item.{i}"), r.rect, &label); if r.clicked() { @@ -80,20 +80,20 @@ pub fn show_recovery(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { ui.add_space(18.0); let id = items[choice]["id"].as_str().unwrap_or("").to_string(); ui.horizontal(|ui| { - if button(app, ui, "recovery.discard", "Discard", false) { + if button(app, ui, "recovery.discard", tl!("Discard"), false) { if let Err(e) = app.session.execute("file.discardRecovery", json!({ "id": id })) { app.ui.status = e.to_string(); } choice = 0; } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if button(app, ui, "recovery.recover", "Recover", true) { + if button(app, ui, "recovery.recover", tl!("Recover"), true) { match app.session.execute("file.recover", json!({ "id": id })) { Ok(_) => open = false, Err(e) => app.ui.status = e.to_string(), } } - if button(app, ui, "recovery.later", "Not Now", false) { + if button(app, ui, "recovery.later", tl!("Not Now"), false) { open = false; } }); @@ -111,20 +111,20 @@ pub fn show_revert(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { let r = egui::Modal::new(egui::Id::new("revert-modal")).frame(modal_frame(&t)).show(ctx, |ui| { ui.set_width(460.0); Frame::new().inner_margin(Margin { left: 28, right: 28, top: 24, bottom: 20 }).show(ui, |ui| { - ui.label(RichText::new("Revert").size(18.0).strong().color(t.text)); + ui.label(RichText::new(tl!("Revert")).size(18.0).strong().color(t.text)); ui.add_space(6.0); ui.separator(); ui.add_space(6.0); - ui.label(RichText::new(format!("Are you sure you want to discard your changes to '{file}'?")).size(13.5).color(t.text)); + ui.label(RichText::new(tlf!("Are you sure you want to discard your changes to '{file}'?", file)).size(13.5).color(t.text)); ui.add_space(18.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if button(app, ui, "revert.yes", "Yes", true) { + if button(app, ui, "revert.yes", tl!("Yes"), true) { if let Err(e) = app.session.execute("file.revert", json!({})) { app.ui.status = e.to_string(); } open = false; } - if button(app, ui, "revert.no", "No", false) { + if button(app, ui, "revert.no", tl!("No"), false) { open = false; } }); diff --git a/crates/ui-egui/src/panels/graphics.rs b/crates/ui-egui/src/panels/graphics.rs index 7aefe56f..307e600e 100644 --- a/crates/ui-egui/src/panels/graphics.rs +++ b/crates/ui-egui/src/panels/graphics.rs @@ -917,14 +917,14 @@ impl Ctx<'_> { _ => 0, }; let mut sel = cur; - let r = egui::ComboBox::from_id_salt(("gfxc", self.clip.0, self.layer, id)).selected_text(opts.get(cur).copied().unwrap_or("")).width(width).show_ui( - vui, - |ui| { + let r = egui::ComboBox::from_id_salt(("gfxc", self.clip.0, self.layer, id)) + .selected_text(opts.get(cur).map_or("", |o| crate::i18n::t(o))) + .width(width) + .show_ui(vui, |ui| { for (i, o) in opts.iter().enumerate() { - ui.selectable_value(&mut sel, i, *o); + ui.selectable_value(&mut sel, i, crate::i18n::t(o)); } - }, - ); + }); self.auto(id, r.response.rect, id); if sel != cur { self.set(id, json!(sel)); @@ -944,7 +944,7 @@ fn section(ui: &mut egui::Ui, app: &mut FilmcraftApp, name: &str, t: &Tokens) -> if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim, ); - ui.painter().text(pos2(r.min.x + 18.0, r.center().y), Align2::LEFT_CENTER, name, Tokens::semibold(13.0), t.text); + ui.painter().text(pos2(r.min.x + 18.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(name), Tokens::semibold(13.0), t.text); app.auto.add(&format!("graphics.section.{}", name.replace(' ', "")), r, name); if resp.clicked() { if open { @@ -1051,9 +1051,9 @@ pub fn graphic_selected(app: &FilmcraftApp) -> bool { pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let Some((clip, it)) = selected_graphic(app) else { - crate::dock::placeholder(ui, rect, &t, "Select a graphic clip, or use the Type tool (T) on the Program monitor"); + crate::dock::placeholder(ui, rect, &t, tl!("Select a graphic clip, or use the Type tool (T) on the Program monitor")); let br = Rect::from_center_size(rect.center() + vec2(0.0, 30.0), vec2(150.0, 26.0)); - let resp = ui.put(br, egui::Button::new("Create new graphic")); + let resp = ui.put(br, egui::Button::new(tl!("Create new graphic"))); app.auto.add("graphics.createNew", br, "Create new graphic"); if resp.clicked() && let Err(e) = app.session.execute("graphics.newText", json!({"text": "New Text"})) @@ -1080,24 +1080,24 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if section(ui, app, "Layers", &t) { ui.horizontal(|ui| { ui.add_space(14.0); - for (label, tip, cmd, p) in [ - ("T", "New Text Layer", "graphics.newText", json!({"clip": clip.0, "text": "New Text"})), - ("", "New Rectangle", "graphics.newShape", json!({"clip": clip.0, "shape": "rectangle"})), - ("", "New Ellipse", "graphics.newShape", json!({"clip": clip.0, "shape": "ellipse", "size": [200, 200]})), + for (label, key, tip, cmd, p) in [ + ("T", "NewTextLayer", tl!("New Text Layer"), "graphics.newText", json!({"clip": clip.0, "text": tl!("New Text")})), + ("", "NewRectangle", tl!("New Rectangle"), "graphics.newShape", json!({"clip": clip.0, "shape": "rectangle"})), + ("", "NewEllipse", tl!("New Ellipse"), "graphics.newShape", json!({"clip": clip.0, "shape": "ellipse", "size": [200, 200]})), ] { let r = letter_button(ui, label, tip, false, &t, Tokens::semibold(13.0)); if label.is_empty() { let ir = Rect::from_center_size(r.rect.center(), vec2(14.0, 14.0)); - icons::paint(ui.painter(), ir, if tip == "New Rectangle" { Icon::Rectangle } else { Icon::Ellipse }, t.text_dim); + icons::paint(ui.painter(), ir, if key == "NewRectangle" { Icon::Rectangle } else { Icon::Ellipse }, t.text_dim); } - autos.push((format!("graphics.{}", tip.replace(' ', "")), r.rect, tip.into())); + autos.push((format!("graphics.{key}"), r.rect, tip.into())); if r.clicked() { actions.push((cmd.into(), p)); } } ui.add_space(8.0); if let Some(l) = sel { - for (label, tip, to) in [("↑", "Bring Forward", "forward"), ("↓", "Send Backward", "backward")] { + for (label, tip, to) in [("↑", tl!("Bring Forward"), "forward"), ("↓", tl!("Send Backward"), "backward")] { let r = letter_button(ui, label, tip, false, &t, Tokens::ui(13.0)); autos.push((format!("graphics.arrange.{to}"), r.rect, tip.into())); if r.clicked() { @@ -1107,7 +1107,7 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let (dr, dresp) = ui.allocate_exact_size(vec2(24.0, 22.0), Sense::click()); icons::paint(ui.painter(), dr.shrink(4.0), Icon::Trash, if dresp.hovered() { t.text } else { t.text_dim }); autos.push(("graphics.deleteLayer".into(), dr, "Delete Layer".into())); - if dresp.on_hover_text("Delete Layer").clicked() { + if dresp.on_hover_text(tl!("Delete Layer")).clicked() { actions.push(("graphics.deleteLayer".into(), json!({"clip": clip.0, "layer": l}))); } } @@ -1146,7 +1146,7 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { crate::panels::graphics_templates::responsive_time(app, ui, clip, &it, &mut autos, &mut actions); } ui.add_space(12.0); - ui.label(egui::RichText::new("Select a layer to edit its properties.").color(t.text_faint)); + ui.label(egui::RichText::new(tl!("Select a layer to edit its properties.")).color(t.text_faint)); return; }; let e = &it.effects[idx[l]]; @@ -1168,12 +1168,12 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.horizontal(|ui| { ui.add_space(14.0); for (how, tip) in [ - ("left", "Align Left"), - ("hcenter", "Align Center Horizontally"), - ("right", "Align Right"), - ("top", "Align Top"), - ("vcenter", "Align Center Vertically"), - ("bottom", "Align Bottom"), + ("left", tl!("Align Left")), + ("hcenter", tl!("Align Center Horizontally")), + ("right", tl!("Align Right")), + ("top", tl!("Align Top")), + ("vcenter", tl!("Align Center Vertically")), + ("bottom", tl!("Align Bottom")), ] { let r = glyph_button(ui, how, tip, false, &t); cx.autos.push((format!("graphics.align.{how}"), r.rect, tip.into())); @@ -1182,7 +1182,7 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } } ui.add_space(6.0); - for (how, axis, tip) in [("dh", "horizontal", "Distribute Horizontally"), ("dv", "vertical", "Distribute Vertically")] { + for (how, axis, tip) in [("dh", "horizontal", tl!("Distribute Horizontally")), ("dv", "vertical", tl!("Distribute Vertically"))] { let r = glyph_button(ui, how, tip, false, &t); cx.autos.push((format!("graphics.distribute.{axis}"), r.rect, tip.into())); if r.clicked() { @@ -1190,11 +1190,11 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } } }); - cx.point(ui, "Position", "position"); - cx.point(ui, "Anchor point", "anchor"); - cx.number(ui, "Scale", "scale", 0.5, (0.0, 100_000.0), 0, " %"); - cx.number(ui, "Rotation", "rotation", 0.5, (-36_000.0, 36_000.0), 1, " °"); - cx.number(ui, "Opacity", "opacity", 0.5, (0.0, 100.0), 0, " %"); + cx.point(ui, tl!("Position"), "position"); + cx.point(ui, tl!("Anchor point"), "anchor"); + cx.number(ui, tl!("Scale"), "scale", 0.5, (0.0, 100_000.0), 0, " %"); + cx.number(ui, tl!("Rotation"), "rotation", 0.5, (-36_000.0, 36_000.0), 1, " °"); + cx.number(ui, tl!("Opacity"), "opacity", 0.5, (0.0, 100.0), 0, " %"); } // ---- text if e.effect == graphic::TEXT_LAYER && section(ui, app, "Text", &t) { @@ -1221,7 +1221,7 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { _ => String::new(), }; let fams = filmcraft_text::families(); - let (_, mut vui) = cx.row(ui, "Font"); + let (_, mut vui) = cx.row(ui, tl!("Font")); let r = egui::ComboBox::from_id_salt(("gfx-font", clip.0, l)).selected_text(&family).width(170.0).height(400.0).show_ui(&mut vui, |ui| { if !filmcraft_text::fonts::system_scanned() { filmcraft_text::fonts::scan_system(); @@ -1234,7 +1234,7 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { }); cx.auto("font", r.response.rect, "Font"); let styles: Vec = fams.iter().find(|(f, _)| f.eq_ignore_ascii_case(&family)).map(|(_, s)| s.clone()).unwrap_or_default(); - let (_, mut vui) = cx.row(ui, "Font Style"); + let (_, mut vui) = cx.row(ui, tl!("Font Style")); let r = egui::ComboBox::from_id_salt(("gfx-style", clip.0, l)).selected_text(&style).width(170.0).show_ui(&mut vui, |ui| { for st in styles.iter().map(String::as_str).chain(["Bold", "Italic", "Bold Italic"]).filter({ let mut seen = Vec::new(); @@ -1253,35 +1253,37 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } }); cx.auto("font_style", r.response.rect, "Font Style"); - cx.number(ui, "Font Size", "size", 0.5, (1.0, 2000.0), 0, ""); + cx.number(ui, tl!("Font Size"), "size", 0.5, (1.0, 2000.0), 0, ""); let align = match pv(e, "align", mt) { ParamValue::Choice(c) => c, _ => 0, }; - let (_, mut vui) = cx.row(ui, "Alignment"); - for (i, (how, tip)) in [("pl", "Left Align Text"), ("pc", "Center Align Text"), ("pr", "Right Align Text"), ("pj", "Justify")].iter().enumerate() { + let (_, mut vui) = cx.row(ui, tl!("Alignment")); + for (i, (how, tip)) in + [("pl", tl!("Left Align Text")), ("pc", tl!("Center Align Text")), ("pr", tl!("Right Align Text")), ("pj", tl!("Justify"))].iter().enumerate() + { let r = glyph_button(&mut vui, how, tip, align == i as u32, &t); cx.auto(&format!("align.{i}"), r.rect, tip); if r.clicked() { cx.set("align", json!(i)); } } - cx.number(ui, "Tracking", "tracking", 1.0, (-1000.0, 10_000.0), 0, ""); - cx.number(ui, "Leading", "leading", 0.5, (-5000.0, 5000.0), 0, ""); - cx.number(ui, "Baseline Shift", "baseline_shift", 0.5, (-5000.0, 5000.0), 0, ""); - cx.number(ui, "Text Box Width", "box_width", 2.0, (0.0, 100_000.0), 0, ""); - let (_, mut vui) = cx.row(ui, "Style"); + cx.number(ui, tl!("Tracking"), "tracking", 1.0, (-1000.0, 10_000.0), 0, ""); + cx.number(ui, tl!("Leading"), "leading", 0.5, (-5000.0, 5000.0), 0, ""); + cx.number(ui, tl!("Baseline Shift"), "baseline_shift", 0.5, (-5000.0, 5000.0), 0, ""); + cx.number(ui, tl!("Text Box Width"), "box_width", 2.0, (0.0, 100_000.0), 0, ""); + let (_, mut vui) = cx.row(ui, tl!("Style")); let flag = |id: &str| pv(e, id, mt).as_bool().unwrap_or(false); let caps = match pv(e, "caps", mt) { ParamValue::Choice(c) => c, _ => 0, }; for (id, label, tip, on, font) in [ - ("faux_bold", "T", "Faux Bold", flag("faux_bold"), Tokens::semibold(14.0)), - ("faux_italic", "T", "Faux Italic", flag("faux_italic"), egui::FontId::new(14.0, egui::FontFamily::Proportional)), - ("all_caps", "TT", "All Caps", caps == 1, Tokens::ui(12.0)), - ("small_caps", "Tt", "Small Caps", caps == 2, Tokens::ui(12.0)), - ("underline", "U", "Underline", flag("underline"), Tokens::ui(13.0)), + ("faux_bold", "T", tl!("Faux Bold"), flag("faux_bold"), Tokens::semibold(14.0)), + ("faux_italic", "T", tl!("Faux Italic"), flag("faux_italic"), egui::FontId::new(14.0, egui::FontFamily::Proportional)), + ("all_caps", "TT", tl!("All Caps"), caps == 1, Tokens::ui(12.0)), + ("small_caps", "Tt", tl!("Small Caps"), caps == 2, Tokens::ui(12.0)), + ("underline", "U", tl!("Underline"), flag("underline"), Tokens::ui(13.0)), ] { let r = letter_button(&mut vui, label, tip, on, &t, font); if id == "faux_italic" { @@ -1298,25 +1300,26 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } } } - let (_, mut vui) = cx.row(ui, "OpenType"); - cx.check(&mut vui, "kerning", "Kerning"); - cx.check(&mut vui, "ligatures", "Ligatures"); + let (_, mut vui) = cx.row(ui, tl!("OpenType")); + cx.check(&mut vui, "kerning", tl!("Kerning")); + cx.check(&mut vui, "ligatures", tl!("Ligatures")); } if e.effect == graphic::SHAPE_LAYER && section(ui, app, "Shape", &t) { - let (_, mut vui) = cx.row(ui, "Shape"); + let (_, mut vui) = cx.row(ui, tl!("Shape")); cx.choice(&mut vui, "shape", graphic::SHAPE_OPTS, 120.0); - cx.point(ui, "Size", "size"); - cx.number(ui, "Corner Radius", "corner_radius", 0.5, (0.0, 10_000.0), 0, ""); - cx.number(ui, "Polygon Sides", "sides", 0.05, (3.0, 64.0), 0, ""); + cx.point(ui, tl!("Size"), "size"); + cx.number(ui, tl!("Corner Radius"), "corner_radius", 0.5, (0.0, 10_000.0), 0, ""); + cx.number(ui, tl!("Polygon Sides"), "sides", 0.05, (3.0, 64.0), 0, ""); } // ---- appearance if section(ui, app, "Appearance", &t) { - let (_, mut vui) = cx.row(ui, "Fill"); + let (_, mut vui) = cx.row(ui, tl!("Fill")); cx.check(&mut vui, "fill", ""); cx.color(&mut vui, "fill_color"); - for (on, col, w, kind, label) in - [("stroke", "stroke_color", "stroke_width", "stroke_type", "Stroke"), ("stroke2", "stroke2_color", "stroke2_width", "stroke2_type", "Stroke 2")] - { + for (on, col, w, kind, label) in [ + ("stroke", "stroke_color", "stroke_width", "stroke_type", tl!("Stroke")), + ("stroke2", "stroke2_color", "stroke2_width", "stroke2_type", tl!("Stroke 2")), + ] { let (_, mut vui) = cx.row(ui, label); cx.check(&mut vui, on, ""); cx.color(&mut vui, col); @@ -1325,23 +1328,23 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { vui.add_space(6.0); cx.choice(&mut vui, kind, graphic::STROKE_OPTS, 70.0); } - let (_, mut vui) = cx.row(ui, "Background"); + let (_, mut vui) = cx.row(ui, tl!("Background")); let bg = cx.check(&mut vui, "background", ""); cx.color(&mut vui, "background_color"); if bg { - cx.number(ui, " Opacity", "background_opacity", 0.5, (0.0, 100.0), 0, " %"); - cx.number(ui, " Size", "background_size", 0.5, (0.0, 1000.0), 0, ""); - cx.number(ui, " Corner Radius", "background_radius", 0.5, (0.0, 1000.0), 0, ""); + cx.number(ui, tl!(" Opacity"), "background_opacity", 0.5, (0.0, 100.0), 0, " %"); + cx.number(ui, tl!(" Size"), "background_size", 0.5, (0.0, 1000.0), 0, ""); + cx.number(ui, tl!(" Corner Radius"), "background_radius", 0.5, (0.0, 1000.0), 0, ""); } - let (_, mut vui) = cx.row(ui, "Shadow"); + let (_, mut vui) = cx.row(ui, tl!("Shadow")); let sh = cx.check(&mut vui, "shadow", ""); cx.color(&mut vui, "shadow_color"); if sh { - cx.number(ui, " Opacity", "shadow_opacity", 0.5, (0.0, 100.0), 0, " %"); - cx.number(ui, " Angle", "shadow_angle", 0.5, (-36_000.0, 36_000.0), 0, " °"); - cx.number(ui, " Distance", "shadow_distance", 0.5, (0.0, 1000.0), 0, ""); - cx.number(ui, " Size", "shadow_size", 0.5, (0.0, 1000.0), 0, ""); - cx.number(ui, " Blur", "shadow_blur", 0.5, (0.0, 1000.0), 0, ""); + cx.number(ui, tl!(" Opacity"), "shadow_opacity", 0.5, (0.0, 100.0), 0, " %"); + cx.number(ui, tl!(" Angle"), "shadow_angle", 0.5, (-36_000.0, 36_000.0), 0, " °"); + cx.number(ui, tl!(" Distance"), "shadow_distance", 0.5, (0.0, 1000.0), 0, ""); + cx.number(ui, tl!(" Size"), "shadow_size", 0.5, (0.0, 1000.0), 0, ""); + cx.number(ui, tl!(" Blur"), "shadow_blur", 0.5, (0.0, 1000.0), 0, ""); } } actions.append(&mut cx.actions); diff --git a/crates/ui-egui/src/panels/graphics_templates.rs b/crates/ui-egui/src/panels/graphics_templates.rs index eb402ee8..d66c346a 100644 --- a/crates/ui-egui/src/panels/graphics_templates.rs +++ b/crates/ui-egui/src/panels/graphics_templates.rs @@ -143,7 +143,7 @@ pub fn essential_graphics(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) let head = Rect::from_min_size(rect.min, vec2(rect.width(), 30.0)); ui.painter().line_segment([head.left_bottom(), head.right_bottom()], Stroke::new(1.0, t.separator)); let mut elems: Elems = Vec::new(); - for (i, (id, label)) in [("browse", "Browse"), ("edit", "Edit")].into_iter().enumerate() { + for (i, (id, label)) in [("browse", tl!("Browse")), ("edit", tl!("Edit"))].into_iter().enumerate() { let r = Rect::from_min_size(pos2(head.min.x + 8.0 + i as f32 * 80.0, head.min.y), vec2(76.0, 30.0)); if tab_button(ui, r, label, tab == id, &t).clicked() { app.ui.gfx_templates.tab = id.into(); @@ -175,32 +175,31 @@ fn browse(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { b.set_clip_rect(rect); let st = &mut app.ui.gfx_templates; b.horizontal(|ui| { - let r = ui.add(egui::TextEdit::singleline(&mut st.query).hint_text("Search templates").desired_width((ui.available_width() - 230.0).max(110.0))); + let r = ui.add(egui::TextEdit::singleline(&mut st.query).hint_text(tl!("Search templates")).desired_width((ui.available_width() - 230.0).max(110.0))); elems.push(("gfxTemplates.search".into(), r.rect, "Search".into())); - let cur = if st.category.is_empty() { "All Categories".to_string() } else { st.category.clone() }; + let cur = if st.category.is_empty() { tl!("All Categories").to_string() } else { crate::i18n::t(&st.category).to_string() }; let r = egui::ComboBox::from_id_salt("gfx-cat").selected_text(cur).width(130.0).show_ui(ui, |ui| { - ui.selectable_value(&mut st.category, String::new(), "All Categories"); + ui.selectable_value(&mut st.category, String::new(), tl!("All Categories")); for c in &cats { - ui.selectable_value(&mut st.category, c.clone(), c); + ui.selectable_value(&mut st.category, c.clone(), crate::i18n::t(c)); } }); elems.push(("gfxTemplates.category".into(), r.response.rect, "Category".into())); - let r = ui.button("Install…").on_hover_text("Install Motion Graphics Template (.fcgt)"); + let r = ui.button(tl!("Install…")).on_hover_text(tl!("Install Motion Graphics Template (.fcgt)")); elems.push(("gfxTemplates.install".into(), r.rect, "Install".into())); install = r.clicked(); }); b.add_space(6.0); - let q = st.query.to_ascii_lowercase(); + let q = st.query.to_lowercase(); let shown: Vec<&LibraryEntry> = lib .iter() .filter(|e| { let tt = &e.template; (st.category.is_empty() || tt.category == st.category) - && (q.is_empty() - || tt.name.to_ascii_lowercase().contains(&q) - || tt.category.to_ascii_lowercase().contains(&q) - || tt.description.to_ascii_lowercase().contains(&q) - || tt.tags.iter().any(|x| x.to_ascii_lowercase().contains(&q))) + && (crate::i18n::matches_query(&tt.name, &q) + || crate::i18n::matches_query(&tt.category, &q) + || crate::i18n::matches_query(&tt.description, &q) + || tt.tags.iter().any(|x| crate::i18n::matches_query(x, &q))) }) .collect(); let selected = st.selected.clone(); @@ -213,7 +212,7 @@ fn browse(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { &mut b, |ui| { if shown.is_empty() { - ui.label(egui::RichText::new("No templates match.").color(t.text_faint)); + ui.label(egui::RichText::new(tl!("No templates match.")).color(t.text_faint)); } for row in shown.chunks(cols) { ui.horizontal(|ui| { @@ -239,14 +238,15 @@ fn browse(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let k = (ir.width() / sz.x).min(ir.height() / sz.y); let tr = Rect::from_center_size(ir.center(), sz * k); ui.painter().image(tex.id(), tr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); - ui.painter().text(pos2(r.min.x + 6.0, ir.max.y + 6.0), Align2::LEFT_TOP, &e.template.name, Tokens::ui(11.5), t.text); - let sub = if e.path.is_some() { format!("{} · My Template", e.template.category) } else { e.template.category.clone() }; + ui.painter().text(pos2(r.min.x + 6.0, ir.max.y + 6.0), Align2::LEFT_TOP, crate::i18n::t(&e.template.name), Tokens::ui(11.5), t.text); + let category = crate::i18n::t(&e.template.category); + let sub = if e.path.is_some() { tlf!("{category} · My Template", category) } else { category.to_string() }; ui.painter().text(pos2(r.min.x + 6.0, ir.max.y + 20.0), Align2::LEFT_TOP, sub, Tokens::ui(10.0), t.text_faint); if on { ui.painter().rect_stroke(r, 4.0, Stroke::new(1.5, t.accent), StrokeKind::Inside); } elems.push((format!("gfxTemplates.item.{}", e.template.id), r, e.template.name.clone())); - let resp = resp.on_hover_text(&e.template.description); + let resp = resp.on_hover_text(crate::i18n::t(&e.template.description)); if resp.drag_started() { crate::panels::start_drag_template(ui, &e.template.id, &e.template.name); } @@ -264,13 +264,13 @@ fn browse(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { b.add_space(4.0); let sel_entry = selected.as_ref().and_then(|s| lib.iter().find(|e| &e.template.id == s)); b.horizontal(|ui| { - let r = ui.add_enabled(sel_entry.is_some(), egui::Button::new("Apply")); + let r = ui.add_enabled(sel_entry.is_some(), egui::Button::new(tl!("Apply"))); elems.push(("gfxTemplates.apply".into(), r.rect, "Apply".into())); if r.clicked() { apply = selected.clone(); } let user = sel_entry.is_some_and(|e| e.path.is_some()); - let r = ui.add_enabled(user, egui::Button::new("Remove")); + let r = ui.add_enabled(user, egui::Button::new(tl!("Remove"))); elems.push(("gfxTemplates.remove".into(), r.rect, "Remove".into())); if r.clicked() && let Some(id) = &selected @@ -278,7 +278,7 @@ fn browse(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(("graphics.template.remove".into(), json!({"template": id}))); } if let Some(e) = sel_entry { - ui.label(egui::RichText::new(format!("{} editable properties", e.template.controls.len())).color(t.text_faint)); + ui.label(egui::RichText::new(tlf!("{n} editable properties", n = e.template.controls.len())).color(t.text_faint)); } }); if let Some(s) = new_sel { @@ -320,7 +320,7 @@ fn hex(c: [f32; 4]) -> String { fn row_label(ui: &mut egui::Ui, label: &str, t: &Tokens) -> egui::Ui { let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 28.0), Sense::hover()); - ui.painter().text(pos2(r.min.x + 18.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::ui(12.0), t.text_dim); + ui.painter().text(pos2(r.min.x + 18.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(label), Tokens::ui(12.0), t.text_dim); ui.new_child( egui::UiBuilder::new() .max_rect(Rect::from_min_max(pos2(r.min.x + 130.0, r.min.y + 3.0), pos2(r.max.x - 6.0, r.max.y - 3.0))) @@ -341,7 +341,7 @@ pub fn template_controls( ) -> bool { let t = app.tokens; let Some(link) = it.graphic.as_ref().and_then(|m| m.template.as_ref()) else { return false }; - ui.label(egui::RichText::new(format!(" Template: {}", link.name)).color(t.text_faint).size(11.0)); + ui.label(egui::RichText::new(tlf!(" Template: {name}", name = link.name)).color(t.text_faint).size(11.0)); for c in &link.controls { let Some(e) = it.effects.iter().find(|e| graphic::is_layer(e) && filmcraft_project::gtemplate::layer_uid(e) == c.layer) else { continue }; let id = format!("gfxTemplates.control.{}", c.id); @@ -352,7 +352,7 @@ pub fn template_controls( && let Some(ParamValue::Text(s0)) = &pv && s0.contains('\n') { - ui.label(egui::RichText::new(format!(" {}", c.name)).color(t.text_dim).size(12.0)); + ui.label(egui::RichText::new(format!(" {}", crate::i18n::t(&c.name))).color(t.text_dim).size(12.0)); let mut s = s0.clone(); let rows = s.lines().count().clamp(2, 8); let r = ui @@ -458,7 +458,7 @@ pub fn responsive_time(app: &FilmcraftApp, ui: &mut egui::Ui, clip: ClipId, it: let rate = app.session.sequence_rate(); let m = it.graphic.as_deref().cloned().unwrap_or_default(); let frames = |x: Tick| rate.frame_at(rate.snap_nearest(x)) as f64; - for (key, label, v) in [("intro", "Intro Duration", m.intro), ("outro", "Outro Duration", m.outro)] { + for (key, label, v) in [("intro", tl!("Intro Duration"), m.intro), ("outro", tl!("Outro Duration"), m.outro)] { let mut vui = row_label(ui, label, &t); let mut f = frames(v); let r = vui.add(egui::DragValue::new(&mut f).speed(0.2).range(0.0..=100_000.0).suffix(" fr")); @@ -467,11 +467,11 @@ pub fn responsive_time(app: &FilmcraftApp, ui: &mut egui::Ui, clip: ClipId, it: actions.push(("graphics.setResponsiveTime".into(), json!({"clip": clip.0, format!("{key}Frames"): f.round()}))); } } - let mut vui = row_label(ui, "Roll", &t); + let mut vui = row_label(ui, tl!("Roll"), &t); let mut mode = m.roll.mode; - let r = egui::ComboBox::from_id_salt(("gfx-roll", clip.0)).selected_text(mode.label()).width(120.0).show_ui(&mut vui, |ui| { + let r = egui::ComboBox::from_id_salt(("gfx-roll", clip.0)).selected_text(crate::i18n::t(mode.label())).width(120.0).show_ui(&mut vui, |ui| { for x in RollMode::ALL { - ui.selectable_value(&mut mode, x, x.label()); + ui.selectable_value(&mut mode, x, crate::i18n::t(x.label())); } }); autos.push(("graphics.roll.mode".into(), r.response.rect, "Roll".into())); @@ -488,7 +488,9 @@ pub fn responsive_time(app: &FilmcraftApp, ui: &mut egui::Ui, clip: ClipId, it: return; } let mut vui = row_label(ui, "", &t); - for (key, label, v) in [("startOffScreen", "Start Off Screen", m.roll.start_off_screen), ("endOffScreen", "End Off Screen", m.roll.end_off_screen)] { + for (key, label, v) in + [("startOffScreen", tl!("Start Off Screen"), m.roll.start_off_screen), ("endOffScreen", tl!("End Off Screen"), m.roll.end_off_screen)] + { let mut b = v; let r = vui.checkbox(&mut b, label); autos.push((format!("graphics.roll.{key}"), r.rect, label.into())); @@ -497,10 +499,10 @@ pub fn responsive_time(app: &FilmcraftApp, ui: &mut egui::Ui, clip: ClipId, it: } } for (key, label, v) in [ - ("preroll", "Preroll", m.roll.preroll), - ("easeIn", "Ease In", m.roll.ease_in), - ("easeOut", "Ease Out", m.roll.ease_out), - ("postroll", "Postroll", m.roll.postroll), + ("preroll", tl!("Preroll"), m.roll.preroll), + ("easeIn", tl!("Ease In"), m.roll.ease_in), + ("easeOut", tl!("Ease Out"), m.roll.ease_out), + ("postroll", tl!("Postroll"), m.roll.postroll), ] { let mut vui = row_label(ui, label, &t); let mut f = frames(v); @@ -527,19 +529,20 @@ pub fn responsive_position( let Some(&ei) = idx.get(layer) else { return }; let pin = it.effects[ei].layer.as_ref().and_then(|x| x.pin.clone()); let target_name = match pin.as_ref().map(|p| p.to) { - None => "None".to_string(), - Some(filmcraft_project::PinTarget::Frame) => "Video Frame".to_string(), + None => tl!("None").to_string(), + Some(filmcraft_project::PinTarget::Frame) => tl!("Video Frame").to_string(), Some(filmcraft_project::PinTarget::Layer(uid)) => (0..idx.len()) .find(|&i| filmcraft_project::gtemplate::layer_uid(&it.effects[idx[i]]) == uid) .map(|i| layer_display_name(&it.effects[idx[i]], i)) .unwrap_or_default(), }; - let mut vui = row_label(ui, "Pin To", &t); + let mut vui = row_label(ui, tl!("Pin To"), &t); let r = egui::ComboBox::from_id_salt(("gfx-pin", clip.0, layer)).selected_text(&target_name).width(150.0).show_ui(&mut vui, |ui| { - if ui.selectable_label(pin.is_none(), "None").clicked() { + if ui.selectable_label(pin.is_none(), tl!("None")).clicked() { actions.push(("graphics.pin".into(), json!({"clip": clip.0, "layer": layer, "to": "none"}))); } - if ui.selectable_label(target_name == "Video Frame", "Video Frame").clicked() { + let on_frame = matches!(pin.as_ref().map(|p| p.to), Some(filmcraft_project::PinTarget::Frame)); + if ui.selectable_label(on_frame, tl!("Video Frame")).clicked() { actions.push(("graphics.pin".into(), json!({"clip": clip.0, "layer": layer, "to": "frame"}))); } for (i, &e) in idx.iter().enumerate() { @@ -553,7 +556,7 @@ pub fn responsive_position( }); autos.push(("graphics.pin.to".into(), r.response.rect, "Pin To".into())); let Some(p) = pin else { return }; - let mut vui = row_label(ui, "Pinned Edges", &t); + let mut vui = row_label(ui, tl!("Pinned Edges"), &t); let mut edges = [p.left, p.top, p.right, p.bottom]; let to = match p.to { filmcraft_project::PinTarget::Frame => json!("frame"), @@ -586,7 +589,7 @@ pub fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: &Val let empty = params.as_object().is_none_or(|m| m.is_empty()); match id { "graphics.template.install" if params.get("path").is_none() => { - let path = app.hooks.pick_open_file.as_mut().and_then(|f| f("FilmCraft Graphics Template", &["fcgt"]))?; + let path = app.hooks.pick_open_file.as_mut().and_then(|f| f(tl!("FilmCraft Graphics Template"), &["fcgt"]))?; let r = app.session.execute(id, json!({"path": path})).map_err(|e| e.to_string()); invalidate(ctx); Some(r) @@ -602,15 +605,15 @@ pub fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: &Val let n = layer_display_name(e, i); if e.effect == graphic::TEXT_LAYER { controls.push(ExportControl { layer: i, param: "text".into(), name: n.clone(), on: true }); - controls.push(ExportControl { layer: i, param: "font".into(), name: format!("{n} Font"), on: false }); - controls.push(ExportControl { layer: i, param: "size".into(), name: format!("{n} Size"), on: false }); + controls.push(ExportControl { layer: i, param: "font".into(), name: tlf!("{n} Font", n), on: false }); + controls.push(ExportControl { layer: i, param: "size".into(), name: tlf!("{n} Size", n), on: false }); } - controls.push(ExportControl { layer: i, param: "fill_color".into(), name: format!("{n} Color"), on: false }); - controls.push(ExportControl { layer: i, param: "position".into(), name: format!("{n} Position"), on: false }); - controls.push(ExportControl { layer: i, param: "enabled".into(), name: format!("Show {n}"), on: false }); + controls.push(ExportControl { layer: i, param: "fill_color".into(), name: tlf!("{n} Color", n), on: false }); + controls.push(ExportControl { layer: i, param: "position".into(), name: tlf!("{n} Position", n), on: false }); + controls.push(ExportControl { layer: i, param: "enabled".into(), name: tlf!("Show {n}", n), on: false }); } app.ui.gfx_templates.export = - Some(ExportDraft { clip: clip.0, name: it.name.clone(), category: "My Templates".into(), controls, ..Default::default() }); + Some(ExportDraft { clip: clip.0, name: it.name.clone(), category: tl!("My Templates").into(), controls, ..Default::default() }); Some(Ok(json!({"dialog": "exportTemplate"}))) } "file.replaceFonts" if empty => { @@ -633,28 +636,29 @@ fn export_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { let Some(mut d) = app.ui.gfx_templates.export.clone() else { return }; let mut elems: Elems = Vec::new(); let mut action: Option = None; - egui::Window::new("Export As Motion Graphics Template") + egui::Window::new(tl!("Export As Motion Graphics Template")) + .id(egui::Id::new("Export As Motion Graphics Template")) .collapsible(false) .resizable(false) .default_width(420.0) .anchor(Align2::CENTER_CENTER, [0.0, 0.0]) .show(ctx, |ui| { egui::Grid::new("gfx-export-grid").num_columns(2).show(ui, |ui| { - ui.label("Name:"); + ui.label(tl!("Name:")); let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(260.0)); elems.push(("exportTemplate.name".into(), r.rect, "Name".into())); ui.end_row(); - ui.label("Category:"); + ui.label(tl!("Category:")); let r = ui.add(egui::TextEdit::singleline(&mut d.category).desired_width(260.0)); elems.push(("exportTemplate.category".into(), r.rect, "Category".into())); ui.end_row(); - ui.label("Description:"); + ui.label(tl!("Description:")); let r = ui.add(egui::TextEdit::singleline(&mut d.description).desired_width(260.0)); elems.push(("exportTemplate.description".into(), r.rect, "Description".into())); ui.end_row(); }); ui.separator(); - ui.label("Editable properties:"); + ui.label(tl!("Editable properties:")); egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { for (n, c) in d.controls.iter_mut().enumerate() { ui.horizontal(|ui| { @@ -665,14 +669,14 @@ fn export_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { }); } }); - ui.label(egui::RichText::new("Saved as a FilmCraft graphics template (.fcgt) in your templates folder.").weak().size(11.0)); + ui.label(egui::RichText::new(tl!("Saved as a FilmCraft graphics template (.fcgt) in your templates folder.")).weak().size(11.0)); if !d.error.is_empty() { ui.colored_label(Color32::from_rgb(0xff, 0x80, 0x80), &d.error); } ui.horizontal(|ui| { - let ok = ui.button("Export"); + let ok = ui.button(tl!("Export")); elems.push(("exportTemplate.ok".into(), ok.rect, "Export".into())); - let cancel = ui.button("Cancel"); + let cancel = ui.button(tl!("Cancel")); elems.push(("exportTemplate.cancel".into(), cancel.rect, "Cancel".into())); if ok.clicked() { action = Some(true); @@ -692,7 +696,7 @@ fn export_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { match app.session.execute("graphics.template.export", p) { Ok(v) => { app.ui.gfx_templates.export = None; - app.ui.status = format!("Exported graphics template to {}", v["path"].as_str().unwrap_or_default()); + app.ui.status = tlf!("Exported graphics template to {path}", path = v["path"].as_str().unwrap_or_default()); invalidate(ctx); } Err(e) => { @@ -711,15 +715,19 @@ fn replace_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { let used = filmcraft_engine::graphic_templates::fonts_used(&app.session); let mut elems: Elems = Vec::new(); let mut action: Option = None; - egui::Window::new("Replace Fonts in Projects").collapsible(false).resizable(false).default_width(440.0).anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show( - ctx, - |ui| { - ui.label("Fonts used in the project:"); + egui::Window::new(tl!("Replace Fonts in Projects")) + .id(egui::Id::new("Replace Fonts in Projects")) + .collapsible(false) + .resizable(false) + .default_width(440.0) + .anchor(Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.label(tl!("Fonts used in the project:")); egui::Grid::new("gfx-fonts-used").striped(true).num_columns(3).show(ui, |ui| { for (f, st, n) in &used { let missing = filmcraft_text::resolve(f, st).missing; ui.label(if missing { - egui::RichText::new(format!("{f} (missing)")).color(Color32::from_rgb(0xff, 0xa0, 0x60)) + egui::RichText::new(tlf!("{f} (missing)", f)).color(Color32::from_rgb(0xff, 0xa0, 0x60)) } else { egui::RichText::new(f) }); @@ -732,14 +740,14 @@ fn replace_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut fams: Vec = used.iter().map(|u| u.0.clone()).collect(); fams.dedup(); ui.horizontal(|ui| { - ui.label("Replace:"); + ui.label(tl!("Replace:")); let r = egui::ComboBox::from_id_salt("gfx-rf-from").selected_text(&d.from).width(160.0).show_ui(ui, |ui| { for f in &fams { ui.selectable_value(&mut d.from, f.clone(), f); } }); elems.push(("replaceFonts.from".into(), r.response.rect, "Replace".into())); - ui.label("with:"); + ui.label(tl!("with:")); let r = egui::ComboBox::from_id_salt("gfx-rf-to").selected_text(&d.to).width(160.0).height(360.0).show_ui(ui, |ui| { for (f, _) in filmcraft_text::families() { ui.selectable_value(&mut d.to, f.clone(), &f); @@ -751,9 +759,9 @@ fn replace_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { ui.colored_label(Color32::from_rgb(0xff, 0x80, 0x80), &d.error); } ui.horizontal(|ui| { - let ok = ui.button("OK"); + let ok = ui.button(tl!("OK")); elems.push(("replaceFonts.ok".into(), ok.rect, "OK".into())); - let cancel = ui.button("Cancel"); + let cancel = ui.button(tl!("Cancel")); elems.push(("replaceFonts.cancel".into(), cancel.rect, "Cancel".into())); if ok.clicked() { action = Some(true); @@ -762,8 +770,7 @@ fn replace_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { action = Some(false); } }); - }, - ); + }); for (id, r, l) in elems { app.auto.add(&id, r, &l); } @@ -771,7 +778,7 @@ fn replace_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { Some(true) => match app.session.execute("file.replaceFonts", json!({"from": d.from, "to": d.to})) { Ok(v) => { app.ui.gfx_templates.replace = None; - app.ui.status = format!("Replaced {} font uses", v["replaced"]); + app.ui.status = tlf!("Replaced {n} font uses", n = v["replaced"]); } Err(e) => { d.error = e.to_string(); diff --git a/crates/ui-egui/src/panels/import_mode.rs b/crates/ui-egui/src/panels/import_mode.rs index 813eea97..5d78e93e 100644 --- a/crates/ui-egui/src/panels/import_mode.rs +++ b/crates/ui-egui/src/panels/import_mode.rs @@ -9,20 +9,20 @@ use crate::theme::Tokens; pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; ui.painter().rect_filled(rect, t.radius, t.panel_bg); - ui.painter().text(rect.min + vec2(24.0, 30.0), Align2::LEFT_CENTER, "Import", Tokens::semibold(22.0), t.text); + ui.painter().text(rect.min + vec2(24.0, 30.0), Align2::LEFT_CENTER, tl!("Import"), Tokens::semibold(22.0), t.text); ui.painter().text( rect.min + vec2(24.0, 58.0), Align2::LEFT_CENTER, - "Add media to your project. Drop files anywhere in the window, or choose a source below.", + tl!("Add media to your project. Drop files anywhere in the window, or choose a source below."), Tokens::ui(13.0), t.text_dim, ); let ctx = ui.ctx().clone(); let cards = [ - ("Browse files…", "file.import", "Movies, audio and stills from disk"), - ("Demo footage", "file.importDemoFootage", "Six procedural 1080p clips with sound"), - ("Demo project", "file.openDemoProject", "A cut sequence with transitions, effects and music"), - ("Bars and Tone", "file.newBarsAndTone", "SMPTE HD bars with 1 kHz reference tone"), + (tl!("Browse files…"), "file.import", tl!("Movies, audio and stills from disk")), + (tl!("Demo footage"), "file.importDemoFootage", tl!("Six procedural 1080p clips with sound")), + (tl!("Demo project"), "file.openDemoProject", tl!("A cut sequence with transitions, effects and music")), + (tl!("Bars and Tone"), "file.newBarsAndTone", tl!("SMPTE HD bars with 1 kHz reference tone")), ]; let cw = 260.0; for (i, (title, cmd, sub)) in cards.iter().enumerate() { @@ -48,11 +48,11 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { // ArtCraft wordmark (first-party trademark, docs/brand/), then the section title. let logo = crate::brand::paint_wordmark(ui, rect.min + vec2(24.0, y), 15.0, app.ui.dark); let tx = logo.map_or(rect.min.x + 24.0, |r| r.max.x + 12.0); - ui.painter().text(pos2(tx, rect.min.y + y), Align2::LEFT_CENTER, "Community", Tokens::semibold(15.0), t.text); + ui.painter().text(pos2(tx, rect.min.y + y), Align2::LEFT_CENTER, tl!("Community"), Tokens::semibold(15.0), t.text); let mut x = rect.min.x + 24.0; for (i, (id, label, url)) in crate::links::ALL.iter().take(4).enumerate() { let primary = i == 0; - let label = if primary { "Join us on Discord" } else { *label }; + let label = if primary { tl!("Join us on Discord") } else { crate::i18n::t(label) }; let g = ui.painter().layout_no_wrap(label.to_string(), Tokens::ui(13.0), t.text); let r = Rect::from_min_size(pos2(x, rect.min.y + y + 18.0), vec2(g.size().x + 44.0, 34.0)); let resp = ui.interact(r, egui::Id::new(("imp-link", *id)), Sense::click()).on_hover_text(*url); diff --git a/crates/ui-egui/src/panels/interchange_export.rs b/crates/ui-egui/src/panels/interchange_export.rs index cd07bbe1..205cdafa 100644 --- a/crates/ui-egui/src/panels/interchange_export.rs +++ b/crates/ui-egui/src/panels/interchange_export.rs @@ -67,28 +67,29 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut apply = false; let mut elems: Vec<(String, egui::Rect, String)> = Vec::new(); let title = if d.omf { "OMF Export Settings" } else { "AAF Export Settings" }; - egui::Window::new(title).collapsible(false).resizable(false).anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { + let shown = if d.omf { tl!("OMF Export Settings") } else { tl!("AAF Export Settings") }; + egui::Window::new(shown).id(egui::Id::new(title)).collapsible(false).resizable(false).anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { ui.set_min_width(380.0); egui::Grid::new("interchange-export-grid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { if d.omf { - ui.label("OMF Title:"); + ui.label(tl!("OMF Title:")); let r = ui.text_edit_singleline(&mut d.title); elems.push((format!("{p}.title"), r.rect, d.title.clone())); ui.end_row(); } else { ui.label(""); - let r = ui.checkbox(&mut d.mixdown_video, "Mixdown video"); + let r = ui.checkbox(&mut d.mixdown_video, tl!("Mixdown video")); elems.push((format!("{p}.mixdownVideo"), r.rect, d.mixdown_video.to_string())); ui.end_row(); } - ui.label("Audio:"); + ui.label(tl!("Audio:")); ui.horizontal(|ui| { - let modes: &[(&'static str, &str)] = if d.omf { - &[("embedded", "Encapsulate"), ("separate", "Separate Audio")] + let modes: Vec<(&'static str, &str)> = if d.omf { + vec![("embedded", tl!("Encapsulate")), ("separate", tl!("Separate Audio"))] } else { - &[("embedded", "Embed"), ("separate", "Separate Files"), ("linked", "Link to Media")] + vec![("embedded", tl!("Embed")), ("separate", tl!("Separate Files")), ("linked", tl!("Link to Media"))] }; - for (k, label) in modes { + for (k, label) in &modes { let r = ui.radio(d.audio == *k, *label); if r.clicked() { d.audio = k; @@ -97,7 +98,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { } }); ui.end_row(); - ui.label("Audio File Format:"); + ui.label(tl!("Audio File Format:")); ui.horizontal(|ui| { let formats: &[(&'static str, &str)] = if d.omf { &[("wav", "Broadcast Wave"), ("aiff", "AIFF")] } else { &[("wav", "Broadcast Wave"), ("aiff", "AIFF"), ("mxf", "OP-Atom MXF")] }; @@ -110,7 +111,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { } }); ui.end_row(); - ui.label("Sample Rate:"); + ui.label(tl!("Sample Rate:")); ui.horizontal(|ui| { for r in RATES { let b = ui.radio(d.sample_rate == r, format!("{r}")); @@ -121,7 +122,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { } }); ui.end_row(); - ui.label("Bits per Sample:"); + ui.label(tl!("Bits per Sample:")); ui.horizontal(|ui| { for b in [16u16, 24] { let r = ui.radio(d.bits == b, format!("{b}")); @@ -132,32 +133,32 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { } }); ui.end_row(); - ui.label("Rendering:"); + ui.label(tl!("Rendering:")); let linked = d.audio == "linked"; - let r = ui.add_enabled(!linked, egui::Checkbox::new(&mut d.trim, "Trim audio files")); + let r = ui.add_enabled(!linked, egui::Checkbox::new(&mut d.trim, tl!("Trim audio files"))); elems.push((format!("{p}.trimAudio"), r.rect, d.trim.to_string())); ui.end_row(); - ui.label("Handle Frames:"); + ui.label(tl!("Handle Frames:")); let r = ui.add_enabled(d.trim && !linked, egui::DragValue::new(&mut d.handles).range(0..=10_000)); elems.push((format!("{p}.handles"), r.rect, d.handles.to_string())); ui.end_row(); ui.label(""); - let r = ui.add_enabled(!linked, egui::Checkbox::new(&mut d.render_effects, "Render audio clip effects")); + let r = ui.add_enabled(!linked, egui::Checkbox::new(&mut d.render_effects, tl!("Render audio clip effects"))); elems.push((format!("{p}.renderAudioEffects"), r.rect, d.render_effects.to_string())); ui.end_row(); ui.label(""); - let r = ui.checkbox(&mut d.breakout, "Breakout to mono"); + let r = ui.checkbox(&mut d.breakout, tl!("Breakout to mono")); elems.push((format!("{p}.breakoutToMono"), r.rect, d.breakout.to_string())); ui.end_row(); }); ui.add_space(8.0); ui.horizontal(|ui| { - let c = ui.button("Cancel"); + let c = ui.button(tl!("Cancel")); elems.push((format!("{p}.cancel"), c.rect, "Cancel".into())); if c.clicked() { close = true; } - let o = ui.add(egui::Button::new(egui::RichText::new("OK").color(egui::Color32::WHITE)).fill(app.tokens.accent)); + let o = ui.add(egui::Button::new(egui::RichText::new(tl!("OK")).color(egui::Color32::WHITE)).fill(app.tokens.accent)); elems.push((format!("{p}.ok"), o.rect, "OK".into())); if o.clicked() { apply = true; @@ -195,7 +196,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { } let cmd = if d.omf { "file.exportOmf" } else { "file.exportAaf" }; match app.session.execute(cmd, params) { - Ok(v) => app.ui.status = format!("Exported {}", v["path"].as_str().unwrap_or_default()), + Ok(v) => app.ui.status = tlf!("Exported {path}", path = v["path"].as_str().unwrap_or_default()), Err(e) => app.ui.status = e.to_string(), } } diff --git a/crates/ui-egui/src/panels/lumetri.rs b/crates/ui-egui/src/panels/lumetri.rs index f9043f12..ff35ec19 100644 --- a/crates/ui-egui/src/panels/lumetri.rs +++ b/crates/ui-egui/src/panels/lumetri.rs @@ -30,15 +30,15 @@ fn selected(app: &FilmcraftApp) -> Option<(ClipId, filmcraft_project::TrackItem) pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let Some((clip, it)) = selected(app) else { - crate::dock::placeholder(ui, rect, &t, "(no clip selected)"); + crate::dock::placeholder(ui, rect, &t, tl!("(no clip selected)")); return; }; let mut bui = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(12.0, 6.0))).id_salt("lumetri2")); let Some(idx) = it.effects.iter().position(|e| e.effect == "lumetri") else { bui.add_space(8.0); - bui.label(egui::RichText::new(format!("Master · {}", it.name)).color(t.text_dim)); + bui.label(egui::RichText::new(tlf!("Master · {name}", name = it.name)).color(t.text_dim)); bui.add_space(8.0); - if bui.button("Add Lumetri Color to clip").clicked() { + if bui.button(tl!("Add Lumetri Color to clip")).clicked() { let _ = app.session.execute("effects.apply", json!({"clips": [clip.0], "effect": "lumetri"})); } return; @@ -52,7 +52,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut actions: Actions = Vec::new(); // header: clip context + fx toggle bui.horizontal(|ui| { - ui.label(egui::RichText::new(format!("Master · {}", it.name)).color(t.text_dim)); + ui.label(egui::RichText::new(tlf!("Master · {name}", name = it.name)).color(t.text_dim)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let fx = ui.add(egui::Button::new(egui::RichText::new("fx").italics().color(if e.enabled { t.text } else { t.text_faint })).frame(false)); if fx.clicked() { @@ -65,25 +65,25 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut lut_actions: Vec<(&'static str, Value)> = Vec::new(); egui::ScrollArea::vertical().auto_shrink([false, false]).show(&mut bui, |ui| { if open_section(ui, app, &e, "Basic Correction", "basic_on", "basic", &mut sections) { - lut_combo(ui, app, &e, "input_lut", "Input LUT", true, &mut lut_actions); - sub(ui, &t, "Color"); - slider(ui, &e, mt, "temperature", "Temperature", Some(Gradient::Temp), &t, &mut actions, clip, idx); - slider(ui, &e, mt, "tint", "Tint", Some(Gradient::Tint), &t, &mut actions, clip, idx); - slider(ui, &e, mt, "saturation", "Saturation", None, &t, &mut actions, clip, idx); - sub(ui, &t, "Light"); + lut_combo(ui, app, &e, "input_lut", tl!("Input LUT"), true, &mut lut_actions); + sub(ui, &t, tl!("Color")); + slider(ui, &e, mt, "temperature", tl!("Temperature"), Some(Gradient::Temp), &t, &mut actions, clip, idx); + slider(ui, &e, mt, "tint", tl!("Tint"), Some(Gradient::Tint), &t, &mut actions, clip, idx); + slider(ui, &e, mt, "saturation", tl!("Saturation"), None, &t, &mut actions, clip, idx); + sub(ui, &t, tl!("Light")); for (id, label) in [ - ("exposure", "Exposure"), - ("contrast", "Contrast"), - ("highlights", "Highlights"), - ("shadows", "Shadows"), - ("whites", "Whites"), - ("blacks", "Blacks"), + ("exposure", tl!("Exposure")), + ("contrast", tl!("Contrast")), + ("highlights", tl!("Highlights")), + ("shadows", tl!("Shadows")), + ("whites", tl!("Whites")), + ("blacks", tl!("Blacks")), ] { slider(ui, &e, mt, id, label, None, &t, &mut actions, clip, idx); } if hdr { - sub(ui, &t, "HDR"); - for (id, label) in [("hdr_white", "HDR White"), ("hdr_specular", "HDR Specular")] { + sub(ui, &t, tl!("HDR")); + for (id, label) in [("hdr_white", tl!("HDR White")), ("hdr_specular", tl!("HDR Specular"))] { let r = slider(ui, &e, mt, id, label, None, &t, &mut actions, clip, idx); auto_rows.push((format!("lumetri.param.{id}"), r, label)); } @@ -99,28 +99,30 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { .and_then(|p| if let filmcraft_project::ParamKind::Choice(o) = p.kind { Some(o) } else { None }) .unwrap_or(&[]); ui.horizontal(|ui| { - ui.add_sized(vec2(110.0, 20.0), egui::Label::new(egui::RichText::new("Look").color(t.text_dim))); + ui.add_sized(vec2(110.0, 20.0), egui::Label::new(egui::RichText::new(tl!("Look")).color(t.text_dim))); let mut sel = look; egui::ComboBox::from_id_salt("lumetri-look") - .selected_text(opts.get(look).copied().unwrap_or("None")) + .selected_text(opts.get(look).map_or(tl!("None"), |o| crate::i18n::t(o))) .width((ui.available_width() - 40.0).max(60.0)) .show_ui(ui, |ui| { for (i, o) in opts.iter().enumerate() { - if ui.selectable_value(&mut sel, i, *o).changed() { + if ui.selectable_value(&mut sel, i, crate::i18n::t(o)).changed() { set(&mut actions, "look", json!(i)); } } }); }); - lut_combo(ui, app, &e, "look_lut", "Look LUT", false, &mut lut_actions); - slider(ui, &e, mt, "look_intensity", "Intensity", None, &t, &mut actions, clip, idx); - sub(ui, &t, "Adjustments"); - for (id, label) in [("faded_film", "Faded Film"), ("sharpen", "Sharpen"), ("vibrance", "Vibrance"), ("creative_sat", "Saturation")] { + lut_combo(ui, app, &e, "look_lut", tl!("Look LUT"), false, &mut lut_actions); + slider(ui, &e, mt, "look_intensity", tl!("Intensity"), None, &t, &mut actions, clip, idx); + sub(ui, &t, tl!("Adjustments")); + for (id, label) in + [("faded_film", tl!("Faded Film")), ("sharpen", tl!("Sharpen")), ("vibrance", tl!("Vibrance")), ("creative_sat", tl!("Saturation"))] + { slider(ui, &e, mt, id, label, None, &t, &mut actions, clip, idx); } } if open_section(ui, app, &e, "Curves", "curves_on", "curves", &mut sections) { - sub(ui, &t, "RGB Curves"); + sub(ui, &t, tl!("RGB Curves")); let key = egui::Id::new("lumetri-curve-channel"); let mut ch: usize = ui.data(|d| d.get_temp(key)).unwrap_or(0); ui.horizontal(|ui| { @@ -146,16 +148,16 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ][ch]; curve_editor(ui, &e, id, false, col, &t, &mut actions, clip, idx); if hdr { - let r = slider(ui, &e, mt, "curves_hdr_range", "HDR Range", None, &t, &mut actions, clip, idx); + let r = slider(ui, &e, mt, "curves_hdr_range", tl!("HDR Range"), None, &t, &mut actions, clip, idx); auto_rows.push(("lumetri.param.curves_hdr_range".into(), r, "HDR Range")); } - sub(ui, &t, "Hue Saturation Curves"); + sub(ui, &t, tl!("Hue Saturation Curves")); for (id, label) in [ - ("hue_vs_sat", "Hue vs Sat"), - ("hue_vs_hue", "Hue vs Hue"), - ("hue_vs_luma", "Hue vs Luma"), - ("luma_vs_sat", "Luma vs Sat"), - ("sat_vs_sat", "Sat vs Sat"), + ("hue_vs_sat", tl!("Hue vs Sat")), + ("hue_vs_hue", tl!("Hue vs Hue")), + ("hue_vs_luma", tl!("Hue vs Luma")), + ("luma_vs_sat", tl!("Luma vs Sat")), + ("sat_vs_sat", tl!("Sat vs Sat")), ] { ui.label(egui::RichText::new(label).color(t.text_dim)); curve_editor(ui, &e, id, true, Color32::WHITE, &t, &mut actions, clip, idx); @@ -165,9 +167,9 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let w = ((ui.available_width() - 24.0) / 3.0).min(120.0); ui.horizontal(|ui| { for (id, lid, label) in [ - ("wheel_shadows", "wheel_shadows_l", "Shadows"), - ("wheel_midtones", "wheel_midtones_l", "Midtones"), - ("wheel_highlights", "wheel_highlights_l", "Highlights"), + ("wheel_shadows", "wheel_shadows_l", tl!("Shadows")), + ("wheel_midtones", "wheel_midtones_l", tl!("Midtones")), + ("wheel_highlights", "wheel_highlights_l", tl!("Highlights")), ] { ui.vertical(|ui| { ui.label(egui::RichText::new(label).color(t.text_dim)); @@ -178,14 +180,14 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { match_controls(ui, app, &mut lut_actions); } if open_section(ui, app, &e, "HSL Secondary", "hsl_on", "hsl", &mut sections) { - sub(ui, &t, "Key"); - slider(ui, &e, mt, "hsl_hue", "Hue", Some(Gradient::Hue), &t, &mut actions, clip, idx); + sub(ui, &t, tl!("Key")); + slider(ui, &e, mt, "hsl_hue", tl!("Hue"), Some(Gradient::Hue), &t, &mut actions, clip, idx); for (id, label) in [ - ("hsl_hue_range", "Hue Range"), - ("hsl_sat_min", "Saturation Min"), - ("hsl_luma_min", "Luma Min"), - ("hsl_luma_max", "Luma Max"), - ("hsl_soft", "Soften"), + ("hsl_hue_range", tl!("Hue Range")), + ("hsl_sat_min", tl!("Saturation Min")), + ("hsl_luma_min", tl!("Luma Min")), + ("hsl_luma_max", tl!("Luma Max")), + ("hsl_soft", tl!("Soften")), ] { slider(ui, &e, mt, id, label, None, &t, &mut actions, clip, idx); } @@ -194,31 +196,35 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { _ => 0, }; ui.horizontal(|ui| { - ui.add_sized(vec2(110.0, 20.0), egui::Label::new(egui::RichText::new("Show Mask").color(t.text_dim))); + ui.add_sized(vec2(110.0, 20.0), egui::Label::new(egui::RichText::new(tl!("Show Mask")).color(t.text_dim))); let mut sel = mask; - egui::ComboBox::from_id_salt("hsl-mask").selected_text(["Off", "Color/Gray", "Color/Black", "White/Black"][mask.min(3)]).show_ui(ui, |ui| { - for (i, o) in ["Off", "Color/Gray", "Color/Black", "White/Black"].iter().enumerate() { + let modes = [tl!("Off"), tl!("Color/Gray"), tl!("Color/Black"), tl!("White/Black")]; + egui::ComboBox::from_id_salt("hsl-mask").selected_text(modes[mask.min(3)]).show_ui(ui, |ui| { + for (i, o) in modes.iter().enumerate() { if ui.selectable_value(&mut sel, i, *o).changed() { set(&mut actions, "hsl_show_mask", json!(i)); } } }); }); - sub(ui, &t, "Refine"); - for (id, label) in [("hsl_denoise", "Denoise"), ("hsl_blur", "Blur")] { + sub(ui, &t, tl!("Refine")); + for (id, label) in [("hsl_denoise", tl!("Denoise")), ("hsl_blur", tl!("Blur"))] { let r = slider(ui, &e, mt, id, label, None, &t, &mut actions, clip, idx); auto_rows.push((format!("lumetri.param.{id}"), r, label)); } - sub(ui, &t, "Correction"); - slider(ui, &e, mt, "hsl_temp", "Temperature", Some(Gradient::Temp), &t, &mut actions, clip, idx); - slider(ui, &e, mt, "hsl_tint", "Tint", Some(Gradient::Tint), &t, &mut actions, clip, idx); - slider(ui, &e, mt, "hsl_sat", "Saturation", None, &t, &mut actions, clip, idx); - slider(ui, &e, mt, "hsl_hue_shift", "Hue Shift", Some(Gradient::Hue), &t, &mut actions, clip, idx); + sub(ui, &t, tl!("Correction")); + slider(ui, &e, mt, "hsl_temp", tl!("Temperature"), Some(Gradient::Temp), &t, &mut actions, clip, idx); + slider(ui, &e, mt, "hsl_tint", tl!("Tint"), Some(Gradient::Tint), &t, &mut actions, clip, idx); + slider(ui, &e, mt, "hsl_sat", tl!("Saturation"), None, &t, &mut actions, clip, idx); + slider(ui, &e, mt, "hsl_hue_shift", tl!("Hue Shift"), Some(Gradient::Hue), &t, &mut actions, clip, idx); } if open_section(ui, app, &e, "Vignette", "vignette_on", "vignette", &mut sections) { - for (id, label) in - [("vignette_amount", "Amount"), ("vignette_midpoint", "Midpoint"), ("vignette_roundness", "Roundness"), ("vignette_feather", "Feather")] - { + for (id, label) in [ + ("vignette_amount", tl!("Amount")), + ("vignette_midpoint", tl!("Midpoint")), + ("vignette_roundness", tl!("Roundness")), + ("vignette_feather", tl!("Feather")), + ] { slider(ui, &e, mt, id, label, None, &t, &mut actions, clip, idx); } } @@ -249,7 +255,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { continue; } if p.get("browse").is_some() { - let Some(path) = app.hooks.pick_open_file.as_mut().and_then(|f| f("LUT", &["cube", "3dl"])) else { continue }; + let Some(path) = app.hooks.pick_open_file.as_mut().and_then(|f| f(tl!("LUT"), &["cube", "3dl"])) else { continue }; p = json!({"path": path}); } p["clip"] = json!(clip.0); @@ -285,7 +291,7 @@ fn lut_combo(ui: &mut egui::Ui, app: &mut FilmcraftApp, e: &EffectInstance, para }; let cmd: &'static str = if input { "lumetri.setInputLut" } else { "lumetri.setLook" }; let shown = filmcraft_render::luts::label(Some(&app.session.project), &cur); - let mut entries: Vec<(String, String)> = vec![(String::new(), "None".into())]; + let mut entries: Vec<(String, String)> = vec![(String::new(), tl!("None").into())]; let builtins: Vec<&filmcraft_render::luts::Builtin> = if input { filmcraft_render::luts::input_builtins().collect() } else { @@ -305,7 +311,7 @@ fn lut_combo(ui: &mut egui::Ui, app: &mut FilmcraftApp, e: &EffectInstance, para } } ui.separator(); - if ui.selectable_label(false, "Browse…").clicked() { + if ui.selectable_label(false, tl!("Browse…")).clicked() { out.push((cmd, json!({"browse": true}))); } }, @@ -329,7 +335,7 @@ fn section(ui: &mut egui::Ui, app: &mut FilmcraftApp, name: &str, on: bool) -> ( if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim, ); - ui.painter().text(pos2(r.min.x + 20.0, r.center().y), Align2::LEFT_CENTER, name, Tokens::semibold(13.0), t.text); + ui.painter().text(pos2(r.min.x + 20.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(name), Tokens::semibold(13.0), t.text); // enable switch (section bypass) let sw = Rect::from_center_size(pos2(r.max.x - 18.0, r.center().y), vec2(26.0, 14.0)); let sw_resp = ui.interact(sw.expand(3.0), egui::Id::new(("lumetri-switch", name)), Sense::click()); @@ -613,7 +619,7 @@ fn color_wheel(ui: &mut egui::Ui, e: &EffectInstance, mt: Tick, id: &str, lid: & /// Apply Match (`lumetri.applyMatch`). fn match_controls(ui: &mut egui::Ui, app: &mut FilmcraftApp, out: &mut Vec<(&'static str, Value)>) { let t = app.tokens; - sub(ui, &t, "Color Match"); + sub(ui, &t, tl!("Color Match")); let key = egui::Id::new("lumetri-match-ref"); let fd_key = egui::Id::new("lumetri-match-face"); let mut tc: String = ui.data(|d| d.get_temp(key)).unwrap_or_else(|| "00:00:00:00".to_string()); @@ -621,7 +627,7 @@ fn match_controls(ui: &mut egui::Ui, app: &mut FilmcraftApp, out: &mut Vec<(&'st // Comparison View: the Program monitor shows the reference frame next to the current one, and // Apply Match uses that reference let comparing = app.ui.program.display_mode() == Some(crate::state::DisplayMode::Comparison); - let b = ui.selectable_label(comparing, "Comparison View"); + let b = ui.selectable_label(comparing, tl!("Comparison View")); app.auto.add("lumetri.match.comparisonView", b.rect, "Comparison View"); if b.clicked() { out.push(("view.display", json!({"display": if comparing { "composite" } else { "comparison" }}))); @@ -631,13 +637,13 @@ fn match_controls(ui: &mut egui::Ui, app: &mut FilmcraftApp, out: &mut Vec<(&'st tc = filmcraft_time::format_time(r, q.settings.frame_rate, q.settings.drop_frame, filmcraft_time::TimeDisplay::Timecode, 48000); } ui.horizontal(|ui| { - ui.add_sized(vec2(110.0, 20.0), egui::Label::new(egui::RichText::new("Reference").color(t.text_dim))); + ui.add_sized(vec2(110.0, 20.0), egui::Label::new(egui::RichText::new(tl!("Reference")).color(t.text_dim))); let r = ui.add(egui::TextEdit::singleline(&mut tc).desired_width(96.0)); app.auto.add("lumetri.match.reference", r.rect, "Reference timecode"); }); - let r = ui.checkbox(&mut face, "Face Detection (protect skin tones)"); + let r = ui.checkbox(&mut face, tl!("Face Detection (protect skin tones)")); app.auto.add("lumetri.match.faceDetection", r.rect, "Face Detection"); - let b = ui.button("Apply Match"); + let b = ui.button(tl!("Apply Match")); app.auto.add("lumetri.match.apply", b.rect, "Apply Match"); if b.clicked() { let p = match reference { diff --git a/crates/ui-egui/src/panels/masks.rs b/crates/ui-egui/src/panels/masks.rs index 86893f61..54cb98b4 100644 --- a/crates/ui-egui/src/panels/masks.rs +++ b/crates/ui-egui/src/panels/masks.rs @@ -46,10 +46,13 @@ pub fn effect_rows( // creation tools: ellipse, 4-point polygon, free-draw Bézier let (r, _) = ui.allocate_exact_size(vec2(body.width(), ROW_H), Sense::hover()); let pen_on = app.ui.mask_pen.as_ref().is_some_and(|p| p.clip == clip.0 && p.effect == idx); - for (i, (icon, kind, tip)) in - [(Icon::Ellipse, "ellipse", "Create ellipse mask"), (Icon::Rectangle, "polygon", "Create 4-point polygon mask"), (Icon::Pen, "pen", "Free draw bezier")] - .into_iter() - .enumerate() + for (i, (icon, kind, tip)) in [ + (Icon::Ellipse, "ellipse", tl!("Create ellipse mask")), + (Icon::Rectangle, "polygon", tl!("Create 4-point polygon mask")), + (Icon::Pen, "pen", tl!("Free draw bezier")), + ] + .into_iter() + .enumerate() { let br = Rect::from_center_size(pos2(r.min.x + 40.0 + i as f32 * 24.0, r.center().y), vec2(20.0, 18.0)); let resp = ui.interact(br, egui::Id::new(("mask-new", clip.0, idx, kind)), Sense::click()).on_hover_text(tip); @@ -63,7 +66,8 @@ pub fn effect_rows( if kind == "pen" { app.ui.mask_pen = if on { None } else { Some(crate::state::MaskPenDraft { clip: clip.0, effect: idx, points: vec![] }) }; if !on { - app.ui.status = "Click in the Program monitor to place mask points (drag for curves); click the first point or press Enter to close".into(); + app.ui.status = + tl!("Click in the Program monitor to place mask points (drag for curves); click the first point or press Enter to close").into(); } } else { actions.push(("masks.add".into(), json!({"clip": clip.0, "effect": idx, "shape": kind}))); @@ -102,9 +106,9 @@ pub fn effect_rows( let mr = Rect::from_min_size(pos2(r.max.x - 118.0, r.min.y + 2.0), vec2(96.0, ROW_H - 4.0)); let mut mui = ui.new_child(egui::UiBuilder::new().max_rect(mr).layout(egui::Layout::left_to_right(egui::Align::Center))); let mut mode = m.mode; - egui::ComboBox::from_id_salt(("mask-mode", clip.0, idx, k)).selected_text(mode.label()).width(88.0).show_ui(&mut mui, |ui| { + egui::ComboBox::from_id_salt(("mask-mode", clip.0, idx, k)).selected_text(crate::i18n::t(mode.label())).width(88.0).show_ui(&mut mui, |ui| { for md in MaskMode::ALL { - if ui.selectable_value(&mut mode, md, md.label()).changed() { + if ui.selectable_value(&mut mode, md, crate::i18n::t(md.label())).changed() { actions.push(("masks.set".into(), json!({"clip": clip.0, "effect": idx, "mask": k, "mode": md.label()}))); } } @@ -124,7 +128,7 @@ pub fn effect_rows( } } resp.context_menu(|ui| { - if ui.button("Delete Mask").clicked() { + if ui.button(tl!("Delete Mask")).clicked() { actions.push(("masks.remove".into(), json!({"clip": clip.0, "effect": idx, "mask": k}))); ui.close(); } @@ -144,7 +148,7 @@ pub fn effect_rows( } // Inverted let (r, _) = ui.allocate_exact_size(vec2(body.width(), ROW_H), Sense::hover()); - ui.painter().text(pos2(r.min.x + 40.0, r.center().y), Align2::LEFT_CENTER, "Inverted", Tokens::ui(12.0), t.text); + ui.painter().text(pos2(r.min.x + 40.0, r.center().y), Align2::LEFT_CENTER, tl!("Inverted"), Tokens::ui(12.0), t.text); let cr = Rect::from_min_size(pos2(r.min.x + (r.width() * 0.5).max(150.0), r.min.y + 2.0), vec2(20.0, ROW_H - 4.0)); let mut cui = ui.new_child(egui::UiBuilder::new().max_rect(cr)); let mut inv = m.inverted; @@ -361,7 +365,7 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra painter.rect_stroke(vr, 0.0, Stroke::new(1.0, col), egui::StrokeKind::Middle); let r = ui .interact(vr.expand(3.0), egui::Id::new(("mask-vertex", i)), Sense::click_and_drag()) - .on_hover_text("Drag: move · Alt-click: smooth/corner · Cmd-click: delete"); + .on_hover_text(tl!("Drag: move · Alt-click: smooth/corner · Cmd-click: delete")); app.auto.add(&format!("program.mask.vertex.{i}"), vr, "Mask vertex"); if r.drag_started() { ui.data_mut(|d| d.insert_temp(sel_v_id, i)); @@ -542,13 +546,13 @@ pub fn path_value(app: &mut FilmcraftApp, ui: &mut egui::Ui, clip: ClipId, effec let target = filmcraft_engine::masks::MaskSel { clip, effect, mask: k }; let running = app.session.mask_jobs.iter().find(|j| j.target == target).map(|j| j.job); for (icon, dir, frames, id, tip) in [ - (Icon::TrackMaskBack, "backward", None, "back", "Track selected mask backward"), - (Icon::TrackMaskBackFrame, "backward", Some(1), "backFrame", "Track selected mask backward 1 frame"), - (Icon::TrackMaskFwdFrame, "forward", Some(1), "fwdFrame", "Track selected mask forward 1 frame"), - (Icon::TrackMaskFwd, "forward", None, "fwd", "Track selected mask forward"), + (Icon::TrackMaskBack, "backward", None, "back", tl!("Track selected mask backward")), + (Icon::TrackMaskBackFrame, "backward", Some(1), "backFrame", tl!("Track selected mask backward 1 frame")), + (Icon::TrackMaskFwdFrame, "forward", Some(1), "fwdFrame", tl!("Track selected mask forward 1 frame")), + (Icon::TrackMaskFwd, "forward", None, "fwd", tl!("Track selected mask forward")), ] { let (r, resp) = ui.allocate_exact_size(vec2(20.0, 18.0), Sense::click()); - let resp = resp.on_hover_text(if running.is_some() { "Stop tracking" } else { tip }); + let resp = resp.on_hover_text(if running.is_some() { tl!("Stop tracking") } else { tip }); if resp.hovered() { ui.painter().rect_filled(r, 3.0, t.hover); } @@ -571,10 +575,10 @@ pub fn path_value(app: &mut FilmcraftApp, ui: &mut egui::Ui, clip: ClipId, effec let (r, resp) = ui.allocate_exact_size(vec2(20.0, 18.0), Sense::click()); icons::paint(ui.painter(), r.shrink(3.0), Icon::Wrench, if resp.hovered() { t.tab_text_active } else { t.icon }); app.auto.add(&format!("{base}.trackMethod"), r, "Tracking method"); - let resp = resp.on_hover_text(format!("Tracking method: {}", m.track_method.label())); + let resp = resp.on_hover_text(tlf!("Tracking method: {method}", method = crate::i18n::t(m.track_method.label()))); egui::Popup::menu(&resp).show(|ui| { for tm in filmcraft_project::TrackMethod::ALL { - if ui.selectable_label(tm == m.track_method, tm.label()).clicked() { + if ui.selectable_label(tm == m.track_method, crate::i18n::t(tm.label())).clicked() { actions.push(("masks.set".into(), json!({"clip": clip.0, "effect": effect, "mask": k, "trackMethod": tm.label()}))); } } diff --git a/crates/ui-egui/src/panels/media_browser.rs b/crates/ui-egui/src/panels/media_browser.rs index f72b5cf4..c19ed5ce 100644 --- a/crates/ui-egui/src/panels/media_browser.rs +++ b/crates/ui-egui/src/panels/media_browser.rs @@ -100,9 +100,9 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let bar = Rect::from_min_size(rect.min + vec2(6.0, 4.0), vec2(rect.width() - 12.0, 24.0)); let mut x = bar.min.x; let nav = [ - (Icon::ChevronLeft, "back", "Go Back", !app.session.browser.back.is_empty()), - (Icon::ChevronRight, "forward", "Go Forward", !app.session.browser.forward.is_empty()), - (Icon::ArrowUp, "up", "Up One Level", mb::parent(&dir).is_some()), + (Icon::ChevronLeft, "back", tl!("Go Back"), !app.session.browser.back.is_empty()), + (Icon::ChevronRight, "forward", tl!("Go Forward"), !app.session.browser.forward.is_empty()), + (Icon::ArrowUp, "up", tl!("Up One Level"), mb::parent(&dir).is_some()), ]; for (icon, id, tip, enabled) in nav { let r = Rect::from_min_size(pos2(x, bar.min.y), vec2(24.0, 24.0)); @@ -138,7 +138,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { // file types let fr = Rect::from_min_size(pos2(x, bar.min.y + 1.0), vec2(150.0, 22.0)); let label = mb::FILE_TYPES.iter().find(|f| f.0 == prefs.file_types).map(|f| f.1.to_string()).unwrap_or_else(|| format!(".{}", prefs.file_types)); - let fresp = crate::widgets::dropdown_text(ui, fr, &label, &t, egui::Id::new("mb-types")).on_hover_text("File Types Displayed"); + let fresp = crate::widgets::dropdown_text(ui, fr, &label, &t, egui::Id::new("mb-types")).on_hover_text(tl!("File Types Displayed")); app.auto.add("mediaBrowser.fileTypes", fr, "File Types Displayed"); egui::Popup::menu(&fresp).show(|ui| { for (k, l) in mb::FILE_TYPES { @@ -150,7 +150,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } } ui.separator(); - ui.menu_button("File Extension", |ui| { + ui.menu_button(tl!("File Extension"), |ui| { for e in mb::extensions() { if ui.selectable_label(prefs.file_types == e, format!(".{e}")).clicked() { exec(app, &ctx, "mediaBrowser.settings", json!({"fileTypes": e})); @@ -160,7 +160,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { }); }); x = fr.max.x + 6.0; - for (icon, v, tip) in [(Icon::ListView, "list", "List View"), (Icon::IconView, "thumbnails", "Thumbnail View")] { + for (icon, v, tip) in [(Icon::ListView, "list", tl!("List View")), (Icon::IconView, "thumbnails", tl!("Thumbnail View"))] { let r = Rect::from_min_size(pos2(x, bar.min.y), vec2(24.0, 24.0)); let resp = ui.interact(r, egui::Id::new(("mb-view", v)), Sense::click()).on_hover_text(tip); app.auto.add(&format!("mediaBrowser.view.{v}"), r, tip); @@ -179,19 +179,19 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let row2 = Rect::from_min_size(pos2(bar.min.x, bar.max.y + 4.0), vec2(bar.width(), 20.0)); let mut ingest = app.session.project.settings.ingest.enabled; let mut c = ui.new_child(egui::UiBuilder::new().max_rect(row2).id_salt("mb-row2").layout(egui::Layout::left_to_right(egui::Align::Center))); - let r = c.checkbox(&mut ingest, "Ingest"); + let r = c.checkbox(&mut ingest, tl!("Ingest")); app.auto.add("mediaBrowser.ingest", r.rect, "Ingest"); if r.changed() { exec(app, &ctx, "project.ingestSettings", json!({"enabled": ingest})); } - let w = c.add(egui::Button::new(egui::RichText::new("⚙").size(12.0)).frame(false)).on_hover_text("Open Ingest Settings"); + let w = c.add(egui::Button::new(egui::RichText::new("⚙").size(12.0)).frame(false)).on_hover_text(tl!("Open Ingest Settings")); app.auto.add("mediaBrowser.ingestSettings", w.rect, "Open Ingest Settings"); if w.clicked() { exec(app, &ctx, "project.ingestSettings", json!({})); } c.add_space(12.0); let mut seq = prefs.import_as_image_sequence; - let r = c.checkbox(&mut seq, "Import as Image Sequence"); + let r = c.checkbox(&mut seq, tl!("Import as Image Sequence")); app.auto.add("mediaBrowser.importAsImageSequence", r.rect, "Import as Image Sequence"); if r.changed() { exec(app, &ctx, "mediaBrowser.settings", json!({"importAsImageSequence": seq})); @@ -219,12 +219,12 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } let n = entries.iter().filter(|e| !e.is_dir).count(); let sel = app.session.browser.selection.len(); - let status = if sel > 0 { format!("{sel} of {n} items selected") } else { format!("{n} items") }; + let status = if sel > 0 { tlf!("{sel} of {total} items selected", sel, total = n) } else { tlf!("{total} items", total = n) }; ui.painter().text(pos2(list_r.min.x + 6.0, rect.max.y - 12.0), Align2::LEFT_CENTER, &status, Tokens::ui(11.0), t.text_dim); app.auto.add("mediaBrowser.count", Rect::from_min_size(pos2(list_r.min.x, rect.max.y - 22.0), vec2(160.0, 20.0)), &status); } Err(e) => { - ui.painter().text(list_r.center(), Align2::CENTER_CENTER, format!("Cannot open {dir}: {e}"), Tokens::ui(11.5), t.text_dim); + ui.painter().text(list_r.center(), Align2::CENTER_CENTER, tlf!("Cannot open {dir}: {e}", dir, e), Tokens::ui(11.5), t.text_dim); } } if prefs.view == "thumbnails" { @@ -251,10 +251,10 @@ fn tree(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, dir: &str) { let mut go: Option = None; egui::ScrollArea::vertical().id_salt("mb-tree-scroll").auto_shrink([false, false]).show(&mut child, |ui| { let sections: [(&str, &str, Icon); 4] = [ - ("favorites", "Favorites", Icon::Star), - ("localDrives", "Local Drives", Icon::Drive), - ("network", "Network", Icon::Network), - ("recent", "Recent Directories", Icon::Clock), + ("favorites", tl!("Favorites"), Icon::Star), + ("localDrives", tl!("Local Drives"), Icon::Drive), + ("network", tl!("Network"), Icon::Network), + ("recent", tl!("Recent Directories"), Icon::Clock), ]; for (key, title, icon) in sections { let list: Vec<(String, String)> = roots[key] @@ -264,7 +264,7 @@ fn tree(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, dir: &str) { let hr = ui.label(egui::RichText::new(title).size(11.0).color(app.tokens.text_dim).strong()); app.auto.add(&format!("mediaBrowser.tree.{key}"), hr.rect, title); if list.is_empty() { - ui.label(egui::RichText::new(" (none)").size(10.5).color(app.tokens.text_faint)); + ui.label(egui::RichText::new(tl!(" (none)")).size(10.5).color(app.tokens.text_faint)); } for (name, path) in list { let expandable = matches!(key, "localDrives" | "network" | "favorites"); @@ -328,18 +328,18 @@ fn node( } resp.context_menu(|ui| { let fav = app.session.prefs.media_browser.favorites.iter().any(|f| f == path); - let label = if fav { "Remove from Favorites" } else { "Add to Favorites" }; + let label = if fav { tl!("Remove from Favorites") } else { tl!("Add to Favorites") }; let b = ui.button(label); app.auto.add("mediaBrowser.treeMenu.favorite", b.rect, label); if b.clicked() { exec(app, ctx, "mediaBrowser.favorite", json!({"path": path, "remove": fav})); ui.close(); } - if ui.button("Import").clicked() { + if ui.button(tl!("Import")).clicked() { exec(app, ctx, "mediaBrowser.import", json!({"paths": [path]})); ui.close(); } - if section == "recent" && ui.button("Clear Recent Directories").clicked() { + if section == "recent" && ui.button(tl!("Clear Recent Directories")).clicked() { exec(app, ctx, "mediaBrowser.clearRecent", json!({})); ui.close(); } @@ -399,14 +399,14 @@ fn entry_interactions(app: &mut FilmcraftApp, ui: &egui::Ui, resp: &egui::Respon } resp.context_menu(|ui| { let paths: Vec = if app.session.browser.selection.contains(&e.path) { app.session.browser.selection.clone() } else { vec![e.path.clone()] }; - let b = ui.button("Import"); + let b = ui.button(tl!("Import")); app.auto.add("mediaBrowser.entryMenu.import", b.rect, "Import"); if b.clicked() { exec(app, &ctx, "mediaBrowser.import", json!({"paths": paths, "imageSequence": false})); ui.close(); } if !e.is_dir { - let b = ui.button("Open In Source Monitor"); + let b = ui.button(tl!("Open In Source Monitor")); app.auto.add("mediaBrowser.entryMenu.openInSource", b.rect, "Open In Source Monitor"); if b.clicked() { exec(app, &ctx, "mediaBrowser.openInSource", json!({"path": e.path})); @@ -414,7 +414,7 @@ fn entry_interactions(app: &mut FilmcraftApp, ui: &egui::Ui, resp: &egui::Respon } } if e.numbered { - let b = ui.button("Import as Image Sequence"); + let b = ui.button(tl!("Import as Image Sequence")); app.auto.add("mediaBrowser.entryMenu.importSequence", b.rect, "Import as Image Sequence"); if b.clicked() { exec(app, &ctx, "mediaBrowser.import", json!({"paths": [e.path], "imageSequence": true})); @@ -423,12 +423,12 @@ fn entry_interactions(app: &mut FilmcraftApp, ui: &egui::Ui, resp: &egui::Respon } if e.is_dir { let fav = app.session.prefs.media_browser.favorites.contains(&e.path); - if ui.button(if fav { "Remove from Favorites" } else { "Add to Favorites" }).clicked() { + if ui.button(if fav { tl!("Remove from Favorites") } else { tl!("Add to Favorites") }).clicked() { exec(app, &ctx, "mediaBrowser.favorite", json!({"path": e.path, "remove": fav})); ui.close(); } } - if ui.button("Reveal in Finder").clicked() { + if ui.button(tl!("Reveal in Finder")).clicked() { let _ = crate::panels::menu_dialogs::open_path(app, &ctx, &e.path, true); ui.close(); } @@ -551,12 +551,12 @@ fn list(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, entries: &[Entry], c let mut x = header.min.x + 4.0 - out.state.offset.x; for (c, w) in columns.iter().zip(&widths) { let cr = Rect::from_min_size(pos2(x, header.min.y), vec2(*w, 20.0)); - hp.text(pos2(cr.min.x + 4.0, cr.center().y), Align2::LEFT_CENTER, c, Tokens::ui(11.0), t.text_dim); + hp.text(pos2(cr.min.x + 4.0, cr.center().y), Align2::LEFT_CENTER, crate::i18n::t(c), Tokens::ui(11.0), t.text_dim); if cr.intersects(header) { let resp = ui.interact(cr.intersect(header), egui::Id::new(("mb-col", c)), Sense::click()); app.auto.add(&format!("mediaBrowser.header.{c}"), cr.intersect(header), c); resp.context_menu(|ui| { - if ui.button("Edit Columns…").clicked() { + if ui.button(tl!("Edit Columns…")).clicked() { app.ui.media_browser.edit_columns = Some(columns.to_vec()); ui.close(); } @@ -731,52 +731,53 @@ pub fn panel_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui) -> bool { app.auto.add(&format!("mediaBrowser.menu.{id}"), r.rect, label); r.clicked() }; - if item(app, ui, "editColumns", "Edit Columns…", true, None, "") { + if item(app, ui, "editColumns", tl!("Edit Columns…"), true, None, "") { app.ui.media_browser.edit_columns = Some(prefs.columns.clone()); close = true; } - if item(app, ui, "importAsImageSequence", "Import as Image Sequence", true, Some(prefs.import_as_image_sequence), "") { + if item(app, ui, "importAsImageSequence", tl!("Import as Image Sequence"), true, Some(prefs.import_as_image_sequence), "") { exec(app, &ctx, "mediaBrowser.settings", json!({"importAsImageSequence": !prefs.import_as_image_sequence})); close = true; } - if item(app, ui, "hoverScrub", "Hover Scrub", true, Some(prefs.hover_scrub), "") { + if item(app, ui, "hoverScrub", tl!("Hover Scrub"), true, Some(prefs.hover_scrub), "") { exec(app, &ctx, "mediaBrowser.settings", json!({"hoverScrub": !prefs.hover_scrub})); close = true; } - item(app, ui, "newPanel", "New Media Browser Panel", false, None, ""); + item(app, ui, "newPanel", tl!("New Media Browser Panel"), false, None, ""); let mprefs = app.session.prefs.media.clone(); - if item(app, ui, "createFolderForImportedProjects", "Create Folder For Imported Projects", true, Some(mprefs.create_folder_for_imported_projects), "") { + if item(app, ui, "createFolderForImportedProjects", tl!("Create Folder For Imported Projects"), true, Some(mprefs.create_folder_for_imported_projects), "") + { let mut p = app.session.prefs.clone(); p.media.create_folder_for_imported_projects = !mprefs.create_folder_for_imported_projects; let _ = app.session.set_prefs(p); close = true; } - if item(app, ui, "allowDuplicateMedia", "Allow Duplicate Media During Project Import", true, Some(mprefs.allow_duplicate_media), "") { + if item(app, ui, "allowDuplicateMedia", tl!("Allow Duplicate Media During Project Import"), true, Some(mprefs.allow_duplicate_media), "") { let mut p = app.session.prefs.clone(); p.media.allow_duplicate_media = !mprefs.allow_duplicate_media; let _ = app.session.set_prefs(p); close = true; } - if item(app, ui, "refresh", "Refresh", true, None, "") { + if item(app, ui, "refresh", tl!("Refresh"), true, None, "") { refresh(&ctx); app.session.browser.probes.clear(); close = true; } ui.separator(); let fav = prefs.favorites.contains(&dir); - if item(app, ui, "favorite", if fav { "Remove from Favorites" } else { "Add to Favorites" }, true, None, "") { + if item(app, ui, "favorite", if fav { tl!("Remove from Favorites") } else { tl!("Add to Favorites") }, true, None, "") { exec(app, &ctx, "mediaBrowser.favorite", json!({"remove": fav})); close = true; } - if item(app, ui, "clearRecent", "Clear Recent Directories", !prefs.recent.is_empty(), None, "") { + if item(app, ui, "clearRecent", tl!("Clear Recent Directories"), !prefs.recent.is_empty(), None, "") { exec(app, &ctx, "mediaBrowser.clearRecent", json!({})); close = true; } - if item(app, ui, "import", "Import", has_sel, None, "") { + if item(app, ui, "import", tl!("Import"), has_sel, None, "") { exec(app, &ctx, "mediaBrowser.import", json!({})); close = true; } - if item(app, ui, "openInSource", "Open In Source Monitor", has_sel, None, "Shift+O") { + if item(app, ui, "openInSource", tl!("Open In Source Monitor"), has_sel, None, "Shift+O") { exec(app, &ctx, "mediaBrowser.openInSource", json!({})); close = true; } @@ -788,14 +789,17 @@ pub fn dialogs(app: &mut FilmcraftApp, ctx: &egui::Context) { let Some(mut cols) = app.ui.media_browser.edit_columns.clone() else { return }; let mut close = false; let mut ok = false; - egui::Window::new("Edit Columns").id(egui::Id::new("mb-edit-columns")).collapsible(false).resizable(false).anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show( - ctx, - |ui| { + egui::Window::new(tl!("Edit Columns")) + .id(egui::Id::new("mb-edit-columns")) + .collapsible(false) + .resizable(false) + .anchor(Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { let mut mv: Option<(usize, isize)> = None; for c in mb::ALL_COLUMNS { ui.horizontal(|ui| { let mut on = cols.iter().any(|x| x == c); - let r = ui.add_enabled(c != "Name", egui::Checkbox::new(&mut on, c)); + let r = ui.add_enabled(c != "Name", egui::Checkbox::new(&mut on, crate::i18n::t(c))); app.auto.add(&format!("mediaBrowser.columns.{c}"), r.rect, c); if r.changed() { if on { @@ -819,15 +823,14 @@ pub fn dialogs(app: &mut FilmcraftApp, ctx: &egui::Context) { } ui.separator(); ui.horizontal(|ui| { - let r = ui.button("Cancel"); + let r = ui.button(tl!("Cancel")); app.auto.add("mediaBrowser.columns.cancel", r.rect, "Cancel"); close |= r.clicked(); - let r = ui.button("OK"); + let r = ui.button(tl!("OK")); app.auto.add("mediaBrowser.columns.ok", r.rect, "OK"); ok = r.clicked(); }); - }, - ); + }); if ok { let r = app.session.execute("mediaBrowser.settings", json!({"columns": cols})); if let Err(e) = r { diff --git a/crates/ui-egui/src/panels/media_dialogs.rs b/crates/ui-egui/src/panels/media_dialogs.rs index 3415a0af..798ad647 100644 --- a/crates/ui-egui/src/panels/media_dialogs.rs +++ b/crates/ui-egui/src/panels/media_dialogs.rs @@ -62,7 +62,7 @@ pub fn route(app: &mut FilmcraftApp, id: &str, params: &Value) -> Option Option Vec<(u64, String, String, String, &'stati let m = it.as_media()?; let filmcraft_project::MediaRef::File { path } = &m.media else { return None }; let fname = path.rsplit(['/', '\\']).next().unwrap_or(path).to_string(); - Some((id.0, it.name.clone(), fname, path.clone(), if m.offline { "Offline" } else { "Missing" })) + Some((id.0, it.name.clone(), fname, path.clone(), if m.offline { tl!("Offline") } else { tl!("Missing") })) }) .collect() } @@ -154,124 +154,130 @@ fn link_media(app: &mut FilmcraftApp, ctx: &egui::Context) { let accent = app.tokens.accent; let sel_path = d.candidate.and_then(|c| d.candidates.get(c)).map(|c| c.0.clone()); let preview = sel_path.as_deref().and_then(|p| preview_texture(app, ctx, p)); - egui::Window::new("Link Media").collapsible(false).resizable(false).default_width(760.0).anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { - ui.label(format!("{} clip(s) can't find their media. Locate them, search a folder, or leave them offline.", rows.len())); - ui.add_space(6.0); - egui::Grid::new("link-media-rows").num_columns(4).striped(true).spacing([14.0, 4.0]).show(ui, |ui| { - for h in ["Clip Name", "File Name", "File Path", "Status"] { - ui.label(RichText::new(h).strong()); - } - ui.end_row(); - for (k, (_, name, fname, path, status)) in rows.iter().enumerate() { - let r = ui.selectable_label(d.row == k, name); - push(&mut elems, format!("linkMedia.row.{k}"), &r, name.clone()); - if r.clicked() && d.row != k { - d.row = k; - d.candidates.clear(); - d.candidate = None; + egui::Window::new(tl!("Link Media")) + .id(egui::Id::new("Link Media")) + .collapsible(false) + .resizable(false) + .default_width(760.0) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.label(tlf!("{n} clip(s) can't find their media. Locate them, search a folder, or leave them offline.", n = rows.len())); + ui.add_space(6.0); + egui::Grid::new("link-media-rows").num_columns(4).striped(true).spacing([14.0, 4.0]).show(ui, |ui| { + for h in [tl!("Clip Name"), tl!("File Name"), tl!("File Path"), tl!("Status")] { + ui.label(RichText::new(h).strong()); } - ui.label(fname); - ui.label(RichText::new(path).small()); - ui.label(RichText::new(*status).color(Color32::from_rgb(0xe0, 0x5a, 0x5a))); ui.end_row(); - } - }); - ui.separator(); - ui.horizontal(|ui| { - ui.label("Match file properties:"); - for (id, label, v) in [ - ("fileName", "File Name", &mut d.file_name), - ("extension", "File Extension", &mut d.extension), - ("clipId", "Clip ID (fingerprint)", &mut d.clip_id), - ("duration", "Duration", &mut d.duration), - ("mediaStart", "Media Start", &mut d.media_start), - ("metadata", "Frame Size / Rate", &mut d.metadata), - ] { - let r = ui.checkbox(v, label); - push(&mut elems, format!("linkMedia.match.{id}"), &r, label); - } - }); - ui.horizontal(|ui| { - let r = ui.checkbox(&mut d.align_timecode, "Align Timecode"); - push(&mut elems, "linkMedia.alignTimecode", &r, "Align Timecode"); - let r = ui.checkbox(&mut d.relink_others, "Relink others automatically"); - push(&mut elems, "linkMedia.relinkOthers", &r, "Relink others automatically"); - }); - ui.separator(); - ui.horizontal(|ui| { - ui.label("Search in:"); - let r = ui.add(egui::TextEdit::singleline(&mut d.folder).desired_width(360.0).hint_text("folder")); - push(&mut elems, "linkMedia.folder", &r, "folder"); - let r = ui.button("Browse…"); - push(&mut elems, "linkMedia.browse", &r, "Browse…"); - if r.clicked() { - action = Some("browse"); - } - let r = ui.checkbox(&mut d.exact_name, "Exact name matches only"); - push(&mut elems, "linkMedia.exactName", &r, "Exact name matches only"); - let r = ui.button("Search"); - push(&mut elems, "linkMedia.search", &r, "Search"); - if r.clicked() { - action = Some("search"); - } - }); - ui.horizontal(|ui| { - ui.vertical(|ui| { - ui.set_width(520.0); - if d.candidates.is_empty() { - ui.label(RichText::new("No candidates yet — Search a folder or Locate the file.").weak()); - } - for (k, (path, ok, idm, problems)) in d.candidates.iter().enumerate() { - let mark = match (ok, idm) { - (true, Some(true)) => "✔ same file", - (true, _) => "✔ matches", - (false, Some(false)) => "✖ different file", - _ => "✖", - }; - let text = format!("{mark} {path}"); - let r = ui.selectable_label( - d.candidate == Some(k), - RichText::new(&text).color(if *ok { Color32::LIGHT_GREEN } else { Color32::from_rgb(0xe0, 0x8a, 0x6a) }), - ); - let r = if problems.is_empty() { r } else { r.on_hover_text(problems) }; - push(&mut elems, format!("linkMedia.candidate.{k}"), &r, text); - if r.clicked() { - d.candidate = Some(k); + for (k, (_, name, fname, path, status)) in rows.iter().enumerate() { + let r = ui.selectable_label(d.row == k, name); + push(&mut elems, format!("linkMedia.row.{k}"), &r, name.clone()); + if r.clicked() && d.row != k { + d.row = k; + d.candidates.clear(); + d.candidate = None; } + ui.label(fname); + ui.label(RichText::new(path).small()); + ui.label(RichText::new(*status).color(Color32::from_rgb(0xe0, 0x5a, 0x5a))); + ui.end_row(); } }); - let (rect, _) = ui.allocate_exact_size(egui::vec2(192.0, 108.0), egui::Sense::hover()); - ui.painter().rect_filled(rect, 3.0, Color32::from_rgb(12, 12, 12)); - match &preview { - Some(t) => { - let fitted = crate::panels::monitor::fit(rect, t.size()[0] as f32, t.size()[1] as f32); - ui.painter().image(t.id(), fitted, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), Color32::WHITE); + ui.separator(); + ui.horizontal(|ui| { + ui.label(tl!("Match file properties:")); + for (id, label, v) in [ + ("fileName", tl!("File Name"), &mut d.file_name), + ("extension", tl!("File Extension"), &mut d.extension), + ("clipId", tl!("Clip ID (fingerprint)"), &mut d.clip_id), + ("duration", tl!("Duration"), &mut d.duration), + ("mediaStart", tl!("Media Start"), &mut d.media_start), + ("metadata", tl!("Frame Size / Rate"), &mut d.metadata), + ] { + let r = ui.checkbox(v, label); + push(&mut elems, format!("linkMedia.match.{id}"), &r, label); } - None => { - ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, "Preview", crate::theme::Tokens::ui(11.0), Color32::GRAY); - } - } - elems.push(("linkMedia.preview".into(), rect, "preview".into())); - }); - if !d.message.is_empty() { - ui.colored_label(Color32::from_rgb(0xe0, 0x8a, 0x6a), &d.message); - } - ui.add_space(6.0); - ui.horizontal(|ui| { - for (id, label) in [("offlineAll", "Offline All"), ("offline", "Offline"), ("cancel", "Cancel"), ("locate", "Locate…")] { - let r = ui.button(label); - push(&mut elems, format!("linkMedia.{id}"), &r, label); + }); + ui.horizontal(|ui| { + let r = ui.checkbox(&mut d.align_timecode, tl!("Align Timecode")); + push(&mut elems, "linkMedia.alignTimecode", &r, "Align Timecode"); + let r = ui.checkbox(&mut d.relink_others, tl!("Relink others automatically")); + push(&mut elems, "linkMedia.relinkOthers", &r, "Relink others automatically"); + }); + ui.separator(); + ui.horizontal(|ui| { + ui.label(tl!("Search in:")); + let r = ui.add(egui::TextEdit::singleline(&mut d.folder).desired_width(360.0).hint_text(tl!("Folder"))); + push(&mut elems, "linkMedia.folder", &r, "folder"); + let r = ui.button(tl!("Browse…")); + push(&mut elems, "linkMedia.browse", &r, "Browse…"); if r.clicked() { - action = Some(id); + action = Some("browse"); } + let r = ui.checkbox(&mut d.exact_name, tl!("Exact name matches only")); + push(&mut elems, "linkMedia.exactName", &r, "Exact name matches only"); + let r = ui.button(tl!("Search")); + push(&mut elems, "linkMedia.search", &r, "Search"); + if r.clicked() { + action = Some("search"); + } + }); + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.set_width(520.0); + if d.candidates.is_empty() { + ui.label(RichText::new(tl!("No candidates yet — Search a folder or Locate the file.")).weak()); + } + for (k, (path, ok, idm, problems)) in d.candidates.iter().enumerate() { + let mark = match (ok, idm) { + (true, Some(true)) => tl!("✔ same file"), + (true, _) => tl!("✔ matches"), + (false, Some(false)) => tl!("✖ different file"), + _ => "✖", + }; + let text = format!("{mark} {path}"); + let r = ui.selectable_label( + d.candidate == Some(k), + RichText::new(&text).color(if *ok { Color32::LIGHT_GREEN } else { Color32::from_rgb(0xe0, 0x8a, 0x6a) }), + ); + let r = if problems.is_empty() { r } else { r.on_hover_text(problems) }; + push(&mut elems, format!("linkMedia.candidate.{k}"), &r, text); + if r.clicked() { + d.candidate = Some(k); + } + } + }); + let (rect, _) = ui.allocate_exact_size(egui::vec2(192.0, 108.0), egui::Sense::hover()); + ui.painter().rect_filled(rect, 3.0, Color32::from_rgb(12, 12, 12)); + match &preview { + Some(t) => { + let fitted = crate::panels::monitor::fit(rect, t.size()[0] as f32, t.size()[1] as f32); + ui.painter().image(t.id(), fitted, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), Color32::WHITE); + } + None => { + ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, tl!("Preview"), crate::theme::Tokens::ui(11.0), Color32::GRAY); + } + } + elems.push(("linkMedia.preview".into(), rect, "preview".into())); + }); + if !d.message.is_empty() { + ui.colored_label(Color32::from_rgb(0xe0, 0x8a, 0x6a), &d.message); } - let r = ui.add_enabled(d.candidate.is_some(), egui::Button::new(RichText::new("Link").color(Color32::WHITE)).fill(accent)); - push(&mut elems, "linkMedia.link", &r, "Link"); - if r.clicked() { - action = Some("link"); - } + ui.add_space(6.0); + ui.horizontal(|ui| { + for (id, label) in [("offlineAll", tl!("Offline All")), ("offline", tl!("Offline")), ("cancel", tl!("Cancel")), ("locate", tl!("Locate…"))] { + let r = ui.button(label); + push(&mut elems, format!("linkMedia.{id}"), &r, label); + if r.clicked() { + action = Some(id); + } + } + let r = ui.add_enabled(d.candidate.is_some(), egui::Button::new(RichText::new(tl!("Link")).color(Color32::WHITE)).fill(accent)); + push(&mut elems, "linkMedia.link", &r, "Link"); + if r.clicked() { + action = Some("link"); + } + }); }); - }); for (id, r, l) in elems { app.auto.add(&id, r, &l); } @@ -288,7 +294,7 @@ fn link_media(app: &mut FilmcraftApp, ctx: &egui::Context) { d.message.clear(); d.candidates.clear(); d.candidate = None; - app.ui.status = format!("Linked {} clip(s)", v["relinked"].as_array().map_or(0, Vec::len)); + app.ui.status = tlf!("Linked {n} clip(s)", n = v["relinked"].as_array().map_or(0, Vec::len)); } Err(e) => d.message = e.to_string(), } @@ -319,7 +325,7 @@ fn link_media(app: &mut FilmcraftApp, ctx: &egui::Context) { }) .unwrap_or_default(); d.candidate = d.candidates.iter().position(|c| c.1); - d.message = if d.candidates.is_empty() { "Nothing found with that name.".into() } else { String::new() }; + d.message = if d.candidates.is_empty() { tl!("Nothing found with that name.").into() } else { String::new() }; } Err(e) => d.message = e.to_string(), } @@ -356,27 +362,32 @@ fn make_offline(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut keep = true; let mut ok = false; let n = app.session.state.project_selection.len(); - egui::Window::new("Make Offline").collapsible(false).resizable(false).anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { - ui.label(format!("Make {n} selected clip(s) offline. Their clips show the offline slate until you link them again.")); - let r = ui.radio(!delete, "Media files remain on disk"); - push(&mut elems, "makeOffline.keep", &r, "Media files remain on disk"); - if r.clicked() { - delete = false; - } - let r = ui.radio(delete, "Media files are deleted"); - push(&mut elems, "makeOffline.delete", &r, "Media files are deleted"); - if r.clicked() { - delete = true; - } - ui.horizontal(|ui| { - let r = ui.button("Cancel"); - push(&mut elems, "makeOffline.cancel", &r, "Cancel"); - keep &= !r.clicked(); - let r = ui.button("OK"); - push(&mut elems, "makeOffline.ok", &r, "OK"); - ok = r.clicked(); + egui::Window::new(tl!("Make Offline")) + .id(egui::Id::new("Make Offline")) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.label(tlf!("Make {n} selected clip(s) offline. Their clips show the offline slate until you link them again.", n)); + let r = ui.radio(!delete, tl!("Media files remain on disk")); + push(&mut elems, "makeOffline.keep", &r, "Media files remain on disk"); + if r.clicked() { + delete = false; + } + let r = ui.radio(delete, tl!("Media files are deleted")); + push(&mut elems, "makeOffline.delete", &r, "Media files are deleted"); + if r.clicked() { + delete = true; + } + ui.horizontal(|ui| { + let r = ui.button(tl!("Cancel")); + push(&mut elems, "makeOffline.cancel", &r, "Cancel"); + keep &= !r.clicked(); + let r = ui.button(tl!("OK")); + push(&mut elems, "makeOffline.ok", &r, "OK"); + ok = r.clicked(); + }); }); - }); for (id, r, l) in elems { app.auto.add(&id, r, &l); } @@ -395,36 +406,42 @@ fn create_proxies(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut keep = true; let mut ok = false; let mut browse = false; - egui::Window::new("Create Proxies").collapsible(false).resizable(false).anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { - ui.label(format!("{} clip(s) selected.", d.items.len())); - ui.add_space(4.0); - ui.label(RichText::new("Format and size").strong()); - for p in filmcraft_engine::proxies::PRESETS.iter().filter(|p| p.proxy) { - let r = ui.radio(d.preset == p.id, p.label); - push(&mut elems, format!("proxies.preset.{}", p.id), &r, p.label); - if r.clicked() { - d.preset = p.id.into(); + egui::Window::new(tl!("Create Proxies")) + .id(egui::Id::new("Create Proxies")) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.label(tlf!("{n} clip(s) selected.", n = d.items.len())); + ui.add_space(4.0); + ui.label(RichText::new(tl!("Format and size")).strong()); + for p in filmcraft_engine::proxies::PRESETS.iter().filter(|p| p.proxy) { + let r = ui.radio(d.preset == p.id, p.label); + push(&mut elems, format!("proxies.preset.{}", p.id), &r, p.label); + if r.clicked() { + d.preset = p.id.into(); + } } - } - ui.add_space(4.0); - ui.label(RichText::new("Destination").strong()); - ui.horizontal(|ui| { - let r = ui.add(egui::TextEdit::singleline(&mut d.destination).desired_width(320.0).hint_text("Next to the original media, in a Proxies folder")); - push(&mut elems, "proxies.destination", &r, "destination"); - let r = ui.button("Browse…"); - push(&mut elems, "proxies.browse", &r, "Browse…"); - browse = r.clicked(); + ui.add_space(4.0); + ui.label(RichText::new(tl!("Destination")).strong()); + ui.horizontal(|ui| { + let r = ui + .add(egui::TextEdit::singleline(&mut d.destination).desired_width(320.0).hint_text(tl!("Next to the original media, in a Proxies folder"))); + push(&mut elems, "proxies.destination", &r, "destination"); + let r = ui.button(tl!("Browse…")); + push(&mut elems, "proxies.browse", &r, "Browse…"); + browse = r.clicked(); + }); + ui.label(RichText::new(tl!("Proxies are made in the background and attached when done. Export always uses full-resolution media.")).weak()); + ui.horizontal(|ui| { + let r = ui.button(tl!("Cancel")); + push(&mut elems, "proxies.cancel", &r, "Cancel"); + keep &= !r.clicked(); + let r = ui.add(egui::Button::new(RichText::new(tl!("OK")).color(Color32::WHITE)).fill(app.tokens.accent)); + push(&mut elems, "proxies.ok", &r, "OK"); + ok = r.clicked(); + }); }); - ui.label(RichText::new("Proxies are made in the background and attached when done. Export always uses full-resolution media.").weak()); - ui.horizontal(|ui| { - let r = ui.button("Cancel"); - push(&mut elems, "proxies.cancel", &r, "Cancel"); - keep &= !r.clicked(); - let r = ui.add(egui::Button::new(RichText::new("OK").color(Color32::WHITE)).fill(app.tokens.accent)); - push(&mut elems, "proxies.ok", &r, "OK"); - ok = r.clicked(); - }); - }); for (id, r, l) in elems { app.auto.add(&id, r, &l); } @@ -434,7 +451,7 @@ fn create_proxies(app: &mut FilmcraftApp, ctx: &egui::Context) { if ok { let p = json!({"items": d.items, "preset": d.preset, "destination": if d.destination.is_empty() { Value::Null } else { json!(d.destination) }}); match app.session.execute("media.createProxies", p) { - Ok(v) => app.ui.status = format!("Creating {} proxy file(s)…", v["outputs"].as_array().map_or(0, Vec::len)), + Ok(v) => app.ui.status = tlf!("Creating {n} proxy file(s)…", n = v["outputs"].as_array().map_or(0, Vec::len)), Err(e) => app.ui.status = e.to_string(), } keep = false; @@ -463,92 +480,97 @@ fn project_manager(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut keep = true; let (mut ok, mut calc, mut browse) = (false, false, false); let seqs: Vec<(u64, String)> = app.session.project.sequences().map(|i| (i.id.0, i.name.clone())).collect(); - egui::Window::new("Project Manager").collapsible(false).resizable(false).anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { - ui.label(RichText::new("Sequences").strong()); - for (id, name) in &seqs { - let mut on = d.sequences.contains(id); - let r = ui.checkbox(&mut on, name); - push(&mut elems, format!("pm.seq.{id}"), &r, name.clone()); - if r.changed() { - if on { - d.sequences.push(*id); - } else { - d.sequences.retain(|s| s != id); - } - } - } - ui.separator(); - ui.label(RichText::new("Resulting Project").strong()); - for (id, label) in [("collect", "Collect Files and Copy to New Location"), ("consolidate", "Consolidate and Transcode")] { - let r = ui.radio(d.mode == id, label); - push(&mut elems, format!("pm.mode.{id}"), &r, label); - if r.clicked() { - d.mode = id.into(); - } - } - if d.mode == "consolidate" { - ui.indent("pm-presets", |ui| { - for p in filmcraft_engine::proxies::PRESETS.iter().filter(|p| !p.proxy) { - let r = ui.radio(d.preset == p.id, p.label); - push(&mut elems, format!("pm.preset.{}", p.id), &r, p.label); - if r.clicked() { - d.preset = p.id.into(); + egui::Window::new(tl!("Project Manager")) + .id(egui::Id::new("Project Manager")) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.label(RichText::new(tl!("Sequences")).strong()); + for (id, name) in &seqs { + let mut on = d.sequences.contains(id); + let r = ui.checkbox(&mut on, name); + push(&mut elems, format!("pm.seq.{id}"), &r, name.clone()); + if r.changed() { + if on { + d.sequences.push(*id); + } else { + d.sequences.retain(|s| s != id); } } + } + ui.separator(); + ui.label(RichText::new(tl!("Resulting Project")).strong()); + for (id, label) in [("collect", tl!("Collect Files and Copy to New Location")), ("consolidate", tl!("Consolidate and Transcode"))] { + let r = ui.radio(d.mode == id, label); + push(&mut elems, format!("pm.mode.{id}"), &r, label); + if r.clicked() { + d.mode = id.into(); + } + } + if d.mode == "consolidate" { + ui.indent("pm-presets", |ui| { + for p in filmcraft_engine::proxies::PRESETS.iter().filter(|p| !p.proxy) { + let r = ui.radio(d.preset == p.id, p.label); + push(&mut elems, format!("pm.preset.{}", p.id), &r, p.label); + if r.clicked() { + d.preset = p.id.into(); + } + } + }); + } + ui.separator(); + ui.label(RichText::new(tl!("Options")).strong()); + let r = ui.checkbox(&mut d.exclude_unused, tl!("Exclude Unused Clips")); + push(&mut elems, "pm.excludeUnused", &r, "Exclude Unused Clips"); + ui.horizontal(|ui| { + ui.add_enabled_ui(d.mode == "consolidate", |ui| { + ui.label(tl!("Include Handles:")); + let r = ui.add(egui::DragValue::new(&mut d.handles).range(0..=600).suffix(tl!(" frames"))); + push(&mut elems, "pm.handles", &r, format!("{} frames", d.handles)); + }); }); - } - ui.separator(); - ui.label(RichText::new("Options").strong()); - let r = ui.checkbox(&mut d.exclude_unused, "Exclude Unused Clips"); - push(&mut elems, "pm.excludeUnused", &r, "Exclude Unused Clips"); - ui.horizontal(|ui| { - ui.add_enabled_ui(d.mode == "consolidate", |ui| { - ui.label("Include Handles:"); - let r = ui.add(egui::DragValue::new(&mut d.handles).range(0..=600).suffix(" frames")); - push(&mut elems, "pm.handles", &r, format!("{} frames", d.handles)); + ui.add_enabled_ui(d.mode == "collect", |ui| { + let r = ui.checkbox(&mut d.include_proxies, tl!("Include Proxies")); + push(&mut elems, "pm.includeProxies", &r, "Include Proxies"); + }); + let r = ui.checkbox(&mut d.include_previews, tl!("Include Preview Files")); + push(&mut elems, "pm.includePreviews", &r, "Include Preview Files"); + ui.separator(); + ui.label(RichText::new(tl!("Destination Path")).strong()); + ui.horizontal(|ui| { + let r = ui.add(egui::TextEdit::singleline(&mut d.destination).desired_width(360.0)); + push(&mut elems, "pm.destination", &r, "destination"); + let r = ui.button(tl!("Browse…")); + push(&mut elems, "pm.browse", &r, "Browse…"); + browse = r.clicked(); + }); + ui.horizontal(|ui| { + let text = match d.estimate { + Some((a, b, n)) => tlf!("Disk space: original {original} · resulting {result} ({n} files)", original = mb(a), result = mb(b), n), + None => tl!("Disk space: —").into(), + }; + let r = ui.label(&text); + push(&mut elems, "pm.sizes", &r, text); + let r = ui.button(tl!("Calculate")); + push(&mut elems, "pm.calculate", &r, "Calculate"); + calc = r.clicked(); + }); + if !d.message.is_empty() { + ui.colored_label(Color32::from_rgb(0xe0, 0x8a, 0x6a), &d.message); + } + ui.horizontal(|ui| { + let r = ui.button(tl!("Cancel")); + push(&mut elems, "pm.cancel", &r, "Cancel"); + keep &= !r.clicked(); + let r = ui.add_enabled( + !d.destination.is_empty() && !d.sequences.is_empty(), + egui::Button::new(RichText::new(tl!("OK")).color(Color32::WHITE)).fill(app.tokens.accent), + ); + push(&mut elems, "pm.ok", &r, "OK"); + ok = r.clicked(); }); }); - ui.add_enabled_ui(d.mode == "collect", |ui| { - let r = ui.checkbox(&mut d.include_proxies, "Include Proxies"); - push(&mut elems, "pm.includeProxies", &r, "Include Proxies"); - }); - let r = ui.checkbox(&mut d.include_previews, "Include Preview Files"); - push(&mut elems, "pm.includePreviews", &r, "Include Preview Files"); - ui.separator(); - ui.label(RichText::new("Destination Path").strong()); - ui.horizontal(|ui| { - let r = ui.add(egui::TextEdit::singleline(&mut d.destination).desired_width(360.0)); - push(&mut elems, "pm.destination", &r, "destination"); - let r = ui.button("Browse…"); - push(&mut elems, "pm.browse", &r, "Browse…"); - browse = r.clicked(); - }); - ui.horizontal(|ui| { - let text = match d.estimate { - Some((a, b, n)) => format!("Disk space: original {} · resulting {} ({n} files)", mb(a), mb(b)), - None => "Disk space: —".into(), - }; - let r = ui.label(&text); - push(&mut elems, "pm.sizes", &r, text); - let r = ui.button("Calculate"); - push(&mut elems, "pm.calculate", &r, "Calculate"); - calc = r.clicked(); - }); - if !d.message.is_empty() { - ui.colored_label(Color32::from_rgb(0xe0, 0x8a, 0x6a), &d.message); - } - ui.horizontal(|ui| { - let r = ui.button("Cancel"); - push(&mut elems, "pm.cancel", &r, "Cancel"); - keep &= !r.clicked(); - let r = ui.add_enabled( - !d.destination.is_empty() && !d.sequences.is_empty(), - egui::Button::new(RichText::new("OK").color(Color32::WHITE)).fill(app.tokens.accent), - ); - push(&mut elems, "pm.ok", &r, "OK"); - ok = r.clicked(); - }); - }); for (id, r, l) in elems { app.auto.add(&id, r, &l); } @@ -578,7 +600,7 @@ fn project_manager(app: &mut FilmcraftApp, ctx: &egui::Context) { if ok { match app.session.execute("file.projectManager", pm_params(&d, false)) { Ok(v) => { - app.ui.status = format!("Project Manager: writing {}", v["project"].as_str().unwrap_or_default()); + app.ui.status = tlf!("Project Manager: writing {path}", path = v["project"].as_str().unwrap_or_default()); keep = false; } Err(e) => d.message = e.to_string(), diff --git a/crates/ui-egui/src/panels/menu_dialogs.rs b/crates/ui-egui/src/panels/menu_dialogs.rs index 4476945f..3e043f95 100644 --- a/crates/ui-egui/src/panels/menu_dialogs.rs +++ b/crates/ui-egui/src/panels/menu_dialogs.rs @@ -52,21 +52,21 @@ fn push(elems: &mut Elems, id: impl Into, r: &egui::Response, label: imp /// Dialog title, automation prefix and OK button text of a command's dialog. fn meta(command: &str) -> Option<(&'static str, &'static str, &'static str)> { Some(match command { - "edit.find" => ("Find", "find", "Find"), - "file.newSearchBin" => ("Create Search Bin", "searchBin", "OK"), - "project.settings" => ("Project Settings", "projectSettings", "OK"), - "file.mediaProperties" => ("Properties", "properties", "Close"), - "clip.editOffline" => ("Edit Offline File", "editOffline", "OK"), - "clip.sourceSettings" => ("Source Settings", "sourceSettings", "OK"), - "clip.updateMetadata" => ("Update Metadata", "updateMetadata", "OK"), - "clip.automateToSequence" => ("Automate To Sequence", "automate", "OK"), - "clip.sceneEditDetection" => ("Scene Edit Detection", "sceneDetect", "Analyze"), - "sequence.normalizeMixTrack" => ("Normalize Mix Track", "normalizeMix", "OK"), - "sequence.simplify" => ("Simplify Sequence", "simplify", "Simplify"), - "sequence.transcribe" => ("Transcribe Sequence", "transcribe", "Transcribe"), - "file.saveAsTemplate" => ("Save as Template", "saveTemplate", "Save"), - "markers.addFlashCue" => ("Flash Cue Marker", "flashCue", "OK"), - "help.systemCompatibilityReport" => ("System Compatibility Report", "systemReport", "Close"), + "edit.find" => (tl!("Find"), "find", tl!("Find")), + "file.newSearchBin" => (tl!("Create Search Bin"), "searchBin", tl!("OK")), + "project.settings" => (tl!("Project Settings"), "projectSettings", tl!("OK")), + "file.mediaProperties" => (tl!("Properties"), "properties", tl!("Close")), + "clip.editOffline" => (tl!("Edit Offline File"), "editOffline", tl!("OK")), + "clip.sourceSettings" => (tl!("Source Settings"), "sourceSettings", tl!("OK")), + "clip.updateMetadata" => (tl!("Update Metadata"), "updateMetadata", tl!("OK")), + "clip.automateToSequence" => (tl!("Automate To Sequence"), "automate", tl!("OK")), + "clip.sceneEditDetection" => (tl!("Scene Edit Detection"), "sceneDetect", tl!("Analyze")), + "sequence.normalizeMixTrack" => (tl!("Normalize Mix Track"), "normalizeMix", tl!("OK")), + "sequence.simplify" => (tl!("Simplify Sequence"), "simplify", tl!("Simplify")), + "sequence.transcribe" => (tl!("Transcribe Sequence"), "transcribe", tl!("Transcribe")), + "file.saveAsTemplate" => (tl!("Save as Template"), "saveTemplate", tl!("Save")), + "markers.addFlashCue" => (tl!("Flash Cue Marker"), "flashCue", tl!("OK")), + "help.systemCompatibilityReport" => (tl!("System Compatibility Report"), "systemReport", tl!("Close")), _ => return None, }) } @@ -152,7 +152,8 @@ pub fn system_report(app: &FilmcraftApp) -> Value { }); let ok = gpu.is_some(); r["gpu"] = gpu.unwrap_or(Value::Null); - let detail = if ok { r["gpu"]["name"].as_str().unwrap_or("wgpu adapter").to_string() } else { "no GPU adapter: the CPU compositor is used".to_string() }; + let detail = + if ok { r["gpu"]["name"].as_str().unwrap_or("wgpu adapter").to_string() } else { tl!("no GPU adapter: the CPU compositor is used").to_string() }; if let Some(c) = r["checks"].as_array_mut() { c.push(json!({"name": "GPU acceleration", "ok": ok, "detail": detail})); } @@ -204,9 +205,9 @@ pub fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: &Val return Some(Err(e)); } let (filter, ext, name) = if id == "file.exportAle" { - ("Avid Log Exchange", "ale", format!("{}.ale", app.session.project.name)) + (tl!("Avid Log Exchange"), "ale", format!("{}.ale", app.session.project.name)) } else { - ("FilmCraft Project", "fcproj", format!("{} Selection.fcproj", app.session.project.name)) + (tl!("FilmCraft Project"), "fcproj", tlf!("{name} Selection.fcproj", name = app.session.project.name)) }; let Some(path) = app.hooks.pick_save_as.as_mut().and_then(|f| f(filter, &[ext], &name)) else { return Some(Ok(Value::Null)); @@ -221,7 +222,7 @@ pub fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: &Val "file.mediaPropertiesFile" if params.get("path").is_none() => { let exts: Vec<&str> = filmcraft_media::VIDEO_EXTENSIONS.iter().chain(filmcraft_media::AUDIO_EXTENSIONS).chain(filmcraft_media::STILL_EXTENSIONS).copied().collect(); - let Some(path) = app.hooks.pick_open_file.as_mut().and_then(|f| f("Media", &exts)) else { return Some(Ok(Value::Null)) }; + let Some(path) = app.hooks.pick_open_file.as_mut().and_then(|f| f(tl!("Media"), &exts)) else { return Some(Ok(Value::Null)) }; let r = app.session.execute(id, json!({"path": path})).map_err(|e| e.to_string()); if let Ok(v) = &r { app.ui.extras.dialog = @@ -320,7 +321,7 @@ fn defaults(app: &mut FilmcraftApp, cmd: &str, id: &str) -> Result<(Value, Value "clip.sceneEditDetection" => (json!({"applyCuts": true, "createSubclips": false, "generateMarkers": false, "sensitivity": 50.0}), Value::Null), "sequence.normalizeMixTrack" => (json!({"db": 0.0}), Value::Null), "sequence.simplify" => { - let name = s.state.active_sequence.and_then(|q| s.project.item(q)).map(|i| format!("{} (Simplified)", i.name)).unwrap_or_default(); + let name = s.state.active_sequence.and_then(|q| s.project.item(q)).map(|i| tlf!("{name} (Simplified)", name = i.name)).unwrap_or_default(); ( json!({"name": name, "removeDisabled": true, "removeEmptyTracks": true, "closeGaps": false, "moveClipsDown": false, "removeVideoEffects": false, "removeAudioEffects": false, "removeText": false, "keep": "both"}), Value::Null, @@ -389,9 +390,9 @@ fn combo(ui: &mut egui::Ui, elems: &mut Elems, id: &str, label: &str, value: &mu } let cur = value.as_str().unwrap_or_default().to_string(); let shown = options.iter().find(|o| o.0 == cur).map(|o| o.1.clone()).unwrap_or(cur.clone()); - let r = egui::ComboBox::from_id_salt(id).selected_text(shown).show_ui(ui, |ui| { + let r = egui::ComboBox::from_id_salt(id).selected_text(crate::i18n::t(&shown)).show_ui(ui, |ui| { for (k, l) in options { - if ui.selectable_label(cur == *k, l).clicked() { + if ui.selectable_label(cur == *k, crate::i18n::t(l)).clicked() { *value = json!(k); } } @@ -415,334 +416,374 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut action: Option<&'static str> = None; let mut browse: Option = None; let info_only = matches!(d.command.as_str(), "file.mediaProperties" | "clip.sourceSettings" | "help.systemCompatibilityReport"); - egui::Window::new(title).collapsible(false).resizable(false).default_width(420.0).anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { - let p = &mut d.params; - match d.command.as_str() { - "edit.find" => { - radios(ui, &mut elems, pre, p, "scope", "Find in:", &[("project", "Project"), ("timeline", "Timeline")]); - ui.separator(); - if p["scope"] == "timeline" { - let mut t = p["rows"][0]["text"].as_str().unwrap_or_default().to_string(); - ui.horizontal(|ui| { - ui.label("Find:"); - let r = ui.add(egui::TextEdit::singleline(&mut t).desired_width(240.0)); - push(&mut elems, "find.row.0.text", &r, "Find"); - }); - p["rows"][0]["text"] = json!(t); - radios(ui, &mut elems, pre, p, "in", "Search:", &[("all", "Clip Names and Markers"), ("clips", "Clip Names"), ("markers", "Markers")]); - } else { - let cols = pairs(&COLUMNS); - let ops: Vec<(String, String)> = FindOp::ALL.iter().map(|o| (o.name().to_string(), o.label().to_string())).collect(); - for n in 0..2 { - let mut row = p["rows"][n].clone(); + egui::Window::new(title) + .id(egui::Id::new(("menu-dialog", pre))) + .collapsible(false) + .resizable(false) + .default_width(420.0) + .anchor(Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + let p = &mut d.params; + match d.command.as_str() { + "edit.find" => { + radios(ui, &mut elems, pre, p, "scope", tl!("Find in:"), &[("project", tl!("Project")), ("timeline", tl!("Timeline"))]); + ui.separator(); + if p["scope"] == "timeline" { + let mut t = p["rows"][0]["text"].as_str().unwrap_or_default().to_string(); ui.horizontal(|ui| { - combo(ui, &mut elems, &format!("find.row.{n}.column"), "", &mut row["column"], &cols); - combo(ui, &mut elems, &format!("find.row.{n}.operator"), "", &mut row["operator"], &ops); - let mut t = row["text"].as_str().unwrap_or_default().to_string(); - let r = ui.add(egui::TextEdit::singleline(&mut t).desired_width(160.0)); - push(&mut elems, format!("find.row.{n}.text"), &r, "Find What"); - row["text"] = json!(t); + ui.label(tl!("Find:")); + let r = ui.add(egui::TextEdit::singleline(&mut t).desired_width(240.0)); + push(&mut elems, "find.row.0.text", &r, "Find"); }); - p["rows"][n] = row; - } - ui.horizontal(|ui| { - ui.label("Match:"); - for (v, l) in [(true, "All"), (false, "Any")] { - let r = ui.radio(p["matchAll"].as_bool() == Some(v), l); - push(&mut elems, format!("find.match.{}", l.to_ascii_lowercase()), &r, l); - if r.clicked() { - p["matchAll"] = json!(v); - } - } - check(ui, &mut elems, pre, p, "caseSensitive", "Case Sensitive"); - }); - } - if let Some(m) = d.info.get("result").and_then(Value::as_str) { - ui.label(RichText::new(m).weak()); - } - } - "file.newSearchBin" => { - text(ui, &mut elems, pre, p, "name", "Name:", 220.0); - combo(ui, &mut elems, "searchBin.column", "Search:", &mut p["column"], &pairs(&COLUMNS)); - let ops: Vec<(String, String)> = FindOp::ALL.iter().map(|o| (o.name().to_string(), o.label().to_string())).collect(); - combo(ui, &mut elems, "searchBin.operator", "", &mut p["operator"], &ops); - text(ui, &mut elems, pre, p, "text", "Find:", 220.0); - } - "project.settings" => { - ui.label(format!("Project: {}", d.info["project"].as_str().unwrap_or_default())); - ui.horizontal(|ui| { - for (k, l) in [("general", "General"), ("scratchDisks", "Scratch Disks"), ("ingest", "Ingest Settings")] { - let r = ui.selectable_label(p["tab"].as_str() == Some(k), l); - push(&mut elems, format!("projectSettings.tab.{k}"), &r, l); - if r.clicked() { - p["tab"] = json!(k); - } - } - }); - ui.separator(); - match p["tab"].as_str().unwrap_or("general") { - "scratchDisks" => { - for (k, l) in [ - ("captured", "Captured and Generated:"), - ("videoPreviews", "Video Previews:"), - ("audioPreviews", "Audio Previews:"), - ("autoSave", "Project Auto Save:"), - ] { - ui.label(RichText::new(l).strong()); - ui.horizontal(|ui| { - let mut v = p[k].as_str().unwrap_or_default().to_string(); - let r = ui.add(egui::TextEdit::singleline(&mut v).hint_text("Same as Project").desired_width(240.0)); - push(&mut elems, format!("projectSettings.scratch.{k}"), &r, l); - if r.changed() { - p[k] = if v.is_empty() { Value::Null } else { json!(v) }; - } - let r = ui.button("Browse…"); - push(&mut elems, format!("projectSettings.scratch.{k}.browse"), &r, "Browse"); - if r.clicked() { - browse = Some(k.to_string()); - } - let r = ui.button("Same as Project"); - push(&mut elems, format!("projectSettings.scratch.{k}.same"), &r, "Same as Project"); - if r.clicked() { - p[k] = Value::Null; - } - }); - let path = d.info["scratch"][k]["path"].as_str().unwrap_or_default(); - ui.label(RichText::new(format!("Path: {path}")).weak().small()); - } - } - "ingest" => { - let mut ing = p["ingest"].clone(); - let mut on = ing["enabled"].as_bool().unwrap_or(false); - let r = ui.checkbox(&mut on, "Ingest"); - push(&mut elems, "projectSettings.ingest.enabled", &r, "Ingest"); - ing["enabled"] = json!(on); - let actions = vec![ - ("copy".to_string(), "Copy".to_string()), - ("transcode".to_string(), "Transcode".to_string()), - ("createProxies".to_string(), "Create Proxies".to_string()), - ("copyAndCreateProxies".to_string(), "Copy and Create Proxies".to_string()), - ]; - combo(ui, &mut elems, "projectSettings.ingest.action", "Action:", &mut ing["action"], &actions); - ui.horizontal(|ui| { - ui.label("Destination:"); - let mut v = ing["destination"].as_str().unwrap_or_default().to_string(); - let r = ui.add(egui::TextEdit::singleline(&mut v).hint_text("Next to the media").desired_width(220.0)); - push(&mut elems, "projectSettings.ingest.destination", &r, "Destination"); - if r.changed() { - ing["destination"] = if v.is_empty() { Value::Null } else { json!(v) }; - } - }); - p["ingest"] = ing; - } - _ => { - ui.label(RichText::new("Video Rendering and Playback").strong()); - let rs = vec![ - (filmcraft_engine::project_tools::RENDERER_GPU.to_string(), filmcraft_engine::project_tools::RENDERER_GPU.to_string()), - (filmcraft_engine::project_tools::RENDERER_SOFTWARE.to_string(), filmcraft_engine::project_tools::RENDERER_SOFTWARE.to_string()), - ]; - combo(ui, &mut elems, "projectSettings.renderer", "Renderer:", &mut p["renderer"], &rs); - ui.label(RichText::new("Video").strong()); - let vd: Vec<(String, String)> = - filmcraft_time::TimeDisplay::ALL.iter().map(|t| (t.label().to_string(), t.label().to_string())).collect(); - combo(ui, &mut elems, "projectSettings.videoDisplay", "Display Format:", &mut p["videoDisplay"], &vd); - ui.label(RichText::new("Audio").strong()); - combo( + p["rows"][0]["text"] = json!(t); + radios( ui, &mut elems, - "projectSettings.audioDisplay", - "Display Format:", - &mut p["audioDisplay"], - &pairs(&["Audio Samples", "Milliseconds"]), + pre, + p, + "in", + tl!("Search:"), + &[("all", tl!("Clip Names and Markers")), ("clips", tl!("Clip Names")), ("markers", tl!("Markers"))], ); - ui.label(RichText::new("Capture").strong()); - combo(ui, &mut elems, "projectSettings.captureFormat", "Capture Format:", &mut p["captureFormat"], &pairs(&["DV", "HDV"])); - ui.label(RichText::new("Action and Title Safe Areas").strong()); - for (k, l) in [("titleSafe", "Title Safe Area"), ("actionSafe", "Action Safe Area")] { + } else { + let cols = pairs(&COLUMNS); + let ops: Vec<(String, String)> = FindOp::ALL.iter().map(|o| (o.name().to_string(), o.label().to_string())).collect(); + for n in 0..2 { + let mut row = p["rows"][n].clone(); ui.horizontal(|ui| { - ui.label(l); - for (i, axis) in ["horizontal", "vertical"].iter().enumerate() { - let mut v = p[k][i].as_f64().unwrap_or(0.0); - let r = ui.add(egui::DragValue::new(&mut v).range(0.0..=50.0).suffix(" %")); - push(&mut elems, format!("projectSettings.{k}.{axis}"), &r, format!("{l} {axis}")); - p[k][i] = json!(v); - ui.label(*axis); + combo(ui, &mut elems, &format!("find.row.{n}.column"), "", &mut row["column"], &cols); + combo(ui, &mut elems, &format!("find.row.{n}.operator"), "", &mut row["operator"], &ops); + let mut t = row["text"].as_str().unwrap_or_default().to_string(); + let r = ui.add(egui::TextEdit::singleline(&mut t).desired_width(160.0)); + push(&mut elems, format!("find.row.{n}.text"), &r, "Find What"); + row["text"] = json!(t); + }); + p["rows"][n] = row; + } + ui.horizontal(|ui| { + ui.label(tl!("Match:")); + for (v, l) in [(true, tl!("All")), (false, tl!("Any"))] { + let r = ui.radio(p["matchAll"].as_bool() == Some(v), l); + push(&mut elems, format!("find.match.{}", l.to_ascii_lowercase()), &r, l); + if r.clicked() { + p["matchAll"] = json!(v); + } + } + check(ui, &mut elems, pre, p, "caseSensitive", tl!("Case Sensitive")); + }); + } + if let Some(m) = d.info.get("result").and_then(Value::as_str) { + ui.label(RichText::new(m).weak()); + } + } + "file.newSearchBin" => { + text(ui, &mut elems, pre, p, "name", tl!("Name:"), 220.0); + combo(ui, &mut elems, "searchBin.column", tl!("Search:"), &mut p["column"], &pairs(&COLUMNS)); + let ops: Vec<(String, String)> = FindOp::ALL.iter().map(|o| (o.name().to_string(), o.label().to_string())).collect(); + combo(ui, &mut elems, "searchBin.operator", "", &mut p["operator"], &ops); + text(ui, &mut elems, pre, p, "text", tl!("Find:"), 220.0); + } + "project.settings" => { + ui.label(tlf!("Project: {name}", name = d.info["project"].as_str().unwrap_or_default())); + ui.horizontal(|ui| { + for (k, l) in [("general", tl!("General")), ("scratchDisks", tl!("Scratch Disks")), ("ingest", tl!("Ingest Settings"))] { + let r = ui.selectable_label(p["tab"].as_str() == Some(k), l); + push(&mut elems, format!("projectSettings.tab.{k}"), &r, l); + if r.clicked() { + p["tab"] = json!(k); + } + } + }); + ui.separator(); + match p["tab"].as_str().unwrap_or("general") { + "scratchDisks" => { + for (k, l) in [ + ("captured", tl!("Captured and Generated:")), + ("videoPreviews", tl!("Video Previews:")), + ("audioPreviews", tl!("Audio Previews:")), + ("autoSave", tl!("Project Auto Save:")), + ] { + ui.label(RichText::new(l).strong()); + ui.horizontal(|ui| { + let mut v = p[k].as_str().unwrap_or_default().to_string(); + let r = ui.add(egui::TextEdit::singleline(&mut v).hint_text(tl!("Same as Project")).desired_width(240.0)); + push(&mut elems, format!("projectSettings.scratch.{k}"), &r, l); + if r.changed() { + p[k] = if v.is_empty() { Value::Null } else { json!(v) }; + } + let r = ui.button(tl!("Browse…")); + push(&mut elems, format!("projectSettings.scratch.{k}.browse"), &r, "Browse"); + if r.clicked() { + browse = Some(k.to_string()); + } + let r = ui.button(tl!("Same as Project")); + push(&mut elems, format!("projectSettings.scratch.{k}.same"), &r, "Same as Project"); + if r.clicked() { + p[k] = Value::Null; + } + }); + let path = d.info["scratch"][k]["path"].as_str().unwrap_or_default(); + ui.label(RichText::new(tlf!("Path: {path}", path)).weak().small()); + } + } + "ingest" => { + let mut ing = p["ingest"].clone(); + let mut on = ing["enabled"].as_bool().unwrap_or(false); + let r = ui.checkbox(&mut on, tl!("Ingest")); + push(&mut elems, "projectSettings.ingest.enabled", &r, "Ingest"); + ing["enabled"] = json!(on); + let actions = vec![ + ("copy".to_string(), tl!("Copy").to_string()), + ("transcode".to_string(), tl!("Transcode").to_string()), + ("createProxies".to_string(), tl!("Create Proxies").to_string()), + ("copyAndCreateProxies".to_string(), tl!("Copy and Create Proxies").to_string()), + ]; + combo(ui, &mut elems, "projectSettings.ingest.action", tl!("Action:"), &mut ing["action"], &actions); + ui.horizontal(|ui| { + ui.label(tl!("Destination:")); + let mut v = ing["destination"].as_str().unwrap_or_default().to_string(); + let r = ui.add(egui::TextEdit::singleline(&mut v).hint_text(tl!("Next to the media")).desired_width(220.0)); + push(&mut elems, "projectSettings.ingest.destination", &r, "Destination"); + if r.changed() { + ing["destination"] = if v.is_empty() { Value::Null } else { json!(v) }; } }); + p["ingest"] = ing; } - } - } - } - "file.mediaProperties" => { - let list = d.info.as_array().cloned().unwrap_or_default(); - egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| { - for v in list { - ui.label(RichText::new(v["name"].as_str().unwrap_or_default()).strong()); - let line = |ui: &mut egui::Ui, k: &str, val: String| { - if !val.is_empty() && val != "null" { - ui.label(format!("{k}: {val}")); - } - }; - let s = |x: &Value| x.as_str().map(str::to_string).unwrap_or_else(|| if x.is_null() { String::new() } else { x.to_string() }); - line(ui, "File Path", s(&v["path"])); - line(ui, "Type", format!("{} ({})", s(&v["type"]), s(&v["container"]))); - line(ui, "File Size", v["fileSize"].as_u64().map(|b| format!("{:.2} MB", b as f64 / 1e6)).unwrap_or_default()); - line(ui, "Total Duration", s(&v["duration"]["timecode"])); - line(ui, "Average Data Rate", v["dataRateKbps"].as_f64().map(|k| format!("{k} kbit/s")).unwrap_or_default()); - if v["video"].is_object() { - let x = &v["video"]; - line( - ui, - "Video", - format!( - "{} · {} x {} ({}) · {} fps · {}", - s(&x["codec"]), - x["width"], - x["height"], - s(&x["pixelAspectRatio"]), - s(&x["frameRateLabel"]), - s(&x["pixelFormat"]) + _ => { + ui.label(RichText::new(tl!("Video Rendering and Playback")).strong()); + let rs = vec![ + (filmcraft_engine::project_tools::RENDERER_GPU.to_string(), filmcraft_engine::project_tools::RENDERER_GPU.to_string()), + ( + filmcraft_engine::project_tools::RENDERER_SOFTWARE.to_string(), + filmcraft_engine::project_tools::RENDERER_SOFTWARE.to_string(), ), + ]; + combo(ui, &mut elems, "projectSettings.renderer", tl!("Renderer:"), &mut p["renderer"], &rs); + ui.label(RichText::new(tl!("Video")).strong()); + let vd: Vec<(String, String)> = + filmcraft_time::TimeDisplay::ALL.iter().map(|t| (t.label().to_string(), t.label().to_string())).collect(); + combo(ui, &mut elems, "projectSettings.videoDisplay", tl!("Display Format:"), &mut p["videoDisplay"], &vd); + ui.label(RichText::new(tl!("Audio")).strong()); + combo( + ui, + &mut elems, + "projectSettings.audioDisplay", + tl!("Display Format:"), + &mut p["audioDisplay"], + &[ + ("Audio Samples".to_string(), tl!("Audio Samples").to_string()), + ("Milliseconds".to_string(), tl!("Milliseconds").to_string()), + ], ); - line(ui, "Colour", s(&x["color"])); + ui.label(RichText::new(tl!("Capture")).strong()); + combo(ui, &mut elems, "projectSettings.captureFormat", tl!("Capture Format:"), &mut p["captureFormat"], &pairs(&["DV", "HDV"])); + ui.label(RichText::new(tl!("Action and Title Safe Areas")).strong()); + for (k, l) in [("titleSafe", tl!("Title Safe Area")), ("actionSafe", tl!("Action Safe Area"))] { + ui.horizontal(|ui| { + ui.label(l); + for (i, axis) in ["horizontal", "vertical"].iter().enumerate() { + let mut v = p[k][i].as_f64().unwrap_or(0.0); + let r = ui.add(egui::DragValue::new(&mut v).range(0.0..=50.0).suffix(" %")); + push(&mut elems, format!("projectSettings.{k}.{axis}"), &r, format!("{l} {axis}")); + p[k][i] = json!(v); + ui.label(*axis); + } + }); + } } - if v["audio"].is_object() { - let x = &v["audio"]; - line(ui, "Audio", format!("{} · {} Hz · {} channel(s)", s(&x["codec"]), x["sampleRate"], x["channels"])); - } - ui.separator(); } - }); - } - "clip.editOffline" => { - text(ui, &mut elems, pre, p, "mediaName", "Media Name:", 220.0); - text(ui, &mut elems, pre, p, "tapeName", "Tape Name:", 220.0); - text(ui, &mut elems, pre, p, "description", "Description:", 220.0); - text(ui, &mut elems, pre, p, "scene", "Scene:", 220.0); - text(ui, &mut elems, pre, p, "shot", "Shot/Take:", 220.0); - text(ui, &mut elems, pre, p, "logNote", "Log Note:", 220.0); - } - "clip.sourceSettings" => { - ui.label(format!("Codec: {}", d.info["codec"].as_str().unwrap_or_default())); - ui.label(RichText::new(d.info["message"].as_str().unwrap_or_default()).weak()); - } - "clip.updateMetadata" => { - ui.label(format!("Write the metadata of the selected clip(s) ({}) to XMP files next to their media?", d.info["count"])); - ui.label(RichText::new("Existing XMP files written by other applications are left unchanged.").weak()); - } - "clip.automateToSequence" => { - ui.label(format!("To {}", d.info["sequence"].as_str().unwrap_or_default())); - radios(ui, &mut elems, pre, p, "ordering", "Ordering:", &[("sort", "Sort Order"), ("selection", "Selection Order")]); - radios(ui, &mut elems, pre, p, "placement", "Placement:", &[("sequentially", "Sequentially"), ("unnumberedMarkers", "At Unnumbered Markers")]); - radios(ui, &mut elems, pre, p, "method", "Method:", &[("insert", "Insert Edit"), ("overwrite", "Overwrite Edit")]); - number(ui, &mut elems, pre, p, "overlapFrames", "Clip Overlap:", 0.0..=600.0, " frames"); - number(ui, &mut elems, pre, p, "stillFrames", "Frames per Still:", 1.0..=100_000.0, " frames"); - ui.label(RichText::new("Transitions").strong()); - check(ui, &mut elems, pre, p, "audioTransition", "Apply Default Audio Transition"); - check(ui, &mut elems, pre, p, "videoTransition", "Apply Default Video Transition"); - ui.label(RichText::new("Ignore Options").strong()); - check(ui, &mut elems, pre, p, "ignoreAudio", "Ignore Audio"); - check(ui, &mut elems, pre, p, "ignoreVideo", "Ignore Video"); - } - "clip.sceneEditDetection" => { - check(ui, &mut elems, pre, p, "applyCuts", "Apply a cut at each detected cut point"); - check(ui, &mut elems, pre, p, "createSubclips", "Create a subclip for each detected cut point"); - check(ui, &mut elems, pre, p, "generateMarkers", "Generate clip markers at each detected cut point"); - ui.horizontal(|ui| { - ui.label("Sensitivity:"); - let mut v = p["sensitivity"].as_f64().unwrap_or(50.0); - let r = ui.add(egui::Slider::new(&mut v, 0.0..=100.0)); - push(&mut elems, "sceneDetect.sensitivity", &r, "Sensitivity"); - p["sensitivity"] = json!(v); - }); - } - "sequence.normalizeMixTrack" => number(ui, &mut elems, pre, p, "db", "Normalize Mix Track to:", -96.0..=24.0, " dB"), - "sequence.simplify" => { - text(ui, &mut elems, pre, p, "name", "Sequence Name:", 240.0); - ui.label(RichText::new("Remove").strong()); - check(ui, &mut elems, pre, p, "removeDisabled", "Disabled clips"); - check(ui, &mut elems, pre, p, "removeEmptyTracks", "Empty tracks"); - check(ui, &mut elems, pre, p, "closeGaps", "Gaps"); - check(ui, &mut elems, pre, p, "removeVideoEffects", "Video effects"); - check(ui, &mut elems, pre, p, "removeAudioEffects", "Audio effects"); - check(ui, &mut elems, pre, p, "removeText", "Text (graphics and captions)"); - ui.label(RichText::new("Clips").strong()); - check(ui, &mut elems, pre, p, "moveClipsDown", "Move clips to the lowest track possible"); - radios(ui, &mut elems, pre, p, "keep", "Keep:", &[("both", "Video and Audio"), ("video", "Video Only"), ("audio", "Audio Only")]); - } - "sequence.transcribe" => { - let langs = pairs(&["auto", "en", "es", "fr", "de", "it", "pt", "ja", "ko", "zh", "nl", "ru"]); - combo(ui, &mut elems, "transcribe.language", "Language:", &mut p["language"], &langs); - let mut tracks: Vec<(String, String)> = vec![("mix".into(), "Mix".into())]; - tracks.extend( - d.info["tracks"] - .as_array() - .cloned() - .unwrap_or_default() - .iter() - .filter_map(|t| t.as_str()) - .map(|t| (t.to_string(), format!("Audio on track {t}"))), - ); - combo(ui, &mut elems, "transcribe.track", "Audio analysis:", &mut p["track"], &tracks); - check(ui, &mut elems, pre, p, "diarize", "Recognize when different speakers are talking"); - } - "file.saveAsTemplate" => text(ui, &mut elems, pre, p, "name", "Template Name:", 240.0), - "markers.addFlashCue" => { - text(ui, &mut elems, pre, p, "name", "Name:", 220.0); - text(ui, &mut elems, pre, p, "comment", "Comments:", 220.0); - } - "help.systemCompatibilityReport" => { - let r = &d.info; - ui.label(RichText::new(r["app"].as_str().unwrap_or_default()).strong()); - for c in r["checks"].as_array().cloned().unwrap_or_default() { - let ok = c["ok"].as_bool().unwrap_or(false); - ui.label(format!( - "{} {}: {}", - if ok { "✔" } else { "⚠" }, - c["name"].as_str().unwrap_or_default(), - c["detail"].as_str().unwrap_or_default() - )); } - ui.separator(); - let list = |ui: &mut egui::Ui, k: &str, v: &Value| { - let items: Vec = v - .as_array() - .cloned() - .unwrap_or_default() - .iter() - .map(|x| { - x.as_str().map(str::to_string).unwrap_or_else(|| { - format!("{}{}", x["format"].as_str().unwrap_or_default(), if x["available"] == false { " (unavailable)" } else { "" }) + "file.mediaProperties" => { + let list = d.info.as_array().cloned().unwrap_or_default(); + egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| { + for v in list { + ui.label(RichText::new(v["name"].as_str().unwrap_or_default()).strong()); + let line = |ui: &mut egui::Ui, k: &str, val: String| { + if !val.is_empty() && val != "null" { + ui.label(format!("{}: {val}", crate::i18n::t(k))); + } + }; + let s = |x: &Value| x.as_str().map(str::to_string).unwrap_or_else(|| if x.is_null() { String::new() } else { x.to_string() }); + line(ui, tl!("File Path"), s(&v["path"])); + line(ui, tl!("Type"), format!("{} ({})", s(&v["type"]), s(&v["container"]))); + line(ui, tl!("File Size"), v["fileSize"].as_u64().map(|b| format!("{:.2} MB", b as f64 / 1e6)).unwrap_or_default()); + line(ui, tl!("Total Duration"), s(&v["duration"]["timecode"])); + line(ui, tl!("Average Data Rate"), v["dataRateKbps"].as_f64().map(|k| format!("{k} kbit/s")).unwrap_or_default()); + if v["video"].is_object() { + let x = &v["video"]; + line( + ui, + tl!("Video"), + format!( + "{} · {} x {} ({}) · {} fps · {}", + s(&x["codec"]), + x["width"], + x["height"], + s(&x["pixelAspectRatio"]), + s(&x["frameRateLabel"]), + s(&x["pixelFormat"]) + ), + ); + line(ui, tl!("Colour"), s(&x["color"])); + } + if v["audio"].is_object() { + let x = &v["audio"]; + line( + ui, + tl!("Audio"), + tlf!("{codec} · {rate} Hz · {n} channel(s)", codec = s(&x["codec"]), rate = x["sampleRate"], n = x["channels"]), + ); + } + ui.separator(); + } + }); + } + "clip.editOffline" => { + text(ui, &mut elems, pre, p, "mediaName", tl!("Media Name:"), 220.0); + text(ui, &mut elems, pre, p, "tapeName", tl!("Tape Name:"), 220.0); + text(ui, &mut elems, pre, p, "description", tl!("Description:"), 220.0); + text(ui, &mut elems, pre, p, "scene", tl!("Scene:"), 220.0); + text(ui, &mut elems, pre, p, "shot", tl!("Shot/Take:"), 220.0); + text(ui, &mut elems, pre, p, "logNote", tl!("Log Note:"), 220.0); + } + "clip.sourceSettings" => { + ui.label(tlf!("Codec: {codec}", codec = d.info["codec"].as_str().unwrap_or_default())); + ui.label(RichText::new(d.info["message"].as_str().unwrap_or_default()).weak()); + } + "clip.updateMetadata" => { + ui.label(tlf!("Write the metadata of the selected clip(s) ({n}) to XMP files next to their media?", n = d.info["count"])); + ui.label(RichText::new(tl!("Existing XMP files written by other applications are left unchanged.")).weak()); + } + "clip.automateToSequence" => { + ui.label(tlf!("To {name}", name = d.info["sequence"].as_str().unwrap_or_default())); + radios(ui, &mut elems, pre, p, "ordering", tl!("Ordering:"), &[("sort", tl!("Sort Order")), ("selection", tl!("Selection Order"))]); + radios( + ui, + &mut elems, + pre, + p, + "placement", + tl!("Placement:"), + &[("sequentially", tl!("Sequentially")), ("unnumberedMarkers", tl!("At Unnumbered Markers"))], + ); + radios(ui, &mut elems, pre, p, "method", tl!("Method:"), &[("insert", tl!("Insert Edit")), ("overwrite", tl!("Overwrite Edit"))]); + number(ui, &mut elems, pre, p, "overlapFrames", tl!("Clip Overlap:"), 0.0..=600.0, " frames"); + number(ui, &mut elems, pre, p, "stillFrames", tl!("Frames per Still:"), 1.0..=100_000.0, " frames"); + ui.label(RichText::new(tl!("Transitions")).strong()); + check(ui, &mut elems, pre, p, "audioTransition", tl!("Apply Default Audio Transition")); + check(ui, &mut elems, pre, p, "videoTransition", tl!("Apply Default Video Transition")); + ui.label(RichText::new(tl!("Ignore Options")).strong()); + check(ui, &mut elems, pre, p, "ignoreAudio", tl!("Ignore Audio")); + check(ui, &mut elems, pre, p, "ignoreVideo", tl!("Ignore Video")); + } + "clip.sceneEditDetection" => { + check(ui, &mut elems, pre, p, "applyCuts", tl!("Apply a cut at each detected cut point")); + check(ui, &mut elems, pre, p, "createSubclips", tl!("Create a subclip for each detected cut point")); + check(ui, &mut elems, pre, p, "generateMarkers", tl!("Generate clip markers at each detected cut point")); + ui.horizontal(|ui| { + ui.label(tl!("Sensitivity:")); + let mut v = p["sensitivity"].as_f64().unwrap_or(50.0); + let r = ui.add(egui::Slider::new(&mut v, 0.0..=100.0)); + push(&mut elems, "sceneDetect.sensitivity", &r, "Sensitivity"); + p["sensitivity"] = json!(v); + }); + } + "sequence.normalizeMixTrack" => number(ui, &mut elems, pre, p, "db", tl!("Normalize Mix Track to:"), -96.0..=24.0, " dB"), + "sequence.simplify" => { + text(ui, &mut elems, pre, p, "name", tl!("Sequence Name:"), 240.0); + ui.label(RichText::new(tl!("Remove")).strong()); + check(ui, &mut elems, pre, p, "removeDisabled", tl!("Disabled clips")); + check(ui, &mut elems, pre, p, "removeEmptyTracks", tl!("Empty tracks")); + check(ui, &mut elems, pre, p, "closeGaps", tl!("Gaps")); + check(ui, &mut elems, pre, p, "removeVideoEffects", tl!("Video effects")); + check(ui, &mut elems, pre, p, "removeAudioEffects", tl!("Audio effects")); + check(ui, &mut elems, pre, p, "removeText", tl!("Text (graphics and captions)")); + ui.label(RichText::new(tl!("Clips")).strong()); + check(ui, &mut elems, pre, p, "moveClipsDown", tl!("Move clips to the lowest track possible")); + radios( + ui, + &mut elems, + pre, + p, + "keep", + tl!("Keep:"), + &[("both", tl!("Video and Audio")), ("video", tl!("Video Only")), ("audio", tl!("Audio Only"))], + ); + } + "sequence.transcribe" => { + let langs = pairs(&["auto", "en", "es", "fr", "de", "it", "pt", "ja", "ko", "zh", "nl", "ru"]); + combo(ui, &mut elems, "transcribe.language", tl!("Language:"), &mut p["language"], &langs); + let mut tracks: Vec<(String, String)> = vec![("mix".into(), tl!("Mix").into())]; + tracks.extend( + d.info["tracks"] + .as_array() + .cloned() + .unwrap_or_default() + .iter() + .filter_map(|t| t.as_str()) + .map(|t| (t.to_string(), tlf!("Audio on track {t}", t))), + ); + combo(ui, &mut elems, "transcribe.track", tl!("Audio analysis:"), &mut p["track"], &tracks); + check(ui, &mut elems, pre, p, "diarize", tl!("Recognize when different speakers are talking")); + } + "file.saveAsTemplate" => text(ui, &mut elems, pre, p, "name", tl!("Template Name:"), 240.0), + "markers.addFlashCue" => { + text(ui, &mut elems, pre, p, "name", tl!("Name:"), 220.0); + text(ui, &mut elems, pre, p, "comment", tl!("Comments:"), 220.0); + } + "help.systemCompatibilityReport" => { + let r = &d.info; + ui.label(RichText::new(r["app"].as_str().unwrap_or_default()).strong()); + for c in r["checks"].as_array().cloned().unwrap_or_default() { + let ok = c["ok"].as_bool().unwrap_or(false); + ui.label(format!( + "{} {}: {}", + if ok { "✔" } else { "⚠" }, + c["name"].as_str().unwrap_or_default(), + c["detail"].as_str().unwrap_or_default() + )); + } + ui.separator(); + let list = |ui: &mut egui::Ui, k: &str, v: &Value| { + let items: Vec = v + .as_array() + .cloned() + .unwrap_or_default() + .iter() + .map(|x| { + x.as_str().map(str::to_string).unwrap_or_else(|| { + format!("{}{}", x["format"].as_str().unwrap_or_default(), if x["available"] == false { tl!(" (unavailable)") } else { "" }) + }) }) - }) - .collect(); - ui.label(format!("{k}: {}", items.join(", "))); - }; - list(ui, "Decoders", &r["decoders"]); - list(ui, "Containers", &r["containers"]); - list(ui, "Export formats", &r["exportFormats"]); - } - _ => {} - } - if !d.error.is_empty() { - ui.colored_label(egui::Color32::from_rgb(0xe0, 0x60, 0x60), &d.error); - } - ui.add_space(6.0); - ui.horizontal(|ui| { - if !info_only { - let r = ui.button(if d.command == "edit.find" { "Done" } else { "Cancel" }); - push(&mut elems, format!("{pre}.cancel"), &r, "Cancel"); - if r.clicked() { - action = Some("cancel"); + .collect(); + ui.label(format!("{k}: {}", items.join(", "))); + }; + list(ui, tl!("Decoders"), &r["decoders"]); + list(ui, tl!("Containers"), &r["containers"]); + list(ui, tl!("Export formats"), &r["exportFormats"]); } + _ => {} } - let r = ui.button(ok_text); - push(&mut elems, format!("{pre}.ok"), &r, ok_text); - if r.clicked() { - action = Some(if info_only { "cancel" } else { "ok" }); + if !d.error.is_empty() { + ui.colored_label(egui::Color32::from_rgb(0xe0, 0x60, 0x60), &d.error); } + ui.add_space(6.0); + ui.horizontal(|ui| { + if !info_only { + let r = ui.button(if d.command == "edit.find" { tl!("Done") } else { tl!("Cancel") }); + push(&mut elems, format!("{pre}.cancel"), &r, "Cancel"); + if r.clicked() { + action = Some("cancel"); + } + } + let r = ui.button(ok_text); + push(&mut elems, format!("{pre}.ok"), &r, ok_text); + if r.clicked() { + action = Some(if info_only { "cancel" } else { "ok" }); + } + }); }); - }); for (id, r, l) in elems { app.auto.add(&id, r, &l); } @@ -783,7 +824,7 @@ fn run(app: &mut FilmcraftApp, d: &ClipDialogDraft) -> Result, St q["rows"] = json!([p["rows"][0]]); } let r = s.execute("edit.find", q).map_err(|e| e.to_string())?; - Ok(Some(format!("Match {} of {}", r["index"].as_u64().unwrap_or(0) + 1, r["matches"].as_u64().unwrap_or(0)))) + Ok(Some(tlf!("Match {n} of {total}", n = r["index"].as_u64().unwrap_or(0) + 1, total = r["matches"].as_u64().unwrap_or(0)))) } "project.settings" => { s.execute( @@ -804,12 +845,12 @@ fn run(app: &mut FilmcraftApp, d: &ClipDialogDraft) -> Result, St } "file.saveAsTemplate" => { let r = s.execute("file.saveAsTemplate", p.clone()).map_err(|e| e.to_string())?; - app.ui.status = format!("Saved template {}", r["path"].as_str().unwrap_or_default()); + app.ui.status = tlf!("Saved template {path}", path = r["path"].as_str().unwrap_or_default()); Ok(None) } "clip.sceneEditDetection" => { s.execute(&d.command, p.clone()).map_err(|e| e.to_string())?; - app.ui.status = "Scene Edit Detection: analysing in the background (see Progress)".into(); + app.ui.status = tl!("Scene Edit Detection: analysing in the background (see Progress)").into(); Ok(None) } cmd => { @@ -873,7 +914,7 @@ pub fn search_bin_rows( } } resp.context_menu(|ui| { - if ui.button("Delete Search Bin").clicked() { + if ui.button(tl!("Delete Search Bin")).clicked() { actions.push(("project.deleteSearchBin".into(), json!({"bin": sb.id.0}))); ui.close(); } diff --git a/crates/ui-egui/src/panels/metadata.rs b/crates/ui-egui/src/panels/metadata.rs index 875eca67..c8c7f558 100644 --- a/crates/ui-egui/src/panels/metadata.rs +++ b/crates/ui-egui/src/panels/metadata.rs @@ -33,7 +33,7 @@ pub fn field_id(name: &str) -> String { pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let Some(item) = target(app) else { - crate::dock::placeholder(ui, rect, &t, "Select a clip to view its metadata."); + crate::dock::placeholder(ui, rect, &t, tl!("Select a clip to view its metadata.")); app.auto.add("metadata.empty", rect, "Select a clip to view its metadata."); return; }; @@ -45,7 +45,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut commit: Option<(String, String)> = None; // header: "Clip: name" with a rule (like the Info panel) let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::hover()); - let g = ui.painter().layout_no_wrap(format!("Clip: {name}"), Tokens::semibold(14.0), t.text); + let g = ui.painter().layout_no_wrap(tlf!("Clip: {name}", name), Tokens::semibold(14.0), t.text); let gw = g.size().x; ui.painter().galley(egui::pos2(hr.min.x, hr.center().y - g.size().y / 2.0), g, t.text); ui.painter().line_segment([egui::pos2(hr.min.x + gw + 10.0, hr.center().y), egui::pos2(hr.max.x, hr.center().y)], egui::Stroke::new(2.0, t.separator)); @@ -57,22 +57,24 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if f.section != section { section = f.section; ui.add_space(4.0); - let r = ui.label(RichText::new(section).color(t.text_dim).strong()); + let r = ui.label(RichText::new(crate::i18n::t(section)).color(t.text_dim).strong()); elems.push((format!("metadata.section.{section}"), r.rect, section.to_string())); } ui.horizontal(|ui| { - ui.add_sized(vec2(label_w, 20.0), egui::Label::new(RichText::new(format!("{}:", f.name)).color(t.text_dim)).truncate()); + ui.add_sized(vec2(label_w, 20.0), egui::Label::new(RichText::new(format!("{}:", crate::i18n::t(&f.name))).color(t.text_dim)).truncate()); if f.name == "Label" { let cur = Label::from_name(&f.value).unwrap_or(Label::Violet); let mut sel = cur; - let r = - egui::ComboBox::from_id_salt(("md-label", item.0)).selected_text(cur.name()).width(ui.available_width().min(200.0)).show_ui(ui, |ui| { + let r = egui::ComboBox::from_id_salt(("md-label", item.0)) + .selected_text(crate::i18n::t(cur.name())) + .width(ui.available_width().min(200.0)) + .show_ui(ui, |ui| { for l in Label::ALL { let c = l.rgb(); let rr = ui.horizontal(|ui| { let (sw, _) = ui.allocate_exact_size(vec2(12.0, 12.0), Sense::hover()); ui.painter().rect_filled(sw, 2.0, egui::Color32::from_rgb(c[0], c[1], c[2])); - ui.selectable_value(&mut sel, l, l.name()) + ui.selectable_value(&mut sel, l, crate::i18n::t(l.name())) }); elems.push((format!("metadata.label.{}", l.name()), rr.inner.rect, l.name().to_string())); } diff --git a/crates/ui-egui/src/panels/misc.rs b/crates/ui-egui/src/panels/misc.rs index d9f55b67..e42a9f09 100644 --- a/crates/ui-egui/src/panels/misc.rs +++ b/crates/ui-egui/src/panels/misc.rs @@ -23,16 +23,16 @@ pub fn history(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.painter().text(pos2(r.min.x + 8.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::ui(12.0), if dim { t.text_faint } else { t.text }); resp.clicked() }; - if row(ui, "Open", undo.is_empty(), false) { + if row(ui, tl!("Open"), undo.is_empty(), false) { target = Some(-(undo.len() as i64)); } for (i, l) in undo.iter().enumerate() { - if row(ui, l, i + 1 == undo.len(), false) { + if row(ui, crate::i18n::t(l), i + 1 == undo.len(), false) { target = Some(i as i64 + 1 - undo.len() as i64); } } for (i, l) in redo.iter().enumerate() { - if row(ui, l, false, true) { + if row(ui, crate::i18n::t(l), false, true) { target = Some(i as i64 + 1); } } @@ -62,7 +62,7 @@ pub const MARKER_FILTER: [filmcraft_project::Label; 7] = { pub fn markers(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let Some(seq) = app.session.active_sequence().cloned() else { - crate::dock::placeholder(ui, rect, &t, "(no sequence)"); + crate::dock::placeholder(ui, rect, &t, tl!("(no sequence)")); return; }; let hidden: Vec<[u8; 3]> = app.session.state.hidden_marker_colors.iter().map(|l| l.marker_rgb()).collect(); @@ -82,7 +82,7 @@ pub fn markers(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.painter().rect_stroke(r.shrink(2.5), 3.0, egui::Stroke::new(1.0, col), egui::StrokeKind::Inside); } elems.push((format!("markers.filter.{}", l.name()), r, format!("{} markers {}", l.name(), if on { "shown" } else { "hidden" }))); - if resp.on_hover_text(format!("Show or hide {} markers", l.name())).clicked() { + if resp.on_hover_text(tlf!("Show or hide {color} markers", color = crate::i18n::t(l.name()))).clicked() { toggle = Some((l, !on)); } } @@ -98,7 +98,7 @@ pub fn markers(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } let c = m.color.marker_rgb(); ui.painter().rect_filled(Rect::from_min_size(r.min + vec2(4.0, 6.0), vec2(4.0, 26.0)), 2.0, Color32::from_rgb(c[0], c[1], c[2])); - let name = if m.name.is_empty() { "Marker" } else { &m.name }; + let name = if m.name.is_empty() { tl!("Marker") } else { &m.name }; ui.painter().text(pos2(r.min.x + 16.0, r.min.y + 12.0), Align2::LEFT_CENTER, name, Tokens::ui(12.0), t.text); let mut tc = format_time(m.start, seq.settings.frame_rate, seq.settings.drop_frame, TimeDisplay::Timecode, 48000); if m.duration > filmcraft_time::Tick::ZERO { @@ -106,7 +106,7 @@ pub fn markers(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { tc = format!("{tc} ({d})"); } if m.kind == filmcraft_project::MarkerKind::Chapter { - tc = format!("{tc} Chapter"); + tc = tlf!("{tc} Chapter", tc); } ui.painter().text(pos2(r.min.x + 16.0, r.min.y + 27.0), Align2::LEFT_CENTER, tc, Tokens::mono(11.0), t.hot_text); elems.push((format!("markers.row.{}", m.id.0), r, name.to_string())); @@ -115,9 +115,9 @@ pub fn markers(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } } if seq.markers.is_empty() { - ui.label(egui::RichText::new("No markers. Press M to add one.").color(t.text_faint)); + ui.label(egui::RichText::new(tl!("No markers. Press M to add one.")).color(t.text_faint)); } else if shown == 0 { - ui.label(egui::RichText::new("All markers are hidden by the colour filter.").color(t.text_faint)); + ui.label(egui::RichText::new(tl!("All markers are hidden by the colour filter.")).color(t.text_faint)); } }); for (id, r, l) in elems { @@ -145,17 +145,17 @@ pub fn info(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { }; if let Some(item) = app.session.state.project_selection.first().and_then(|i| app.session.project.item(*i)).cloned() { ui.label(egui::RichText::new(&item.name).strong()); - line(ui, "Type:", item.type_label().to_string()); + line(ui, tl!("Type:"), crate::i18n::t(item.type_label()).to_string()); if let Some(m) = item.as_media() { if let Some(v) = &m.info.video { - line(ui, "Video:", format!("{} fps, {} x {} ({:.4})", v.frame_rate.label(), v.width, v.height, v.par.0 as f32 / v.par.1 as f32)); - line(ui, "Codec:", v.codec.clone()); + line(ui, tl!("Video:"), format!("{} fps, {} x {} ({:.4})", v.frame_rate.label(), v.width, v.height, v.par.0 as f32 / v.par.1 as f32)); + line(ui, tl!("Codec:"), v.codec.clone()); } if let Some(a) = &m.info.audio { - line(ui, "Audio:", format!("{} Hz - {} ch - {}", a.sample_rate, a.channels, a.codec)); + line(ui, tl!("Audio:"), format!("{} Hz - {} ch - {}", a.sample_rate, a.channels, a.codec)); } } - line(ui, "Duration:", format_time(item.duration(), item.frame_rate(), false, TimeDisplay::Timecode, 48000)); + line(ui, tl!("Duration:"), format_time(item.duration(), item.frame_rate(), false, TimeDisplay::Timecode, 48000)); ui.separator(); } if let Some(seq) = app.session.active_sequence() { @@ -163,19 +163,19 @@ pub fn info(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.label(egui::RichText::new(name).strong()); line( ui, - "Settings:", + tl!("Settings:"), format!("{}x{} · {} fps · {} Hz", seq.settings.width, seq.settings.height, seq.settings.frame_rate.label(), seq.settings.sample_rate), ); - line(ui, "Playhead:", format_time(app.session.playhead(), seq.settings.frame_rate, seq.settings.drop_frame, TimeDisplay::Timecode, 48000)); + line(ui, tl!("Playhead:"), format_time(app.session.playhead(), seq.settings.frame_rate, seq.settings.drop_frame, TimeDisplay::Timecode, 48000)); for (i, tr) in seq.video_tracks.iter().enumerate().rev() { let at = tr.item_at(app.session.playhead()).map(|x| x.name.clone()).unwrap_or_default(); - line(ui, &format!("Video {}:", i + 1), at); + line(ui, &tlf!("Video {n}:", n = i + 1), at); } for (i, tr) in seq.audio_tracks.iter().enumerate() { let at = tr.item_at(app.session.playhead()).map(|x| x.name.clone()).unwrap_or_default(); - line(ui, &format!("Audio {}:", i + 1), at); + line(ui, &tlf!("Audio {n}:", n = i + 1), at); } } ui.separator(); - line(ui, "UI:", format!("{:.0} fps · {} frames queued", app.fps, app.frames.queue_len())); + line(ui, tl!("UI:"), tlf!("{fps} fps · {n} frames queued", fps = format!("{:.0}", app.fps), n = app.frames.queue_len())); } diff --git a/crates/ui-egui/src/panels/mixer.rs b/crates/ui-egui/src/panels/mixer.rs index 77cd4f0c..c5bfe64f 100644 --- a/crates/ui-egui/src/panels/mixer.rs +++ b/crates/ui-egui/src/panels/mixer.rs @@ -203,7 +203,7 @@ fn targets(seq: &Sequence, id: TrackId) -> Vec<&Track> { pub fn track_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let Some(seq) = app.session.active_sequence().cloned() else { - crate::dock::placeholder(ui, rect, &t, "(no sequence)"); + crate::dock::placeholder(ui, rect, &t, tl!("(no sequence)")); return; }; let meters = poll_meters(app, ui); @@ -212,7 +212,7 @@ pub fn track_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let body = Rect::from_min_max(pos2(rect.min.x, rect.min.y + 2.0), pos2(rect.max.x, footer.min.y)); // effects / sends disclosure at the left edge let arrow = Rect::from_min_size(pos2(body.min.x + 2.0, body.min.y + 4.0), vec2(14.0, 18.0)); - let aresp = ui.interact(arrow, egui::Id::new("mixer-fx-toggle"), Sense::click()).on_hover_text("Show/Hide Effects and Sends"); + let aresp = ui.interact(arrow, egui::Id::new("mixer-fx-toggle"), Sense::click()).on_hover_text(tl!("Show/Hide Effects and Sends")); icons::paint(ui.painter(), arrow.shrink(2.0), if app.ui.mixer_fx_open { Icon::ChevronDown } else { Icon::ChevronRight }, t.icon); app.auto.add("mixer.showEffects", arrow, "Show/Hide Effects and Sends"); if aresp.clicked() { @@ -220,19 +220,19 @@ pub fn track_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } // panel menu: Show/Hide Tracks, Meter Input(s) Only let menu_r = Rect::from_min_size(pos2(body.min.x + 2.0, body.min.y + 26.0), vec2(14.0, 18.0)); - let mresp = ui.interact(menu_r, egui::Id::new("mixer-menu"), Sense::click()).on_hover_text("Audio Track Mixer menu"); + let mresp = ui.interact(menu_r, egui::Id::new("mixer-menu"), Sense::click()).on_hover_text(tl!("Audio Track Mixer menu")); icons::paint(ui.painter(), menu_r.shrink(2.0), Icon::Hamburger, if mresp.hovered() { t.tab_text_active } else { t.icon }); app.auto.add("mixer.menu", menu_r, "Audio Track Mixer menu"); let show_hide_id = egui::Id::new("mixer-show-hide-open"); let mut show_hide: bool = ui.data(|d| d.get_temp(show_hide_id)).unwrap_or(false); egui::Popup::menu(&mresp).show(|ui| { - let r = ui.button("Show/Hide Tracks…"); + let r = ui.button(tl!("Show/Hide Tracks…")); app.auto.add("mixer.menu.showHide", r.rect, "Show/Hide Tracks…"); if r.clicked() { show_hide = true; } let on = app.ui.mixer_meter_input_only; - let r = ui.selectable_label(on, "Meter Input(s) Only"); + let r = ui.selectable_label(on, tl!("Meter Input(s) Only")); app.auto.add("mixer.menu.meterInputOnly", r.rect, "Meter Input(s) Only"); if r.clicked() { app.ui.mixer_meter_input_only = !on; @@ -241,34 +241,37 @@ pub fn track_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if show_hide { let mut open = true; let mut close = false; - egui::Window::new("Show/Hide Tracks").id(egui::Id::new("mixer-show-hide")).collapsible(false).resizable(false).open(&mut open).show(ui.ctx(), |ui| { - for id in seq.audio_tracks.iter().chain(&seq.submix_tracks).map(|t| t.id) { - let label = strip_label(&seq, id); - let name = seq.mix_track(id).map(|t| t.name.clone()).unwrap_or_default(); - let mut shown = !app.ui.mixer_hidden.contains(&id.0); - let r = ui.checkbox(&mut shown, format!("{label} {name}")); - app.auto.add(&format!("mixer.showHide.{label}"), r.rect, &format!("Show {label}")); - if r.changed() { - if shown { - app.ui.mixer_hidden.retain(|h| *h != id.0); - } else if !app.ui.mixer_hidden.contains(&id.0) { - app.ui.mixer_hidden.push(id.0); + egui::Window::new(tl!("Show/Hide Tracks")).id(egui::Id::new("mixer-show-hide")).collapsible(false).resizable(false).open(&mut open).show( + ui.ctx(), + |ui| { + for id in seq.audio_tracks.iter().chain(&seq.submix_tracks).map(|t| t.id) { + let label = strip_label(&seq, id); + let name = seq.mix_track(id).map(|t| t.name.clone()).unwrap_or_default(); + let mut shown = !app.ui.mixer_hidden.contains(&id.0); + let r = ui.checkbox(&mut shown, format!("{label} {name}")); + app.auto.add(&format!("mixer.showHide.{label}"), r.rect, &format!("Show {label}")); + if r.changed() { + if shown { + app.ui.mixer_hidden.retain(|h| *h != id.0); + } else if !app.ui.mixer_hidden.contains(&id.0) { + app.ui.mixer_hidden.push(id.0); + } } } - } - ui.horizontal(|ui| { - let r = ui.button("Show All"); - app.auto.add("mixer.showHide.all", r.rect, "Show All"); - if r.clicked() { - app.ui.mixer_hidden.clear(); - } - let r = ui.button("OK"); - app.auto.add("mixer.showHide.ok", r.rect, "OK"); - if r.clicked() { - close = true; - } - }); - }); + ui.horizontal(|ui| { + let r = ui.button(tl!("Show All")); + app.auto.add("mixer.showHide.all", r.rect, "Show All"); + if r.clicked() { + app.ui.mixer_hidden.clear(); + } + let r = ui.button(tl!("OK")); + app.auto.add("mixer.showHide.ok", r.rect, "OK"); + if r.clicked() { + close = true; + } + }); + }, + ); show_hide = open && !close; } ui.data_mut(|d| d.insert_temp(show_hide_id, show_hide)); @@ -390,7 +393,7 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s match fx { Some(e) => { if crate::panels::audio_fx_editor::has_editor(&e.effect) { - let r0 = ui.button("Edit…"); + let r0 = ui.button(tl!("Edit…")); app.auto.add(&format!("{ap}.fx.{k}.edit"), r0.rect, "Edit…"); if r0.clicked() { open_editor = Some(crate::panels::audio_fx_editor::FxTarget::Insert { strip: id.0, slot: k }); @@ -398,18 +401,18 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s ui.separator(); } let on = e.enabled; - let r1 = ui.selectable_label(!on, "Bypass"); + let r1 = ui.selectable_label(!on, tl!("Bypass")); app.auto.add(&format!("{ap}.fx.{k}.bypass"), r1.rect, "Bypass"); if r1.clicked() { cx.cmd("mixer.setInsert", json!({"strip": sid, "slot": k, "enabled": !on})); } - let r2 = ui.selectable_label(e.post_fader, "Post-Fader"); + let r2 = ui.selectable_label(e.post_fader, tl!("Post-Fader")); app.auto.add(&format!("{ap}.fx.{k}.postFader"), r2.rect, "Post-Fader"); if r2.clicked() { cx.cmd("mixer.setInsert", json!({"strip": sid, "slot": k, "postFader": !e.post_fader})); } ui.separator(); - let r3 = ui.button("Remove Effect"); + let r3 = ui.button(tl!("Remove Effect")); app.auto.add(&format!("{ap}.fx.{k}.remove"), r3.rect, "Remove Effect"); if r3.clicked() { cx.cmd("mixer.removeInsert", json!({"strip": sid, "slot": k})); @@ -446,7 +449,7 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s } } None => { - ui.label("Fill the slots above first"); + ui.label(tl!("Fill the slots above first")); } } }); @@ -466,7 +469,7 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s let r = Rect::from_min_size(pos2(x + 4.0, y), vec2(w - 8.0, SLOT_H - 2.0)); let snd = tr.mixer.sends.get(k); let text = match snd { - Some(s) => format!("{}{} {}", if s.pre_fader { "pre " } else { "" }, strip_label(seq, s.target), db_text(s.level_db)), + Some(s) => format!("{}{} {}", if s.pre_fader { tl!("pre ") } else { "" }, strip_label(seq, s.target), db_text(s.level_db)), None => String::new(), }; if is_master { @@ -487,18 +490,18 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s if sl.changed() { cx.cmd("mixer.setSend", json!({"strip": sid, "send": k, "levelDb": lvl})); } - let r1 = ui.selectable_label(s.pre_fader, "Pre-Fader"); + let r1 = ui.selectable_label(s.pre_fader, tl!("Pre-Fader")); app.auto.add(&format!("{ap}.send.{k}.preFader"), r1.rect, "Pre-Fader"); if r1.clicked() { cx.cmd("mixer.setSend", json!({"strip": sid, "send": k, "preFader": !s.pre_fader})); } - let r2 = ui.selectable_label(s.muted, "Mute Send"); + let r2 = ui.selectable_label(s.muted, tl!("Mute Send")); app.auto.add(&format!("{ap}.send.{k}.mute"), r2.rect, "Mute Send"); if r2.clicked() { cx.cmd("mixer.setSend", json!({"strip": sid, "send": k, "muted": !s.muted})); } ui.separator(); - let r3 = ui.button("Remove Send"); + let r3 = ui.button(tl!("Remove Send")); app.auto.add(&format!("{ap}.send.{k}.remove"), r3.rect, "Remove Send"); if r3.clicked() { cx.cmd("mixer.removeSend", json!({"strip": sid, "send": k})); @@ -514,7 +517,7 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s } } if seq.submix_tracks.iter().all(|s| s.id != id) { - let r = ui.button("New Submix"); + let r = ui.button(tl!("New Submix")); app.auto.add(&format!("{ap}.send.{k}.newSubmix"), r.rect, "New Submix"); if r.clicked() { let n = seq.submix_tracks.len(); @@ -524,7 +527,7 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s } } None => { - ui.label("Fill the slots above first"); + ui.label(tl!("Fill the slots above first")); } } }); @@ -536,11 +539,11 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s // ---- input / output if !is_master { let ir = Rect::from_min_size(pos2(x + 4.0, y), vec2(w - 8.0, 20.0)); - let iresp = crate::widgets::dropdown_text(ui, ir, tr.mixer.input_map.label(), &t, egui::Id::new((&ap, "input"))); + let iresp = crate::widgets::dropdown_text(ui, ir, crate::i18n::t(tr.mixer.input_map.label()), &t, egui::Id::new((&ap, "input"))); app.auto.add(&format!("{ap}.input"), ir, "Input channel mapping"); egui::Popup::menu(&iresp).show(|ui| { for m in InputMap::ALL { - let r = ui.selectable_label(m == tr.mixer.input_map, m.label()); + let r = ui.selectable_label(m == tr.mixer.input_map, crate::i18n::t(m.label())); app.auto.add(&format!("{ap}.input.{}", m.label()), r.rect, m.label()); if r.clicked() { cx.cmd("mixer.setStrip", json!({"strip": sid, "inputMap": m.label()})); @@ -549,12 +552,13 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s }); y += 24.0; let or = Rect::from_min_size(pos2(x + 4.0, y), vec2(w - 8.0, 20.0)); - let out_label = tr.mixer.output.map(|o| seq.mix_track(o).map(|s| s.name.clone()).unwrap_or_else(|| "Mix".into())).unwrap_or_else(|| "Mix".into()); + let out_label = + tr.mixer.output.map(|o| seq.mix_track(o).map(|s| s.name.clone()).unwrap_or_else(|| tl!("Mix").into())).unwrap_or_else(|| tl!("Mix").into()); let oresp = crate::widgets::dropdown_text(ui, or, &out_label, &t, egui::Id::new((&ap, "output"))); app.auto.add(&format!("{ap}.output"), or, "Track output assignment"); let tg = targets(seq, id); egui::Popup::menu(&oresp).show(|ui| { - let r = ui.selectable_label(tr.mixer.output.is_none(), "Mix"); + let r = ui.selectable_label(tr.mixer.output.is_none(), tl!("Mix")); app.auto.add(&format!("{ap}.output.Mix"), r.rect, "Mix"); if r.clicked() { cx.cmd("mixer.setStrip", json!({"strip": sid, "output": "Mix"})); @@ -576,7 +580,7 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s let pan = shown(app, &tr, LANE_PAN, cx.now); let kc = pos2(sr.center().x, y + 20.0); let kr = Rect::from_center_size(kc, vec2(38.0, 38.0)); - let kresp = ui.interact(kr, egui::Id::new((&ap, "pan")), Sense::click_and_drag()).on_hover_text("Pan / balance (drag; double-click: centre)"); + let kresp = ui.interact(kr, egui::Id::new((&ap, "pan")), Sense::click_and_drag()).on_hover_text(tl!("Pan / balance (drag; double-click: centre)")); let ring = if kresp.hovered() || kresp.dragged() { t.tab_text_active } else { Color32::from_gray(0xb0) }; ui.painter().circle_stroke(kc, 16.0, Stroke::new(2.0, ring)); let ang = (pan / 100.0) as f32 * 135f32.to_radians(); @@ -613,14 +617,14 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s // ---- automation mode let mr = Rect::from_min_size(pos2(x + 4.0, y), vec2(w - 8.0, 20.0)); let mode = tr.mixer.mode; - let mresp = crate::widgets::dropdown_text(ui, mr, mode.label(), &t, egui::Id::new((&ap, "mode"))); + let mresp = crate::widgets::dropdown_text(ui, mr, crate::i18n::t(mode.label()), &t, egui::Id::new((&ap, "mode"))); if cx.recording && mode.writes() { ui.painter().rect_stroke(mr, 4.0, Stroke::new(1.0, RED), StrokeKind::Inside); } app.auto.add(&format!("{ap}.mode"), mr, &format!("Automation mode: {}", mode.label())); egui::Popup::menu(&mresp).show(|ui| { for m in AutomationMode::ALL { - let r = ui.selectable_label(m == mode, m.label()); + let r = ui.selectable_label(m == mode, crate::i18n::t(m.label())); app.auto.add(&format!("{ap}.mode.{}", m.label()), r.rect, m.label()); if r.clicked() { cx.cmd("mixer.setStrip", json!({"strip": sid, "mode": m.label()})); @@ -654,14 +658,14 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s let is_sub = seq.submix_tracks.iter().any(|s| s.id == id); if is_sub { let r_r = crate::widgets::letter_toggle(ui, b(2.0), "◆", tr.mixer.solo_safe, Color32::from_gray(0xc8), &t, egui::Id::new((&ap, "safe"))) - .on_hover_text("Solo safe"); + .on_hover_text(tl!("Solo safe")); app.auto.add(&format!("{ap}.soloSafe"), b(2.0), "Solo Safe"); if r_r.clicked() { cx.cmd("mixer.setStrip", json!({"strip": sid, "soloSafe": !tr.mixer.solo_safe})); } } else { let r_r = crate::widgets::letter_toggle(ui, b(2.0), "R", tr.mixer.record_arm, RED, &t, egui::Id::new((&ap, "R"))) - .on_hover_text("Enable track for recording"); + .on_hover_text(tl!("Enable track for recording")); app.auto.add(&format!("{ap}.record"), b(2.0), "Enable Track for Recording"); if r_r.clicked() { cx.cmd("mixer.setStrip", json!({"strip": sid, "recordArm": !tr.mixer.record_arm})); @@ -691,7 +695,8 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s let vol = shown(app, &tr, LANE_VOLUME, cx.now); let cy = ypos(vol); let cap = Rect::from_center_size(pos2(track.center().x, cy), vec2(18.0, 26.0)); - let fresp = ui.interact(cap.expand(3.0), egui::Id::new((&ap, "fader")), Sense::click_and_drag()).on_hover_text("Volume (drag; double-click: 0 dB)"); + let fresp = + ui.interact(cap.expand(3.0), egui::Id::new((&ap, "fader")), Sense::click_and_drag()).on_hover_text(tl!("Volume (drag; double-click: 0 dB)")); let held = app.session.previews.live.get(id, LANE_VOLUME).is_some(); let col = if fresp.dragged() || held { t.accent } else { Color32::from_gray(0xe0) }; ui.painter().rect_filled(cap, 3.0, Color32::from_gray(0x2a)); @@ -744,7 +749,7 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s let nr = Rect::from_min_max(pos2(x, sr.max.y - name_h), sr.max); ui.painter().line_segment([pos2(nr.min.x + 4.0, nr.min.y), pos2(nr.max.x - 4.0, nr.min.y)], Stroke::new(1.0, t.separator)); if is_master { - small_text(ui, nr.center(), Align2::CENTER_CENTER, "Mix", 12.0, t.text); + small_text(ui, nr.center(), Align2::CENTER_CENTER, tl!("Mix"), 12.0, t.text); } else { small_text(ui, pos2(nr.min.x + 6.0, nr.center().y), Align2::LEFT_CENTER, &label, 12.0, t.text); let name = ui.painter().with_clip_rect(Rect::from_min_max(pos2(nr.min.x + 26.0, nr.min.y), nr.max).intersect(ui.clip_rect())); @@ -761,7 +766,8 @@ fn pan51_panner(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, tr: &Tr let px = shown(app, tr, LANE_PAN51_X, cx.now); let py = shown(app, tr, LANE_PAN51_Y, cx.now); let sq = Rect::from_min_size(pos2(r.min.x, r.min.y), vec2(46.0, 46.0)); - let resp = ui.interact(sq, egui::Id::new((ap, "pan51")), Sense::click_and_drag()).on_hover_text("5.1 panner (drag the puck; double-click: front centre)"); + let resp = + ui.interact(sq, egui::Id::new((ap, "pan51")), Sense::click_and_drag()).on_hover_text(tl!("5.1 panner (drag the puck; double-click: front centre)")); let p = ui.painter(); p.rect_filled(sq, 3.0, Color32::from_gray(0x16)); p.rect_stroke(sq, 3.0, Stroke::new(1.0, if resp.hovered() || resp.dragged() { t.tab_text_active } else { Color32::from_gray(0x60) }), StrokeKind::Inside); @@ -798,7 +804,7 @@ fn pan51_panner(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, tr: &Tr let center = shown(app, tr, LANE_PAN51_CENTER, cx.now); let lfe = shown(app, tr, LANE_PAN51_LFE, cx.now); let col_x = sq.max.x + 4.0; - small_text(ui, pos2(col_x, r.min.y + 6.0), Align2::LEFT_CENTER, "Center", 9.0, t.text_dim); + small_text(ui, pos2(col_x, r.min.y + 6.0), Align2::LEFT_CENTER, tl!("Center"), 9.0, t.text_dim); let cr = Rect::from_min_size(pos2(col_x, r.min.y + 12.0), vec2(r.max.x - col_x, 14.0)); let mut cui = ui.new_child(egui::UiBuilder::new().max_rect(cr)); let (cresp, nc) = crate::widgets::hot_number(&mut cui, egui::Id::new((ap, "pan51.center")), center, 1.0, (0.0, 100.0), 0, "%", &t); @@ -827,12 +833,12 @@ fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, cx: &mut Ctx) ui.painter().text(pos2(row.max.x - 12.0, row.center().y), Align2::RIGHT_CENTER, dur, Tokens::mono(12.0), t.text_dim); let playing = app.playback.playing; let buttons: [(Icon, &str, &str); 6] = [ - (Icon::GoToIn, "markers.goToIn", "Go to In Point"), - (Icon::GoToOut, "markers.goToOut", "Go to Out Point"), - (if playing { Icon::Pause } else { Icon::Play }, "playback.toggle", "Play-Stop Toggle (Space)"), - (Icon::Play, "playback.inToOut", "Play In to Out"), - (Icon::Loop, "playback.loop", "Loop"), - (Icon::Mic, "voiceover.recordToggle", "Record (voice-over on the record-enabled track)"), + (Icon::GoToIn, "markers.goToIn", tl!("Go to In Point")), + (Icon::GoToOut, "markers.goToOut", tl!("Go to Out Point")), + (if playing { Icon::Pause } else { Icon::Play }, "playback.toggle", tl!("Play-Stop Toggle (Space)")), + (Icon::Play, "playback.inToOut", tl!("Play In to Out")), + (Icon::Loop, "playback.loop", tl!("Loop")), + (Icon::Mic, "voiceover.recordToggle", tl!("Record (voice-over on the record-enabled track)")), ]; let bw = 30.0; let mut x = row.center().x - bw * buttons.len() as f32 / 2.0; @@ -878,7 +884,7 @@ fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, cx: &mut Ctx) pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let Some(seq) = app.session.active_sequence().cloned() else { - crate::dock::placeholder(ui, rect, &t, "(no sequence)"); + crate::dock::placeholder(ui, rect, &t, tl!("(no sequence)")); return; }; let meters = poll_meters(app, ui); @@ -944,11 +950,11 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { // automation mode (Clip Mixer) let mode = filmcraft_engine::mixer::clip_mode(&app.session, tr.id); let mdr = Rect::from_min_size(pos2(sr.min.x + 4.0, sr.min.y + 2.0), vec2(sr.width() - 8.0, 16.0)); - let mdresp = crate::widgets::dropdown_text(ui, mdr, mode.label(), &t, egui::Id::new((&ap, "mode"))); + let mdresp = crate::widgets::dropdown_text(ui, mdr, crate::i18n::t(mode.label()), &t, egui::Id::new((&ap, "mode"))); app.auto.add(&format!("{ap}.mode"), mdr, &format!("Clip automation mode: {}", mode.label())); egui::Popup::menu(&mdresp).show(|ui| { for m in AutomationMode::ALL { - let r = ui.selectable_label(m == mode, m.label()); + let r = ui.selectable_label(m == mode, crate::i18n::t(m.label())); app.auto.add(&format!("{ap}.mode.{}", m.label()), r.rect, m.label()); if r.clicked() { acts.push(("clipMixer.setMode".into(), json!({"track": tr.id.0, "mode": m.label()}))); @@ -969,7 +975,7 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { acts.push(("mixer.setStrip".into(), json!({"strip": tr.id.0, "solo": !tr.solo}))); } let kresp = - crate::widgets::icon_toggle(ui, b(2.0), Icon::Keyframe, kf, &t, egui::Id::new((&ap, "kf")), Some(t.accent)).on_hover_text("Write keyframes"); + crate::widgets::icon_toggle(ui, b(2.0), Icon::Keyframe, kf, &t, egui::Id::new((&ap, "kf")), Some(t.accent)).on_hover_text(tl!("Write keyframes")); app.auto.add(&format!("{ap}.keyframe"), b(2.0), "Write Keyframes"); if kresp.clicked() { write_kf.insert(tr.id.0, !kf); diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 9099daf4..7cad79e8 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -84,7 +84,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rect) PanelKind::ReferenceMonitor => reference::show(app, ui, rect), PanelKind::Text => text::show(app, ui, rect), PanelKind::EssentialSound => essential_sound::show(app, ui, rect), - other => crate::dock::placeholder(ui, rect, &app.tokens, &format!("{} — coming in a later milestone", other.title())), + other => crate::dock::placeholder(ui, rect, &app.tokens, &tlf!("{panel} — coming in a later milestone", panel = crate::i18n::t(other.title()))), } } @@ -188,41 +188,41 @@ pub fn panel_menu_popup(app: &mut FilmcraftApp, ui: &mut egui::Ui) { // the Timeline's tabs are its open sequences: Close Panel closes the active one and // keeps the panel (Premiere's wording and behaviour) if p == PanelKind::Timeline && app.session.state.active_sequence.is_some() { - let r = ui.button("Close Panel"); + let r = ui.button(tl!("Close Panel")); app.auto.add("panel.menu.Timeline.close", r.rect, "Close Panel"); if r.clicked() { let _ = app.session.execute("sequence.close", serde_json::json!({})); close = true; } - let r = ui.add_enabled(app.session.state.open_sequences.len() > 1, egui::Button::new("Close Other Timeline Panels")); + let r = ui.add_enabled(app.session.state.open_sequences.len() > 1, egui::Button::new(tl!("Close Other Timeline Panels"))); app.auto.add("panel.menu.Timeline.closeOthers", r.rect, "Close Other Timeline Panels"); if r.clicked() { let _ = app.session.execute("sequence.closeOthers", serde_json::json!({})); close = true; } - } else if ui.button("Close Panel").clicked() { + } else if ui.button(tl!("Close Panel")).clicked() { app.ui.dock.close(p); close = true; } - if ui.button("Maximize Frame").clicked() { + if ui.button(tl!("Maximize Frame")).clicked() { app.ui.dock = crate::dock::DockNode::Tabs { panels: vec![p], active: 0 }; close = true; } - if ui.button("Restore Workspace").clicked() { + if ui.button(tl!("Restore Workspace")).clicked() { let w = app.ui.workspace.clone(); app.set_workspace(&w); close = true; } if p == PanelKind::Timeline { ui.separator(); - let r = ui.add_enabled(app.session.state.active_sequence.is_some(), egui::Button::new("Reveal Sequence in Project")); + let r = ui.add_enabled(app.session.state.active_sequence.is_some(), egui::Button::new(tl!("Reveal Sequence in Project"))); app.auto.add("panel.menu.Timeline.revealSequence", r.rect, "Reveal Sequence in Project"); if r.clicked() { let _ = app.session.execute("sequence.revealInProject", serde_json::json!({})); close = true; } - ui.checkbox(&mut app.ui.timeline.show_thumbnails, "Video Thumbnails"); - ui.checkbox(&mut app.ui.timeline.show_waveforms, "Audio Waveforms"); + ui.checkbox(&mut app.ui.timeline.show_thumbnails, tl!("Video Thumbnails")); + ui.checkbox(&mut app.ui.timeline.show_waveforms, tl!("Audio Waveforms")); } if p == PanelKind::Project { ui.separator(); diff --git a/crates/ui-egui/src/panels/monitor.rs b/crates/ui-egui/src/panels/monitor.rs index 6213eb3c..6fe4bdf2 100644 --- a/crates/ui-egui/src/panels/monitor.rs +++ b/crates/ui-egui/src/panels/monitor.rs @@ -33,12 +33,12 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) let (target, frame_size, rate, time, duration, drop_frame, mark_in, mark_out, name) = match which { Which::Program => { let Some(seq_id) = app.session.state.active_sequence else { - crate::dock::placeholder(ui, rect, &t, "(no sequences)"); + crate::dock::placeholder(ui, rect, &t, tl!("(no sequences)")); return; }; // A damaged project can name an active sequence that no longer exists. let Some(q) = app.session.active_sequence() else { - crate::dock::placeholder(ui, rect, &t, "(no sequences)"); + crate::dock::placeholder(ui, rect, &t, tl!("(no sequences)")); return; }; ( @@ -55,7 +55,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) } Which::Source => { let Some(item) = app.session.state.source_item else { - crate::dock::placeholder(ui, rect, &t, "(no clips)"); + crate::dock::placeholder(ui, rect, &t, tl!("(no clips)")); return; }; let Some(pi) = app.session.project.item(item) else { return }; @@ -224,7 +224,16 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) ui.painter().line_segment([c - vec2(0.0, 8.0), c + vec2(0.0, 8.0)], Stroke::new(1.0, Color32::from_white_alpha(120))); } if display != DisplayMode::Composite && !compare { - let label = format!("{display:?}"); + let label = match display { + DisplayMode::Composite => tl!("Composite"), + DisplayMode::Alpha => tl!("Alpha"), + DisplayMode::Red => tl!("Red"), + DisplayMode::Green => tl!("Green"), + DisplayMode::Blue => tl!("Blue"), + DisplayMode::AudioWaveform => tl!("Audio Waveform"), + DisplayMode::Comparison => tl!("Comparison"), + DisplayMode::VideoAndWaveform => tl!("Video and Waveform"), + }; ui.painter().text(pos2(video_area.max.x - 8.0, video_area.min.y + 6.0), Align2::RIGHT_TOP, label, Tokens::ui(10.0), t.text_dim); } } @@ -271,7 +280,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) // zoom + resolution dropdowns centred-ish let zr = Rect::from_min_size(pos2(row1.min.x + 116.0, row1.min.y), vec2(70.0, 24.0)); let zoom_label = match mv.zoom { - None => "Fit".to_string(), + None => tl!("Fit").to_string(), Some(z) => format!("{}%", (z * 100.0).round() as i32), }; let zresp = crate::widgets::dropdown_text(ui, zr, &zoom_label, &t, egui::Id::new((prefix, "zoom"))); @@ -279,11 +288,11 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) monitor_view::zoom_menu(app, &zresp, which); let res = mv.res; let rr = Rect::from_min_size(pos2(row1.max.x - 196.0, row1.min.y), vec2(62.0, 24.0)); - let rresp = crate::widgets::dropdown_text(ui, rr, res.label(), &t, egui::Id::new((prefix, "res"))); + let rresp = crate::widgets::dropdown_text(ui, rr, crate::i18n::t(res.label()), &t, egui::Id::new((prefix, "res"))); app.auto.add(&format!("{prefix}.resolution"), rr, "Select Playback Resolution"); egui::Popup::menu(&rresp).show(|ui| { for r in PlaybackRes::ALL { - if ui.selectable_label(r == res, r.label()).clicked() { + if ui.selectable_label(r == res, crate::i18n::t(r.label())).clicked() { monitor_view::view_mut(app, which).res = r; } } @@ -296,38 +305,38 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) ui.set_min_width(240.0); monitor_view::wrench_items(app, ui, which); let mv = monitor_view::view_mut(app, which); - ui.checkbox(&mut mv.safe_margins, "Safe Margins"); - ui.checkbox(&mut mv.show_transport, "Show Transport Controls"); + ui.checkbox(&mut mv.safe_margins, tl!("Safe Margins")); + ui.checkbox(&mut mv.show_transport, tl!("Show Transport Controls")); if which == Which::Program { ui.separator(); - ui.checkbox(&mut app.ui.show_scopes, "Lumetri Scopes"); - ui.checkbox(&mut app.playback.looping, "Loop"); + ui.checkbox(&mut app.ui.show_scopes, tl!("Lumetri Scopes")); + ui.checkbox(&mut app.playback.looping, tl!("Loop")); ui.separator(); let mut follows = app.session.state.multicam_audio_follows_video; - if ui.checkbox(&mut follows, "Multi-Camera Audio Follows Video").changed() { + if ui.checkbox(&mut follows, tl!("Multi-Camera Audio Follows Video")).changed() { let _ = app.session.execute("multicam.audioFollowsVideo", json!({"enabled": follows})); } - ui.checkbox(&mut app.ui.multicam_record, "Multi-Camera Record"); + ui.checkbox(&mut app.ui.multicam_record, tl!("Multi-Camera Record")); let v = app.session.state.multicam_view.clone(); for (cmd, label, on) in [ - ("multicam.selectionTopDown", "Multi-Camera Selection Top Down", v.top_down), - ("multicam.showPreviewMonitor", "Show Multi-Camera Preview Monitor", v.show_preview), - ("multicam.autoAdjustQuality", "Auto-Adjust Multi-Camera Playback Quality", v.auto_quality), - ("multicam.transmitView", "Transmit Multi-Camera View", v.transmit), + ("multicam.selectionTopDown", tl!("Multi-Camera Selection Top Down"), v.top_down), + ("multicam.showPreviewMonitor", tl!("Show Multi-Camera Preview Monitor"), v.show_preview), + ("multicam.autoAdjustQuality", tl!("Auto-Adjust Multi-Camera Playback Quality"), v.auto_quality), + ("multicam.transmitView", tl!("Transmit Multi-Camera View"), v.transmit), ] { let mut b = on; if ui.checkbox(&mut b, label).changed() { let _ = app.session.execute(cmd, json!({"enabled": b})); } } - ui.menu_button("Multi-Camera Layout", |ui| { - for (k, label) in [("auto", "Automatic"), ("2x2", "2 × 2"), ("3x3", "3 × 3"), ("4x4", "4 × 4")] { + ui.menu_button(tl!("Multi-Camera Layout"), |ui| { + for (k, label) in [("auto", tl!("Automatic")), ("2x2", "2 × 2"), ("3x3", "3 × 3"), ("4x4", "4 × 4")] { if ui.selectable_label(v.layout_name() == k, label).clicked() { let _ = app.session.execute("multicam.gridLayout", json!({"layout": k})); } } }); - if ui.button("Edit Cameras…").clicked() { + if ui.button(tl!("Edit Cameras…")).clicked() { let _ = crate::panels::multicam::route(app, "multicam.editCamerasDialog", &json!({})); ui.close(); } @@ -450,33 +459,33 @@ fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, which: Which) let src = which == Which::Source; let buttons: Vec<(Icon, &str, &str)> = if src { vec![ - (Icon::Marker, "markers.add", "Add Marker (M)"), - (Icon::MarkIn, "src.markIn", "Mark In (I)"), - (Icon::MarkOut, "src.markOut", "Mark Out (O)"), - (Icon::GoToIn, "src.goIn", "Go to In (Shift+I)"), - (Icon::StepBack, "src.stepBack", "Step Back 1 Frame (Left)"), - (Icon::Play, "src.play", "Play-Stop Toggle (Space)"), - (Icon::StepFwd, "src.stepFwd", "Step Forward 1 Frame (Right)"), - (Icon::GoToOut, "src.goOut", "Go to Out (Shift+O)"), - (Icon::Insert, "source.insert", "Insert (,)"), - (Icon::Overwrite, "source.overwrite", "Overwrite (.)"), - (Icon::Camera, "exportFrame", "Export Frame (Shift+E)"), - (Icon::Proxy, "media.toggleProxies", "Toggle Proxies"), + (Icon::Marker, "markers.add", tl!("Add Marker (M)")), + (Icon::MarkIn, "src.markIn", tl!("Mark In (I)")), + (Icon::MarkOut, "src.markOut", tl!("Mark Out (O)")), + (Icon::GoToIn, "src.goIn", tl!("Go to In (Shift+I)")), + (Icon::StepBack, "src.stepBack", tl!("Step Back 1 Frame (Left)")), + (Icon::Play, "src.play", tl!("Play-Stop Toggle (Space)")), + (Icon::StepFwd, "src.stepFwd", tl!("Step Forward 1 Frame (Right)")), + (Icon::GoToOut, "src.goOut", tl!("Go to Out (Shift+O)")), + (Icon::Insert, "source.insert", tl!("Insert (,)")), + (Icon::Overwrite, "source.overwrite", tl!("Overwrite (.)")), + (Icon::Camera, "exportFrame", tl!("Export Frame (Shift+E)")), + (Icon::Proxy, "media.toggleProxies", tl!("Toggle Proxies")), ] } else { vec![ - (Icon::Marker, "markers.add", "Add Marker (M)"), - (Icon::MarkIn, "markers.markIn", "Mark In (I)"), - (Icon::MarkOut, "markers.markOut", "Mark Out (O)"), - (Icon::GoToIn, "markers.goToIn", "Go to In (Shift+I)"), - (Icon::StepBack, "playhead.stepBack", "Step Back 1 Frame (Left)"), - (if app.playback.playing { Icon::Pause } else { Icon::Play }, "playback.toggle", "Play-Stop Toggle (Space)"), - (Icon::StepFwd, "playhead.stepForward", "Step Forward 1 Frame (Right)"), - (Icon::GoToOut, "markers.goToOut", "Go to Out (Shift+O)"), - (Icon::Lift, "sequence.lift", "Lift (;)"), - (Icon::Extract, "sequence.extract", "Extract (')"), - (Icon::Camera, "exportFrame", "Export Frame (Shift+E)"), - (Icon::Proxy, "media.toggleProxies", "Toggle Proxies"), + (Icon::Marker, "markers.add", tl!("Add Marker (M)")), + (Icon::MarkIn, "markers.markIn", tl!("Mark In (I)")), + (Icon::MarkOut, "markers.markOut", tl!("Mark Out (O)")), + (Icon::GoToIn, "markers.goToIn", tl!("Go to In (Shift+I)")), + (Icon::StepBack, "playhead.stepBack", tl!("Step Back 1 Frame (Left)")), + (if app.playback.playing { Icon::Pause } else { Icon::Play }, "playback.toggle", tl!("Play-Stop Toggle (Space)")), + (Icon::StepFwd, "playhead.stepForward", tl!("Step Forward 1 Frame (Right)")), + (Icon::GoToOut, "markers.goToOut", tl!("Go to Out (Shift+O)")), + (Icon::Lift, "sequence.lift", tl!("Lift (;)")), + (Icon::Extract, "sequence.extract", tl!("Extract (')")), + (Icon::Camera, "exportFrame", tl!("Export Frame (Shift+E)")), + (Icon::Proxy, "media.toggleProxies", tl!("Toggle Proxies")), ] }; let bw = 30.0; @@ -512,7 +521,7 @@ fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, which: Which) } "src.play" => Ok(serde_json::Value::Null), "exportFrame" => { - app.ui.status = "Export Frame: use Export mode (M6)".into(); + app.ui.status = tl!("Export Frame: use Export mode (M6)").into(); Ok(serde_json::Value::Null) } c => crate::menus::invoke(app, &ctx, c, json!({})), @@ -525,7 +534,7 @@ fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, which: Which) } // button editor "+" let r = Rect::from_min_size(pos2(row.max.x - 30.0, row.min.y + 4.0), vec2(22.0, 22.0)); - let resp = ui.interact(r, egui::Id::new((prefix, "btn-editor")), Sense::click()).on_hover_text("Button Editor"); + let resp = ui.interact(r, egui::Id::new((prefix, "btn-editor")), Sense::click()).on_hover_text(tl!("Button Editor")); icons::paint(ui.painter(), r.shrink(5.0), Icon::Plus, if resp.hovered() { t.tab_text_active } else { t.text_dim }); } diff --git a/crates/ui-egui/src/panels/monitor_view.rs b/crates/ui-egui/src/panels/monitor_view.rs index cf33d04e..fa9ec8fc 100644 --- a/crates/ui-egui/src/panels/monitor_view.rs +++ b/crates/ui-egui/src/panels/monitor_view.rs @@ -179,7 +179,7 @@ pub fn route(app: &mut FilmcraftApp, id: &str, params: &Value) -> Option { - p.text(area.center(), Align2::CENTER_CENTER, "(no audio)", Tokens::ui(12.0), t.text_dim); + p.text(area.center(), Align2::CENTER_CENTER, tl!("(no audio)"), Tokens::ui(12.0), t.text_dim); } None => { - p.text(area.center(), Align2::CENTER_CENTER, "Generating waveform…", Tokens::ui(12.0), t.text_dim); + p.text(area.center(), Align2::CENTER_CENTER, tl!("Generating waveform…"), Tokens::ui(12.0), t.text_dim); ui.ctx().request_repaint_after(std::time::Duration::from_millis(100)); } } @@ -613,11 +613,13 @@ pub fn compare_bar(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, rate: f let t = app.tokens; let rf = Tick(app.ui.program.compare_ref.unwrap_or(app.session.playhead().0)); let tc = format_time(rf, rate, drop_frame, TimeDisplay::Timecode, 48000); - ui.painter().text(pos2(bar.min.x + 4.0, bar.center().y), Align2::LEFT_CENTER, format!("Reference {tc}"), Tokens::ui(11.0), t.text_dim); + ui.painter().text(pos2(bar.min.x + 4.0, bar.center().y), Align2::LEFT_CENTER, tlf!("Reference {tc}", tc), Tokens::ui(11.0), t.text_dim); let mut x = bar.max.x; - for (key, label, tip) in - [("set", "Set", "Set the reference to the playhead"), ("next", "▶", "Reference: next frame"), ("prev", "◀", "Reference: previous frame")] - { + for (key, label, tip) in [ + ("set", tl!("Set"), tl!("Set the reference to the playhead")), + ("next", "▶", tl!("Reference: next frame")), + ("prev", "◀", tl!("Reference: previous frame")), + ] { let r = Rect::from_min_max(pos2(x - 34.0, bar.min.y + 1.0), pos2(x - 2.0, bar.max.y - 1.0)); x -= 34.0; let resp = ui.interact(r, egui::Id::new(("compare", key)), Sense::click()).on_hover_text(tip); @@ -647,45 +649,45 @@ pub fn wrench_items(app: &mut FilmcraftApp, ui: &mut egui::Ui, w: Which) { let v = view(app, w).clone(); let mode = v.display_mode(); let mut out: Picks = Vec::new(); - pick(ui, &mut out, "display.composite", "Composite Video", mode == Some(DisplayMode::Composite)); - ui.menu_button("RGBA Channels", |ui| { + pick(ui, &mut out, "display.composite", tl!("Composite Video"), mode == Some(DisplayMode::Composite)); + ui.menu_button(tl!("RGBA Channels"), |ui| { for (k, l, m) in [ - ("alpha", "Alpha", DisplayMode::Alpha), - ("red", "Red", DisplayMode::Red), - ("green", "Green", DisplayMode::Green), - ("blue", "Blue", DisplayMode::Blue), + ("alpha", tl!("Alpha"), DisplayMode::Alpha), + ("red", tl!("Red"), DisplayMode::Red), + ("green", tl!("Green"), DisplayMode::Green), + ("blue", tl!("Blue"), DisplayMode::Blue), ] { pick(ui, &mut out, &format!("display.{k}"), l, mode == Some(m)); } }); if w == Which::Program { - pick(ui, &mut out, "display.multicam", "Multi-Camera", v.multicam); - pick(ui, &mut out, "display.comparison", "Comparison View", mode == Some(DisplayMode::Comparison)); + pick(ui, &mut out, "display.multicam", tl!("Multi-Camera"), v.multicam); + pick(ui, &mut out, "display.comparison", tl!("Comparison View"), mode == Some(DisplayMode::Comparison)); } else { - pick(ui, &mut out, "display.audioWaveform", "Audio Waveform", mode == Some(DisplayMode::AudioWaveform)); - pick(ui, &mut out, "display.videoAndWaveform", "Video and Audio Waveform Split", mode == Some(DisplayMode::VideoAndWaveform)); + pick(ui, &mut out, "display.audioWaveform", tl!("Audio Waveform"), mode == Some(DisplayMode::AudioWaveform)); + pick(ui, &mut out, "display.videoAndWaveform", tl!("Video and Audio Waveform Split"), mode == Some(DisplayMode::VideoAndWaveform)); } ui.separator(); - ui.menu_button("Playback Resolution", |ui| { + ui.menu_button(tl!("Playback Resolution"), |ui| { for (k, r) in RES_NAMES { - pick(ui, &mut out, &format!("playbackRes.{k}"), r.label(), v.res == r); + pick(ui, &mut out, &format!("playbackRes.{k}"), crate::i18n::t(r.label()), v.res == r); } }); - ui.menu_button("Paused Resolution", |ui| { + ui.menu_button(tl!("Paused Resolution"), |ui| { for (k, r) in RES_NAMES { - pick(ui, &mut out, &format!("pausedRes.{k}"), r.label(), v.paused_res == r); + pick(ui, &mut out, &format!("pausedRes.{k}"), crate::i18n::t(r.label()), v.paused_res == r); } }); - pick(ui, &mut out, "highQualityPlayback", "High Quality Playback", v.high_quality); + pick(ui, &mut out, "highQualityPlayback", tl!("High Quality Playback"), v.high_quality); ui.separator(); - pick(ui, &mut out, "showRulers", "Show Rulers", v.show_rulers); - pick(ui, &mut out, "showGuides", "Show Guides", v.show_guides); + pick(ui, &mut out, "showRulers", tl!("Show Rulers"), v.show_rulers); + pick(ui, &mut out, "showGuides", tl!("Show Guides"), v.show_guides); if !v.guides.is_empty() { - pick(ui, &mut out, "lockGuides", "Lock Guides", v.lock_guides); - pick(ui, &mut out, "clearGuides", "Clear Guides", false); + pick(ui, &mut out, "lockGuides", tl!("Lock Guides"), v.lock_guides); + pick(ui, &mut out, "clearGuides", tl!("Clear Guides"), false); } if w == Which::Program { - pick(ui, &mut out, "snapInProgramMonitor", "Snap in Program Monitor", v.snap); + pick(ui, &mut out, "snapInProgramMonitor", tl!("Snap in Program Monitor"), v.snap); } ui.separator(); let pfx = prefix(w); @@ -703,7 +705,7 @@ pub fn zoom_menu(app: &mut FilmcraftApp, resp: &egui::Response, w: Which) { let mut out: Picks = Vec::new(); egui::Popup::menu(resp).show(|ui| { ui.set_min_width(80.0); - pick(ui, &mut out, "fit", "Fit", zoom.is_none()); + pick(ui, &mut out, "fit", tl!("Fit"), zoom.is_none()); ui.separator(); for (k, z) in ZOOMS { pick(ui, &mut out, k, &format!("{k}%"), zoom == Some(z)); @@ -731,100 +733,103 @@ pub fn dialogs(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut close = ctx.input(|i| i.key_pressed(egui::Key::Escape)); let mut act: Option<(String, Value)> = None; let templates: Vec = app.session.prefs.guides.templates.iter().map(|t| t.name.clone()).collect(); - let title = match &d { - GuideDialog::Add { .. } => "Add Guide", - GuideDialog::SaveTemplate { .. } => "Save Guides as Template", - GuideDialog::Manage { .. } => "Manage Guides", + let (title, shown) = match &d { + GuideDialog::Add { .. } => ("Add Guide", tl!("Add Guide")), + GuideDialog::SaveTemplate { .. } => ("Save Guides as Template", tl!("Save Guides as Template")), + GuideDialog::Manage { .. } => ("Manage Guides", tl!("Manage Guides")), }; - egui::Window::new(title).collapsible(false).resizable(false).anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| match &mut d { - GuideDialog::Add { vertical, position, source } => { - let m = if *source { "source" } else { "program" }; - ui.horizontal(|ui| { - ui.label("Orientation:"); - let r = ui.radio(*vertical, "Vertical"); - push(&mut elems, "guides.add.vertical", &r, "Vertical"); - if r.clicked() { - *vertical = true; - } - let r = ui.radio(!*vertical, "Horizontal"); - push(&mut elems, "guides.add.horizontal", &r, "Horizontal"); - if r.clicked() { - *vertical = false; - } - }); - ui.horizontal(|ui| { - ui.label("Position:"); - let r = ui.add(egui::DragValue::new(position).speed(1.0).suffix(" px")); - push(&mut elems, "guides.add.position", &r, "Position"); - }); - ui.horizontal(|ui| { - let r = ui.button("Cancel"); - push(&mut elems, "guides.add.cancel", &r, "Cancel"); - close |= r.clicked(); - let r = ui.button("OK"); - push(&mut elems, "guides.add.ok", &r, "OK"); - if r.clicked() { - act = Some(( - "view.addGuide".into(), - json!({"monitor": m, "orientation": if *vertical { "vertical" } else { "horizontal" }, "position": *position}), - )); - } - }); - } - GuideDialog::SaveTemplate { name, source } => { - let m = if *source { "source" } else { "program" }; - ui.horizontal(|ui| { - ui.label("Name:"); - let r = ui.text_edit_singleline(name); - push(&mut elems, "guides.save.name", &r, "Name"); - }); - ui.horizontal(|ui| { - let r = ui.button("Cancel"); - push(&mut elems, "guides.save.cancel", &r, "Cancel"); - close |= r.clicked(); - let r = ui.button("OK"); - push(&mut elems, "guides.save.ok", &r, "OK"); - if r.clicked() { - act = Some(("view.guideTemplates.save".into(), json!({"monitor": m, "name": name.clone()}))); - } - }); - } - GuideDialog::Manage { selected, source } => { - let m = if *source { "source" } else { "program" }; - if templates.is_empty() { - ui.label("No saved guide templates."); + egui::Window::new(shown).id(egui::Id::new(title)).collapsible(false).resizable(false).anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show( + ctx, + |ui| match &mut d { + GuideDialog::Add { vertical, position, source } => { + let m = if *source { "source" } else { "program" }; + ui.horizontal(|ui| { + ui.label(tl!("Orientation:")); + let r = ui.radio(*vertical, tl!("Vertical")); + push(&mut elems, "guides.add.vertical", &r, "Vertical"); + if r.clicked() { + *vertical = true; + } + let r = ui.radio(!*vertical, tl!("Horizontal")); + push(&mut elems, "guides.add.horizontal", &r, "Horizontal"); + if r.clicked() { + *vertical = false; + } + }); + ui.horizontal(|ui| { + ui.label(tl!("Position:")); + let r = ui.add(egui::DragValue::new(position).speed(1.0).suffix(" px")); + push(&mut elems, "guides.add.position", &r, "Position"); + }); + ui.horizontal(|ui| { + let r = ui.button(tl!("Cancel")); + push(&mut elems, "guides.add.cancel", &r, "Cancel"); + close |= r.clicked(); + let r = ui.button(tl!("OK")); + push(&mut elems, "guides.add.ok", &r, "OK"); + if r.clicked() { + act = Some(( + "view.addGuide".into(), + json!({"monitor": m, "orientation": if *vertical { "vertical" } else { "horizontal" }, "position": *position}), + )); + } + }); } - for (i, n) in templates.iter().enumerate() { - let r = ui.selectable_label(*selected == Some(i), n); - push(&mut elems, &format!("guides.manage.row.{i}"), &r, n); - if r.clicked() { - *selected = Some(i); - } + GuideDialog::SaveTemplate { name, source } => { + let m = if *source { "source" } else { "program" }; + ui.horizontal(|ui| { + ui.label(tl!("Name:")); + let r = ui.text_edit_singleline(name); + push(&mut elems, "guides.save.name", &r, "Name"); + }); + ui.horizontal(|ui| { + let r = ui.button(tl!("Cancel")); + push(&mut elems, "guides.save.cancel", &r, "Cancel"); + close |= r.clicked(); + let r = ui.button(tl!("OK")); + push(&mut elems, "guides.save.ok", &r, "OK"); + if r.clicked() { + act = Some(("view.guideTemplates.save".into(), json!({"monitor": m, "name": name.clone()}))); + } + }); } - ui.separator(); - ui.horizontal(|ui| { - let sel = selected.and_then(|i| templates.get(i)).cloned(); - let r = ui.add_enabled(sel.is_some(), egui::Button::new("Apply")); - push(&mut elems, "guides.manage.apply", &r, "Apply"); - if r.clicked() - && let Some(n) = &sel - { - act = Some(("view.guideTemplates.apply".into(), json!({"monitor": m, "name": n}))); + GuideDialog::Manage { selected, source } => { + let m = if *source { "source" } else { "program" }; + if templates.is_empty() { + ui.label(tl!("No saved guide templates.")); } - let r = ui.add_enabled(sel.is_some(), egui::Button::new("Delete")); - push(&mut elems, "guides.manage.delete", &r, "Delete"); - if r.clicked() - && let Some(n) = &sel - { - act = Some(("view.guideTemplates.delete".into(), json!({"name": n}))); - *selected = None; + for (i, n) in templates.iter().enumerate() { + let r = ui.selectable_label(*selected == Some(i), n); + push(&mut elems, &format!("guides.manage.row.{i}"), &r, n); + if r.clicked() { + *selected = Some(i); + } } - let r = ui.button("Close"); - push(&mut elems, "guides.manage.close", &r, "Close"); - close |= r.clicked(); - }); - } - }); + ui.separator(); + ui.horizontal(|ui| { + let sel = selected.and_then(|i| templates.get(i)).cloned(); + let r = ui.add_enabled(sel.is_some(), egui::Button::new(tl!("Apply"))); + push(&mut elems, "guides.manage.apply", &r, "Apply"); + if r.clicked() + && let Some(n) = &sel + { + act = Some(("view.guideTemplates.apply".into(), json!({"monitor": m, "name": n}))); + } + let r = ui.add_enabled(sel.is_some(), egui::Button::new(tl!("Delete"))); + push(&mut elems, "guides.manage.delete", &r, "Delete"); + if r.clicked() + && let Some(n) = &sel + { + act = Some(("view.guideTemplates.delete".into(), json!({"name": n}))); + *selected = None; + } + let r = ui.button(tl!("Close")); + push(&mut elems, "guides.manage.close", &r, "Close"); + close |= r.clicked(); + }); + } + }, + ); for (id, r, l) in elems { app.auto.add(&id, r, &l); } diff --git a/crates/ui-egui/src/panels/multicam.rs b/crates/ui-egui/src/panels/multicam.rs index 5cec515b..b5403b3d 100644 --- a/crates/ui-egui/src/panels/multicam.rs +++ b/crates/ui-egui/src/panels/multicam.rs @@ -53,7 +53,7 @@ pub fn route(app: &mut FilmcraftApp, id: &str, params: &Value) -> Option { app.ui.multicam_record = params.get("enabled").and_then(Value::as_bool).unwrap_or(!app.ui.multicam_record); - app.ui.status = format!("Multi-Camera Record {}", if app.ui.multicam_record { "on" } else { "off" }); + app.ui.status = if app.ui.multicam_record { tl!("Multi-Camera Record on") } else { tl!("Multi-Camera Record off") }.to_string(); return Some(Ok(json!({"record": app.ui.multicam_record}))); } _ => {} @@ -73,8 +73,8 @@ pub fn route(app: &mut FilmcraftApp, id: &str, params: &Value) -> Option = app.session.state.project_selection.iter().map(|i| i.0).collect(); let first = app.session.state.project_selection.first().and_then(|i| app.session.project.item(*i)).map(|i| i.name.clone()).unwrap_or_default(); let name = match kind { - "merge" => format!("{first} - Merged"), - "multicam" => format!("{first} Multicam"), + "merge" => tlf!("{first} - Merged", first), + "multicam" => tlf!("{first} Multicam", first), _ => String::new(), }; app.ui.sync_dialog = Some(SyncDraft { kind: kind.into(), items, name, ..Default::default() }); @@ -85,120 +85,131 @@ fn push(elems: &mut Elems, id: impl Into, r: &egui::Response, label: imp elems.push((id.into(), r.rect, label.into())); } -const METHODS: [(&str, &str); 5] = [("in", "In Points"), ("out", "Out Points"), ("timecode", "Timecode"), ("marker", "Clip Marker"), ("audio", "Audio")]; +/// Synchronize Point choices: (key, label). +fn methods() -> [(&'static str, &'static str); 5] { + [("in", tl!("In Points")), ("out", tl!("Out Points")), ("timecode", tl!("Timecode")), ("marker", tl!("Clip Marker")), ("audio", tl!("Audio"))] +} /// Draw the open Synchronize / Merge Clips / Create Multi-Camera dialog. pub fn show_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { let Some(mut d) = app.ui.sync_dialog.clone() else { return }; let (title, pre) = match d.kind.as_str() { - "merge" => ("Merge Clips", "merge"), - "multicam" => ("Create Multi-Camera Source Sequence", "mcam"), - _ => ("Synchronize Clips", "sync"), + "merge" => (tl!("Merge Clips"), "merge"), + "multicam" => (tl!("Create Multi-Camera Source Sequence"), "mcam"), + _ => (tl!("Synchronize Clips"), "sync"), }; let mut elems: Elems = Vec::new(); let mut action: Option<&str> = None; let accent = app.tokens.accent; let names: Vec = d.items.iter().filter_map(|i| app.session.project.item(ItemId(*i)).map(|it| it.name.clone())).collect(); - egui::Window::new(title).collapsible(false).resizable(false).default_width(440.0).anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { - if pre != "sync" { - ui.horizontal(|ui| { - ui.label(if pre == "merge" { "Clip Name:" } else { "Sequence Name:" }); - let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(300.0)); - push(&mut elems, format!("{pre}.name"), &r, "name"); - }); - ui.label(RichText::new(format!("{} clip(s): {}", names.len(), names.join(", "))).weak().small()); - ui.add_space(4.0); - } else { - let n = app.session.state.selection.len(); - ui.label(RichText::new(format!("{n} clip(s) selected; the clip on the reference track stays put.")).weak().small()); - } - ui.group(|ui| { - ui.set_min_width(400.0); - ui.label(RichText::new("Synchronize Point").strong()); - for (m, label) in METHODS { + egui::Window::new(title) + .id(egui::Id::new(("sync-dialog", pre))) + .collapsible(false) + .resizable(false) + .default_width(440.0) + .anchor(Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + if pre != "sync" { ui.horizontal(|ui| { - let r = ui.radio(d.method == m, label); - push(&mut elems, format!("{pre}.method.{m}"), &r, label); - if r.clicked() { - d.method = m.into(); - } - match m { - "timecode" => { - let r = ui.add_enabled(d.method == "timecode", egui::Checkbox::new(&mut d.ignore_hours, "Ignore Hours")); - push(&mut elems, format!("{pre}.ignoreHours"), &r, "Ignore Hours"); - } - "marker" => { - let r = - ui.add_enabled(d.method == "marker", egui::TextEdit::singleline(&mut d.marker).hint_text("first marker").desired_width(140.0)); - push(&mut elems, format!("{pre}.marker"), &r, "marker name"); - } - _ => {} - } + ui.label(if pre == "merge" { tl!("Clip Name:") } else { tl!("Sequence Name:") }); + let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(300.0)); + push(&mut elems, format!("{pre}.name"), &r, "name"); }); + ui.label(RichText::new(tlf!("{n} clip(s): {names}", n = names.len(), names = names.join(", "))).weak().small()); + ui.add_space(4.0); + } else { + let n = app.session.state.selection.len(); + ui.label(RichText::new(tlf!("{n} clip(s) selected; the clip on the reference track stays put.", n)).weak().small()); } - ui.horizontal(|ui| { - ui.label(if pre == "merge" { "Offset Audio by:" } else { "Offset:" }); - let r = ui.add(egui::DragValue::new(&mut d.offset).range(-100_000..=100_000).suffix(" frames")); - push(&mut elems, format!("{pre}.offset"), &r, "offset"); - }); - }); - match pre { - "sync" => { - ui.horizontal(|ui| { - ui.label("Reference track:"); - let r = ui.add(egui::TextEdit::singleline(&mut d.track).hint_text("lowest (V1…)").desired_width(90.0)); - push(&mut elems, "sync.track", &r, "reference track"); - }); - } - "merge" => { - let r = ui.checkbox(&mut d.remove_video_audio, "Remove Audio from AV Clip"); - push(&mut elems, "merge.removeVideoAudio", &r, "Remove Audio from AV Clip"); - } - _ => { - ui.group(|ui| { - ui.set_min_width(400.0); - ui.label(RichText::new("Audio").strong()); + ui.group(|ui| { + ui.set_min_width(400.0); + ui.label(RichText::new(tl!("Synchronize Point")).strong()); + for (m, label) in methods() { ui.horizontal(|ui| { - for (a, label) in [("camera1", "Camera 1"), ("all", "All Cameras"), ("switch", "Switch Audio")] { - let r = ui.radio(d.audio == a, label); - push(&mut elems, format!("mcam.audio.{a}"), &r, label); - if r.clicked() { - d.audio = a.into(); - } + let r = ui.radio(d.method == m, label); + push(&mut elems, format!("{pre}.method.{m}"), &r, label); + if r.clicked() { + d.method = m.into(); } - }); - ui.label(RichText::new("Camera Names").strong()); - ui.horizontal(|ui| { - for (a, label) in [("clip", "Clip Names"), ("track", "Enumerate Cameras"), ("metadata", "Camera Angle Metadata")] { - let r = ui.radio(d.camera_names == a, label); - push(&mut elems, format!("mcam.cameraNames.{a}"), &r, label); - if r.clicked() { - d.camera_names = a.into(); + match m { + "timecode" => { + let r = ui.add_enabled(d.method == "timecode", egui::Checkbox::new(&mut d.ignore_hours, tl!("Ignore Hours"))); + push(&mut elems, format!("{pre}.ignoreHours"), &r, "Ignore Hours"); } + "marker" => { + let r = ui.add_enabled( + d.method == "marker", + egui::TextEdit::singleline(&mut d.marker).hint_text(tl!("first marker")).desired_width(140.0), + ); + push(&mut elems, format!("{pre}.marker"), &r, "marker name"); + } + _ => {} } }); + } + ui.horizontal(|ui| { + ui.label(if pre == "merge" { tl!("Offset Audio by:") } else { tl!("Offset:") }); + let r = ui.add(egui::DragValue::new(&mut d.offset).range(-100_000..=100_000).suffix(tl!(" frames"))); + push(&mut elems, format!("{pre}.offset"), &r, "offset"); }); - let r = ui.checkbox(&mut d.processed_bin, "Move source clips to Processed Clips bin"); - push(&mut elems, "mcam.processedBin", &r, "Move source clips to Processed Clips bin"); + }); + match pre { + "sync" => { + ui.horizontal(|ui| { + ui.label(tl!("Reference track:")); + let r = ui.add(egui::TextEdit::singleline(&mut d.track).hint_text(tl!("lowest (V1…)")).desired_width(90.0)); + push(&mut elems, "sync.track", &r, "reference track"); + }); + } + "merge" => { + let r = ui.checkbox(&mut d.remove_video_audio, tl!("Remove Audio from AV Clip")); + push(&mut elems, "merge.removeVideoAudio", &r, "Remove Audio from AV Clip"); + } + _ => { + ui.group(|ui| { + ui.set_min_width(400.0); + ui.label(RichText::new(tl!("Audio")).strong()); + ui.horizontal(|ui| { + for (a, label) in [("camera1", tl!("Camera 1")), ("all", tl!("All Cameras")), ("switch", tl!("Switch Audio"))] { + let r = ui.radio(d.audio == a, label); + push(&mut elems, format!("mcam.audio.{a}"), &r, label); + if r.clicked() { + d.audio = a.into(); + } + } + }); + ui.label(RichText::new(tl!("Camera Names")).strong()); + ui.horizontal(|ui| { + for (a, label) in [("clip", tl!("Clip Names")), ("track", tl!("Enumerate Cameras")), ("metadata", tl!("Camera Angle Metadata"))] { + let r = ui.radio(d.camera_names == a, label); + push(&mut elems, format!("mcam.cameraNames.{a}"), &r, label); + if r.clicked() { + d.camera_names = a.into(); + } + } + }); + }); + let r = ui.checkbox(&mut d.processed_bin, tl!("Move source clips to Processed Clips bin")); + push(&mut elems, "mcam.processedBin", &r, "Move source clips to Processed Clips bin"); + } } - } - if !d.message.is_empty() { - ui.colored_label(Color32::from_rgb(0xe0, 0x8a, 0x6a), &d.message); - } - ui.add_space(6.0); - ui.horizontal(|ui| { - let r = ui.button("Cancel"); - push(&mut elems, format!("{pre}.cancel"), &r, "Cancel"); - if r.clicked() { - action = Some("cancel"); - } - let r = ui.add(egui::Button::new(RichText::new("OK").color(Color32::WHITE)).fill(accent)); - push(&mut elems, format!("{pre}.ok"), &r, "OK"); - if r.clicked() { - action = Some("ok"); + if !d.message.is_empty() { + ui.colored_label(Color32::from_rgb(0xe0, 0x8a, 0x6a), &d.message); } + ui.add_space(6.0); + ui.horizontal(|ui| { + let r = ui.button(tl!("Cancel")); + push(&mut elems, format!("{pre}.cancel"), &r, "Cancel"); + if r.clicked() { + action = Some("cancel"); + } + let r = ui.add(egui::Button::new(RichText::new(tl!("OK")).color(Color32::WHITE)).fill(accent)); + push(&mut elems, format!("{pre}.ok"), &r, "OK"); + if r.clicked() { + action = Some("ok"); + } + }); }); - }); for (id, r, l) in elems { app.auto.add(&id, r, &l); } @@ -244,9 +255,9 @@ pub fn show_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { match app.session.execute(cmd, p) { Ok(v) => { app.ui.status = match pre { - "sync" => format!("Synchronized {} clip group(s)", v["moved"]), - "merge" => "Merged clip created".into(), - _ => format!("Multi-camera source sequence with {} camera(s)", v["cameras"].as_array().map_or(0, Vec::len)), + "sync" => tlf!("Synchronized {n} clip group(s)", n = v["moved"]), + "merge" => tl!("Merged clip created").into(), + _ => tlf!("Multi-camera source sequence with {n} camera(s)", n = v["cameras"].as_array().map_or(0, Vec::len)), }; app.ui.sync_dialog = None; return; @@ -284,7 +295,7 @@ pub fn grid(app: &mut FilmcraftApp, ui: &mut egui::Ui, area: Rect) { let playhead = app.session.playhead(); let info = filmcraft_engine::multicam::inspect_at(&app.session, playhead); let Some(src) = info["source"].as_u64().map(ItemId) else { - ui.painter().text(area.center(), Align2::CENTER_CENTER, "No multi-camera clip at the playhead", Tokens::ui(12.0), t.text_dim); + ui.painter().text(area.center(), Align2::CENTER_CENTER, tl!("No multi-camera clip at the playhead"), Tokens::ui(12.0), t.text_dim); return; }; let Some(q) = app.session.project.sequence(src) else { return }; @@ -339,7 +350,7 @@ pub fn grid(app: &mut FilmcraftApp, ui: &mut egui::Ui, area: Rect) { let cell_i = k - lay.first; let cell = Rect::from_min_size(pic.min + vec2((cell_i % cols) as f32 * cw, (cell_i / cols) as f32 * ch), vec2(cw, ch)); let name = cams.cameras.get(angle).map(|c| c.name.clone()).unwrap_or_default(); - let resp = ui.interact(cell, egui::Id::new(("multicam-angle", k)), Sense::click()).on_hover_text(format!("Camera {} — {name}", k + 1)); + let resp = ui.interact(cell, egui::Id::new(("multicam-angle", k)), Sense::click()).on_hover_text(tlf!("Camera {n} — {name}", n = k + 1, name)); app.auto.add(&format!("program.multicam.angle.{}", k + 1), cell, &name); if resp.clicked() { // modifiers held now, or carried by the click event (synthetic input) @@ -366,11 +377,15 @@ pub fn grid(app: &mut FilmcraftApp, ui: &mut egui::Ui, area: Rect) { if paged { let bar = Rect::from_min_max(pos2(area.min.x, area.max.y - 22.0), area.max); let mid = bar.center(); - ui.painter().text(mid, Align2::CENTER_CENTER, format!("Page {} of {}", lay.page + 1, lay.pages), Tokens::ui(11.0), t.text_dim); + ui.painter().text(mid, Align2::CENTER_CENTER, tlf!("Page {page} of {pages}", page = lay.page + 1, pages = lay.pages), Tokens::ui(11.0), t.text_dim); for (id, label, dx, cmd) in [("pagePrev", "◀", -70.0, "multicam.prevPage"), ("pageNext", "▶", 70.0, "multicam.nextPage")] { let r = Rect::from_center_size(mid + vec2(dx, 0.0), vec2(26.0, 18.0)); let enabled = if cmd == "multicam.prevPage" { lay.page > 0 } else { lay.page + 1 < lay.pages }; - let resp = ui.interact(r, egui::Id::new(("multicam-page", id)), Sense::click()).on_hover_text(if dx < 0.0 { "Previous page" } else { "Next page" }); + let resp = ui.interact(r, egui::Id::new(("multicam-page", id)), Sense::click()).on_hover_text(if dx < 0.0 { + tl!("Previous page") + } else { + tl!("Next page") + }); ui.painter().rect_filled(r, 3.0, if resp.hovered() && enabled { t.hover } else { t.field_bg }); ui.painter().text(r.center(), Align2::CENTER_CENTER, label, Tokens::ui(10.0), if enabled { t.text } else { t.text_faint }); app.auto.add(&format!("program.multicam.{id}"), r, label); @@ -382,7 +397,7 @@ pub fn grid(app: &mut FilmcraftApp, ui: &mut egui::Ui, area: Rect) { // record indicator let rr = Rect::from_min_size(area.min + vec2(8.0, 8.0), vec2(64.0, 18.0)); let rec_on = app.ui.multicam_record; - let resp = ui.interact(rr, egui::Id::new("multicam-record"), Sense::click()).on_hover_text("Multi-Camera Record On/Off (0)"); + let resp = ui.interact(rr, egui::Id::new("multicam-record"), Sense::click()).on_hover_text(tl!("Multi-Camera Record On/Off (0)")); app.auto.add("program.multicam.record", rr, "Multi-Camera Record"); let dot = if recording { Color32::from_rgb(0xe0, 0x3a, 0x3a) @@ -393,7 +408,7 @@ pub fn grid(app: &mut FilmcraftApp, ui: &mut egui::Ui, area: Rect) { }; ui.painter().rect_filled(rr, 3.0, Color32::from_black_alpha(140)); ui.painter().circle_filled(rr.left_center() + vec2(9.0, 0.0), 4.5, dot); - ui.painter().text(rr.left_center() + vec2(18.0, 0.0), Align2::LEFT_CENTER, if recording { "REC" } else { "Record" }, Tokens::ui(10.5), Color32::WHITE); + ui.painter().text(rr.left_center() + vec2(18.0, 0.0), Align2::LEFT_CENTER, if recording { "REC" } else { tl!("Record") }, Tokens::ui(10.5), Color32::WHITE); if resp.clicked() { app.ui.multicam_record = !app.ui.multicam_record; } @@ -435,45 +450,51 @@ pub fn show_edit_cameras(app: &mut FilmcraftApp, ctx: &egui::Context) { } let th = thumb_w * q.settings.height.max(1) as f32 / q.settings.width.max(1) as f32; let tokens = app.tokens; - egui::Window::new("Edit Cameras").collapsible(false).resizable(false).default_width(420.0).anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { - ui.label(RichText::new("Cameras shown in the Multi-Camera view (uncheck to hide).").weak().small()); - egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| { - for a in 0..d.names.len() { - ui.horizontal(|ui| { - let r = ui.checkbox(&mut d.enabled[a], ""); - push(&mut elems, format!("editCameras.enabled.{a}"), &r, "Enabled"); - let (tr, _) = ui.allocate_exact_size(vec2(thumb_w, th), Sense::hover()); - ui.painter().rect_filled(tr, 2.0, tokens.monitor_bg); - match thumbs[a] { - (_, Some(tex)) => { - ui.painter().image(tex, tr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + egui::Window::new(tl!("Edit Cameras")) + .id(egui::Id::new("Edit Cameras")) + .collapsible(false) + .resizable(false) + .default_width(420.0) + .anchor(Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.label(RichText::new(tl!("Cameras shown in the Multi-Camera view (uncheck to hide).")).weak().small()); + egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| { + for a in 0..d.names.len() { + ui.horizontal(|ui| { + let r = ui.checkbox(&mut d.enabled[a], ""); + push(&mut elems, format!("editCameras.enabled.{a}"), &r, "Enabled"); + let (tr, _) = ui.allocate_exact_size(vec2(thumb_w, th), Sense::hover()); + ui.painter().rect_filled(tr, 2.0, tokens.monitor_bg); + match thumbs[a] { + (_, Some(tex)) => { + ui.painter().image(tex, tr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + (false, None) => { + ui.painter().text(tr.center(), Align2::CENTER_CENTER, tl!("Audio"), Tokens::ui(10.0), tokens.text_dim); + } + _ => {} } - (false, None) => { - ui.painter().text(tr.center(), Align2::CENTER_CENTER, "Audio", Tokens::ui(10.0), tokens.text_dim); - } - _ => {} - } - elems.push((format!("editCameras.thumb.{a}"), tr, format!("Camera {}", a + 1))); - ui.label(format!("{}", a + 1)); - let r = ui.add(egui::TextEdit::singleline(&mut d.names[a]).desired_width(180.0)); - push(&mut elems, format!("editCameras.name.{a}"), &r, "Camera name"); - }); - } + elems.push((format!("editCameras.thumb.{a}"), tr, format!("Camera {}", a + 1))); + ui.label(format!("{}", a + 1)); + let r = ui.add(egui::TextEdit::singleline(&mut d.names[a]).desired_width(180.0)); + push(&mut elems, format!("editCameras.name.{a}"), &r, "Camera name"); + }); + } + }); + ui.add_space(6.0); + ui.horizontal(|ui| { + let r = ui.button(tl!("Cancel")); + push(&mut elems, "editCameras.cancel", &r, "Cancel"); + if r.clicked() { + action = Some("cancel"); + } + let r = ui.add(egui::Button::new(RichText::new(tl!("OK")).color(Color32::WHITE)).fill(tokens.accent)); + push(&mut elems, "editCameras.ok", &r, "OK"); + if r.clicked() { + action = Some("ok"); + } + }); }); - ui.add_space(6.0); - ui.horizontal(|ui| { - let r = ui.button("Cancel"); - push(&mut elems, "editCameras.cancel", &r, "Cancel"); - if r.clicked() { - action = Some("cancel"); - } - let r = ui.add(egui::Button::new(RichText::new("OK").color(Color32::WHITE)).fill(tokens.accent)); - push(&mut elems, "editCameras.ok", &r, "OK"); - if r.clicked() { - action = Some("ok"); - } - }); - }); for (id, r, l) in elems { app.auto.add(&id, r, &l); } @@ -511,7 +532,7 @@ pub fn on_stop(app: &mut FilmcraftApp) { if app.session.mcrec.active() { let t = app.session.playhead(); match app.session.execute("multicam.recordStop", json!({"time": t.0})) { - Ok(v) if v["cuts"].as_u64().unwrap_or(0) > 0 => app.ui.status = format!("Recorded {} multi-camera cut(s)", v["cuts"]), + Ok(v) if v["cuts"].as_u64().unwrap_or(0) > 0 => app.ui.status = tlf!("Recorded {n} multi-camera cut(s)", n = v["cuts"]), Ok(_) => {} Err(e) => app.ui.status = e.to_string(), } diff --git a/crates/ui-egui/src/panels/presets.rs b/crates/ui-egui/src/panels/presets.rs index 46b94db9..61e6bb18 100644 --- a/crates/ui-egui/src/panels/presets.rs +++ b/crates/ui-egui/src/panels/presets.rs @@ -18,7 +18,7 @@ pub fn folder_rows(app: &mut FilmcraftApp, ui: &mut egui::Ui, filter: &str) -> O let t = app.tokens; let mut action = None; for p in app.session.presets.all() { - if !filter.is_empty() && !p.name.to_ascii_lowercase().contains(filter) { + if !crate::i18n::matches_query(&p.name, filter) { continue; } let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 20.0), Sense::click_and_drag()); @@ -32,9 +32,9 @@ pub fn folder_rows(app: &mut FilmcraftApp, ui: &mut egui::Ui, filter: &str) -> O Icon::Sparkle, if p.builtin { t.text_dim } else { t.accent }, ); - ui.painter().text(pos2(x + 12.0, r.center().y), Align2::LEFT_CENTER, &p.name, Tokens::ui(12.0), t.text); + ui.painter().text(pos2(x + 12.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(&p.name), Tokens::ui(12.0), t.text); app.auto.add(&format!("effects.preset.{}", p.name), r, &p.name); - let tip = if p.description.is_empty() { p.name.clone() } else { p.description.clone() }; + let tip = crate::i18n::t(if p.description.is_empty() { &p.name } else { &p.description }).to_string(); let resp = resp.on_hover_text(tip); if resp.drag_started() { crate::panels::start_drag_effect(ui, &format!("preset:{}", p.name)); @@ -43,15 +43,15 @@ pub fn folder_rows(app: &mut FilmcraftApp, ui: &mut egui::Ui, filter: &str) -> O action = Some(("presets.apply".to_string(), p.name.clone())); } resp.context_menu(|ui| { - if !p.builtin && ui.button("Delete Preset").clicked() { + if !p.builtin && ui.button(tl!("Delete Preset")).clicked() { action = Some(("presets.delete".to_string(), p.name.clone())); ui.close(); } - if ui.button("Export Preset…").clicked() { + if ui.button(tl!("Export Preset…")).clicked() { action = Some(("presets.export".to_string(), p.name.clone())); ui.close(); } - if ui.button("Import Presets…").clicked() { + if ui.button(tl!("Import Presets…")).clicked() { action = Some(("presets.import".to_string(), String::new())); ui.close(); } @@ -66,11 +66,11 @@ pub fn run(app: &mut FilmcraftApp, cmd: &str, name: &str) { "presets.apply" => app.session.execute(cmd, json!({"preset": name})), "presets.delete" => app.session.execute(cmd, json!({"name": name})), "presets.export" => { - let Some(path) = app.hooks.pick_save_as.as_mut().and_then(|f| f("Effect presets", &["json"], &format!("{name}.json"))) else { return }; + let Some(path) = app.hooks.pick_save_as.as_mut().and_then(|f| f(tl!("Effect presets"), &["json"], &format!("{name}.json"))) else { return }; app.session.execute(cmd, json!({"path": path, "names": [name]})) } "presets.import" => { - let Some(path) = app.hooks.pick_open_file.as_mut().and_then(|f| f("Effect presets", &["json"])) else { return }; + let Some(path) = app.hooks.pick_open_file.as_mut().and_then(|f| f(tl!("Effect presets"), &["json"])) else { return }; app.session.execute(cmd, json!({"path": path})) } _ => return, @@ -90,35 +90,43 @@ pub fn save_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut open = true; let mut close = false; let mut apply = false; - egui::Window::new("Save Preset").open(&mut open).collapsible(false).resizable(false).default_width(340.0).show(ctx, |ui| { - ui.horizontal(|ui| { - ui.label("Name:"); - let r = ui.text_edit_singleline(&mut d.name); - app.auto.add("savePreset.name", r.rect, "Name"); - }); - ui.add_space(4.0); - ui.label(egui::RichText::new("Type").strong()); - for (key, label) in [("scale", "Scale"), ("anchorIn", "Anchor to In Point"), ("anchorOut", "Anchor to Out Point"), ("none", "Without keyframes")] { - let r = ui.radio(d.keyframes == key, label); - app.auto.add(&format!("savePreset.keyframes.{key}"), r.rect, label); - if r.clicked() { - d.keyframes = key.into(); + egui::Window::new(tl!("Save Preset")).id(egui::Id::new("Save Preset")).open(&mut open).collapsible(false).resizable(false).default_width(340.0).show( + ctx, + |ui| { + ui.horizontal(|ui| { + ui.label(tl!("Name:")); + let r = ui.text_edit_singleline(&mut d.name); + app.auto.add("savePreset.name", r.rect, "Name"); + }); + ui.add_space(4.0); + ui.label(egui::RichText::new(tl!("Type")).strong()); + for (key, label) in [ + ("scale", tl!("Scale")), + ("anchorIn", tl!("Anchor to In Point")), + ("anchorOut", tl!("Anchor to Out Point")), + ("none", tl!("Without keyframes")), + ] { + let r = ui.radio(d.keyframes == key, label); + app.auto.add(&format!("savePreset.keyframes.{key}"), r.rect, label); + if r.clicked() { + d.keyframes = key.into(); + } } - } - ui.add_space(4.0); - ui.label("Description:"); - let r = ui.text_edit_multiline(&mut d.description); - app.auto.add("savePreset.description", r.rect, "Description"); - ui.add_space(6.0); - ui.horizontal(|ui| { - let c = ui.button("Cancel"); - app.auto.add("savePreset.cancel", c.rect, "Cancel"); - let o = ui.add_enabled(!d.name.trim().is_empty(), egui::Button::new("OK")); - app.auto.add("savePreset.ok", o.rect, "OK"); - apply = o.clicked(); - close = apply || c.clicked(); - }); - }); + ui.add_space(4.0); + ui.label(tl!("Description:")); + let r = ui.text_edit_multiline(&mut d.description); + app.auto.add("savePreset.description", r.rect, "Description"); + ui.add_space(6.0); + ui.horizontal(|ui| { + let c = ui.button(tl!("Cancel")); + app.auto.add("savePreset.cancel", c.rect, "Cancel"); + let o = ui.add_enabled(!d.name.trim().is_empty(), egui::Button::new(tl!("OK"))); + app.auto.add("savePreset.ok", o.rect, "OK"); + apply = o.clicked(); + close = apply || c.clicked(); + }); + }, + ); app.ui.save_preset = if close || !open { None } else { Some(d.clone()) }; if apply { let r = app.session.execute( @@ -126,7 +134,7 @@ pub fn save_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { json!({"clip": d.clip, "effects": d.effects, "name": d.name.trim(), "description": d.description, "keyframes": d.keyframes}), ); match r { - Ok(_) => app.ui.status = format!("Saved preset \u{201c}{}\u{201d}", d.name.trim()), + Ok(_) => app.ui.status = tlf!("Saved preset “{name}”", name = d.name.trim()), Err(e) => app.ui.status = e.to_string(), } } diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 399a12b8..09b04069 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -277,10 +277,10 @@ fn tab_strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, strip: Rect, docked: &[u ui.painter().line_segment([strip.left_bottom(), strip.right_bottom()], Stroke::new(1.0, t.separator)); let mut x = strip.min.x + 6.0; let active = shown_inst(app); - let mut entries: Vec<(Option, String)> = vec![(None, format!("Project: {}", app.session.project.name))]; + let mut entries: Vec<(Option, String)> = vec![(None, tlf!("Project: {name}", name = app.session.project.name))]; for k in docked { let name = app.session.project.root.find_bin(BinId(app.ui.project_panel.tabs[*k].bin)).map(|b| b.name.clone()).unwrap_or_default(); - entries.push((Some(*k), format!("Bin: {name}"))); + entries.push((Some(*k), tlf!("Bin: {name}", name))); } let mut close = None; for (k, label) in entries { @@ -304,7 +304,7 @@ fn tab_strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, strip: Rect, docked: &[u } if let Some(k) = k { let cr = Rect::from_center_size(pos2(r.max.x - 11.0, r.center().y), vec2(12.0, 12.0)); - let cresp = ui.interact(cr, egui::Id::new(("ptab-close", k)), Sense::click()).on_hover_text("Close"); + let cresp = ui.interact(cr, egui::Id::new(("ptab-close", k)), Sense::click()).on_hover_text(tl!("Close")); app.auto.add(&format!("project.tab.{k}.close"), cr, "Close"); icons::paint(ui.painter(), cr.shrink(2.0), Icon::Close, if cresp.hovered() { t.text } else { t.text_dim }); if cresp.clicked() || resp.middle_clicked() { @@ -334,7 +334,7 @@ pub fn floating(app: &mut FilmcraftApp, ctx: &egui::Context) { }; let mut open = true; let root = app.ui.project_panel.tabs[k].bin == app.session.project.root.id.0; - egui::Window::new(if root { format!("Project: {}", app.session.project.name) } else { format!("Bin: {name}") }) + egui::Window::new(if root { tlf!("Project: {name}", name = app.session.project.name) } else { tlf!("Bin: {name}", name) }) .id(egui::Id::new(("bin-window", k))) .open(&mut open) .default_size(vec2(520.0, 360.0)) @@ -380,7 +380,7 @@ fn body(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &View, actions Inst::Tab(k) => BinId(app.ui.project_panel.tabs[k].bin), }; let up = Rect::from_min_size(pos2(head.min.x, head.center().y - 9.0), vec2(22.0, 18.0)); - let up_resp = ui.interact(up, egui::Id::new((&pre, "up")), Sense::click()).on_hover_text("Up one level"); + let up_resp = ui.interact(up, egui::Id::new((&pre, "up")), Sense::click()).on_hover_text(tl!("Up one level")); app.auto.add(&format!("{pre}.up"), up, "Up one level"); let can_up = v.bin != top; if up_resp.hovered() && can_up { @@ -408,11 +408,11 @@ fn body(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &View, actions let search_rect = Rect::from_min_size(row.min, vec2(sw, 22.0)); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(search_rect).id_salt((&pre, "search"))); let mut q = app.ui.project_search.clone(); - crate::widgets::search_field(&mut child, &mut q, "Search", search_rect.width(), &t); + crate::widgets::search_field(&mut child, &mut q, tl!("Search"), search_rect.width(), &t); app.ui.project_search = q; app.auto.add(&format!("{pre}.search"), search_rect, "Search"); let find = Rect::from_min_size(pos2(search_rect.max.x + 8.0, row.min.y), vec2(24.0, 22.0)); - let fr = ui.interact(find, egui::Id::new((&pre, "find")), Sense::click()).on_hover_text("Find…"); + let fr = ui.interact(find, egui::Id::new((&pre, "find")), Sense::click()).on_hover_text(tl!("Find…")); app.auto.add(&format!("{pre}.find"), find, "Find…"); if fr.hovered() { ui.painter().rect_filled(find, 3.0, t.hover); @@ -424,7 +424,7 @@ fn body(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &View, actions let filter = app.ui.project_search.to_ascii_lowercase(); let total = count_items(app, v.bin); let sel = app.session.state.project_selection.len(); - let count = if sel > 0 { format!("{sel} of {total} items selected") } else { format!("{total} items") }; + let count = if sel > 0 { tlf!("{sel} of {total} items selected", sel, total) } else { tlf!("{total} items", total) }; ui.painter().text(pos2(row.max.x, row.center().y - 1.0), Align2::RIGHT_CENTER, &count, Tokens::ui(fs - 1.0), t.text_dim); app.auto.add(&format!("{pre}.count"), Rect::from_min_max(pos2(row.max.x - 160.0, row.min.y), row.max), &count); @@ -495,7 +495,7 @@ fn preview_area(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, pre: &str) { ui.painter().rect_filled(r, 4.0, t.tl_header_bg); app.auto.add(&format!("{pre}.previewArea"), r, "Preview Area"); let Some(id) = app.session.state.project_selection.first().copied() else { - ui.painter().text(r.center(), Align2::CENTER_CENTER, "Select an item to preview it", Tokens::ui(11.0), t.text_faint); + ui.painter().text(r.center(), Align2::CENTER_CENTER, tl!("Select an item to preview it"), Tokens::ui(11.0), t.text_faint); return; }; let Some(it) = app.session.project.item(id).cloned() else { return }; @@ -513,10 +513,10 @@ fn preview_area(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, pre: &str) { let (vu, au) = filmcraft_engine::project_panel::usage(p, id); let lines = [ it.name.clone(), - format!("{}{}", it.type_label(), if it.has_video() { format!(", {}", col("Video Info")) } else { String::new() }), + format!("{}{}", crate::i18n::t(it.type_label()), if it.has_video() { format!(", {}", col("Video Info")) } else { String::new() }), [col("Media Duration"), col("Frame Rate")].into_iter().filter(|s| !s.is_empty()).collect::>().join(", "), col("Audio Info"), - format!("Video Usage: {vu} Audio Usage: {au}"), + tlf!("Video Usage: {vu} Audio Usage: {au}", vu, au), ]; let mut y = r.min.y + 12.0; for (i, l) in lines.iter().enumerate() { @@ -539,9 +539,11 @@ fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, v: &View, action let t = app.tokens; let pre = &v.prefix; let mut x = bar.min.x + 6.0; - for (icon, mode, tip) in - [(Icon::ListView, ViewMode::List, "List View"), (Icon::IconView, ViewMode::Icon, "Icon View"), (Icon::Freeform, ViewMode::Freeform, "Freeform View")] - { + for (icon, mode, tip) in [ + (Icon::ListView, ViewMode::List, tl!("List View")), + (Icon::IconView, ViewMode::Icon, tl!("Icon View")), + (Icon::Freeform, ViewMode::Freeform, tl!("Freeform View")), + ] { let r = Rect::from_min_size(pos2(x, bar.min.y + 5.0), vec2(26.0, 22.0)); let resp = ui.interact(r, egui::Id::new((pre, "pv", tip)), Sense::click()).on_hover_text(tip); app.auto.add(&format!("{pre}.view.{mode:?}"), r, tip); @@ -575,7 +577,7 @@ fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, v: &View, action // Sort Icons (Icon view) if v.mode == ViewMode::Icon { let r = Rect::from_min_size(pos2(x, bar.min.y + 5.0), vec2(24.0, 22.0)); - let resp = ui.interact(r, egui::Id::new((pre, "sortIcons")), Sense::click()).on_hover_text("Sort Icons"); + let resp = ui.interact(r, egui::Id::new((pre, "sortIcons")), Sense::click()).on_hover_text(tl!("Sort Icons")); app.auto.add(&format!("{pre}.sortIcons"), r, "Sort Icons"); if resp.hovered() { ui.painter().rect_filled(r, 3.0, t.hover); @@ -585,11 +587,11 @@ fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, v: &View, action } let mut rx = bar.max.x - 6.0; for (icon, id, tip) in [ - (Icon::Trash, "project.delete", "Clear (Delete)"), - (Icon::NewItem, "new-item", "New Item"), - (Icon::Folder, "file.newBin", "New Bin"), - (Icon::Search, "find", "Find"), - (Icon::Automate, "clip.automateToSequence", "Automate to Sequence"), + (Icon::Trash, "project.delete", tl!("Clear (Delete)")), + (Icon::NewItem, "new-item", tl!("New Item")), + (Icon::Folder, "file.newBin", tl!("New Bin")), + (Icon::Search, "find", tl!("Find")), + (Icon::Automate, "clip.automateToSequence", tl!("Automate to Sequence")), ] { let r = Rect::from_min_size(pos2(rx - 24.0, bar.min.y + 5.0), vec2(24.0, 22.0)); let resp = ui.interact(r, egui::Id::new((pre, "pb", id)), Sense::click()).on_hover_text(tip); @@ -607,7 +609,7 @@ fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, v: &View, action let parent = (v.bin != app.session.project.root.id).then_some(v.bin.0); actions.push(("file.newBin".into(), json!({"name": "New Bin", "parent": parent}))); } - "project.delete" if app.session.state.project_selection.is_empty() => app.ui.status = "Select items to clear".into(), + "project.delete" if app.session.state.project_selection.is_empty() => app.ui.status = tl!("Select items to clear").into(), _ => actions.push((id.into(), json!({}))), } } @@ -628,7 +630,7 @@ pub const NEW_ITEMS: [(&str, &str); 8] = [ pub fn new_item_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, pre: &str, actions: &mut Actions) { for (label, c) in NEW_ITEMS { - let b = ui.button(label); + let b = ui.button(crate::i18n::t(label)); app.auto.add(&format!("{pre}.newItem.{c}"), b.rect, label); if b.clicked() { actions.push((c.into(), json!({}))); @@ -639,9 +641,9 @@ pub fn new_item_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, pre: &str, actio fn sort_icons_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, pre: &str, actions: &mut Actions) { let cur = app.session.prefs.project_panel.view.icon_sort.clone(); - let mut entries: Vec<(String, String)> = vec![("".into(), "User Order".into())]; + let mut entries: Vec<(String, String)> = vec![("".into(), tl!("User Order").into())]; for c in ["Name", "Label", "Media Type", "Frame Rate", "Media Start", "Media End", "Media Duration", "Video Info", "Audio Info", "Date Created"] { - entries.push((c.into(), c.into())); + entries.push((c.into(), crate::i18n::t(c).into())); } for (col, label) in entries { let on = cur.column == col; @@ -653,7 +655,7 @@ fn sort_icons_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, pre: &str, actions } } ui.separator(); - let r = ui.selectable_label(cur.descending, "Descending"); + let r = ui.selectable_label(cur.descending, tl!("Descending")); app.auto.add(&format!("{pre}.sortIcons.descending"), r.rect, "Descending"); if r.clicked() { actions.push(("project.view.set".into(), json!({"iconSort": {"descending": !cur.descending}}))); @@ -684,45 +686,47 @@ pub fn panel_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui) -> bool { app.auto.add(&format!("project.menu.{id}"), r.rect, label); r.clicked() }; - if item(ui, "closeProject", "Close Project", true, s_close.as_deref()) { + if item(ui, "closeProject", tl!("Close Project"), true, s_close.as_deref()) { actions.push(("file.closeProject".into(), json!({}))); } - item(ui, "closeAllOtherProjects", "Close All Other Projects", false, None); - if item(ui, "saveProject", "Save Project", true, s_save.as_deref()) { + item(ui, "closeAllOtherProjects", tl!("Close All Other Projects"), false, None); + if item(ui, "saveProject", tl!("Save Project"), true, s_save.as_deref()) { actions.push(("file.save".into(), json!({}))); } - item(ui, "refreshProject", "Refresh Project", false, None); - if item(ui, "revealProject", "Reveal Project in Finder…", has_project_path, None) { + item(ui, "refreshProject", tl!("Refresh Project"), false, None); + if item(ui, "revealProject", tl!("Reveal Project in Finder…"), has_project_path, None) { actions.push(("projectPanel.revealProject".into(), json!({}))); } ui.separator(); - if item(ui, "newBin", "New Bin", true, s_bin.as_deref()) { + if item(ui, "newBin", tl!("New Bin"), true, s_bin.as_deref()) { let parent = (v.bin != app.session.project.root.id).then_some(v.bin.0); actions.push(("file.newBin".into(), json!({"name": "New Bin", "parent": parent}))); } - if item(ui, "newBinFromSelection", "New Bin From Selection", !app.session.state.project_selection.is_empty(), Some("Shift+B")) { + if item(ui, "newBinFromSelection", tl!("New Bin From Selection"), !app.session.state.project_selection.is_empty(), Some("Shift+B")) { actions.push(("file.newBinFromSelection".into(), json!({}))); } - if item(ui, "newSearchBin", "New Search Bin", true, None) { + if item(ui, "newSearchBin", tl!("New Search Bin"), true, None) { actions.push(("file.newSearchBin".into(), json!({}))); } - item(ui, "newProjectShortcut", "New Project Shortcut", false, None); - if item(ui, "rename", "Rename", has_sel, None) { + item(ui, "newProjectShortcut", tl!("New Project Shortcut"), false, None); + if item(ui, "rename", tl!("Rename"), has_sel, None) { actions.push(("projectPanel.rename".into(), json!({}))); } - if item(ui, "delete", "Delete", !app.session.state.project_selection.is_empty(), Some("Backspace")) { + if item(ui, "delete", tl!("Delete"), !app.session.state.project_selection.is_empty(), Some("Backspace")) { actions.push(("project.delete".into(), json!({}))); } ui.separator(); - if item(ui, "automateToSequence", "Automate to Sequence…", !app.session.state.project_selection.is_empty(), None) { + if item(ui, "automateToSequence", tl!("Automate to Sequence…"), !app.session.state.project_selection.is_empty(), None) { actions.push(("clip.automateToSequence".into(), json!({}))); } - if item(ui, "find", "Find…", true, s_find.as_deref()) { + if item(ui, "find", tl!("Find…"), true, s_find.as_deref()) { actions.push(("edit.find".into(), json!({}))); } ui.separator(); } - for (mode, label, key) in [(ViewMode::List, "List", "Cmd+PageUp"), (ViewMode::Icon, "Icon", "Cmd+PageDown"), (ViewMode::Freeform, "Freeform", "")] { + for (mode, label, key) in + [(ViewMode::List, tl!("List"), "Cmd+PageUp"), (ViewMode::Icon, tl!("Icon"), "Cmd+PageDown"), (ViewMode::Freeform, tl!("Freeform"), "")] + { let r = ui.add(egui::Button::selectable(v.mode == mode, label).shortcut_text(key)); app.auto.add(&format!("project.menu.view.{mode:?}"), r.rect, label); if r.clicked() { @@ -732,11 +736,11 @@ pub fn panel_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui) -> bool { } ui.separator(); for (key, label, on, shortcut) in [ - ("previewArea", "Preview Area", pp.preview_area, ""), - ("thumbnails", "Thumbnails", pp.thumbnails, ""), - ("thumbnailsShowEffects", "Thumbnails show effects applied", pp.thumbnails_show_effects, ""), - ("hoverScrub", "Hover Scrub", pp.hover_scrub, "Shift+H"), - ("thumbnailControlsAllDevices", "Thumbnail controls for all pointing devices", pp.thumbnail_controls_all_devices, ""), + ("previewArea", tl!("Preview Area"), pp.preview_area, ""), + ("thumbnails", tl!("Thumbnails"), pp.thumbnails, ""), + ("thumbnailsShowEffects", tl!("Thumbnails show effects applied"), pp.thumbnails_show_effects, ""), + ("hoverScrub", tl!("Hover Scrub"), pp.hover_scrub, "Shift+H"), + ("thumbnailControlsAllDevices", tl!("Thumbnail controls for all pointing devices"), pp.thumbnail_controls_all_devices, ""), ] { let r = ui.add(egui::Button::selectable(on, label).shortcut_text(shortcut)); app.auto.add(&format!("project.menu.{key}"), r.rect, label); @@ -746,9 +750,9 @@ pub fn panel_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui) -> bool { } } ui.separator(); - let fr = ui.menu_button("Font Size", |ui| { + let fr = ui.menu_button(tl!("Font Size"), |ui| { for f in FontSize::ALL { - let r = ui.selectable_label(pp.view.font_size == f, f.label()); + let r = ui.selectable_label(pp.view.font_size == f, crate::i18n::t(f.label())); app.auto.add(&format!("project.menu.fontSize.{f:?}"), r.rect, f.label()); if r.clicked() { actions.push(("project.view.set".into(), json!({"fontSize": f}))); @@ -758,27 +762,27 @@ pub fn panel_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui) -> bool { }); app.auto.add("project.menu.fontSize", fr.response.rect, "Font Size"); ui.separator(); - let r = ui.button("Refresh Sort Order"); + let r = ui.button(tl!("Refresh Sort Order")); app.auto.add("project.menu.refreshSortOrder", r.rect, "Refresh Sort Order"); if r.clicked() { actions.push(("project.sort".into(), json!({"column": pp.view.sort.column, "descending": pp.view.sort.descending}))); close = true; } ui.separator(); - let r = ui.button("Metadata Display…"); + let r = ui.button(tl!("Metadata Display…")); app.auto.add("project.menu.metadataDisplay", r.rect, "Metadata Display…"); if r.clicked() { actions.push(("projectPanel.metadataDisplay".into(), json!({}))); close = true; } let current = pp.current_preset.filter(|i| pp.presets.get(*i).is_some_and(Option::is_some)); - let r = ui.add_enabled(current.is_some(), egui::Button::new("Save Current View Preset")); + let r = ui.add_enabled(current.is_some(), egui::Button::new(tl!("Save Current View Preset"))); app.auto.add("project.menu.saveViewPreset", r.rect, "Save Current View Preset"); if r.clicked() { actions.push(("project.viewPreset.save".into(), json!({}))); close = true; } - let r = ui.button("Save As New View Preset"); + let r = ui.button(tl!("Save As New View Preset")); app.auto.add("project.menu.saveViewPresetAs", r.rect, "Save As New View Preset"); if r.clicked() { actions.push(("projectPanel.saveViewPresetAs".into(), json!({}))); @@ -786,9 +790,9 @@ pub fn panel_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui) -> bool { } let any = pp.presets.iter().any(Option::is_some); let rr = ui.add_enabled_ui(any, |ui| { - ui.menu_button("Restore View Preset", |ui| { + ui.menu_button(tl!("Restore View Preset"), |ui| { for (i, p) in pp.presets.iter().enumerate() { - let label = p.as_ref().map(|p| p.name.clone()).unwrap_or_else(|| format!("Project View Preset {}", i + 1)); + let label = p.as_ref().map(|p| p.name.clone()).unwrap_or_else(|| tlf!("Project View Preset {n}", n = i + 1)); let r = ui.add_enabled(p.is_some(), egui::Button::selectable(pp.current_preset == Some(i), label.clone())); app.auto.add(&format!("project.menu.restoreViewPreset.{}", i + 1), r.rect, &label); if r.clicked() { @@ -799,14 +803,14 @@ pub fn panel_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui) -> bool { }) }); app.auto.add("project.menu.restoreViewPreset", rr.response.rect, "Restore View Preset"); - let r = ui.add_enabled(any, egui::Button::new("Manage Saved View Presets")); + let r = ui.add_enabled(any, egui::Button::new(tl!("Manage Saved View Presets"))); app.auto.add("project.menu.manageViewPresets", r.rect, "Manage Saved View Presets"); if r.clicked() { actions.push(("projectPanel.manageViewPresets".into(), json!({}))); close = true; } ui.separator(); - let r = ui.add_enabled(v.mode == ViewMode::Freeform, egui::Button::new("Freeform View Options…")); + let r = ui.add_enabled(v.mode == ViewMode::Freeform, egui::Button::new(tl!("Freeform View Options…"))); app.auto.add("project.menu.freeformOptions", r.rect, "Freeform View Options…"); if r.clicked() { actions.push(("projectPanel.freeformOptions".into(), json!({}))); @@ -866,7 +870,7 @@ pub fn route(app: &mut FilmcraftApp, ctx: &egui::Context, op: &str, params: &Val } "saveViewPresetAs" => { let n = app.session.prefs.project_panel.presets.iter().position(Option::is_none).map_or(1, |i| i + 1); - app.ui.project_panel.dialog = Some(ProjectDialog::SavePresetAs { name: format!("Project View Preset {n}") }); + app.ui.project_panel.dialog = Some(ProjectDialog::SavePresetAs { name: tlf!("Project View Preset {n}", n) }); Ok(Value::Null) } "manageViewPresets" => { diff --git a/crates/ui-egui/src/panels/project_dialogs.rs b/crates/ui-egui/src/panels/project_dialogs.rs index 17b6c7a3..c16d3a6e 100644 --- a/crates/ui-egui/src/panels/project_dialogs.rs +++ b/crates/ui-egui/src/panels/project_dialogs.rs @@ -18,11 +18,11 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut close = false; let mut run: Option<(String, Value)> = None; let title = match &d { - ProjectDialog::MetadataDisplay { .. } => "Metadata Display", - ProjectDialog::SavePresetAs { .. } => "Save As New View Preset", - ProjectDialog::ManagePresets { .. } => "Manage Saved View Presets", - ProjectDialog::FreeformOptions { .. } => "Freeform View Options", - ProjectDialog::SaveArrangement { .. } => "Save Arrangement", + ProjectDialog::MetadataDisplay { .. } => tl!("Metadata Display"), + ProjectDialog::SavePresetAs { .. } => tl!("Save As New View Preset"), + ProjectDialog::ManagePresets { .. } => tl!("Manage Saved View Presets"), + ProjectDialog::FreeformOptions { .. } => tl!("Freeform View Options"), + ProjectDialog::SaveArrangement { .. } => tl!("Save Arrangement"), }; let mut win = egui::Window::new(title).id(egui::Id::new("project-dialog")).collapsible(false).resizable(false).anchor(Align2::CENTER_CENTER, [0.0, 0.0]); // a fixed size keeps the two-list dialog from shifting while its lists settle @@ -34,14 +34,14 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { ProjectDialog::MetadataDisplay { columns, filter } => metadata_display(app, ui, columns, filter, &mut run), ProjectDialog::SavePresetAs { name } => { ui.horizontal(|ui| { - ui.label("Name:"); + ui.label(tl!("Name:")); let r = ui.add(egui::TextEdit::singleline(name).desired_width(240.0)); push(app, "projectDialog.name", &r, "Name"); }); if ui.input(|i| i.key_pressed(egui::Key::Enter)) { run = Some(("project.viewPreset.saveAs".into(), json!({"name": name}))); } - ui.label(egui::RichText::new("Saves the view, columns, sort, thumbnail and font size.").weak().size(11.0)); + ui.label(egui::RichText::new(tl!("Saves the view, columns, sort, thumbnail and font size.")).weak().size(11.0)); if ok_cancel(app, ui, &mut close) { run = Some(("project.viewPreset.saveAs".into(), json!({"name": name}))); } @@ -49,19 +49,19 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { ProjectDialog::ManagePresets { selected, name } => manage_presets(app, ui, selected, name, &mut run, &mut close), ProjectDialog::FreeformOptions { options } => { egui::Grid::new("ff-options").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| { - ui.label("Grid spacing:"); + ui.label(tl!("Grid spacing:")); let r = ui.add(egui::DragValue::new(&mut options.grid).range(4.0..=200.0).suffix(" pt")); push(app, "projectDialog.grid", &r, "Grid spacing"); ui.end_row(); - ui.label("Default clip size:"); + ui.label(tl!("Default clip size:")); let r = ui.add(egui::DragValue::new(&mut options.card_size).range(48.0..=480.0).suffix(" pt")); push(app, "projectDialog.cardSize", &r, "Default clip size"); ui.end_row(); }); for (id, label, v) in [ - ("snap", "Snap to grid", &mut options.snap), - ("showNames", "Show clip names", &mut options.show_names), - ("showDurations", "Show durations", &mut options.show_durations), + ("snap", tl!("Snap to grid"), &mut options.snap), + ("showNames", tl!("Show clip names"), &mut options.show_names), + ("showDurations", tl!("Show durations"), &mut options.show_durations), ] { let r = ui.checkbox(v, label); push(app, &format!("projectDialog.{id}"), &r, label); @@ -72,7 +72,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { } ProjectDialog::SaveArrangement { name, bin } => { ui.horizontal(|ui| { - ui.label("Name:"); + ui.label(tl!("Name:")); let r = ui.add(egui::TextEdit::singleline(name).desired_width(240.0)); push(app, "projectDialog.name", &r, "Name"); }); @@ -107,12 +107,12 @@ fn ok_cancel(app: &mut FilmcraftApp, ui: &mut egui::Ui, close: &mut bool) -> boo ui.separator(); let mut ok = false; ui.horizontal(|ui| { - let r = ui.button("Cancel"); + let r = ui.button(tl!("Cancel")); push(app, "projectDialog.cancel", &r, "Cancel"); if r.clicked() { *close = true; } - let r = ui.button("OK"); + let r = ui.button(tl!("OK")); push(app, "projectDialog.ok", &r, "OK"); ok = r.clicked(); }); @@ -122,8 +122,8 @@ fn ok_cancel(app: &mut FilmcraftApp, ui: &mut egui::Ui, close: &mut bool) -> boo fn metadata_display(app: &mut FilmcraftApp, ui: &mut egui::Ui, columns: &mut Vec, filter: &mut String, run: &mut Option<(String, Value)>) { let all = filmcraft_engine::project_panel::all_columns(&app.session.project); ui.horizontal(|ui| { - ui.label("Show:"); - let r = ui.add(egui::TextEdit::singleline(filter).hint_text("Search fields").desired_width(220.0)); + ui.label(tl!("Show:")); + let r = ui.add(egui::TextEdit::singleline(filter).hint_text(tl!("Search fields")).desired_width(220.0)); push(app, "projectDialog.filter", &r, "Search fields"); }); ui.add_space(4.0); @@ -131,13 +131,13 @@ fn metadata_display(app: &mut FilmcraftApp, ui: &mut egui::Ui, columns: &mut Vec ui.horizontal_top(|ui| { // every field, checked when shown ui.vertical(|ui| { - ui.label(egui::RichText::new("Project Metadata").strong()); + ui.label(egui::RichText::new(tl!("Project Metadata")).strong()); egui::ScrollArea::vertical().id_salt("md-all").max_height(340.0).min_scrolled_height(340.0).show(ui, |ui| { ui.set_min_width(220.0); for c in all.iter().filter(|c| f.is_empty() || c.to_ascii_lowercase().contains(&f)) { let mut on = columns.contains(c); let name_col = c == "Name"; - let r = ui.add_enabled(!name_col, egui::Checkbox::new(&mut on, c.as_str())); + let r = ui.add_enabled(!name_col, egui::Checkbox::new(&mut on, crate::i18n::t(c))); push(app, &format!("projectDialog.field.{c}"), &r, c); if r.changed() { if on { @@ -149,7 +149,7 @@ fn metadata_display(app: &mut FilmcraftApp, ui: &mut egui::Ui, columns: &mut Vec } }); ui.horizontal(|ui| { - let r = ui.button("Add Property…"); + let r = ui.button(tl!("Add Property…")); push(app, "projectDialog.addProperty", &r, "Add Property…"); if r.clicked() && !filter.trim().is_empty() && !columns.iter().any(|c| c.eq_ignore_ascii_case(filter.trim())) { // a custom metadata field (typed in the search box) @@ -160,7 +160,7 @@ fn metadata_display(app: &mut FilmcraftApp, ui: &mut egui::Ui, columns: &mut Vec ui.separator(); // the shown columns, in order ui.vertical(|ui| { - ui.label(egui::RichText::new("Column order").strong()); + ui.label(egui::RichText::new(tl!("Column order")).strong()); egui::ScrollArea::vertical().id_salt("md-order").max_height(340.0).min_scrolled_height(340.0).show(ui, |ui| { ui.set_min_width(200.0); let mut mv: Option<(usize, isize)> = None; @@ -170,7 +170,7 @@ fn metadata_display(app: &mut FilmcraftApp, ui: &mut egui::Ui, columns: &mut Vec push(app, &format!("projectDialog.up.{c}"), &up, "Move up"); let down = ui.add_enabled(i > 0 && i + 1 < columns.len(), egui::Button::new("▼").small()); push(app, &format!("projectDialog.down.{c}"), &down, "Move down"); - ui.label(c); + ui.label(crate::i18n::t(c)); if up.clicked() { mv = Some((i, -1)); } @@ -203,7 +203,7 @@ fn manage_presets(app: &mut FilmcraftApp, ui: &mut egui::Ui, selected: &mut usiz let p = presets.get(i).cloned().flatten(); let label = match &p { Some(p) => format!("{}. {}", i + 1, p.name), - None => format!("{}. (empty)", i + 1), + None => tlf!("{n}. (empty)", n = i + 1), }; let r = ui.selectable_label(*selected == i, label.clone()); push(app, &format!("projectDialog.preset.{}", i + 1), &r, &label); @@ -215,27 +215,27 @@ fn manage_presets(app: &mut FilmcraftApp, ui: &mut egui::Ui, selected: &mut usiz }); let filled = presets.get(*selected).is_some_and(Option::is_some); ui.horizontal(|ui| { - ui.label("Name:"); + ui.label(tl!("Name:")); let r = ui.add_enabled(filled, egui::TextEdit::singleline(name).desired_width(200.0)); push(app, "projectDialog.name", &r, "Name"); - let b = ui.add_enabled(filled, egui::Button::new("Rename")); + let b = ui.add_enabled(filled, egui::Button::new(tl!("Rename"))); push(app, "projectDialog.rename", &b, "Rename"); if b.clicked() { *run = Some(("project.viewPreset.rename".into(), json!({"slot": *selected + 1, "name": name}))); } }); ui.horizontal(|ui| { - let b = ui.add_enabled(filled, egui::Button::new("Restore")); + let b = ui.add_enabled(filled, egui::Button::new(tl!("Restore"))); push(app, "projectDialog.restore", &b, "Restore"); if b.clicked() { *run = Some(("project.viewPreset.restore".into(), json!({"slot": *selected + 1}))); } - let b = ui.add_enabled(filled, egui::Button::new("Delete")); + let b = ui.add_enabled(filled, egui::Button::new(tl!("Delete"))); push(app, "projectDialog.delete", &b, "Delete"); if b.clicked() { *run = Some(("project.viewPreset.delete".into(), json!({"slot": *selected + 1}))); } - let b = ui.button("Overwrite with Current View"); + let b = ui.button(tl!("Overwrite with Current View")); push(app, "projectDialog.overwrite", &b, "Overwrite with Current View"); if b.clicked() { *run = Some(( @@ -245,7 +245,7 @@ fn manage_presets(app: &mut FilmcraftApp, ui: &mut egui::Ui, selected: &mut usiz } }); ui.separator(); - let r = ui.button("Done"); + let r = ui.button(tl!("Done")); push(app, "projectDialog.ok", &r, "Done"); if r.clicked() { *close = true; diff --git a/crates/ui-egui/src/panels/project_views.rs b/crates/ui-egui/src/panels/project_views.rs index 742c16e7..f1e72aab 100644 --- a/crates/ui-egui/src/panels/project_views.rs +++ b/crates/ui-egui/src/panels/project_views.rs @@ -50,12 +50,12 @@ pub fn media_badge(app: &FilmcraftApp, it: &filmcraft_project::ProjectItem) -> B let Some(m) = target.and_then(|t| t.as_media()) else { return b }; let id = target.map(|t| t.id).unwrap_or(it.id); if m.offline { - (b.offline, b.offline_text) = (true, "Offline"); + (b.offline, b.offline_text) = (true, tl!("Offline")); } else if app.session.offline.missing.contains(&id) { - (b.offline, b.offline_text) = (true, "Media missing"); + (b.offline, b.offline_text) = (true, tl!("Media missing")); } else if let Some(st) = app.session.media.offline_status(id) { b.offline = true; - b.offline_text = if st.reason == filmcraft_render::offline::OfflineReason::Unreadable { "Unreadable" } else { "Media missing" }; + b.offline_text = if st.reason == filmcraft_render::offline::OfflineReason::Unreadable { tl!("Unreadable") } else { tl!("Media missing") }; } b.proxy = m.proxy.is_some(); b.proxy_on = app.session.media.use_proxies(); @@ -196,45 +196,47 @@ pub fn item_interactions( fn item_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, id: ItemId, kind: &ItemKind, actions: &mut Actions) { let sel = || json!({"items": [id.0]}); - if ui.button("Rename").clicked() { + if ui.button(tl!("Rename")).clicked() { actions.push(("projectPanel.rename".into(), json!({"item": id.0}))); ui.close(); } - if ui.button("Open in Source Monitor").clicked() { + if ui.button(tl!("Open in Source Monitor")).clicked() { actions.push(("source.open".into(), json!({"item": id.0}))); ui.close(); } - if matches!(kind, ItemKind::Sequence(_)) && ui.button("Open in Timeline").clicked() { + if matches!(kind, ItemKind::Sequence(_)) && ui.button(tl!("Open in Timeline")).clicked() { actions.push(("sequence.open".into(), json!({"item": id.0}))); ui.close(); } - if ui.button("New Sequence From Clip").clicked() { + if ui.button(tl!("New Sequence From Clip")).clicked() { actions.push(("file.newSequence".into(), json!({"fromItem": id.0}))); ui.close(); } - if ui.button("Duplicate").clicked() { + if ui.button(tl!("Duplicate")).clicked() { actions.push(("project.select".into(), sel())); actions.push(("edit.duplicate".into(), json!({}))); ui.close(); } - if ui.button("Set Poster Frame").clicked() { + if ui.button(tl!("Set Poster Frame")).clicked() { // the hover-scrubbed time when there is one let h = app.ui.project_panel.hover.filter(|h| h.item == id.0); actions.push(("project.select".into(), sel())); actions.push(("clip.setPosterFrame".into(), h.map(|h| json!({"item": id.0, "time": h.time})).unwrap_or(json!({})))); ui.close(); } - if ui.button("Interpret Footage…").clicked() { + if ui.button(tl!("Interpret Footage…")).clicked() { actions.push(("clip.interpretFootage".into(), json!({"items": [id.0]}))); ui.close(); } // subclips: Make Subclip (media), Edit Subclip and Convert to Master Clip (subclips) - let subclip_items: &[(&str, &str, &str)] = match kind { - ItemKind::Media(_) => &[("makeSubclip", "Make Subclip…", "clip.makeSubclip")], - ItemKind::Subclip { .. } => &[("editSubclip", "Edit Subclip…", "clip.editSubclip"), ("convertToMaster", "Convert to Master Clip", "clip.editSubclip")], - _ => &[], + let subclip_items: Vec<(&str, &str, &str)> = match kind { + ItemKind::Media(_) => vec![("makeSubclip", tl!("Make Subclip…"), "clip.makeSubclip")], + ItemKind::Subclip { .. } => { + vec![("editSubclip", tl!("Edit Subclip…"), "clip.editSubclip"), ("convertToMaster", tl!("Convert to Master Clip"), "clip.editSubclip")] + } + _ => Vec::new(), }; - for (key, label, cmd) in subclip_items { + for (key, label, cmd) in &subclip_items { let b = ui.button(*label); app.auto.add(&format!("project.itemMenu.{key}"), b.rect, label); if b.clicked() { @@ -244,9 +246,9 @@ fn item_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, id: ItemId, kind: &ItemK ui.close(); } } - ui.menu_button("Label", |ui| { + ui.menu_button(tl!("Label"), |ui| { for l in filmcraft_project::Label::ALL { - if ui.button(l.name()).clicked() { + if ui.button(crate::i18n::t(l.name())).clicked() { actions.push(("project.select".into(), json!({"items": [id.0]}))); actions.push(("edit.label".into(), json!({"label": l.name()}))); ui.close(); @@ -255,19 +257,19 @@ fn item_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, id: ItemId, kind: &ItemK }); if matches!(kind, ItemKind::Media(m) if matches!(m.media, filmcraft_project::MediaRef::File { .. })) { ui.separator(); - for (label, cmd) in [("Link Media…", "media.linkMedia"), ("Make Offline…", "media.makeOffline")] { + for (label, cmd) in [(tl!("Link Media…"), "media.linkMedia"), (tl!("Make Offline…"), "media.makeOffline")] { if ui.button(label).clicked() { actions.push(("project.select".into(), sel())); actions.push((cmd.into(), json!({}))); ui.close(); } } - ui.menu_button("Proxy", |ui| { + ui.menu_button(tl!("Proxy"), |ui| { for (label, cmd) in [ - ("Create Proxies…", "media.createProxies"), - ("Attach Proxies…", "media.attachProxies"), - ("Reconnect Full Resolution Media…", "media.reconnectFullRes"), - ("Detach Proxies", "media.detachProxies"), + (tl!("Create Proxies…"), "media.createProxies"), + (tl!("Attach Proxies…"), "media.attachProxies"), + (tl!("Reconnect Full Resolution Media…"), "media.reconnectFullRes"), + (tl!("Detach Proxies"), "media.detachProxies"), ] { if ui.button(label).clicked() { actions.push(("project.select".into(), json!({"items": [id.0]}))); @@ -278,14 +280,14 @@ fn item_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, id: ItemId, kind: &ItemK }); } ui.separator(); - if ui.button("Clear").clicked() { + if ui.button(tl!("Clear")).clicked() { actions.push(("project.delete".into(), json!({"items": [id.0]}))); ui.close(); } } fn bin_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, bin: u64, actions: &mut Actions) { - for (label, how) in [("Open in Place", "inPlace"), ("Open in New Tab", "newTab"), ("Open in New Window", "newWindow")] { + for (label, how) in [(tl!("Open in Place"), "inPlace"), (tl!("Open in New Tab"), "newTab"), (tl!("Open in New Window"), "newWindow")] { let b = ui.button(label); app.auto.add(&format!("{}.binMenu.{how}", v.prefix), b.rect, label); if b.clicked() { @@ -297,11 +299,11 @@ fn bin_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, bin: u64, actio } } ui.separator(); - if ui.button("Rename").clicked() { + if ui.button(tl!("Rename")).clicked() { actions.push(("projectPanel.rename".into(), json!({"bin": bin}))); ui.close(); } - if ui.button("New Bin").clicked() { + if ui.button(tl!("New Bin")).clicked() { actions.push(("file.newBin".into(), json!({"name": "New Bin", "parent": bin}))); ui.close(); } @@ -435,12 +437,12 @@ pub fn list_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &View let vis = cr.intersect(hr); if vis.width() > 1.0 { let id = egui::Id::new((&v.prefix, "col", &c.name)); - let resp = ui.interact(vis, id, Sense::click()).on_hover_text(&c.name); + let resp = ui.interact(vis, id, Sense::click()).on_hover_text(crate::i18n::t(&c.name)); app.auto.add(&format!("{}.list.header.{}", v.prefix, c.name), vis, &c.name); if resp.hovered() { hp.rect_filled(cr, 0.0, t.hover); } - let g = hp.layout_no_wrap(c.name.clone(), Tokens::ui(f - 0.5), t.text_dim); + let g = hp.layout_no_wrap(crate::i18n::t(&c.name).to_string(), Tokens::ui(f - 0.5), t.text_dim); let gw = g.size().x; hp.with_clip_rect(cr.shrink2(vec2(4.0, 0.0)).intersect(hr)).galley(pos2(cr.min.x + 6.0, cr.center().y - g.size().y / 2.0), g, t.text_dim); if sort.column == c.name { @@ -454,7 +456,7 @@ pub fn list_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &View actions.push(("project.sort".into(), json!({"column": c.name}))); } resp.context_menu(|ui| { - if ui.button("Metadata Display…").clicked() { + if ui.button(tl!("Metadata Display…")).clicked() { actions.push(("projectPanel.metadataDisplay".into(), json!({}))); ui.close(); } @@ -501,19 +503,19 @@ fn empty_space(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, actions: &mu fn background_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, bin: Option, actions: &mut Actions) { for (label, cmd) in [ - ("New Bin", "file.newBin"), - ("New Search Bin", "file.newSearchBin"), - ("Find…", "edit.find"), - ("Automate to Sequence…", "clip.automateToSequence"), - ("Import…", "file.import"), + (tl!("New Bin"), "file.newBin"), + (tl!("New Search Bin"), "file.newSearchBin"), + (tl!("Find…"), "edit.find"), + (tl!("Automate to Sequence…"), "clip.automateToSequence"), + (tl!("Import…"), "file.import"), ] { if ui.button(label).clicked() { - let p = if cmd == "file.newBin" { json!({"name": "New Bin", "parent": bin}) } else { json!({}) }; + let p = if cmd == "file.newBin" { json!({"name": tl!("New Bin"), "parent": bin}) } else { json!({}) }; actions.push((cmd.into(), p)); ui.close(); } } - ui.menu_button("New Item", |ui| crate::panels::project::new_item_menu(app, ui, &v.prefix, actions)); + ui.menu_button(tl!("New Item"), |ui| crate::panels::project::new_item_menu(app, ui, &v.prefix, actions)); } #[allow(clippy::too_many_arguments)] @@ -612,9 +614,10 @@ fn list_bin(app: &mut FilmcraftApp, ui: &mut egui::Ui, bin: &Bin, depth: usize, let p = ui.painter().with_clip_rect(cr.shrink2(vec2(2.0, 0.0)).intersect(ui.clip_rect())); if c.name == "Label" { p.rect_filled(Rect::from_center_size(pos2(cr.min.x + 12.0, cr.center().y), vec2(10.0, 10.0)), 2.0, label_color(app, it.label)); - p.text(pos2(cr.min.x + 22.0, cr.center().y), Align2::LEFT_CENTER, it.label.name(), Tokens::ui(lc.font - 0.5), t.text_dim); + p.text(pos2(cr.min.x + 22.0, cr.center().y), Align2::LEFT_CENTER, crate::i18n::t(it.label.name()), Tokens::ui(lc.font - 0.5), t.text_dim); } else { let (text, _) = pp::cell(&app.session.project, &it, &c.name); + let text = if c.name == "Media Type" { crate::i18n::t(&text).to_string() } else { text }; p.text(pos2(cr.min.x + 6.0, cr.center().y), Align2::LEFT_CENTER, text, Tokens::ui(lc.font - 0.5), t.text_dim); } x += c.width; @@ -766,7 +769,7 @@ fn bin_card(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, b: &Bin, v: &Vie let nr = Rect::from_min_max(pos2(th.min.x, th.max.y + 2.0), pos2(th.max.x, th.max.y + 20.0)); if !rename_field(app, ui, nr, None, Some(b.id.0), &v.prefix) { ui.painter().with_clip_rect(nr).text(pos2(th.min.x, th.max.y + 11.0), Align2::LEFT_CENTER, &b.name, Tokens::ui(f - 0.5), t.text); - ui.painter().text(pos2(th.max.x, th.max.y + 11.0), Align2::RIGHT_CENTER, format!("{} items", n.len()), Tokens::ui(f - 1.5), t.text_dim); + ui.painter().text(pos2(th.max.x, th.max.y + 11.0), Align2::RIGHT_CENTER, tlf!("{n} items", n = n.len()), Tokens::ui(f - 1.5), t.text_dim); } app.auto.add(&format!("{}.bin.{}", v.prefix, b.id.0), th, &b.name); accept_bin_drop(app, ui, th, b.id.0, actions); @@ -949,27 +952,27 @@ fn cards_stack(cards: &[Card], item: u64) -> Option { fn card_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, item: u64, actions: &mut Actions) { let sel: Vec = app.session.state.project_selection.iter().map(|i| i.0).collect(); let items: Vec = if sel.contains(&item) { sel.clone() } else { vec![item] }; - let b = ui.add_enabled(items.len() > 1, egui::Button::new("Stack")); + let b = ui.add_enabled(items.len() > 1, egui::Button::new(tl!("Stack"))); app.auto.add(&format!("{}.cardMenu.stack", v.prefix), b.rect, "Stack"); if b.clicked() { actions.push(("project.freeform.stack".into(), json!({"items": items}))); ui.close(); } let stacked = app.session.project.item(ItemId(item)).is_some_and(|i| i.metadata.contains_key(pp::FREEFORM_STACK)); - let b = ui.add_enabled(stacked, egui::Button::new("Unstack")); + let b = ui.add_enabled(stacked, egui::Button::new(tl!("Unstack"))); app.auto.add(&format!("{}.cardMenu.unstack", v.prefix), b.rect, "Unstack"); if b.clicked() { actions.push(("project.freeform.unstack".into(), json!({"items": items}))); ui.close(); } - ui.menu_button("Clip Size", |ui| { - for (label, step) in [("Larger", 1), ("Smaller", -1)] { + ui.menu_button(tl!("Clip Size"), |ui| { + for (label, step) in [(tl!("Larger"), 1), (tl!("Smaller"), -1)] { if ui.button(label).clicked() { actions.push(("project.freeform.resize".into(), json!({"items": items, "step": step}))); ui.close(); } } - if ui.button("Default").clicked() { + if ui.button(tl!("Default")).clicked() { actions.push(("project.freeform.resize".into(), json!({"items": items, "size": app.session.prefs.project_panel.freeform.card_size}))); ui.close(); } @@ -990,10 +993,10 @@ fn canvas_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, bin: Option< ui.close(); } } - entry(app, ui, v, actions, "alignToGrid", "Align to Grid", "project.freeform.alignToGrid", json!({"bin": bin})); - entry(app, ui, v, actions, "resetToGrid", "Reset to Grid", "project.freeform.reset", json!({"bin": bin})); + entry(app, ui, v, actions, "alignToGrid", tl!("Align to Grid"), "project.freeform.alignToGrid", json!({"bin": bin})); + entry(app, ui, v, actions, "resetToGrid", tl!("Reset to Grid"), "project.freeform.reset", json!({"bin": bin})); ui.separator(); - entry(app, ui, v, actions, "saveArrangement", "Save Arrangement…", "projectPanel.saveArrangement", json!({})); + entry(app, ui, v, actions, "saveArrangement", tl!("Save Arrangement…"), "projectPanel.saveArrangement", json!({})); let names: Vec = app .session .execute("project.freeform.arrangements", json!({"bin": bin})) @@ -1001,7 +1004,7 @@ fn canvas_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, bin: Option< .and_then(|v| serde_json::from_value(v["arrangements"].clone()).ok()) .unwrap_or_default(); ui.add_enabled_ui(!names.is_empty(), |ui| { - ui.menu_button("Restore Arrangement", |ui| { + ui.menu_button(tl!("Restore Arrangement"), |ui| { for n in &names { if ui.button(n).clicked() { actions.push(("project.freeform.restoreArrangement".into(), json!({"name": n, "bin": bin}))); @@ -1009,7 +1012,7 @@ fn canvas_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, bin: Option< } } }); - ui.menu_button("Delete Arrangement", |ui| { + ui.menu_button(tl!("Delete Arrangement"), |ui| { for n in &names { if ui.button(n).clicked() { actions.push(("project.freeform.deleteArrangement".into(), json!({"name": n, "bin": bin}))); diff --git a/crates/ui-egui/src/panels/reference.rs b/crates/ui-egui/src/panels/reference.rs index 7b7b3890..9c561563 100644 --- a/crates/ui-egui/src/panels/reference.rs +++ b/crates/ui-egui/src/panels/reference.rs @@ -31,7 +31,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let ctx = ui.ctx().clone(); let Some(seq_id) = app.session.state.active_sequence else { - crate::dock::placeholder(ui, rect, &t, "(no sequences)"); + crate::dock::placeholder(ui, rect, &t, tl!("(no sequences)")); return; }; let Some(frame) = frame(app) else { return }; @@ -116,19 +116,19 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { elems.push((id.to_string(), r, label.to_string())); resp.on_hover_text(label).clicked() }; - if button(ui, "reference.stepBack", "Step Back 1 Frame", Some(Icon::StepBack), false, &mut elems) { + if button(ui, "reference.stepBack", tl!("Step Back 1 Frame"), Some(Icon::StepBack), false, &mut elems) { step = -1; } - if button(ui, "reference.stepForward", "Step Forward 1 Frame", Some(Icon::StepFwd), false, &mut elems) { + if button(ui, "reference.stepForward", tl!("Step Forward 1 Frame"), Some(Icon::StepFwd), false, &mut elems) { step = 1; } - let matched = button(ui, "reference.matchPlayhead", "Match Playhead", None, false, &mut elems); - let gang = button(ui, "reference.gang", "Gang to Program", None, st.ganged, &mut elems); + let matched = button(ui, "reference.matchPlayhead", tl!("Match Playhead"), None, false, &mut elems); + let gang = button(ui, "reference.gang", tl!("Gang to Program"), None, st.ganged, &mut elems); // display dropdown at the right let dr = Rect::from_min_size(pos2(bar.max.x - 150.0, bar.center().y - 11.0), vec2(142.0, 22.0)); let mut display = st.display; let mut child = ui.new_child(egui::UiBuilder::new().max_rect(dr)); - let label = |d: RefDisplay| if d == RefDisplay::Composite { "Composite Video" } else { "Lumetri Scopes" }; + let label = |d: RefDisplay| if d == RefDisplay::Composite { tl!("Composite Video") } else { tl!("Lumetri Scopes") }; let cb = egui::ComboBox::from_id_salt("reference-display").selected_text(label(display)).width(134.0).show_ui(&mut child, |ui| { for d in [RefDisplay::Composite, RefDisplay::Scopes] { let r = ui.selectable_value(&mut display, d, label(d)); diff --git a/crates/ui-egui/src/panels/remix.rs b/crates/ui-egui/src/panels/remix.rs index 9a105bf2..459fcdd9 100644 --- a/crates/ui-egui/src/panels/remix.rs +++ b/crates/ui-egui/src/panels/remix.rs @@ -31,7 +31,7 @@ pub fn open(app: &mut FilmcraftApp, ctx: &egui::Context) -> Result = Vec::new(); - egui::Window::new("Remix Properties").collapsible(false).resizable(false).anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { - ui.set_min_width(320.0); - egui::Grid::new("remix-props").num_columns(2).spacing([12.0, 10.0]).show(ui, |ui| { - ui.label("Target Duration:"); - let r = ui.add(egui::DragValue::new(&mut d.seconds).speed(0.1).range(1.0..=36_000.0).suffix(" s").max_decimals(2)); - elems.push(("remixProperties.duration".into(), r.rect, format!("{:.2} s", d.seconds))); - ui.end_row(); - ui.label("Segments:"); - let r = ui.add(egui::Slider::new(&mut d.segments, 0.0..=100.0).step_by(1.0).text("Fewer · More")); - elems.push(("remixProperties.segments".into(), r.rect, format!("{:.0}", d.segments))); - ui.end_row(); - ui.label("Variations:"); - let r = ui.add(egui::Slider::new(&mut d.variations, 0.0..=100.0).step_by(1.0).text("Fewer · More")); - elems.push(("remixProperties.variations".into(), r.rect, format!("{:.0}", d.variations))); - ui.end_row(); + egui::Window::new(tl!("Remix Properties")) + .id(egui::Id::new("Remix Properties")) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.set_min_width(320.0); + egui::Grid::new("remix-props").num_columns(2).spacing([12.0, 10.0]).show(ui, |ui| { + ui.label(tl!("Target Duration:")); + let r = ui.add(egui::DragValue::new(&mut d.seconds).speed(0.1).range(1.0..=36_000.0).suffix(" s").max_decimals(2)); + elems.push(("remixProperties.duration".into(), r.rect, format!("{:.2} s", d.seconds))); + ui.end_row(); + ui.label(tl!("Segments:")); + let r = ui.add(egui::Slider::new(&mut d.segments, 0.0..=100.0).step_by(1.0).text(tl!("Fewer · More"))); + elems.push(("remixProperties.segments".into(), r.rect, format!("{:.0}", d.segments))); + ui.end_row(); + ui.label(tl!("Variations:")); + let r = ui.add(egui::Slider::new(&mut d.variations, 0.0..=100.0).step_by(1.0).text(tl!("Fewer · More"))); + elems.push(("remixProperties.variations".into(), r.rect, format!("{:.0}", d.variations))); + ui.end_row(); + }); + ui.add_space(6.0); + let i = ui.weak(&d.info); + elems.push(("remixProperties.info".into(), i.rect, d.info.clone())); + ui.add_space(8.0); + ui.horizontal(|ui| { + let c = ui.button(tl!("Cancel")); + elems.push(("remixProperties.cancel".into(), c.rect, "Cancel".into())); + if c.clicked() { + close = true; + } + let o = ui.add(egui::Button::new(egui::RichText::new(tl!("OK")).color(egui::Color32::WHITE)).fill(app.tokens.accent)); + elems.push(("remixProperties.ok".into(), o.rect, "OK".into())); + if o.clicked() { + apply = true; + } + }); }); - ui.add_space(6.0); - let i = ui.weak(&d.info); - elems.push(("remixProperties.info".into(), i.rect, d.info.clone())); - ui.add_space(8.0); - ui.horizontal(|ui| { - let c = ui.button("Cancel"); - elems.push(("remixProperties.cancel".into(), c.rect, "Cancel".into())); - if c.clicked() { - close = true; - } - let o = ui.add(egui::Button::new(egui::RichText::new("OK").color(egui::Color32::WHITE)).fill(app.tokens.accent)); - elems.push(("remixProperties.ok".into(), o.rect, "OK".into())); - if o.clicked() { - apply = true; - } - }); - }); for (id, r, l) in elems { app.auto.add(&id, r, &l); } @@ -89,7 +94,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { if apply { let p = json!({"clip": d.clip, "seconds": d.seconds, "segments": d.segments, "variations": d.variations}); match app.session.execute("clip.remix.properties", p) { - Ok(v) => app.ui.status = format!("Remixed to {:.2} s", v["seconds"].as_f64().unwrap_or(0.0)), + Ok(v) => app.ui.status = tlf!("Remixed to {seconds} s", seconds = format!("{:.2}", v["seconds"].as_f64().unwrap_or(0.0))), Err(e) => app.ui.status = e.to_string(), } close = true; diff --git a/crates/ui-egui/src/panels/scopes.rs b/crates/ui-egui/src/panels/scopes.rs index c5a81519..f68b0575 100644 --- a/crates/ui-egui/src/panels/scopes.rs +++ b/crates/ui-egui/src/panels/scopes.rs @@ -96,7 +96,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let sf = frame.and_then(|f| frame_signal(app, &ctx, "program", f, &st, prio)); match &sf { Some(sf) => draw(app, ui, area, &st.shown, sf, &st, "scopes"), - None => crate::dock::placeholder(ui, area, &t, if app.session.state.active_sequence.is_some() { "…" } else { "(no sequence)" }), + None => crate::dock::placeholder(ui, area, &t, if app.session.state.active_sequence.is_some() { "…" } else { tl!("(no sequence)") }), } // right-click anywhere on the scopes: the settings menu let resp = ui.interact(area, egui::Id::new("scopes-area"), Sense::click()); @@ -110,7 +110,7 @@ fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, space: Option "Rec. 2100".to_string(), - s => s.label().to_string(), + s => crate::i18n::t(s.label()).to_string(), }; let lr = ui.painter().text(pos2(r.min.x + 8.0, r.center().y), Align2::LEFT_CENTER, &label, Tokens::ui(12.0), t.text); app.auto.add("scopes.space", lr, &label); @@ -119,19 +119,19 @@ fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, space: Option format!("Parade ({})", st.parade_type.label()), - ScopeKind::Waveform => format!("Waveform ({})", st.waveform_type.label()), - k => k.label().to_string(), + ScopeKind::Parade => tlf!("Parade ({kind})", kind = crate::i18n::t(st.parade_type.label())), + ScopeKind::Waveform => tlf!("Waveform ({kind})", kind = crate::i18n::t(st.waveform_type.label())), + k => crate::i18n::t(k.label()).to_string(), }; pick(ui, &mut out, &format!("scopes.menu.{}", k.name()), &label, st.shown.contains(&k)); } @@ -182,38 +182,38 @@ pub fn settings_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui) { let r = ui.menu_button(label, |ui| f(ui, out)); out.push((format!("scopes.menu.{id}"), r.response.rect, label.into(), false)); }; - sub(ui, &mut out, "paradeTypes", "Parade Type", &mut |ui, out| { + sub(ui, &mut out, "paradeTypes", tl!("Parade Type"), &mut |ui, out| { for p in ParadeType::ALL { - pick(ui, out, &format!("scopes.menu.paradeType.{}", serde_name(&p)), p.label(), st.parade_type == p); + pick(ui, out, &format!("scopes.menu.paradeType.{}", serde_name(&p)), crate::i18n::t(p.label()), st.parade_type == p); } }); - sub(ui, &mut out, "waveformTypes", "Waveform Type", &mut |ui, out| { + sub(ui, &mut out, "waveformTypes", tl!("Waveform Type"), &mut |ui, out| { for w in WaveformType::ALL { - pick(ui, out, &format!("scopes.menu.waveformType.{}", serde_name(&w)), w.label(), st.waveform_type == w); + pick(ui, out, &format!("scopes.menu.waveformType.{}", serde_name(&w)), crate::i18n::t(w.label()), st.waveform_type == w); } }); - sub(ui, &mut out, "vectorscopeTargets", "Vectorscope Targets", &mut |ui, out| { + sub(ui, &mut out, "vectorscopeTargets", tl!("Vectorscope Targets"), &mut |ui, out| { for v in [Targets::Percent75, Targets::Percent100] { - pick(ui, out, &format!("scopes.menu.targets.{}", serde_name(&v)), v.label(), st.targets == v); + pick(ui, out, &format!("scopes.menu.targets.{}", serde_name(&v)), crate::i18n::t(v.label()), st.targets == v); } }); ui.separator(); - sub(ui, &mut out, "colorSpaces", "Colour Space", &mut |ui, out| { + sub(ui, &mut out, "colorSpaces", tl!("Colour Space"), &mut |ui, out| { for c in ColorSpace::ALL { - pick(ui, out, &format!("scopes.menu.colorSpace.{}", serde_name(&c)), c.label(), st.color_space == c); + pick(ui, out, &format!("scopes.menu.colorSpace.{}", serde_name(&c)), crate::i18n::t(c.label()), st.color_space == c); } }); - sub(ui, &mut out, "brightnesses", "Brightness", &mut |ui, out| { + sub(ui, &mut out, "brightnesses", tl!("Brightness"), &mut |ui, out| { for b in Brightness::ALL { - pick(ui, out, &format!("scopes.menu.brightness.{}", serde_name(&b)), b.label(), st.brightness == b); + pick(ui, out, &format!("scopes.menu.brightness.{}", serde_name(&b)), crate::i18n::t(b.label()), st.brightness == b); } }); - sub(ui, &mut out, "scales", "Signal Scale", &mut |ui, out| { + sub(ui, &mut out, "scales", tl!("Signal Scale"), &mut |ui, out| { for s in Scale::ALL { - pick(ui, out, &format!("scopes.menu.scale.{}", s.label().replace(' ', "")), s.label(), st.scale == s); + pick(ui, out, &format!("scopes.menu.scale.{}", s.label().replace(' ', "")), crate::i18n::t(s.label()), st.scale == s); } }); - pick(ui, &mut out, "scopes.menu.clamp", "Clamp Signal", st.clamp); + pick(ui, &mut out, "scopes.menu.clamp", tl!("Clamp Signal"), st.clamp); let mut close = false; for (id, r, label, clicked) in out { app.auto.add(&id, r, &label); @@ -391,8 +391,8 @@ fn draw_one(app: &mut FilmcraftApp, ui: &mut egui::Ui, cell: Rect, k: ScopeKind, } } let title = match k { - ScopeKind::Parade => format!("Parade ({})", st.parade_type.label()), - _ => format!("Waveform ({})", st.waveform_type.label()), + ScopeKind::Parade => tlf!("Parade ({kind})", kind = crate::i18n::t(st.parade_type.label())), + _ => tlf!("Waveform ({kind})", kind = crate::i18n::t(st.waveform_type.label())), }; ui.painter().text(plot.left_top() + vec2(4.0, 2.0), Align2::LEFT_TOP, title, Tokens::ui(9.0), Color32::from_gray(110)); if nits_axis(st, sf) { @@ -409,7 +409,7 @@ fn draw_one(app: &mut FilmcraftApp, ui: &mut egui::Ui, cell: Rect, k: ScopeKind, if let Some(tex) = tex { ui.painter().image(tex.id(), plot, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } - ui.painter().text(plot.left_top() + vec2(4.0, 2.0), Align2::LEFT_TOP, "Histogram", Tokens::ui(9.0), Color32::from_gray(110)); + ui.painter().text(plot.left_top() + vec2(4.0, 2.0), Align2::LEFT_TOP, tl!("Histogram"), Tokens::ui(9.0), Color32::from_gray(110)); if nits_axis(st, sf) { app.auto.add(&format!("{prefix}.hdrWaveform"), plot, "HDR levels (cd/m²)"); } diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 564fbb59..241c5975 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -154,7 +154,7 @@ fn group(ui: &mut Ui, t: &Tokens, title: &str, add: impl FnOnce(&mut Ui)) { add(ui); }); let rect = r.response.rect; - let galley = ui.painter().layout_no_wrap(title.to_string(), Tokens::ui(12.0), t.text_dim); + let galley = ui.painter().layout_no_wrap(crate::i18n::t(title).to_string(), Tokens::ui(12.0), t.text_dim); let pos = rect.left_top() + vec2(12.0, -galley.size().y / 2.0); ui.painter().rect_filled(Rect::from_min_size(pos - vec2(4.0, 0.0), galley.size() + vec2(8.0, 0.0)), 0.0, t.panel_bg); ui.painter().galley(pos, galley, t.text_dim); @@ -166,14 +166,14 @@ fn field_label(ui: &mut Ui, text: &str) { let w = LABEL_W.min(ui.available_width() * 0.45); ui.allocate_ui_with_layout(vec2(w, 24.0), Layout::right_to_left(Align::Center), |ui| { if !text.is_empty() { - ui.label(RichText::new(format!("{text}:")).size(12.5)); + ui.label(RichText::new(format!("{}:", crate::i18n::t(text))).size(12.5)); } }); } fn unit(ui: &mut Ui, u: &str) { if !u.is_empty() { - ui.label(RichText::new(u).size(12.5)); + ui.label(RichText::new(crate::i18n::t(u)).size(12.5)); } } @@ -191,15 +191,18 @@ fn combo(app: &mut FilmcraftApp, ui: &mut Ui, key: &str, current: &str, opts: &[ let shown = opts.iter().find(|o| o.0 == current).map(|o| o.1.clone()).unwrap_or_else(|| current.to_string()); let mut chosen = None; let mut items: Vec<(String, Rect, String)> = Vec::new(); - let r = egui::ComboBox::from_id_salt(("settings", key)).selected_text(RichText::new(&shown).size(12.5)).width(width).height(480.0).show_ui(ui, |ui| { - for (v, l) in opts { - let r = ui.selectable_label(v == current, l); - items.push((format!("settings.{key}.{v}"), r.rect, l.clone())); - if r.clicked() { - chosen = Some(v.clone()); + let r = egui::ComboBox::from_id_salt(("settings", key)).selected_text(RichText::new(crate::i18n::t(&shown)).size(12.5)).width(width).height(480.0).show_ui( + ui, + |ui| { + for (v, l) in opts { + let r = ui.selectable_label(v == current, crate::i18n::t(l)); + items.push((format!("settings.{key}.{v}"), r.rect, l.clone())); + if r.clicked() { + chosen = Some(v.clone()); + } } - } - }); + }, + ); app.auto.add(&format!("settings.{key}"), r.response.rect, &shown); for (id, rect, l) in items { app.auto.add(&id, rect, &l); @@ -221,7 +224,7 @@ fn device_opts(app: &mut FilmcraftApp, d: &mut SettingsDraft, list: DeviceList) DeviceList::Inputs => dev.inputs, DeviceList::Outputs => dev.outputs, }; - let mut v = vec![(String::new(), "System Default".to_string())]; + let mut v = vec![(String::new(), tl!("System Default").to_string())]; v.extend(names.into_iter().map(|n| (n.clone(), n))); v } @@ -246,7 +249,7 @@ fn draw_field(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, f: &Fi match f.kind { Kind::Bool => { let mut b = cur.as_bool().unwrap_or(false); - let r = ui.checkbox(&mut b, RichText::new(f.label).size(12.5)); + let r = ui.checkbox(&mut b, RichText::new(crate::i18n::t(f.label)).size(12.5)); app.auto.add(&format!("settings.{}", f.key), r.rect, f.label); if r.changed() { put(&mut d.values, f.key, json!(b)); @@ -308,8 +311,8 @@ fn draw_field(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, f: &Fi field_label(ui, f.label); let mut s = choice_text(&cur); let hint = match f.kind { - Kind::Path => "Default location", - Kind::Font => "Font family", + Kind::Path => tl!("Default location"), + Kind::Font => tl!("Font family"), _ => "", }; let r = ui.add(egui::TextEdit::singleline(&mut s).desired_width(if matches!(f.kind, Kind::Path) { 300.0 } else { 220.0 }).hint_text(hint)); @@ -318,7 +321,7 @@ fn draw_field(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, f: &Fi put(&mut d.values, f.key, json!(s)); } if matches!(f.kind, Kind::Path) { - let b = ui.button("Browse…"); + let b = ui.button(tl!("Browse…")); app.auto.add(&format!("settings.{}.browse", f.key), b.rect, "Browse…"); if b.clicked() && let Some(dir) = app.hooks.pick_folder.as_mut().and_then(|p| p()) @@ -354,7 +357,7 @@ fn color_field(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, key: } fn note(ui: &mut Ui, t: &Tokens, text: &str) { - ui.add(egui::Label::new(RichText::new(text).size(11.5).color(t.text_dim)).wrap()); + ui.add(egui::Label::new(RichText::new(crate::i18n::t(text)).size(11.5).color(t.text_dim)).wrap()); } fn mb(bytes: u64) -> String { @@ -366,7 +369,7 @@ fn custom(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, name: &str match name { "labelColors" => { let tt = t; - group(ui, &tt, "Label Colors", |ui| { + group(ui, &tt, tl!("Label Colors"), |ui| { egui::Grid::new("settings-label-colors").num_columns(2).spacing(vec2(10.0, 5.0)).show(ui, |ui| { for l in Label::ALL.iter() { let id = settings::label_id(*l); @@ -391,41 +394,46 @@ fn custom(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, name: &str } "memoryInfo" => { let (used, budget) = app.frames.cache_usage(); - note(ui, &t, &format!("Decoded frames held for the monitors and thumbnails: {} of {}.", mb(used as u64), mb(budget as u64))); - note(ui, &t, "FilmCraft keeps the rest of the RAM free for other applications."); + note(ui, &t, &tlf!("Decoded frames held for the monitors and thumbnails: {used} of {budget}.", used = mb(used as u64), budget = mb(budget as u64))); + note(ui, &t, tl!("FilmCraft keeps the rest of the RAM free for other applications.")); } "mediaCacheInfo" => { if d.cache_info.is_null() { d.cache_info = app.session.execute("mediaCache.info", json!({})).unwrap_or(json!({})); } - let loc = d.cache_info["location"].as_str().unwrap_or("not available in this session").to_string(); + let loc = d.cache_info["location"].as_str().unwrap_or(tl!("not available in this session")).to_string(); let size = mb(d.cache_info["bytes"].as_u64().unwrap_or(0)); let files = d.cache_info["files"].as_u64().unwrap_or(0); - note(ui, &t, &format!("Currently: {loc} — {files} file(s), {size}")); + note(ui, &t, &tlf!("Currently: {loc} — {files} file(s), {size}", loc, files, size)); } "outputMapping" => { let ch = d.devices.as_ref().map(|x| x.output_channels).unwrap_or(0); let l = get(&d.values, "audioHardware.mapLeft").as_u64().unwrap_or(0) + 1; let r = get(&d.values, "audioHardware.mapRight").as_u64().unwrap_or(1) + 1; - let dev = if ch > 0 { format!(" ({ch} output channels)") } else { String::new() }; + let dev = if ch > 0 { tlf!(" ({ch} output channels)", ch) } else { String::new() }; note( ui, &t, - &format!("Programme left → device output {l}, right → device output {r}{dev}. Outputs beyond the device's channels fall back to 1 and 2."), + &tlf!( + "Programme left → device output {l}, right → device output {r}{dev}. Outputs beyond the device's channels fall back to 1 and 2.", + l, + r, + dev + ), ); } "autoSaveStatus" => { let status = app.session.execute("file.autoSaveStatus", json!({})).unwrap_or_default(); if let Some(dir) = status["autoSaveDir"].as_str() { - note(ui, &t, &format!("Auto-saves go to: {dir}")); + note(ui, &t, &tlf!("Auto-saves go to: {dir}", dir)); } if let Some(dir) = status["sessionDir"].as_str() { - note(ui, &t, &format!("Recovery copy: {dir}")); + note(ui, &t, &tlf!("Recovery copy: {dir}", dir)); } let last = match (status["lastAutoSaveAt"].as_str(), status["lastJournalAt"].as_str()) { - (Some(a), Some(j)) => format!("Last auto-save {a} · last recovery copy {j}"), - (Some(a), None) => format!("Last auto-save {a}"), - (None, Some(j)) => format!("Last recovery copy {j}"), + (Some(a), Some(j)) => tlf!("Last auto-save {a} · last recovery copy {j}", a, j), + (Some(a), None) => tlf!("Last auto-save {a}", a), + (None, Some(j)) => tlf!("Last recovery copy {j}", j), (None, None) => String::new(), }; if !last.is_empty() { @@ -464,12 +472,12 @@ fn rows(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, list: &[Row] Row::Note(text) => note(ui, &t, text), Row::Button { id, label, command } => { ui.horizontal(|ui| { - field_label(ui, "Remove Media Cache Files"); - let b = ui.button(*label); + field_label(ui, tl!("Remove Media Cache Files")); + let b = ui.button(crate::i18n::t(label)); app.auto.add(&format!("settings.{id}"), b.rect, label); if b.clicked() { d.message = match app.session.execute(command, json!({})) { - Ok(v) => format!("Deleted {} file(s), {}.", v["files"].as_u64().unwrap_or(0), mb(v["bytes"].as_u64().unwrap_or(0))), + Ok(v) => tlf!("Deleted {n} file(s), {size}.", n = v["files"].as_u64().unwrap_or(0), size = mb(v["bytes"].as_u64().unwrap_or(0))), Err(e) => e.to_string(), }; d.cache_info = Value::Null; @@ -493,7 +501,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { // title bar (Premiere keeps "Preferences" as the window title) let (bar, _) = ui.allocate_exact_size(vec2(W, 30.0), Sense::hover()); ui.painter().rect_filled(bar, CornerRadius { nw: 10, ne: 10, sw: 0, se: 0 }, t.header_bg); - ui.painter().text(bar.center(), Align2::CENTER_CENTER, "Preferences", Tokens::semibold(13.0), t.text_dim); + ui.painter().text(bar.center(), Align2::CENTER_CENTER, tl!("Preferences"), Tokens::semibold(13.0), t.text_dim); app.auto.add("settings.window", bar, "Preferences"); ui.add_space(14.0); ui.horizontal_top(|ui| { @@ -510,7 +518,13 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { } else if r.hovered() { ui.painter().rect_filled(row, 0.0, t.hover.gamma_multiply(0.6)); } - ui.painter().text(row.left_center() + vec2(8.0, 0.0), Align2::LEFT_CENTER, c.title, Tokens::ui(12.5), if sel { t.text } else { t.text_dim }); + ui.painter().text( + row.left_center() + vec2(8.0, 0.0), + Align2::LEFT_CENTER, + crate::i18n::t(c.title), + Tokens::ui(12.5), + if sel { t.text } else { t.text_dim }, + ); app.auto.add(&format!("settings.category.{}", c.id), row, c.title); if r.clicked() { d.page = c.id.into(); @@ -536,10 +550,10 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { ui.add_space(14.0); ui.horizontal(|ui| { ui.add_space(20.0); - if button(app, ui, "settings.help", "Help", false) { + if button(app, ui, "settings.help", tl!("Help"), false) { crate::links::open(ctx, &format!("{}/blob/main/docs/project-files.md#settings", crate::links::GITHUB)); } - if button(app, ui, "settings.reset", "Reset…", false) { + if button(app, ui, "settings.reset", tl!("Reset…"), false) { // this category back to its defaults (OK applies it) let defaults = filmcraft_engine::autosave::Preferences::default().to_value(); let keep = get(&d.values, "general.recentProjects").clone(); @@ -548,10 +562,10 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { } ui.with_layout(Layout::right_to_left(Align::Center), |ui| { ui.add_space(20.0); - if button(app, ui, "settings.ok", "OK", true) { + if button(app, ui, "settings.ok", tl!("OK"), true) { apply = true; } - if button(app, ui, "settings.cancel", "Cancel", false) { + if button(app, ui, "settings.cancel", tl!("Cancel"), false) { close = true; } }); diff --git a/crates/ui-egui/src/panels/shortcuts_dialog.rs b/crates/ui-egui/src/panels/shortcuts_dialog.rs index 262d830c..05d3a574 100644 --- a/crates/ui-egui/src/panels/shortcuts_dialog.rs +++ b/crates/ui-egui/src/panels/shortcuts_dialog.rs @@ -77,7 +77,7 @@ fn label_of(app: &FilmcraftApp, id: &str) -> String { .command(id) .map(|c| c.label) .or_else(|| filmcraft_engine::find_command(id).map(|c| c.label.to_string())) - .unwrap_or_else(|| id.to_string()) + .map_or_else(|| id.to_string(), |l| crate::i18n::t(&l).to_string()) } fn exec(app: &mut FilmcraftApp, cmd: &str, params: Value) -> Option { @@ -99,7 +99,7 @@ fn assign(app: &mut FilmcraftApp, command: &str, keys: &str, add: bool) { let mut msg = format!("{disp} → {}", label_of(app, command)); let moved: Vec = r["reassigned"].as_array().into_iter().flatten().filter_map(|b| b["command"].as_str().map(|c| label_of(app, c))).collect(); if !moved.is_empty() { - msg.push_str(&format!(" (removed from {})", moved.join(", "))); + msg.push_str(&tlf!(" (removed from {list})", list = moved.join(", "))); } app.shortcut_editor.message = msg; } @@ -123,12 +123,12 @@ fn record(app: &mut FilmcraftApp, ctx: &egui::Context) { let Some((key, m)) = got else { return }; app.shortcut_editor.recording = None; if key == egui::Key::Escape && !m.any() { - app.shortcut_editor.message = "Cancelled".into(); + app.shortcut_editor.message = tl!("Cancelled").into(); return; } match chord_of(key, m) { Some(k) => assign(app, &cmd, &k, add), - None => app.shortcut_editor.message = format!("{key:?} can't be used as a shortcut"), + None => app.shortcut_editor.message = tlf!("{key} can't be used as a shortcut", key = format!("{key:?}")), } } @@ -140,7 +140,8 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { let size = vec2((screen.width() - 40.0).clamp(760.0, 1320.0), (screen.height() - 60.0).clamp(560.0, 900.0)); let mut close: Option = None; // Some(true) = OK, Some(false) = Cancel let mut open = true; - egui::Window::new("Keyboard Shortcuts") + egui::Window::new(tl!("Keyboard Shortcuts")) + .id(egui::Id::new("Keyboard Shortcuts")) .id(egui::Id::new("keyboard-shortcuts")) .open(&mut open) .collapsible(false) @@ -202,68 +203,68 @@ fn header(app: &mut FilmcraftApp, ui: &mut egui::Ui, t: &Tokens) { let builtin: Vec = presets["builtin"].as_array().into_iter().flatten().filter_map(|v| v.as_str().map(str::to_string)).collect(); let custom: Vec = presets["custom"].as_array().into_iter().flatten().filter_map(|v| v.as_str().map(str::to_string)).collect(); ui.horizontal(|ui| { - ui.label(RichText::new("Preset:").size(13.0).color(t.text_dim)); - let shown = if modified { format!("{active} (modified)") } else { active.clone() }; + ui.label(RichText::new(tl!("Preset:")).size(13.0).color(t.text_dim)); + let shown = if modified { tlf!("{active} (modified)", active) } else { active.clone() }; let r = egui::ComboBox::from_id_salt("shortcuts-preset").width(280.0).selected_text(shown).show_ui(ui, |ui| { for name in builtin.iter().chain(custom.iter()) { let r = ui.selectable_label(*name == active && !modified, name); app.auto.add(&format!("shortcuts.preset.{name}"), r.rect, name); if r.clicked() { exec(app, "shortcuts.loadPreset", json!({"name": name})); - app.shortcut_editor.message = format!("Loaded “{name}”"); + app.shortcut_editor.message = tlf!("Loaded “{name}”", name); } } }); app.auto.add("shortcuts.preset", r.response.rect, "Preset"); ui.add_space(8.0); - if small_button(app, ui, "shortcuts.saveAs", "Save As…", true) { - app.shortcut_editor.save_name = Some(if builtin.contains(&active) { "My Shortcuts".into() } else { active.clone() }); + if small_button(app, ui, "shortcuts.saveAs", tl!("Save As…"), true) { + app.shortcut_editor.save_name = Some(if builtin.contains(&active) { tl!("My Shortcuts").into() } else { active.clone() }); } let can_delete = custom.contains(&active); - if small_button(app, ui, "shortcuts.delete", "Delete", can_delete) { + if small_button(app, ui, "shortcuts.delete", tl!("Delete"), can_delete) { exec(app, "shortcuts.deletePreset", json!({"name": active})); - app.shortcut_editor.message = format!("Deleted preset “{active}”"); + app.shortcut_editor.message = tlf!("Deleted preset “{active}”", active); } - if small_button(app, ui, "shortcuts.export", "Export…", true) { - let picked = app.hooks.pick_save_as.as_mut().and_then(|f| f("Keyboard Shortcuts", &["json"], &format!("{active}.json"))); + if small_button(app, ui, "shortcuts.export", tl!("Export…"), true) { + let picked = app.hooks.pick_save_as.as_mut().and_then(|f| f(tl!("Keyboard Shortcuts"), &["json"], &format!("{active}.json"))); match picked { Some(path) => { if exec(app, "shortcuts.export", json!({"path": path})).is_some() { - app.shortcut_editor.message = format!("Exported to {path}"); + app.shortcut_editor.message = tlf!("Exported to {path}", path); } } - None => app.shortcut_editor.message = "Export: no file chosen (agents: shortcuts.export {path})".into(), + None => app.shortcut_editor.message = tl!("Export: no file chosen (agents: shortcuts.export {path})").into(), } } - if small_button(app, ui, "shortcuts.import", "Import…", true) { - let picked = app.hooks.pick_open_file.as_mut().and_then(|f| f("Keyboard Shortcuts", &["json"])); + if small_button(app, ui, "shortcuts.import", tl!("Import…"), true) { + let picked = app.hooks.pick_open_file.as_mut().and_then(|f| f(tl!("Keyboard Shortcuts"), &["json"])); match picked { Some(path) => { if let Some(r) = exec(app, "shortcuts.import", json!({"path": path})) { - app.shortcut_editor.message = format!("Imported “{}”", r["name"].as_str().unwrap_or("")); + app.shortcut_editor.message = tlf!("Imported “{name}”", name = r["name"].as_str().unwrap_or("")); } } - None => app.shortcut_editor.message = "Import: no file chosen (agents: shortcuts.import {path})".into(), + None => app.shortcut_editor.message = tl!("Import: no file chosen (agents: shortcuts.import {path})").into(), } } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.label(RichText::new(if cfg!(target_os = "macos") { "Keyboard: US (macOS)" } else { "Keyboard: US" }).size(12.0).color(t.text_dim)); + ui.label(RichText::new(if cfg!(target_os = "macos") { tl!("Keyboard: US (macOS)") } else { tl!("Keyboard: US") }).size(12.0).color(t.text_dim)); }); }); if let Some(mut name) = app.shortcut_editor.save_name.clone() { ui.horizontal(|ui| { - ui.label(RichText::new("Save preset as:").size(13.0).color(t.text_dim)); + ui.label(RichText::new(tl!("Save preset as:")).size(13.0).color(t.text_dim)); let r = ui.add(egui::TextEdit::singleline(&mut name).desired_width(260.0)); app.auto.add("shortcuts.saveAs.name", r.rect, "Preset name"); let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); - if small_button(app, ui, "shortcuts.saveAs.ok", "Save", !name.trim().is_empty()) || enter { + if small_button(app, ui, "shortcuts.saveAs.ok", tl!("Save"), !name.trim().is_empty()) || enter { if exec(app, "shortcuts.savePreset", json!({"name": name})).is_some() { - app.shortcut_editor.message = format!("Saved preset “{}”", name.trim()); + app.shortcut_editor.message = tlf!("Saved preset “{name}”", name = name.trim()); app.shortcut_editor.save_name = None; } else { app.shortcut_editor.save_name = Some(name.clone()); } - } else if small_button(app, ui, "shortcuts.saveAs.cancel", "Cancel", true) { + } else if small_button(app, ui, "shortcuts.saveAs.cancel", tl!("Cancel"), true) { app.shortcut_editor.save_name = None; } else { app.shortcut_editor.save_name = Some(name.clone()); @@ -272,12 +273,14 @@ fn header(app: &mut FilmcraftApp, ui: &mut egui::Ui, t: &Tokens) { } ui.add_space(4.0); ui.horizontal(|ui| { - ui.label(RichText::new("Commands:").size(13.0).color(t.text_dim)); + ui.label(RichText::new(tl!("Commands:")).size(13.0).color(t.text_dim)); let cur = app.shortcut_editor.context.clone(); - let r = - egui::ComboBox::from_id_salt("shortcuts-context").width(280.0).selected_text(if cur.is_empty() { APPLICATION } else { &cur }).show_ui(ui, |ui| { + let r = egui::ComboBox::from_id_salt("shortcuts-context") + .width(280.0) + .selected_text(crate::i18n::t(if cur.is_empty() { APPLICATION } else { &cur })) + .show_ui(ui, |ui| { for c in std::iter::once(APPLICATION).chain(PANELS.iter().copied()) { - let label = if c == APPLICATION { c.to_string() } else { format!("{c} Panel") }; + let label = if c == APPLICATION { crate::i18n::t(c).to_string() } else { tlf!("{panel} Panel", panel = crate::i18n::t(c)) }; let r = ui.selectable_label(cur == c, label); app.auto.add(&format!("shortcuts.context.{c}"), r.rect, c); if r.clicked() { @@ -567,9 +570,9 @@ fn keyboard(app: &mut FilmcraftApp, ui: &mut egui::Ui, area: Rect, t: &Tokens) { fn legend(ui: &mut egui::Ui, t: &Tokens) { ui.horizontal(|ui| { for (c, text) in [ - (APP_KEY, "Application shortcuts work whatever panel has focus"), - (PANEL_KEY, "Panel shortcuts override application shortcuts while that panel has focus"), - (MOD_KEY, "Modifier held / selected"), + (APP_KEY, tl!("Application shortcuts work whatever panel has focus")), + (PANEL_KEY, tl!("Panel shortcuts override application shortcuts while that panel has focus")), + (MOD_KEY, tl!("Modifier held / selected")), ] { let (r, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover()); ui.painter().rect_filled(r, 2.0, c); @@ -583,21 +586,23 @@ fn command_list(app: &mut FilmcraftApp, ui: &mut egui::Ui, height: f32, t: &Toke let plat = Platform::current(); ui.horizontal(|ui| { let mut q = app.shortcut_editor.search.clone(); - let r = ui.add(egui::TextEdit::singleline(&mut q).hint_text("Search commands or keys").desired_width(320.0)); + let r = ui.add(egui::TextEdit::singleline(&mut q).hint_text(tl!("Search commands or keys")).desired_width(320.0)); app.auto.add("shortcuts.search", r.rect, "Search"); app.shortcut_editor.search = q; if let Some(sel) = app.shortcut_editor.selected.clone() { - ui.label(RichText::new(format!("Selected: {}", label_of(app, &sel))).size(12.0).color(t.text_dim)); + ui.label(RichText::new(tlf!("Selected: {command}", command = label_of(app, &sel))).size(12.0).color(t.text_dim)); } }); ui.add_space(4.0); - let rows = app.session.execute("shortcuts.list", json!({"query": app.shortcut_editor.search})).unwrap_or_default(); + let rows = app.session.execute("shortcuts.list", json!({})).unwrap_or_default(); let rows = rows.as_array().cloned().unwrap_or_default(); + let query = app.shortcut_editor.search.to_lowercase(); + let rows: Vec<_> = rows.into_iter().filter(|row| matches_search(row, &query)).collect(); let w = ui.available_width(); // column header let (hr, _) = ui.allocate_exact_size(vec2(w, 22.0), Sense::hover()); - ui.painter().text(hr.left_center() + vec2(8.0, 0.0), Align2::LEFT_CENTER, "Command", Tokens::semibold(12.5), t.text_dim); - ui.painter().text(pos2(hr.min.x + w * 0.58, hr.center().y), Align2::LEFT_CENTER, "Shortcut", Tokens::semibold(12.5), t.text_dim); + ui.painter().text(hr.left_center() + vec2(8.0, 0.0), Align2::LEFT_CENTER, tl!("Command"), Tokens::semibold(12.5), t.text_dim); + ui.painter().text(pos2(hr.min.x + w * 0.58, hr.center().y), Align2::LEFT_CENTER, tl!("Shortcut"), Tokens::semibold(12.5), t.text_dim); egui::ScrollArea::vertical().id_salt("shortcuts-list").max_height(height - 50.0).auto_shrink([false, false]).show(ui, |ui| { let mut last_cat = String::new(); for (i, row) in rows.iter().enumerate() { @@ -607,7 +612,7 @@ fn command_list(app: &mut FilmcraftApp, ui: &mut egui::Ui, height: f32, t: &Toke let (cr, _) = ui.allocate_exact_size(vec2(w, 22.0), Sense::hover()); let c = cr.left_center() + vec2(9.0, 0.0); ui.painter().add(egui::Shape::convex_polygon(vec![c + vec2(-4.0, -2.5), c + vec2(4.0, -2.5), c + vec2(0.0, 3.0)], t.text_dim, Stroke::NONE)); - ui.painter().text(cr.left_center() + vec2(20.0, 0.0), Align2::LEFT_CENTER, &cat, Tokens::semibold(12.0), t.text); + ui.painter().text(cr.left_center() + vec2(20.0, 0.0), Align2::LEFT_CENTER, crate::i18n::t(&cat), Tokens::semibold(12.0), t.text); last_cat = cat; } let (r, resp) = ui.allocate_exact_size(vec2(w, 26.0), Sense::click()); @@ -620,7 +625,13 @@ fn command_list(app: &mut FilmcraftApp, ui: &mut egui::Ui, height: f32, t: &Toke t.row_alt }; ui.painter().rect_filled(r, 0.0, bg); - ui.painter().text(r.left_center() + vec2(22.0, 0.0), Align2::LEFT_CENTER, row["label"].as_str().unwrap_or(""), Tokens::ui(12.5), t.text); + ui.painter().text( + r.left_center() + vec2(22.0, 0.0), + Align2::LEFT_CENTER, + crate::i18n::t(row["label"].as_str().unwrap_or("")), + Tokens::ui(12.5), + t.text, + ); app.auto.add(&format!("shortcuts.row.{id}"), r, row["label"].as_str().unwrap_or("")); if resp.clicked() { app.shortcut_editor.selected = Some(id.clone()); @@ -629,7 +640,7 @@ fn command_list(app: &mut FilmcraftApp, ui: &mut egui::Ui, height: f32, t: &Toke let cell = Rect::from_min_max(pos2(r.min.x + w * 0.58, r.min.y + 2.0), pos2(r.max.x - 34.0, r.max.y - 2.0)); let recording = app.shortcut_editor.recording.as_ref().is_some_and(|(c, _)| *c == id); let text = if recording { - "Type a shortcut… (Esc cancels)".to_string() + tl!("Type a shortcut… (Esc cancels)").to_string() } else { row["shortcuts"] .as_array() @@ -658,7 +669,7 @@ fn command_list(app: &mut FilmcraftApp, ui: &mut egui::Ui, height: f32, t: &Toke app.shortcut_editor.recording = Some((id.clone(), false)); } let plus = Rect::from_min_size(pos2(r.max.x - 30.0, r.min.y + 3.0), vec2(20.0, 20.0)); - let presp = ui.interact(plus, egui::Id::new(("shortcut-add", &id)), Sense::click()).on_hover_text("Add another shortcut"); + let presp = ui.interact(plus, egui::Id::new(("shortcut-add", &id)), Sense::click()).on_hover_text(tl!("Add another shortcut")); ui.painter().text( plus.center(), Align2::CENTER_CENTER, @@ -673,24 +684,31 @@ fn command_list(app: &mut FilmcraftApp, ui: &mut egui::Ui, height: f32, t: &Toke } } if rows.is_empty() { - ui.label(RichText::new("No matching commands").color(t.text_dim)); + ui.label(RichText::new(tl!("No matching commands")).color(t.text_dim)); } }); let _ = plat; } +fn matches_search(row: &serde_json::Value, query: &str) -> bool { + ["id", "label", "category"].iter().any(|key| crate::i18n::matches_query(row.get(key).and_then(serde_json::Value::as_str).unwrap_or(""), query)) + || row.get("shortcuts").and_then(serde_json::Value::as_array).into_iter().flatten().any(|binding| { + ["keys", "display", "panel"].iter().any(|key| crate::i18n::matches_query(binding.get(key).and_then(serde_json::Value::as_str).unwrap_or(""), query)) + }) +} + fn key_detail(app: &mut FilmcraftApp, ui: &mut egui::Ui, height: f32, t: &Tokens) { let plat = Platform::current(); let key = app.shortcut_editor.key.clone(); ui.horizontal(|ui| { - ui.label(RichText::new("Key:").size(13.0).strong().color(t.text)); + ui.label(RichText::new(tl!("Key:")).size(13.0).strong().color(t.text)); if let Some(k) = &key { ui.label(RichText::new(key_legend(k, plat.is_mac())).size(13.0).color(t.hot_text)); } }); let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::hover()); - ui.painter().text(hr.left_center() + vec2(8.0, 0.0), Align2::LEFT_CENTER, "Modifiers", Tokens::semibold(12.5), t.text_dim); - ui.painter().text(hr.left_center() + vec2(110.0, 0.0), Align2::LEFT_CENTER, "Command", Tokens::semibold(12.5), t.text_dim); + ui.painter().text(hr.left_center() + vec2(8.0, 0.0), Align2::LEFT_CENTER, tl!("Modifiers"), Tokens::semibold(12.5), t.text_dim); + ui.painter().text(hr.left_center() + vec2(110.0, 0.0), Align2::LEFT_CENTER, tl!("Command"), Tokens::semibold(12.5), t.text_dim); let list = key.as_ref().and_then(|k| app.session.execute("shortcuts.forKey", json!({"key": k})).ok()).unwrap_or_default(); egui::ScrollArea::vertical().id_salt("shortcuts-key").max_height(height - 90.0).auto_shrink([false, false]).show(ui, |ui| { let w = ui.available_width(); @@ -703,7 +721,7 @@ fn key_detail(app: &mut FilmcraftApp, ui: &mut egui::Ui, height: f32, t: &Tokens let d = k.display(plat); let legend = key_legend(c.key, plat.is_mac()); let m = d.trim_end_matches(&legend).trim_end_matches(c.key).trim_end_matches('+').to_string(); - if m.is_empty() { "None".to_string() } else { m } + if m.is_empty() { tl!("None").to_string() } else { m } }); ui.painter().text(r.left_center() + vec2(8.0, 0.0), Align2::LEFT_CENTER, mods.unwrap_or_default(), Tokens::ui(12.5), t.text); let cmd = b["command"].as_str().unwrap_or(""); @@ -714,17 +732,17 @@ fn key_detail(app: &mut FilmcraftApp, ui: &mut egui::Ui, height: f32, t: &Tokens ui.painter().text(r.left_center() + vec2(110.0, 0.0), Align2::LEFT_CENTER, label, Tokens::ui(12.5), t.text); } if key.is_some() && list.as_array().is_none_or(|a| a.is_empty()) { - ui.label(RichText::new("Nothing is assigned to this key").color(t.text_dim)); + ui.label(RichText::new(tl!("Nothing is assigned to this key")).color(t.text_dim)); } if key.is_none() { - ui.label(RichText::new("Click a key on the keyboard to see its shortcuts").color(t.text_dim)); + ui.label(RichText::new(tl!("Click a key on the keyboard to see its shortcuts")).color(t.text_dim)); } }); // assign the selected command to the clicked key with the selected modifiers if let (Some(k), Some(cmd)) = (key, app.shortcut_editor.selected.clone()) { let keys = format!("{}{}", app.shortcut_editor.mods.prefix(), k); let disp = Chord::parse(&keys).map(|c| c.display(plat)).unwrap_or(keys.clone()); - let label = format!("Assign {disp} to {}", label_of(app, &cmd)); + let label = tlf!("Assign {key} to {command}", key = disp, command = label_of(app, &cmd)); if small_button(app, ui, "shortcuts.assignKey", &label, true) { assign(app, &cmd, &keys, false); } @@ -742,11 +760,15 @@ fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, t: &Tokens) -> Option for (ctx, keys, cmds) in conflicts.iter().take(2) { let disp = Chord::parse(keys).map(|c| c.display(plat)).unwrap_or(keys.clone()); let names: Vec = cmds.iter().map(|c| label_of(app, c)).collect(); - let r = ui.label(RichText::new(format!("Conflict: {disp} is assigned to {} ({ctx})", names.join(" and "))).size(12.0).color(WARN)); + let r = ui.label( + RichText::new(tlf!("Conflict: {key} is assigned to {commands} ({ctx})", key = disp, commands = names.join(tl!(" and ")), ctx)) + .size(12.0) + .color(WARN), + ); app.auto.add("shortcuts.conflict", r.rect, "conflict"); } let msg = if app.shortcut_editor.message.is_empty() { - "Click a command's Shortcut cell and press keys to assign it; + adds another shortcut.".to_string() + tl!("Click a command's Shortcut cell and press keys to assign it; + adds another shortcut.").to_string() } else { app.shortcut_editor.message.clone() }; @@ -754,13 +776,14 @@ fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, t: &Tokens) -> Option app.auto.add("shortcuts.message", r.rect, &msg); }); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let ok = - ui.add(egui::Button::new(RichText::new("OK").size(13.0).color(Color32::WHITE)).fill(t.accent).min_size(vec2(72.0, 28.0)).corner_radius(14.0)); + let ok = ui.add( + egui::Button::new(RichText::new(tl!("OK")).size(13.0).color(Color32::WHITE)).fill(t.accent).min_size(vec2(72.0, 28.0)).corner_radius(14.0), + ); app.auto.add("shortcuts.ok", ok.rect, "OK"); if ok.clicked() { close = Some(true); } - let cancel = ui.add(egui::Button::new(RichText::new("Cancel").size(13.0)).min_size(vec2(72.0, 28.0)).corner_radius(14.0)); + let cancel = ui.add(egui::Button::new(RichText::new(tl!("Cancel")).size(13.0)).min_size(vec2(72.0, 28.0)).corner_radius(14.0)); app.auto.add("shortcuts.cancel", cancel.rect, "Cancel"); if cancel.clicked() { close = Some(false); @@ -768,20 +791,36 @@ fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, t: &Tokens) -> Option ui.add_space(16.0); let sel = app.shortcut_editor.selected.clone(); let panel = app.shortcut_editor.panel().map(str::to_string); - if small_button(app, ui, "shortcuts.clear", "Clear", sel.is_some()) + if small_button(app, ui, "shortcuts.clear", tl!("Clear"), sel.is_some()) && let Some(c) = sel && let Some(r) = exec(app, "shortcuts.clear", json!({"command": c, "panel": panel.clone().unwrap_or_else(|| APPLICATION.into())})) { - app.shortcut_editor.message = format!("Cleared {} shortcut(s) of {}", r["removed"], label_of(app, &c)); + app.shortcut_editor.message = tlf!("Cleared {n} shortcut(s) of {command}", n = r["removed"], command = label_of(app, &c)); } let (cu, cr) = (app.session.shortcuts.can_undo(), app.session.shortcuts.can_redo()); - if small_button(app, ui, "shortcuts.redo", "Redo", cr) { + if small_button(app, ui, "shortcuts.redo", tl!("Redo"), cr) { exec(app, "shortcuts.redo", json!({})); } - if small_button(app, ui, "shortcuts.undo", "Undo", cu) { + if small_button(app, ui, "shortcuts.undo", tl!("Undo"), cu) { exec(app, "shortcuts.undo", json!({})); } }); }); close } + +#[cfg(test)] +mod localization_tests { + use super::*; + + #[test] + fn command_search_accepts_spanish_labels_ids_and_keys() { + crate::i18n::set_current(crate::i18n::Language::Es); + let row = json!({"id": "edit.undo", "label": "Undo", "category": "Edit", "shortcuts": [{"keys": "Cmd+Z", "display": "Ctrl+Z"}]}); + for query in ["deshacer", "undo", "edit.undo", "ctrl+z", "cmd+z"] { + assert!(matches_search(&row, query), "{query}"); + } + assert!(!matches_search(&row, "rehacer")); + crate::i18n::set_current(crate::i18n::Language::En); + } +} diff --git a/crates/ui-egui/src/panels/text.rs b/crates/ui-egui/src/panels/text.rs index 74835412..4408779d 100644 --- a/crates/ui-egui/src/panels/text.rs +++ b/crates/ui-egui/src/panels/text.rs @@ -22,14 +22,15 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { // tabs let mut x = rect.min.x + 12.0; for tab in TABS { - let w = tab.len() as f32 * 7.0 + 16.0; + let shown = crate::i18n::t(tab); + let w = shown.chars().count() as f32 * 7.0 + 16.0; let r = Rect::from_min_size(pos2(x, rect.min.y + 4.0), vec2(w, 24.0)); let resp = ui.interact(r, egui::Id::new(("text-tab", tab)), Sense::click()); let active = app.ui.text_tab == tab; ui.painter().text( pos2(r.min.x, r.center().y), Align2::LEFT_CENTER, - tab, + shown, if active { Tokens::semibold(12.5) } else { Tokens::ui(12.5) }, if active { t.text } else { t.text_dim }, ); @@ -46,7 +47,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { match app.ui.text_tab.as_str() { "Captions" => captions(app, ui, body), "Transcript" => transcript(app, ui, body), - _ => crate::dock::placeholder(ui, body, &t, "Graphics text search arrives with M10.1–M10.2"), + _ => crate::dock::placeholder(ui, body, &t, tl!("Graphics text search arrives with M10.1–M10.2")), } } @@ -64,19 +65,21 @@ fn tool_button(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, icon: Icon, i fn captions(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let Some(seq) = app.session.active_sequence().cloned() else { - crate::dock::placeholder(ui, rect, &t, "Open a sequence to work with captions"); + crate::dock::placeholder(ui, rect, &t, tl!("Open a sequence to work with captions")); return; }; let mut actions: Vec<(String, Value)> = Vec::new(); if seq.caption_tracks.is_empty() { let c = rect.center(); icons::paint(ui.painter(), Rect::from_center_size(c - vec2(0.0, 70.0), vec2(40.0, 40.0)), Icon::Captions, t.text_dim); - ui.painter().text(c - vec2(0.0, 30.0), Align2::CENTER_CENTER, "Add captions", Tokens::semibold(16.0), t.text); - ui.painter().text(c - vec2(0.0, 8.0), Align2::CENTER_CENTER, "Create a caption track or import a caption file.", Tokens::ui(12.0), t.text_dim); - for (i, (id, label, cmd)) in - [("text.captions.newTrack", "Create new caption track", "captions.newTrack"), ("text.captions.import", "Import captions file…", "captions.import")] - .into_iter() - .enumerate() + ui.painter().text(c - vec2(0.0, 30.0), Align2::CENTER_CENTER, tl!("Add captions"), Tokens::semibold(16.0), t.text); + ui.painter().text(c - vec2(0.0, 8.0), Align2::CENTER_CENTER, tl!("Create a caption track or import a caption file."), Tokens::ui(12.0), t.text_dim); + for (i, (id, label, cmd)) in [ + ("text.captions.newTrack", tl!("Create new caption track"), "captions.newTrack"), + ("text.captions.import", tl!("Import captions file…"), "captions.import"), + ] + .into_iter() + .enumerate() { let r = Rect::from_center_size(c + vec2(0.0, 26.0 + i as f32 * 34.0), vec2(200.0, 26.0)); let resp = ui.interact(r, egui::Id::new(id), Sense::click()); @@ -102,7 +105,7 @@ fn captions(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { // ---- toolbar: search, track picker, add / split / merge / delete let bar = Rect::from_min_size(rect.min + vec2(10.0, 2.0), vec2(rect.width() - 20.0, 26.0)); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_size(bar.min, vec2(170.0f32.min(bar.width() * 0.4), 24.0)))); - let sresp = crate::widgets::search_field(&mut child, &mut app.ui.caption_search, "Search", 170.0f32.min(bar.width() * 0.4), &t); + let sresp = crate::widgets::search_field(&mut child, &mut app.ui.caption_search, tl!("Search"), 170.0f32.min(bar.width() * 0.4), &t); app.auto.add("text.captions.search", sresp.rect, "Search captions"); let mut x = bar.min.x + 180.0f32.min(bar.width() * 0.4 + 10.0); let picker = Rect::from_min_size(pos2(x, bar.min.y + 1.0), vec2(130.0, 22.0)); @@ -111,13 +114,13 @@ fn captions(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add("text.captions.track", picker, "Caption track"); egui::Popup::menu(&presp).show(|ui| { for (i, tr) in seq.caption_tracks.iter().enumerate() { - if ui.selectable_label(i == track_idx, format!("C{} · {} ({})", i + 1, tr.name, tr.format.label())).clicked() { + if ui.selectable_label(i == track_idx, format!("C{} · {} ({})", i + 1, tr.name, crate::i18n::t(tr.format.label()))).clicked() { ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("text-cap-track"), i)); } } ui.separator(); for f in CaptionFormat::ALL { - if ui.button(format!("New {} track", f.label())).clicked() { + if ui.button(tlf!("New {format} track", format = crate::i18n::t(f.label()))).clicked() { actions.push(("captions.newTrack".into(), json!({"format": f.label()}))); } } @@ -127,17 +130,17 @@ fn captions(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let any_sel = !sel.is_empty(); let under = track.caption_at(ph).filter(|c| c.start < ph).map(|c| c.id); let tools: [(Icon, &str, &str, bool, &str, Value); 5] = [ - (Icon::Plus, "text.captions.add", "Add caption at playhead", track.caption_at(ph).is_none(), "captions.add", json!({"track": track.id.0})), + (Icon::Plus, "text.captions.add", tl!("Add caption at playhead"), track.caption_at(ph).is_none(), "captions.add", json!({"track": track.id.0})), ( Icon::Razor, "text.captions.split", - "Split caption at playhead", + tl!("Split caption at playhead"), under.is_some(), "captions.split", json!({"caption": under.map(|c| c.0), "time": ph.0}), ), - (Icon::Link, "text.captions.merge", "Merge selected captions", sel.len() > 1, "captions.merge", json!({})), - (Icon::Trash, "text.captions.delete", "Delete selected captions", any_sel, "captions.delete", json!({})), + (Icon::Link, "text.captions.merge", tl!("Merge selected captions"), sel.len() > 1, "captions.merge", json!({})), + (Icon::Trash, "text.captions.delete", tl!("Delete selected captions"), any_sel, "captions.delete", json!({})), (Icon::Export, "text.captions.export", "Export captions…", !track.captions.is_empty(), "captions.export", json!({"track": track.id.0})), ]; for (icon, id, label, enabled, cmd, params) in tools { @@ -252,8 +255,12 @@ fn captions(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } if shown == 0 { ui.label( - egui::RichText::new(if q.is_empty() { "No captions on this track. Press + to add one at the playhead." } else { "No matching captions." }) - .color(t.text_faint), + egui::RichText::new(if q.is_empty() { + tl!("No captions on this track. Press + to add one at the playhead.") + } else { + tl!("No matching captions.") + }) + .color(t.text_faint), ); } }); @@ -263,7 +270,7 @@ fn captions(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { fn transcript(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; if app.session.active_sequence().is_none() { - crate::dock::placeholder(ui, rect, &t, "Open a sequence to see its transcript"); + crate::dock::placeholder(ui, rect, &t, tl!("Open a sequence to see its transcript")); return; } let mut actions: Vec<(String, Value)> = Vec::new(); @@ -271,17 +278,17 @@ fn transcript(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if words.is_empty() { let c = rect.center(); icons::paint(ui.painter(), Rect::from_center_size(c - vec2(0.0, 70.0), vec2(40.0, 40.0)), Icon::Captions, t.text_dim); - ui.painter().text(c - vec2(0.0, 30.0), Align2::CENTER_CENTER, "Transcribe sequence", Tokens::semibold(16.0), t.text); + ui.painter().text(c - vec2(0.0, 30.0), Align2::CENTER_CENTER, tl!("Transcribe sequence"), Tokens::semibold(16.0), t.text); let note = if filmcraft_speech_available(app) { - "Speech-to-text turns the dialogue into editable text." + tl!("Speech-to-text turns the dialogue into editable text.") } else { - "This build has no speech-to-text; import a transcript with transcript.set." + tl!("This build has no speech-to-text; import a transcript with transcript.set.") }; ui.painter().text(c - vec2(0.0, 8.0), Align2::CENTER_CENTER, note, Tokens::ui(12.0), t.text_dim); let r = Rect::from_center_size(c + vec2(0.0, 26.0), vec2(200.0, 26.0)); let resp = ui.interact(r, egui::Id::new("text.transcript.generate"), Sense::click()); ui.painter().rect_filled(r, 13.0, if resp.hovered() { t.accent_hover } else { t.accent }); - ui.painter().text(r.center(), Align2::CENTER_CENTER, "Transcribe", Tokens::semibold(12.0), Color32::WHITE); + ui.painter().text(r.center(), Align2::CENTER_CENTER, tl!("Transcribe"), Tokens::semibold(12.0), Color32::WHITE); app.auto.add("text.transcript.generate", r, "Transcribe"); if resp.clicked() { actions.push(("transcript.generate".into(), json!({}))); @@ -296,16 +303,16 @@ fn transcript(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let bar = Rect::from_min_size(rect.min + vec2(10.0, 2.0), vec2(rect.width() - 20.0, 26.0)); let sw = 170.0f32.min(bar.width() * 0.4); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_size(bar.min, vec2(sw, 24.0)))); - let sresp = crate::widgets::search_field(&mut child, &mut app.ui.transcript_search, "Search", sw, &t); + let sresp = crate::widgets::search_field(&mut child, &mut app.ui.transcript_search, tl!("Search"), sw, &t); app.auto.add("text.transcript.search", sresp.rect, "Search transcript"); let hits: Vec> = filmcraft_edit::transcript::search(&words, &app.ui.transcript_search); let mut x = bar.min.x + sw + 10.0; let range = sel.map(|(a, b)| json!({"from": a.min(b), "to": a.max(b)})); let tools: [(Icon, &str, &str, bool, &str, Value); 4] = [ - (Icon::Razor, "text.transcript.extract", "Extract selected text", sel.is_some(), "transcript.extract", range.clone().unwrap_or_default()), - (Icon::Trash, "text.transcript.lift", "Lift selected text", sel.is_some(), "transcript.lift", range.unwrap_or_default()), - (Icon::Link, "text.transcript.removeFillers", "Remove filler words", true, "transcript.removeFillers", json!({})), - (Icon::Captions, "text.transcript.createCaptions", "Create captions", true, "transcript.createCaptions", json!({})), + (Icon::Razor, "text.transcript.extract", tl!("Extract selected text"), sel.is_some(), "transcript.extract", range.clone().unwrap_or_default()), + (Icon::Trash, "text.transcript.lift", tl!("Lift selected text"), sel.is_some(), "transcript.lift", range.unwrap_or_default()), + (Icon::Link, "text.transcript.removeFillers", tl!("Remove filler words"), true, "transcript.removeFillers", json!({})), + (Icon::Captions, "text.transcript.createCaptions", tl!("Create captions"), true, "transcript.createCaptions", json!({})), ]; for (icon, id, label, enabled, cmd, params) in tools { let r = Rect::from_min_size(pos2(x, bar.min.y), vec2(24.0, 24.0)); @@ -334,7 +341,7 @@ fn transcript(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.set_width(list.width() - 16.0); for (pi, pr) in paras.iter().enumerate() { let w0 = &words[pr.start]; - let head = format!("{} {}", w0.speaker.as_deref().unwrap_or("Speaker"), format_time(w0.start, rate, df, TimeDisplay::Timecode, 48_000)); + let head = format!("{} {}", w0.speaker.as_deref().unwrap_or(tl!("Speaker")), format_time(w0.start, rate, df, TimeDisplay::Timecode, 48_000)); let hr = ui.label(egui::RichText::new(head).size(11.0).color(t.text_dim).strong()); app.auto.add(&format!("text.transcript.paragraph.{pi}"), hr.rect, "Paragraph"); ui.horizontal_wrapped(|ui| { @@ -392,22 +399,30 @@ fn style_strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, track_idx: us actions.push(("captions.setStyle".into(), json!({"track": tr.id.0, "color": c}))); } let mut bg = st.background; - let resp = ui.checkbox(&mut bg, "Box"); + let resp = ui.checkbox(&mut bg, tl!("Box")); app.auto.add("text.captions.style.background", resp.rect, "Background box"); if resp.changed() { actions.push(("captions.setStyle".into(), json!({"track": tr.id.0, "background": bg}))); } - ui.label(egui::RichText::new("Align").size(11.0).color(app.tokens.text_dim)); - for (a, icon_txt, name) in [(CaptionAlign::Left, "L", "left"), (CaptionAlign::Center, "C", "center"), (CaptionAlign::Right, "R", "right")] { - let resp = ui.selectable_label(st.align == a, icon_txt).on_hover_text(format!("Align {name}")); + ui.label(egui::RichText::new(tl!("Align")).size(11.0).color(app.tokens.text_dim)); + for (a, icon_txt, name, tip) in [ + (CaptionAlign::Left, "L", "left", tl!("Align left")), + (CaptionAlign::Center, "C", "center", tl!("Align center")), + (CaptionAlign::Right, "R", "right", tl!("Align right")), + ] { + let resp = ui.selectable_label(st.align == a, icon_txt).on_hover_text(tip); app.auto.add(&format!("text.captions.style.align.{name}"), resp.rect, name); if resp.clicked() { actions.push(("captions.setStyle".into(), json!({"track": tr.id.0, "align": name}))); } } - ui.label(egui::RichText::new("Position").size(11.0).color(app.tokens.text_dim)); - for (a, name) in [(CaptionAnchor::Top, "top"), (CaptionAnchor::Middle, "middle"), (CaptionAnchor::Bottom, "bottom")] { - let resp = ui.selectable_label(st.anchor == a, name[..1].to_uppercase()).on_hover_text(format!("Position: {name}")); + ui.label(egui::RichText::new(tl!("Position")).size(11.0).color(app.tokens.text_dim)); + for (a, name, tip) in [ + (CaptionAnchor::Top, "top", tl!("Position: top")), + (CaptionAnchor::Middle, "middle", tl!("Position: middle")), + (CaptionAnchor::Bottom, "bottom", tl!("Position: bottom")), + ] { + let resp = ui.selectable_label(st.anchor == a, name[..1].to_uppercase()).on_hover_text(tip); app.auto.add(&format!("text.captions.style.anchor.{name}"), resp.rect, name); if resp.clicked() { actions.push(("captions.setStyle".into(), json!({"track": tr.id.0, "anchor": name}))); diff --git a/crates/ui-egui/src/panels/timecode.rs b/crates/ui-egui/src/panels/timecode.rs index 02d0c3e4..edfb5048 100644 --- a/crates/ui-egui/src/panels/timecode.rs +++ b/crates/ui-egui/src/panels/timecode.rs @@ -79,7 +79,8 @@ pub fn row_text(app: &FilmcraftApp, row: &TimecodeRow) -> (String, String) { }), }; let Some(v) = view else { - return ("--:--:--:--".into(), format!("{} · no {}", row.mode.label(), if source == TcSource::Source { "clip" } else { "sequence" })); + let mode = crate::i18n::t(row.mode.label()); + return ("--:--:--:--".into(), if source == TcSource::Source { tlf!("{mode} · no clip", mode) } else { tlf!("{mode} · no sequence", mode) }); }; let fmt = |t: Tick, rate: FrameRate, df: bool| format_time(t, rate, df, row.display, v.sample_rate); let (a, b) = (v.marks.0.unwrap_or(Tick::ZERO), v.marks.1.unwrap_or(v.dur)); @@ -94,10 +95,10 @@ pub fn row_text(app: &FilmcraftApp, row: &TimecodeRow) -> (String, String) { TcMode::Remaining => fmt((b - v.t).max(Tick::ZERO), v.rate, v.df), }; let src = match source { - TcSource::Source => "Source", - _ => "Program", + TcSource::Source => tl!("Source"), + _ => tl!("Program"), }; - (value, format!("{src} · {} · {}", row.mode.label(), v.name)) + (value, format!("{src} · {} · {}", crate::i18n::t(row.mode.label()), v.name)) } pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { @@ -132,26 +133,26 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { r.clicked() }; for m in TcMode::ALL { - if pick(ui, format!("timecode.row.{i}.mode.{}", serde_name(&m)), m.label(), row.mode == m) { + if pick(ui, format!("timecode.row.{i}.mode.{}", serde_name(&m)), crate::i18n::t(m.label()), row.mode == m) { nr.mode = m; } } ui.separator(); for src in TcSource::ALL { - if pick(ui, format!("timecode.row.{i}.source.{}", serde_name(&src)), src.label(), row.source == src) { + if pick(ui, format!("timecode.row.{i}.source.{}", serde_name(&src)), crate::i18n::t(src.label()), row.source == src) { nr.source = src; } } ui.separator(); for (n, d) in TimeDisplay::ALL.into_iter().enumerate() { - if pick(ui, format!("timecode.row.{i}.display.{n}"), d.label(), row.display == d) { + if pick(ui, format!("timecode.row.{i}.display.{n}"), crate::i18n::t(d.label()), row.display == d) { nr.display = d; } } let mut remove = false; if rows.len() > 1 { ui.separator(); - remove = pick(ui, format!("timecode.row.{i}.remove"), "Remove Row", false); + remove = pick(ui, format!("timecode.row.{i}.remove"), tl!("Remove Row"), false); } for (id, r, l, _) in &picks { elems.push((id.clone(), *r, l.clone())); @@ -171,7 +172,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let aresp = ui.interact(ar, egui::Id::new("timecode-add"), Sense::click()); ui.painter().text(ar.center(), Align2::CENTER_CENTER, "+", Tokens::ui(16.0), if aresp.hovered() { t.tab_text_active } else { t.icon }); elems.push(("timecode.addRow".into(), ar, "Add Row".into())); - if aresp.on_hover_text("Add a timecode row").clicked() { + if aresp.on_hover_text(tl!("Add a timecode row")).clicked() { app.ui.panels.timecode.rows.push(TimecodeRow { mode: TcMode::Duration, ..rows.last().copied().unwrap_or_default() }); } for (id, r, l) in elems { diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 8d860b63..7d8fc20c 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -411,7 +411,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { // master track let master = Rect::from_min_max(pos2(rect.min.x, tracks_area.max.y - MASTER_H), pos2(content.max.x, tracks_area.max.y)); painter.rect_filled(master, 0.0, t.tl_header_bg); - painter.text(pos2(rect.min.x + 44.0, master.center().y), Align2::LEFT_CENTER, "Mix", Tokens::ui(11.5), t.text); + painter.text(pos2(rect.min.x + 44.0, master.center().y), Align2::LEFT_CENTER, tl!("Mix"), Tokens::ui(11.5), t.text); painter.text( pos2(rect.min.x + header_w - 12.0, master.center().y), Align2::RIGHT_CENTER, @@ -491,16 +491,16 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { fn empty_state(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; - ui.painter().text(rect.center() - vec2(0.0, 12.0), Align2::CENTER_CENTER, "Drop media here to create sequence.", Tokens::ui(13.0), t.text_dim); + ui.painter().text(rect.center() - vec2(0.0, 12.0), Align2::CENTER_CENTER, tl!("Drop media here to create sequence."), Tokens::ui(13.0), t.text_dim); if app.session.project.items.values().any(|i| matches!(i.kind, filmcraft_project::ItemKind::Sequence(_))) { - let hint = "Double-click a sequence in the Project panel to open it here."; + let hint = tl!("Double-click a sequence in the Project panel to open it here."); ui.painter().text(rect.center() + vec2(0.0, 50.0), Align2::CENTER_CENTER, hint, Tokens::ui(12.0), t.text_dim); } let b = Rect::from_center_size(rect.center() + vec2(0.0, 20.0), vec2(170.0, 26.0)); let resp = ui.interact(b, egui::Id::new("tl-open-demo"), Sense::click()); app.auto.add("timeline.openDemo", b, "Open Demo Project"); ui.painter().rect_filled(b, 13.0, if resp.hovered() { t.accent_hover } else { t.accent }); - ui.painter().text(b.center(), Align2::CENTER_CENTER, "Open Demo Project", Tokens::semibold(12.0), Color32::WHITE); + ui.painter().text(b.center(), Align2::CENTER_CENTER, tl!("Open Demo Project"), Tokens::semibold(12.0), Color32::WHITE); if resp.clicked() { let _ = app.session.execute("file.openDemoProject", json!({})); } @@ -1088,12 +1088,12 @@ fn draw_top(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq: &Sequenc let mut x = rect.min.x + 12.0; let y = rect.min.y + 26.0; let toggles: [(Icon, &str, bool, bool, &str); 6] = [ - (Icon::Nest, "nest", !app.session.state.sequences_as_clips, true, "Insert and overwrite sequences as nests or individual clips"), - (Icon::Magnet, "snap", app.session.state.snapping, true, "Snap in Timeline (S)"), - (Icon::Link, "linked", app.session.state.linked_selection, true, "Linked Selection"), - (Icon::Captions, "captions", false, false, "Caption track options"), - (Icon::Marker, "marker", false, false, "Add Marker (M)"), - (Icon::Wrench, "settings", false, false, "Timeline Display Settings"), + (Icon::Nest, "nest", !app.session.state.sequences_as_clips, true, tl!("Insert and overwrite sequences as nests or individual clips")), + (Icon::Magnet, "snap", app.session.state.snapping, true, tl!("Snap in Timeline (S)")), + (Icon::Link, "linked", app.session.state.linked_selection, true, tl!("Linked Selection")), + (Icon::Captions, "captions", false, false, tl!("Caption track options")), + (Icon::Marker, "marker", false, false, tl!("Add Marker (M)")), + (Icon::Wrench, "settings", false, false, tl!("Timeline Display Settings")), ]; for (icon, key, on, toggle, tip) in toggles { let r = Rect::from_min_size(pos2(x, y), vec2(28.0, 28.0)); @@ -1116,21 +1116,21 @@ fn draw_top(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq: &Sequenc } if key == "settings" { egui::Popup::menu(&resp).show(|ui| { - ui.checkbox(&mut app.ui.timeline.show_thumbnails, "Show Video Thumbnails"); - ui.checkbox(&mut app.ui.timeline.show_waveforms, "Show Audio Waveform"); + ui.checkbox(&mut app.ui.timeline.show_thumbnails, tl!("Show Video Thumbnails")); + ui.checkbox(&mut app.ui.timeline.show_waveforms, tl!("Show Audio Waveform")); ui.separator(); let mut te = app.session.state.show_through_edits; - let c = ui.checkbox(&mut te, "Show Through Edits"); + let c = ui.checkbox(&mut te, tl!("Show Through Edits")); app.auto.add("timeline.settings.showThroughEdits", c.rect, "Show Through Edits"); if c.changed() { let _ = app.session.execute("sequence.showThroughEdits", json!({"on": te})); } ui.separator(); - if ui.button("Expand All Tracks").clicked() { + if ui.button(tl!("Expand All Tracks")).clicked() { app.ui.timeline.video_track_h = 64.0; app.ui.timeline.audio_track_h = 64.0; } - if ui.button("Minimize All Tracks").clicked() { + if ui.button(tl!("Minimize All Tracks")).clicked() { app.ui.timeline.video_track_h = 26.0; app.ui.timeline.audio_track_h = 26.0; } @@ -1213,9 +1213,9 @@ fn draw_top(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq: &Sequenc use filmcraft_engine::previews::BarState; let (c, what) = match span.state { BarState::None => continue, - BarState::Yellow => (t.render_yellow, "Unrendered: should play back in real time"), - BarState::Red => (t.render_red, "Unrendered: render to play back in real time"), - BarState::Green => (t.render_green, "Rendered preview"), + BarState::Yellow => (t.render_yellow, tl!("Unrendered: should play back in real time")), + BarState::Red => (t.render_red, tl!("Unrendered: render to play back in real time")), + BarState::Green => (t.render_green, tl!("Rendered preview")), }; let r = Rect::from_min_max(pos2(layout.x_of(span.start), rb.min.y), pos2(layout.x_of(span.end), rb.max.y)).intersect(ruler); if r.width() <= 0.0 { @@ -1572,7 +1572,7 @@ fn shift_track(seq: &Sequence, tid: TrackId, delta: i32) -> Option { /// The clip context menu: groups (separated by rules) of (label, command id). Entries marked `…` /// open their dialog through `menus::invoke`, like the same item in the Clip menu. -const CLIP_MENU: &[&[(&str, &str)]] = &[ +pub(crate) const CLIP_MENU: &[&[(&str, &str)]] = &[ &[ ("Cut", "edit.cut"), ("Copy", "edit.copy"), @@ -1622,8 +1622,8 @@ fn multicam_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, picked: &[&TrackItem let shown = nests.first().and_then(|it| it.multicam).filter(|m| m.enabled).map(|m| m.angle as usize); let mut run: Option<(&str, Value)> = None; ui.add_enabled_ui(!nests.is_empty(), |ui| { - let r = ui.menu_button("Multi-Camera", |ui| { - for (label, cmd) in [(if enabled { "✓ Enable" } else { "Enable" }, "clip.multicamEnable"), ("Flatten", "clip.multicamFlatten")] { + let r = ui.menu_button(tl!("Multi-Camera"), |ui| { + for (label, cmd) in [(if enabled { tl!("✓ Enable") } else { tl!("Enable") }, "clip.multicamEnable"), (tl!("Flatten"), "clip.multicamFlatten")] { let r = ui.add_enabled(app.session.is_enabled(cmd), egui::Button::new(label)); app.auto.add(&format!("timeline.clipMenu.{cmd}"), r.rect, label); if r.clicked() { @@ -2071,9 +2071,9 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & } for &(label, cmd) in *group { if cmd == "edit.label" { - ui.menu_button(label, |ui| { + ui.menu_button(crate::i18n::t(label), |ui| { for l in filmcraft_project::Label::ALL { - if ui.button(l.name()).clicked() { + if ui.button(crate::i18n::t(l.name())).clicked() { let _ = app.session.execute("edit.label", json!({"label": l.name()})); ui.close(); } @@ -2086,9 +2086,9 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & continue; } let label = match cmd { - "clip.enable" if all_enabled => "✓ Enable", - "clip.link" if linked => "Unlink", - _ => label, + "clip.enable" if all_enabled => tl!("✓ Enable"), + "clip.link" if linked => tl!("Unlink"), + _ => crate::i18n::t(label), }; let r = ui.add_enabled(!sel.is_empty() && app.session.is_enabled(cmd), egui::Button::new(label)); app.auto.add(&format!("timeline.clipMenu.{cmd}"), r.rect, label); @@ -2120,7 +2120,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & ui.painter().rect_filled(r, 3.0, Color32::from_white_alpha(40)); ui.painter().rect_stroke(r, 3.0, Stroke::new(1.5, Color32::WHITE), StrokeKind::Inside); if mods.command { - ui.painter().text(r.left_top() + vec2(4.0, -2.0), Align2::LEFT_BOTTOM, "Insert", Tokens::ui(10.0), Color32::WHITE); + ui.painter().text(r.left_top() + vec2(4.0, -2.0), Align2::LEFT_BOTTOM, tl!("Insert"), Tokens::ui(10.0), Color32::WHITE); } } if ctx.input(|i| i.pointer.any_released()) diff --git a/crates/ui-egui/src/panels/timeline_automation.rs b/crates/ui-egui/src/panels/timeline_automation.rs index e34e6c25..783dc5ba 100644 --- a/crates/ui-egui/src/panels/timeline_automation.rs +++ b/crates/ui-egui/src/panels/timeline_automation.rs @@ -27,16 +27,19 @@ const LANE_COL: Color32 = Color32::from_rgb(0xe8, 0xc5, 0x47); /// The lanes an audio track can show: (key, label). pub fn lane_options(tr: &Track) -> Vec<(String, String)> { - let mut v = - vec![(LANE_VOLUME.to_string(), "Volume".to_string()), (LANE_PAN.to_string(), "Panner".to_string()), (LANE_MUTE.to_string(), "Mute".to_string())]; + let mut v = vec![ + (LANE_VOLUME.to_string(), tl!("Volume").to_string()), + (LANE_PAN.to_string(), tl!("Panner").to_string()), + (LANE_MUTE.to_string(), tl!("Mute").to_string()), + ]; for (i, s) in tr.mixer.sends.iter().enumerate() { let _ = s; - v.push((send_lane(i), format!("Send {} Level", i + 1))); + v.push((send_lane(i), tlf!("Send {n} Level", n = i + 1))); } for (slot, e) in tr.effects.iter().enumerate() { let Some(def) = e.def() else { continue }; for p in def.params.iter().filter(|p| matches!(p.kind, ParamKind::Float { .. }) && p.animatable) { - v.push((filmcraft_project::mixer::fx_lane(slot, p.id), format!("{}: {}", def.name, p.label))); + v.push((filmcraft_project::mixer::fx_lane(slot, p.id), format!("{}: {}", crate::i18n::t(def.name), crate::i18n::t(p.label)))); } } v @@ -96,18 +99,18 @@ pub fn header_button(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, let showing = app.ui.timeline.track_lanes.get(&r.track.0).cloned(); let resp = crate::widgets::icon_toggle(ui, rect.intersect(visible), Icon::Keyframe, showing.is_some(), t, egui::Id::new(("lanebtn", r.track.0)), Some(LANE_COL)) - .on_hover_text("Show Keyframes"); + .on_hover_text(tl!("Show Keyframes")); app.auto.add(&format!("timeline.track.{label}.keyframes"), rect, "Show Keyframes"); let opts = lane_options(tr); egui::Popup::menu(&resp).show(|ui| { ui.set_min_width(170.0); - let c = ui.selectable_label(showing.is_none(), "Clip Keyframes"); + let c = ui.selectable_label(showing.is_none(), tl!("Clip Keyframes")); app.auto.add(&format!("timeline.track.{label}.keyframes.clip"), c.rect, "Clip Keyframes"); if c.clicked() { app.ui.timeline.track_lanes.remove(&r.track.0); } ui.separator(); - ui.label(egui::RichText::new("Track Keyframes").small()); + ui.label(egui::RichText::new(tl!("Track Keyframes")).small()); for (k, l) in &opts { let e = ui.selectable_label(showing.as_deref() == Some(k.as_str()), l); app.auto.add(&format!("timeline.track.{label}.keyframes.{k}"), e.rect, l); @@ -222,7 +225,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & let time = k.time; let key2 = key.clone(); resp.context_menu(|ui| { - if ui.button("Delete").clicked() { + if ui.button(tl!("Delete")).clicked() { acts.push(("mixer.deleteKeyframe", json!({"strip": r.track.0, "lane": key2, "time": time.0}))); ui.close(); } diff --git a/crates/ui-egui/src/panels/timeline_captions.rs b/crates/ui-egui/src/panels/timeline_captions.rs index b154f554..90189ab0 100644 --- a/crates/ui-egui/src/panels/timeline_captions.rs +++ b/crates/ui-egui/src/panels/timeline_captions.rs @@ -112,7 +112,7 @@ pub fn paint(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, area: Re if eresp.clicked() { actions.push(("captions.setTrack".into(), json!({"track": tr.id.0, "enabled": !tr.enabled}))); } - let name = format!("{} · {}", tr.name, tr.format.label()); + let name = format!("{} · {}", tr.name, crate::i18n::t(tr.format.label())); painter.with_clip_rect(Rect::from_min_max(pos2(hrect.min.x + 86.0, row.min.y), pos2(hrect.max.x - 4.0, row.max.y))).text( pos2(hrect.min.x + 90.0, row.center().y), Align2::LEFT_CENTER, diff --git a/crates/ui-egui/src/panels/tools.rs b/crates/ui-egui/src/panels/tools.rs index 9119ddb3..f59a15de 100644 --- a/crates/ui-egui/src/panels/tools.rs +++ b/crates/ui-egui/src/panels/tools.rs @@ -16,7 +16,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let r = Rect::from_min_size(pos2(rect.center().x - size / 2.0, y), vec2(size, size)); let resp = ui.interact(r, egui::Id::new(("tool", format!("{:?}", group[0]))), Sense::click()).on_hover_text(format!( "{} ({})", - current.label(), + crate::i18n::t(current.label()), current.shortcut() )); for tl in &group { @@ -39,7 +39,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if group.len() > 1 { egui::Popup::context_menu(&resp).show(|ui| { for tl in &group { - if ui.selectable_label(app.ui.tool == *tl, format!("{} {}", tl.label(), tl.shortcut())).clicked() { + if ui.selectable_label(app.ui.tool == *tl, format!("{} {}", crate::i18n::t(tl.label()), tl.shortcut())).clicked() { app.ui.tool = *tl; } } diff --git a/crates/ui-egui/src/panels/trim_monitor.rs b/crates/ui-egui/src/panels/trim_monitor.rs index 4347bf6f..5433b7aa 100644 --- a/crates/ui-egui/src/panels/trim_monitor.rs +++ b/crates/ui-egui/src/panels/trim_monitor.rs @@ -173,8 +173,8 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let col = if kind == "ripple" { RIPPLE_RED } else { ROLL_YELLOW }; // headers: clip names for (side, r, key) in [(&info["outgoing"], left, "outgoing"), (&info["incoming"], right, "incoming")] { - let name = side["name"].as_str().map(|n| format!("{} ({})", n, side["track"].as_str().unwrap_or(""))).unwrap_or_else(|| "(gap)".into()); - let label = if key == "outgoing" { format!("Out: {name}") } else { format!("In: {name}") }; + let name = side["name"].as_str().map(|n| format!("{} ({})", n, side["track"].as_str().unwrap_or(""))).unwrap_or_else(|| tl!("(gap)").into()); + let label = if key == "outgoing" { tlf!("Out: {name}", name) } else { tlf!("In: {name}", name) }; ui.painter().text(pos2(r.min.x + 2.0, rect.min.y + 4.0 + header_h / 2.0), Align2::LEFT_CENTER, label, Tokens::ui(11.5), t.text_dim); } let mut pics = [left, right]; @@ -182,7 +182,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if side.is_null() { let p = fit(r, 16.0, 9.0); ui.painter().rect_filled(p, 0.0, Color32::BLACK); - ui.painter().text(p.center(), Align2::CENTER_CENTER, "(gap)", Tokens::ui(12.0), t.text_faint); + ui.painter().text(p.center(), Align2::CENTER_CENTER, tl!("(gap)"), Tokens::ui(12.0), t.text_faint); pics[i] = p; } else { pics[i] = picture(app, ui, r, side, if i == 0 { "trim-outgoing" } else { "trim-incoming" }); @@ -259,12 +259,12 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let n = info["largeTrimOffset"].as_i64().unwrap_or(5); let bar = Rect::from_min_size(pos2(rect.min.x, row.max.y + 6.0), vec2(rect.width(), 28.0)); let buttons: [(&str, String, &str, f32); 6] = [ - ("trimMonitor.playAround", String::new(), "Play Around Edit (Space loops, Shift+K once)", 30.0), - ("trimMonitor.backwardMany", format!("−{n}"), "Trim Backward Many", 40.0), - ("trimMonitor.backward", "−1".into(), "Trim Backward", 40.0), - ("trimMonitor.applyTransition", "Apply Default Transitions".into(), "Apply Default Transitions to Selection (Shift+D)", 176.0), - ("trimMonitor.forward", "+1".into(), "Trim Forward", 40.0), - ("trimMonitor.forwardMany", format!("+{n}"), "Trim Forward Many", 40.0), + ("trimMonitor.playAround", String::new(), tl!("Play Around Edit (Space loops, Shift+K once)"), 30.0), + ("trimMonitor.backwardMany", format!("−{n}"), tl!("Trim Backward Many"), 40.0), + ("trimMonitor.backward", "−1".into(), tl!("Trim Backward"), 40.0), + ("trimMonitor.applyTransition", tl!("Apply Default Transitions").into(), tl!("Apply Default Transitions to Selection (Shift+D)"), 176.0), + ("trimMonitor.forward", "+1".into(), tl!("Trim Forward"), 40.0), + ("trimMonitor.forwardMany", format!("+{n}"), tl!("Trim Forward Many"), 40.0), ]; let total: f32 = buttons.iter().map(|b| b.3 + 4.0).sum(); let mut x = bar.center().x - total / 2.0; @@ -304,7 +304,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } // exit trim mode let r = Rect::from_min_size(pos2(rect.max.x - 30.0, bar.min.y + 3.0), vec2(22.0, 22.0)); - let resp = ui.interact(r, egui::Id::new("trimMonitor.exit"), Sense::click()).on_hover_text("Exit Trim Mode"); + let resp = ui.interact(r, egui::Id::new("trimMonitor.exit"), Sense::click()).on_hover_text(tl!("Exit Trim Mode")); let c = if resp.hovered() { t.tab_text_active } else { t.text_dim }; ui.painter().line_segment([r.min + vec2(6.0, 6.0), r.max - vec2(6.0, 6.0)], Stroke::new(1.5, c)); ui.painter().line_segment([pos2(r.max.x - 6.0, r.min.y + 6.0), pos2(r.min.x + 6.0, r.max.y - 6.0)], Stroke::new(1.5, c)); diff --git a/crates/ui-egui/src/panels/voiceover.rs b/crates/ui-egui/src/panels/voiceover.rs index 10f7eb54..d3f10632 100644 --- a/crates/ui-egui/src/panels/voiceover.rs +++ b/crates/ui-egui/src/panels/voiceover.rs @@ -55,9 +55,9 @@ pub fn header_button(app: &mut FilmcraftApp, ui: &mut egui::Ui, track: TrackId, if resp.clicked() { act = Some(Action::Toggle(track)); } - let resp = resp.on_hover_text("Voice-over record (right-click: Voice-Over Record Settings…)"); + let resp = resp.on_hover_text(tl!("Voice-over record (right-click: Voice-Over Record Settings…)")); resp.context_menu(|ui| { - let b = ui.button("Voice-Over Record Settings…"); + let b = ui.button(tl!("Voice-Over Record Settings…")); app.auto.add(&format!("{id}.settings"), b.rect, "Voice-Over Record Settings…"); if b.clicked() { act = Some(Action::Settings); @@ -100,7 +100,7 @@ pub fn toggle(app: &mut FilmcraftApp, track: Option) -> Result Result { let t = app.session.playhead(); let r = app.session.execute("audio.voiceover.stop", json!({"time": t.0})).map_err(|e| e.to_string())?; app.ui.status = match r["path"].as_str() { - Some(p) => format!("Voice-over recorded: {p}"), - None => "Voice-over recording stopped (nothing recorded)".into(), + Some(p) => tlf!("Voice-over recorded: {p}", p), + None => tl!("Voice-over recording stopped (nothing recorded)").into(), }; Ok(r) } @@ -214,9 +214,9 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { let left = (rec.record_start - now).seconds().ceil().max(1.0) as i64; format!("{left}") } else if rec.punch_out.is_some_and(|o| now >= o) { - "Post-roll".into() + tl!("Post-roll").into() } else { - "● Recording".into() + tl!("● Recording").into() }; let r = Rect::from_center_size(pos2(screen.center().x, screen.min.y + 90.0), vec2(220.0, 64.0)); egui::Area::new(egui::Id::new("voiceover-countdown")).order(egui::Order::Foreground).fixed_pos(r.min).interactable(false).show(ctx, |ui| { @@ -235,74 +235,81 @@ fn settings_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut close = false; let mut apply = false; let accent = app.tokens.accent; - egui::Window::new("Voice-Over Record Settings").collapsible(false).resizable(false).anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { - egui::Grid::new("voiceover-grid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { - ui.label("Name:"); - let r = ui.add(egui::TextEdit::singleline(&mut d.prefs.name).desired_width(220.0)); - elems.push(("voiceover.name".into(), r.rect, d.prefs.name.clone())); - ui.end_row(); - ui.label("Source:"); - let shown = if d.prefs.source.is_empty() { "Default Input".to_string() } else { d.prefs.source.clone() }; - let cb = egui::ComboBox::from_id_salt("voiceover-source").selected_text(&shown).width(220.0).show_ui(ui, |ui| { - let mut opts = vec![(String::new(), "Default Input".to_string())]; - opts.extend(d.devices.iter().map(|x| (x.clone(), x.clone()))); - for (i, (val, label)) in opts.into_iter().enumerate() { - let r = ui.selectable_label(d.prefs.source == val, &label); - elems.push((format!("voiceover.source.{i}"), r.rect, label)); - if r.clicked() { - d.prefs.source = val; + egui::Window::new(tl!("Voice-Over Record Settings")) + .id(egui::Id::new("Voice-Over Record Settings")) + .collapsible(false) + .resizable(false) + .anchor(Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + egui::Grid::new("voiceover-grid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + ui.label(tl!("Name:")); + let r = ui.add(egui::TextEdit::singleline(&mut d.prefs.name).desired_width(220.0)); + elems.push(("voiceover.name".into(), r.rect, d.prefs.name.clone())); + ui.end_row(); + ui.label(tl!("Source:")); + let shown = if d.prefs.source.is_empty() { tl!("Default Input").to_string() } else { d.prefs.source.clone() }; + let cb = egui::ComboBox::from_id_salt("voiceover-source").selected_text(&shown).width(220.0).show_ui(ui, |ui| { + let mut opts = vec![(String::new(), tl!("Default Input").to_string())]; + opts.extend(d.devices.iter().map(|x| (x.clone(), x.clone()))); + for (i, (val, label)) in opts.into_iter().enumerate() { + let r = ui.selectable_label(d.prefs.source == val, &label); + elems.push((format!("voiceover.source.{i}"), r.rect, label)); + if r.clicked() { + d.prefs.source = val; + } } - } - }); - elems.push(("voiceover.source".into(), cb.response.rect, shown)); - ui.end_row(); - ui.label("Input:"); - let n = d.channels.max(1) as u32; - let cur = format!("Channel {}", d.prefs.input_channel + 1); - let cb = egui::ComboBox::from_id_salt("voiceover-input").selected_text(&cur).width(220.0).show_ui(ui, |ui| { - for c in 0..n.max(d.prefs.input_channel + 1) { - let label = format!("Channel {}", c + 1); - let r = ui.selectable_label(d.prefs.input_channel == c, &label); - elems.push((format!("voiceover.input.{c}"), r.rect, label)); - if r.clicked() { - d.prefs.input_channel = c; + }); + elems.push(("voiceover.source".into(), cb.response.rect, shown)); + ui.end_row(); + ui.label(tl!("Input:")); + let n = d.channels.max(1) as u32; + let cur = tlf!("Channel {n}", n = d.prefs.input_channel + 1); + let cb = egui::ComboBox::from_id_salt("voiceover-input").selected_text(&cur).width(220.0).show_ui(ui, |ui| { + for c in 0..n.max(d.prefs.input_channel + 1) { + let label = tlf!("Channel {n}", n = c + 1); + let r = ui.selectable_label(d.prefs.input_channel == c, &label); + elems.push((format!("voiceover.input.{c}"), r.rect, label)); + if r.clicked() { + d.prefs.input_channel = c; + } } - } + }); + elems.push(("voiceover.input".into(), cb.response.rect, cur)); + ui.end_row(); + ui.label(""); + let r = ui.checkbox(&mut d.prefs.countdown_sound_cues, tl!("Countdown Sound Cues")); + elems.push(("voiceover.countdown".into(), r.rect, "Countdown Sound Cues".into())); + ui.end_row(); + ui.label(tl!("Pre-roll:")); + let r = ui.add(egui::DragValue::new(&mut d.prefs.preroll_seconds).speed(0.1).range(0.0..=60.0).suffix(tl!(" seconds"))); + elems.push(("voiceover.preroll".into(), r.rect, format!("{}", d.prefs.preroll_seconds))); + ui.end_row(); + ui.label(tl!("Post-roll:")); + let r = ui.add(egui::DragValue::new(&mut d.prefs.postroll_seconds).speed(0.1).range(0.0..=60.0).suffix(tl!(" seconds"))); + elems.push(("voiceover.postroll".into(), r.rect, format!("{}", d.prefs.postroll_seconds))); + ui.end_row(); }); - elems.push(("voiceover.input".into(), cb.response.rect, cur)); - ui.end_row(); - ui.label(""); - let r = ui.checkbox(&mut d.prefs.countdown_sound_cues, "Countdown Sound Cues"); - elems.push(("voiceover.countdown".into(), r.rect, "Countdown Sound Cues".into())); - ui.end_row(); - ui.label("Pre-roll:"); - let r = ui.add(egui::DragValue::new(&mut d.prefs.preroll_seconds).speed(0.1).range(0.0..=60.0).suffix(" seconds")); - elems.push(("voiceover.preroll".into(), r.rect, format!("{}", d.prefs.preroll_seconds))); - ui.end_row(); - ui.label("Post-roll:"); - let r = ui.add(egui::DragValue::new(&mut d.prefs.postroll_seconds).speed(0.1).range(0.0..=60.0).suffix(" seconds")); - elems.push(("voiceover.postroll".into(), r.rect, format!("{}", d.prefs.postroll_seconds))); - ui.end_row(); - }); - ui.label( - RichText::new("Playback starts the pre-roll before the playhead (or the In point); with In/Out marked, recording punches in and out there.") + ui.label( + RichText::new(tl!( + "Playback starts the pre-roll before the playhead (or the In point); with In/Out marked, recording punches in and out there." + )) .weak() .small(), - ); - ui.add_space(8.0); - ui.horizontal(|ui| { - let c = ui.button("Cancel"); - elems.push(("voiceover.cancel".into(), c.rect, "Cancel".into())); - if c.clicked() { - close = true; - } - let o = ui.add(egui::Button::new(RichText::new("OK").color(Color32::WHITE)).fill(accent)); - elems.push(("voiceover.ok".into(), o.rect, "OK".into())); - if o.clicked() { - apply = true; - } + ); + ui.add_space(8.0); + ui.horizontal(|ui| { + let c = ui.button(tl!("Cancel")); + elems.push(("voiceover.cancel".into(), c.rect, "Cancel".into())); + if c.clicked() { + close = true; + } + let o = ui.add(egui::Button::new(RichText::new(tl!("OK")).color(Color32::WHITE)).fill(accent)); + elems.push(("voiceover.ok".into(), o.rect, "OK".into())); + if o.clicked() { + apply = true; + } + }); }); - }); for (id, r, l) in elems { app.auto.add(&id, r, &l); } diff --git a/crates/ui-egui/src/panels/workspaces.rs b/crates/ui-egui/src/panels/workspaces.rs index 31e3ea37..69d52944 100644 --- a/crates/ui-egui/src/panels/workspaces.rs +++ b/crates/ui-egui/src/panels/workspaces.rs @@ -33,7 +33,7 @@ pub fn route(app: &mut FilmcraftApp, id: &str, params: &Value) -> Option match arg("name") { Some(n) => save_new(app, &n), None => { - app.ui.workspace_dialog = Some(WorkspaceDialog::SaveAs { name: "Untitled Workspace".into() }); + app.ui.workspace_dialog = Some(WorkspaceDialog::SaveAs { name: tl!("Untitled Workspace").into() }); Ok(json!({"dialog": "saveWorkspace"})) } }, @@ -161,74 +161,77 @@ pub fn dialogs(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut act: Option<(&str, Value)> = None; let names = dock::names(&app.workspaces); let changed: Vec = app.workspaces.saved.iter().map(|s| s.name.clone()).collect(); - let title = match &d { - WorkspaceDialog::SaveAs { .. } => "New Workspace", - WorkspaceDialog::Edit { .. } => "Edit Workspaces", + let (title, shown) = match &d { + WorkspaceDialog::SaveAs { .. } => ("New Workspace", tl!("New Workspace")), + WorkspaceDialog::Edit { .. } => ("Edit Workspaces", tl!("Edit Workspaces")), }; - egui::Window::new(title).collapsible(false).resizable(false).anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| match &mut d { - WorkspaceDialog::SaveAs { name } => { - ui.horizontal(|ui| { - ui.label("Name:"); - let r = ui.text_edit_singleline(name); - push("workspaces.save.name", &r, "Name"); - }); - ui.horizontal(|ui| { - let r = ui.button("Cancel"); - push("workspaces.save.cancel", &r, "Cancel"); - close |= r.clicked(); - let r = ui.button("OK"); - push("workspaces.save.ok", &r, "OK"); - if r.clicked() { - act = Some(("window.workspace.saveAs", json!({"name": name.clone()}))); - } - }); - } - WorkspaceDialog::Edit { selected, name } => { - for (i, n) in names.iter().enumerate() { - let r = ui.selectable_label(selected.as_ref() == Some(n), n); - push(&format!("workspaces.edit.row.{i}"), &r, n); - if r.clicked() { - *selected = Some(n.clone()); - *name = n.clone(); - } + egui::Window::new(shown).id(egui::Id::new(title)).collapsible(false).resizable(false).anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show( + ctx, + |ui| match &mut d { + WorkspaceDialog::SaveAs { name } => { + ui.horizontal(|ui| { + ui.label(tl!("Name:")); + let r = ui.text_edit_singleline(name); + push("workspaces.save.name", &r, "Name"); + }); + ui.horizontal(|ui| { + let r = ui.button(tl!("Cancel")); + push("workspaces.save.cancel", &r, "Cancel"); + close |= r.clicked(); + let r = ui.button(tl!("OK")); + push("workspaces.save.ok", &r, "OK"); + if r.clicked() { + act = Some(("window.workspace.saveAs", json!({"name": name.clone()}))); + } + }); } - ui.separator(); - let sel = selected.clone().filter(|s| names.contains(s)); - let own = sel.as_deref().is_some_and(|s| !is_builtin(s)); - ui.horizontal(|ui| { - ui.label("Name:"); - let r = ui.add_enabled(own, egui::TextEdit::singleline(name)); - push("workspaces.edit.name", &r, "Name"); - let r = ui.add_enabled(own && sel.as_deref() != Some(name.trim()), egui::Button::new("Rename")); - push("workspaces.edit.rename", &r, "Rename"); - if r.clicked() - && let Some(s) = &sel - { - act = Some(("window.workspace.rename", json!({"from": s, "to": name.clone()}))); - *selected = Some(name.trim().to_string()); - } - }); - ui.horizontal(|ui| { - // a built-in workspace can't be deleted; its saved changes can - let restore = sel.as_deref().is_some_and(|s| is_builtin(s) && changed.iter().any(|c| c == s)); - let label = if restore { "Restore Original" } else { "Delete" }; - let r = ui.add_enabled(own || restore, egui::Button::new(label)); - push("workspaces.edit.delete", &r, label); - if r.clicked() - && let Some(s) = &sel - { - act = Some(("window.workspace.delete", json!({"name": s}))); - if own { - *selected = None; - name.clear(); + WorkspaceDialog::Edit { selected, name } => { + for (i, n) in names.iter().enumerate() { + let r = ui.selectable_label(selected.as_ref() == Some(n), crate::i18n::t(n)); + push(&format!("workspaces.edit.row.{i}"), &r, n); + if r.clicked() { + *selected = Some(n.clone()); + *name = n.clone(); } } - let r = ui.button("Close"); - push("workspaces.edit.close", &r, "Close"); - close |= r.clicked(); - }); - } - }); + ui.separator(); + let sel = selected.clone().filter(|s| names.contains(s)); + let own = sel.as_deref().is_some_and(|s| !is_builtin(s)); + ui.horizontal(|ui| { + ui.label(tl!("Name:")); + let r = ui.add_enabled(own, egui::TextEdit::singleline(name)); + push("workspaces.edit.name", &r, "Name"); + let r = ui.add_enabled(own && sel.as_deref() != Some(name.trim()), egui::Button::new(tl!("Rename"))); + push("workspaces.edit.rename", &r, "Rename"); + if r.clicked() + && let Some(s) = &sel + { + act = Some(("window.workspace.rename", json!({"from": s, "to": name.clone()}))); + *selected = Some(name.trim().to_string()); + } + }); + ui.horizontal(|ui| { + // a built-in workspace can't be deleted; its saved changes can + let restore = sel.as_deref().is_some_and(|s| is_builtin(s) && changed.iter().any(|c| c == s)); + let label = if restore { "Restore Original" } else { "Delete" }; + let r = ui.add_enabled(own || restore, egui::Button::new(if restore { tl!("Restore Original") } else { tl!("Delete") })); + push("workspaces.edit.delete", &r, label); + if r.clicked() + && let Some(s) = &sel + { + act = Some(("window.workspace.delete", json!({"name": s}))); + if own { + *selected = None; + name.clear(); + } + } + let r = ui.button(tl!("Close")); + push("workspaces.edit.close", &r, "Close"); + close |= r.clicked(); + }); + } + }, + ); for (id, r, l) in elems { app.auto.add(&id, r, &l); } diff --git a/crates/ui-egui/tests/scripted.rs b/crates/ui-egui/tests/scripted.rs index 806ad1b0..f7651b01 100644 --- a/crates/ui-egui/tests/scripted.rs +++ b/crates/ui-egui/tests/scripted.rs @@ -186,6 +186,26 @@ fn apply_effect_appears_in_effect_controls() { assert!(!d.element_ids("effectControls.effect.").iter().any(|i| i == "effectControls.effect.gaussian_blur")); } +#[test] +fn spanish_search_finds_effects_and_presets_with_stable_automation_ids() { + let mut d = Driver::demo(); + d.ok("ui.menu.invoke", json!({"id": "app.language.spanish"})); + d.ok("ui.panel.show", json!({"panel": "Effects"})); + d.ok("ui.set", json!({"effectsSearch": "DESENFOQUE"})); + d.frames(3); + assert!(d.element_ids("effects.item.").contains(&"effects.item.gaussian_blur".to_string())); + d.ok("ui.set", json!({"effectsSearch": "ENTRADA CON DESENFOQUE"})); + d.frames(3); + assert!(d.element_ids("effects.preset.").contains(&"effects.preset.Blur In".to_string())); + let clip = track_clips(&d.sequence(), 0)[0]["clip"].as_u64().unwrap(); + d.exec("timeline.select", json!({"clips": [clip]})); + d.exec("effects.apply", json!({"effect": "gaussian_blur"})); + d.ok("ui.panel.show", json!({"panel": "Effect Controls"})); + d.frames(3); + assert!(d.element_ids("effectControls.effect.").contains(&"effectControls.effect.gaussian_blur".to_string())); + d.ok("ui.menu.invoke", json!({"id": "app.language.english"})); +} + #[test] fn effects_panel_lists_the_premiere_transition_folders() { let mut d = Driver::demo(); From 3d67c4b73dd1247466cd7616277fe4205b7065a5 Mon Sep 17 00:00:00 2001 From: txiki739 <111882715+txiki739@users.noreply.github.com> Date: Thu, 8 Oct 2026 01:41:48 +0200 Subject: [PATCH 02/15] tests: the MPEG oracle reads ffprobe's best-effort timestamps Recent ffprobe versions report PTS=N/A for a program-stream picture whose timestamp is inferred, so the oracle dropped that frame and compared 29 of 30. best_effort_timestamp keeps the inferred time and all 30 frames are compared. Co-Authored-By: Claude Opus 5.5 --- crates/codecs/tests/mpeg_oracle.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/codecs/tests/mpeg_oracle.rs b/crates/codecs/tests/mpeg_oracle.rs index 5fdbc0bf..5e302493 100644 --- a/crates/codecs/tests/mpeg_oracle.rs +++ b/crates/codecs/tests/mpeg_oracle.rs @@ -177,11 +177,12 @@ fn open(p: &Path) -> MpegSource { MpegSource::open(p.file_name().unwrap().to_str().unwrap(), bytes(p)).unwrap() } -/// ffprobe's video frames in display order: pts (90 kHz). +/// ffprobe's video frames in display order: presentation timestamps (90 kHz), including +/// inferred timestamps where an MPEG program stream omits a picture's PTS. fn ffprobe_frame_pts(file: &Path) -> Option> { let fp = filmcraft_testkit::ffprobe()?; let o = std::process::Command::new(fp) - .args(["-v", "error", "-select_streams", "v:0", "-show_entries", "frame=pts", "-of", "csv=p=0"]) + .args(["-v", "error", "-select_streams", "v:0", "-show_entries", "frame=best_effort_timestamp", "-of", "csv=p=0"]) .arg(file) .output() .ok()?; From c7ddd4f314fbae51c494db9102d53a69d508b5c6 Mon Sep 17 00:00:00 2001 From: txiki739 <111882715+txiki739@users.noreply.github.com> Date: Thu, 8 Oct 2026 01:41:48 +0200 Subject: [PATCH 03/15] tests: shortcut conflict test compares against the platform's own baseline On Windows and Linux the macOS Control key folds into the primary modifier, so the default set already reports conflicts there (documented in shortcuts.rs). The test now checks that assigning adds exactly one conflict and that undo restores the original set, on every platform. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/shortcuts_tests.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/shortcuts_tests.rs b/crates/engine/src/shortcuts_tests.rs index 0bf0b63b..3188e538 100644 --- a/crates/engine/src/shortcuts_tests.rs +++ b/crates/engine/src/shortcuts_tests.rs @@ -74,6 +74,9 @@ fn defaults_follow_the_registry_and_the_premiere_audit() { #[test] fn assign_reassigns_conflicts_and_undoes() { let mut s = Session::default(); + // macOS Control folds into the primary modifier on Linux/Windows, so compare changes + // against the platform's original conflicts rather than assuming the set starts empty. + let baseline = s.execute("shortcuts.conflicts", json!({})).unwrap()["conflicts"].clone(); let r = s.execute("shortcuts.set", json!({"command": "edit.undo", "keys": "Cmd+Shift+K"})).unwrap(); assert_eq!(r["reassigned"][0]["command"], json!("sequence.addEditAllTracks"), "{r}"); assert!(s.shortcuts.primary("sequence.addEditAllTracks").is_none()); @@ -85,12 +88,12 @@ fn assign_reassigns_conflicts_and_undoes() { s.execute("shortcuts.set", json!({"command": "edit.undo", "keys": "Cmd+Z", "add": true})).unwrap(); assert_eq!(s.shortcuts.for_command("edit.undo").iter().filter(|b| b.panel.is_none()).count(), 2); let r = s.execute("shortcuts.set", json!({"command": "edit.redo", "keys": "M", "keepConflicts": true})).unwrap(); - assert_eq!(r["conflicts"].as_array().unwrap().len(), 1, "{r}"); + assert_eq!(r["conflicts"].as_array().unwrap().len(), baseline.as_array().unwrap().len() + 1, "{r}"); let c = s.execute("shortcuts.conflicts", json!({"platform": "mac"})).unwrap(); assert_eq!(c["conflicts"][0]["keys"], json!("M")); // undo / redo within the editor s.execute("shortcuts.undo", json!({})).unwrap(); - assert!(s.execute("shortcuts.conflicts", json!({})).unwrap()["conflicts"].as_array().unwrap().is_empty()); + assert_eq!(s.execute("shortcuts.conflicts", json!({})).unwrap()["conflicts"], baseline); s.execute("shortcuts.undo", json!({})).unwrap(); s.execute("shortcuts.undo", json!({})).unwrap(); assert_eq!(s.shortcuts.primary("edit.undo").as_deref(), Some("Cmd+Z")); From 7a042cd72f07c01ef44c816af32b7f0e5c8f174c Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 8 Oct 2026 11:25:54 +0200 Subject: [PATCH 04/15] README: install on Gentoo from the ::snakebyte overlay --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 62742389..954ef4a6 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,15 @@ CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo run --release -p filmcraft The control protocol is documented in [docs/control-protocol.md](docs/control-protocol.md). Stuck, or want to show what you made? Ask in [Discord](https://discord.gg/artcraft). +Each [GitHub release](https://github.com/storytold/filmcraft/releases) has ready-made builds, on Linux as an AppImage, a `.deb`, an `.rpm` and a tarball. On Gentoo, the community [::snakebyte overlay](https://github.com/switch87/snakebyte-overlay) packages the Linux release as `media-video/filmcraft-bin` (not maintained by the FilmCraft team): + +```sh +eselect repository add snakebyte git https://github.com/switch87/snakebyte-overlay.git +emaint sync -r snakebyte +echo 'media-video/filmcraft-bin ~amd64' >> /etc/portage/package.accept_keywords/filmcraft +emerge --ask media-video/filmcraft-bin +``` + ## Documentation | | | From d018fb9abbe57117c77704fca17ec587bec95c44 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 8 Oct 2026 11:27:34 +0200 Subject: [PATCH 05/15] Contributors: credit switch87 as Gert Pellin --- contributors/people.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 89c6db20..fd3d0317 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -21,3 +21,8 @@ real_name = "Brandon Thomas" display_name = "Brandon Thomas" sync_github_name = false + +[people.switch87] +real_name = "Gert Pellin" +display_name = "Gert Pellin" +sync_github_name = false From 10879f385e9ea5305fae4b757e14dd17bd1545bf Mon Sep 17 00:00:00 2001 From: Dario Carnelutti Date: Thu, 8 Oct 2026 10:13:07 -0600 Subject: [PATCH 06/15] export: free the frame pool when an export ends The export pipeline hands the float image of every frame it converts back to `filmcraft_frame::pool` (#184), which keeps up to 320 MiB of them, and the decoders' plane shelves hold up to 192 MiB of each type. An export's images are the size of its frames, which nothing else asks for, so a finished export left them idle until the next one. `pool::trim()` frees every idle buffer. `Pipeline` calls it when it is dropped, one place for every way an export ends (done, failed, cancelled, dropped half way by a host that steps it, as the web build does) and for every caller of `export` / `Exporter` (previews, proxies and AAF/OMF mixdowns share the pipeline). A job still rendering at that moment allocates a few images again: the pool is only a cache. Measured with a synthetic 3 s 1080p24 matte exported as PNG frames three times in one process (Apple M1, `footprint`, 10 s after the last export): 575 MB without the trim, the pool holding 332 MB idle, and 258 MB with it. macOS returns the freed pages over a few seconds, not at once. Tests: `crates/frame/tests/pool_trim.rs` (every shelf emptied, the pool works afterwards) and `crates/export/tests/pool_trim.rs` (an image sequence, a finished and a cancelled video, an export that could not start and a stepped export dropped half way each leave nothing idle; without the trim they fail). Co-Authored-By: Claude Sonnet 5.5 --- crates/export/src/pipeline.rs | 10 ++++ crates/export/tests/pool_trim.rs | 95 ++++++++++++++++++++++++++++++++ crates/frame/src/pool.rs | 16 ++++++ crates/frame/tests/pool_trim.rs | 53 ++++++++++++++++++ docs/architecture.md | 7 ++- docs/control-protocol.md | 2 +- docs/performance.md | 11 ++++ 7 files changed, 190 insertions(+), 4 deletions(-) create mode 100644 crates/export/tests/pool_trim.rs create mode 100644 crates/frame/tests/pool_trim.rs diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index fde70f83..0ef9ac95 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -68,6 +68,16 @@ pub(crate) struct Pipeline { drop_frame: bool, } +impl Drop for Pipeline { + /// The export is over, however it ended (done, failed, cancelled, dropped half way). The float + /// images its frames left on the shelves of `filmcraft_frame::pool` are the size of its frames, + /// which nothing else asks for, so free them instead of holding up to 320 MiB idle. A job still + /// rendering at that moment allocates a few images again; the pool is only a cache. + fn drop(&mut self) { + filmcraft_frame::pool::trim(); + } +} + impl Pipeline { pub fn new(project: Arc, seq: ItemId, settings: &ExportSettings, hdr_out: bool) -> Result { let q = project.sequence(seq).ok_or(ExportError::NoSequence)?; diff --git a/crates/export/tests/pool_trim.rs b/crates/export/tests/pool_trim.rs new file mode 100644 index 00000000..678a995c --- /dev/null +++ b/crates/export/tests/pool_trim.rs @@ -0,0 +1,95 @@ +//! An export gives back what it left on the frame pool (`filmcraft_frame::pool`) when it ends, +//! however it ends. One test in this file on purpose: the pool is shared by every test of a binary, +//! and its numbers mean something only while nothing else renders. + +use std::sync::Arc; +use std::sync::atomic::Ordering; + +use filmcraft_export::{ExportSettings, Exporter, Format, Progress, Step, export}; +use filmcraft_frame::pool; +use filmcraft_media::generators::GeneratorSource; +use filmcraft_media::{Generator, MediaSource}; +use filmcraft_project::{ItemId, ItemKind, Label, MediaClip, MediaRef, Project, SequenceSettings, TrackKind}; +use filmcraft_render::SourceMap; +use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick, TimeRange}; + +/// A red matte filling a 320×180 sequence for a second. +fn project() -> (Arc, ItemId, SourceMap) { + let mut p = Project::new("x"); + let rate = FrameRate::FPS_24; + let g = GeneratorSource::new(Generator::ColorMatte { color: [1.0, 0.0, 0.0, 1.0] }, 320, 180, rate, Tick(2 * TICKS_PER_SECOND)); + let info = g.info().clone(); + let clip = MediaClip { + media: MediaRef::Generator(g.generator.clone()), + info: info.clone(), + interpret: Default::default(), + mark_in: None, + mark_out: None, + markers: vec![], + offline: false, + proxy: None, + identity: None, + }; + let red = p.add_item(&info.name, Label::Iris, ItemKind::Media(clip), None); + let seq = p.new_sequence("s", SequenceSettings { width: 320, height: 180, frame_rate: rate, ..Default::default() }, 1, 1, None); + let item = p.make_track_item(red, TrackKind::Video, Tick::ZERO, TimeRange::new(Tick::ZERO, rate.tick_of(24)), rate).unwrap(); + p.sequence_mut(seq).unwrap().video_tracks[0].items.push(item); + let mut sources = SourceMap::default(); + sources.0.insert(red, Arc::new(g)); + (Arc::new(p), seq, sources) +} + +fn idle_mb() -> f64 { + pool::stats().idle_bytes as f64 / 1e6 +} + +#[test] +fn the_pool_is_empty_when_an_export_ends_however_it_ended() { + let (p, seq, sources) = project(); + let dir = std::env::temp_dir().join(format!("fc-pool-trim-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let settings = + |format: Format, name: &str| ExportSettings { format, path: dir.join(name).to_string_lossy().to_string(), include_audio: false, ..Default::default() }; + // an image the pool could keep, standing in for what the frames of an export leave (the frames + // themselves do too: the checks below fail without the trim) + let leave_something = || { + pool::recycle_f32(vec![0.0; 100_000]); + assert!(pool::stats().idle_bytes > 0); + }; + let check = |what: &str| assert_eq!(pool::stats().idle_bytes, 0, "after {what}: {:.1} MB idle", idle_mb()); + + // images, which run in `export` itself + leave_something(); + export(&p, seq, &settings(Format::PngSequence, "seq.png"), &sources, &Progress::default()).unwrap(); + check("an image sequence"); + + // an encoded video, run to the end + leave_something(); + export(&p, seq, &settings(Format::ProRes, "a.mov"), &sources, &Progress::default()).unwrap(); + check("a finished video"); + + // cancelled before the first frame + leave_something(); + let cancelled = Progress::default(); + cancelled.cancel.store(true, Ordering::Relaxed); + assert!(export(&p, seq, &settings(Format::ProRes, "b.mov"), &sources, &cancelled).is_err()); + check("a cancelled video"); + + // an encoder that is not there: the pipeline was built, the export never started + leave_something(); + assert!(export(&p, seq, &settings(Format::Hevc, "c.mp4"), &sources, &Progress::default()).is_err()); + check("an export that could not start"); + + // stepped by the host (the web build), dropped half way: 6 frames of 24 + let progress = Progress::default(); + let mut ex = Exporter::new(p.clone(), seq, &settings(Format::ProRes, "d.mov"), &progress).unwrap(); + ex.set_batch(2); + for _ in 0..3 { + assert!(matches!(ex.step(&sources, &progress), Ok(Step::Progress))); + } + leave_something(); + drop(ex); + check("an export dropped half way"); + + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/frame/src/pool.rs b/crates/frame/src/pool.rs index 22f04b9e..8b778418 100644 --- a/crates/frame/src/pool.rs +++ b/crates/frame/src/pool.rs @@ -9,6 +9,9 @@ //! The compositor's working images (premultiplied linear `f32`, 33 MB at 1080p) get the same //! treatment ([`take_f32_overwritten`], [`recycle_f32`]): allocated and freed once per layer per //! frame they cost a zero-fill and fresh page faults every time. +//! +//! The shelves are sized for the busiest work, so they are a lot to keep once it is over: whoever +//! finishes such work (an export) calls [`trim`]. use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, PoisonError}; @@ -138,6 +141,19 @@ pub fn recycle(frame: Arc) { } } +/// Free every idle buffer. An export leaves the shelves full (up to [`F32_MAX_BYTES`] of float +/// images, each the size of that export's frames, and [`MAX_BYTES`] of each plane type) and +/// nothing else asks for those sizes again, so they would sit idle until the next export. What +/// is still in use is not touched, and the next job starts cold, for the price of its first +/// allocations. +pub fn trim() { + // taken under the locks, freed after them: giving back hundreds of megabytes takes a moment + let floats = std::mem::take(&mut *F32.lock().unwrap_or_else(PoisonError::into_inner)); + let planes8 = std::mem::replace(&mut *U8.lock().unwrap_or_else(PoisonError::into_inner), Shelf::new()); + let planes16 = std::mem::replace(&mut *U16.lock().unwrap_or_else(PoisonError::into_inner), Shelf::new()); + drop((floats, planes8, planes16)); +} + /// Idle buffers held and buffers handed out again so far (`perf.stats`). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct PoolStats { diff --git a/crates/frame/tests/pool_trim.rs b/crates/frame/tests/pool_trim.rs new file mode 100644 index 00000000..64c4b67e --- /dev/null +++ b/crates/frame/tests/pool_trim.rs @@ -0,0 +1,53 @@ +//! `pool::trim` frees every idle buffer of the process-wide pool. This file holds one test on +//! purpose: the pool is shared by every test of a binary, and another test recycling or taking +//! buffers while this one counts them would make the counts meaningless. + +use std::sync::Arc; + +use filmcraft_frame::{Chroma, PixelData, VideoFrame, pool}; + +const FLOATS: usize = 300_000; +const BYTES: usize = 200_000; +const WORDS: usize = 100_000; + +fn frame(data: PixelData) -> Arc { + Arc::new(VideoFrame { width: 1, height: 1, data, color: filmcraft_color::ColorInfo::SRGB_FULL, par: (1, 1), pts: filmcraft_time::Tick::ZERO }) +} + +/// Hand the pool one 8-bit and one 16-bit plane (the other planes are too small to be kept). +fn recycle_planes() { + let empty = || Arc::new(Vec::::new()); + pool::recycle(frame(PixelData::Yuv8 { planes: [Arc::new(vec![7u8; BYTES]), empty(), empty()], chroma: Chroma::C420, alpha: None })); + let empty = || Arc::new(Vec::::new()); + pool::recycle(frame(PixelData::Yuv16 { planes: [Arc::new(vec![7u16; WORDS]), empty(), empty()], chroma: Chroma::C420, bits: 10, alpha: None })); +} + +#[test] +fn trim_frees_every_idle_buffer_and_the_pool_keeps_working() { + assert_eq!(pool::stats().idle_bytes, 0, "nothing has been recycled yet"); + + // one idle buffer on each of the three shelves + pool::recycle_f32(vec![f32::NAN; FLOATS]); + recycle_planes(); + let held = FLOATS * 4 + BYTES + WORDS * 2; + assert!(pool::stats().idle_bytes >= held, "idle {} of {held}", pool::stats().idle_bytes); + + pool::trim(); + assert_eq!(pool::stats().idle_bytes, 0, "every shelf is empty"); + + // nothing comes back from the shelves: the float image is a fresh (zeroed) one and no take + // counts as a reuse + let reused = pool::stats().reused; + let image = pool::take_f32_overwritten(FLOATS); + assert!(image.iter().all(|v| *v == 0.0), "the recycled image was freed"); + drop((pool::take_u8(BYTES), pool::take_u16(WORDS))); + assert_eq!(pool::stats().reused, reused); + + // the pool works after a trim, and trimming twice (or an empty pool) is fine + pool::recycle_f32(image); + recycle_planes(); + assert!(pool::stats().idle_bytes >= held); + pool::trim(); + pool::trim(); + assert_eq!(pool::stats().idle_bytes, 0); +} diff --git a/docs/architecture.md b/docs/architecture.md index e0a7885c..9e460c96 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -306,9 +306,10 @@ file ──► codecs (MP4/MOV, MKV, audio) demux + decode, GOP-aware see not zero (`VideoFrame::alpha_region`, `to_linear_f32_region`, `blend::composite_at`), the rest being transparent anyway; and working images are recycled through `filmcraft_frame::pool` (`take_f32_overwritten` / `recycle_f32`: the buffer comes back with its old contents, so there is - no zero-fill) by the export pipeline once a frame is converted to 8 bits. A clip with effects, - opacity masks, frame blending, colour management, or a picture that is not placed one to one on - the output takes the general path. + no zero-fill) by the export pipeline once a frame is converted to 8 bits; the pipeline trims the + pool (`pool::trim`) when it goes away, so a finished export leaves no idle images behind. A clip + with effects, opacity masks, frame blending, colour management, or a picture that is not placed + one to one on the output takes the general path. - **Frame scheduling.** `crates/ui-egui/src/frames.rs` runs a small pool of worker threads with prioritised jobs: the frame on screen first, then playback prefetch, then thumbnails. The UI never decodes. It shows the exact frame when it is ready and holds the nearest cached frame meanwhile. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 6419e154..d586e9b8 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -39,7 +39,7 @@ Methods (handlers in `crates/ui-egui/src/control.rs`): | `ui.playback` | `{action: play|stop|toggle, speed?}` | | | `ui.screenshot` | `{path?, panel?}` | PNG of the window or one panel; fails after 10 s when no frame is presented (window hidden, display asleep) | | `ui.resize`, `ui.focus`, `app.quit` | | `ui.focus` is the only request that activates the app and takes keyboard focus | -| `perf.stats` | – | performance counters (also the command `perf.stats`, so `engine.execute` and MCP `command_run` reach it; headless sessions return the engine part). `decode`: GOP-cache requests / hits / `cacheHitRate`, decoder seeks, samples decoded and skipped while catching up, `draftFrames` (decoded in draft mode), `h264Threads` (frame-threading workers per H.264 decoder), `liveDecoders` (sources holding a decoder now), `cacheMB` / `cacheBudgetMB` (decoded frames all sources hold, and the shared budget), `planePoolMB` / `planesReused` (idle plane buffers of evicted frames, and planes decoded into a recycled one), decoder ms (total and per sample), `framesDecoded`, `hardware` (Settings ▸ Playback ▸ Hardware decoding: `enabled`, pictures from hardware decoders `frames` vs `softwareFrames`, `sessions`, `declined` streams, mid-stream `fallbacks`); `playback`: playing, shown / dropped frames and `dropRate` of the current or last play, resolution, `draftDecode` (Settings ▸ Playback ▸ Draft decoding), `audio` (desktop: sound is mixed 200 ms ahead of the device; device `callbacks`, `underruns` and `missingFrames` played as silence because the mixer fell behind, `minLeadMs` the least sound left buffered); `frames`: frame-worker jobs, cancelled, `requestHitRate`, `decodeMs` / `renderMs` (mean, p50, p95 of the last 256 jobs), queue, render-cost estimate, cache use; `ui`: fps and frame ms; `process`: CPU seconds; `media`, `jobs`. Counters are cumulative: diff two readings to measure an interval ([performance.md](performance.md)) | +| `perf.stats` | – | performance counters (also the command `perf.stats`, so `engine.execute` and MCP `command_run` reach it; headless sessions return the engine part). `decode`: GOP-cache requests / hits / `cacheHitRate`, decoder seeks, samples decoded and skipped while catching up, `draftFrames` (decoded in draft mode), `h264Threads` (frame-threading workers per H.264 decoder), `liveDecoders` (sources holding a decoder now), `cacheMB` / `cacheBudgetMB` (decoded frames all sources hold, and the shared budget), `planePoolMB` / `planesReused` (idle buffers of the frame pool, the planes of evicted frames and the compositor's float images, which an export frees when it ends; and buffers taken from it instead of allocated), decoder ms (total and per sample), `framesDecoded`, `hardware` (Settings ▸ Playback ▸ Hardware decoding: `enabled`, pictures from hardware decoders `frames` vs `softwareFrames`, `sessions`, `declined` streams, mid-stream `fallbacks`); `playback`: playing, shown / dropped frames and `dropRate` of the current or last play, resolution, `draftDecode` (Settings ▸ Playback ▸ Draft decoding), `audio` (desktop: sound is mixed 200 ms ahead of the device; device `callbacks`, `underruns` and `missingFrames` played as silence because the mixer fell behind, `minLeadMs` the least sound left buffered); `frames`: frame-worker jobs, cancelled, `requestHitRate`, `decodeMs` / `renderMs` (mean, p50, p95 of the last 256 jobs), queue, render-cost estimate, cache use; `ui`: fps and frame ms; `process`: CPU seconds; `media`, `jobs`. Counters are cumulative: diff two readings to measure an interval ([performance.md](performance.md)) | **Selection or explicit targets.** A command that acts on the selection is "not available right now" when nothing is selected. When `params` name the targets under a key the command documents (`clips` / diff --git a/docs/performance.md b/docs/performance.md index 5fd62d73..70e33011 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -95,6 +95,17 @@ compositor's savings are a larger part of the export. What is left in a frame without overlays is the Y'CbCr → linear float conversion of the camera picture (4.5 ms in the synthetic case) and the conversion to 8 bits (2 ms). +### Memory after an export + +The recycled images stay on the shelves of `filmcraft_frame::pool` (up to 320 MiB of them, and +192 MiB of each plane type), and nothing else asks for images of an export's size, so a finished +export left them idle. The export pipeline now trims the pool when it goes away, however the +export ended (done, failed, cancelled, dropped half way): `perf.stats` reads `decode.planePoolMB` +0 afterwards. A process that exported a synthetic 3 s 1080p24 matte as PNG frames three times +(Apple M1, `footprint`, 10 s after the last export) rested at **575 MB** with the pool holding +332 MB idle and at **258 MB** with the trim. The memory does not come back at once: macOS returns +freed pages over a few seconds (892 MB right after the export in both cases, 726 MB after 2 s). + ## Results (GPU2: standard effects on the GPU compositor, #30, before → after) Before = this change with clips that carry standard effects sent back to the CPU layer path in From 0e0f0485da1a9cf9feba6b60f04cecd58219d22a Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:06:37 +1000 Subject: [PATCH 07/15] LINUX.VALIDATION: honor preset shortcuts and retain MPEG oracle frames Resolve inherited physical key collisions when loading built-in presets, while preserving established explicit winners and deliberate custom conflicts. Read every ffprobe display-frame timestamp and register the requested credit. Verify exact frame-plane reuse without depending on concurrent global metrics. Co-authored-by: Codex --- Cargo.lock | 1 + contributors/people.toml | 4 + crates/codecs/Cargo.toml | 1 + crates/codecs/tests/mpeg_oracle.rs | 59 ++++++++++-- crates/engine/src/shortcuts.rs | 22 +++-- crates/engine/src/shortcuts_tests.rs | 130 +++++++++++++++++++++++++++ crates/frame/src/pool.rs | 38 +++++--- 7 files changed, 234 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 900806b9..7dda81f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1816,6 +1816,7 @@ dependencies = [ "filmcraft-vp9", "image", "log", + "serde_json", "symphonia", "thiserror 2.0.21", "web-time", diff --git a/contributors/people.toml b/contributors/people.toml index 89c6db20..0fd5e8bf 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -21,3 +21,7 @@ real_name = "Brandon Thomas" display_name = "Brandon Thomas" sync_github_name = false + +[people.sanyamsingh001] +display_name = "sanyamsingh001" +sync_github_name = false diff --git a/crates/codecs/Cargo.toml b/crates/codecs/Cargo.toml index 8765f3eb..3a490e2c 100644 --- a/crates/codecs/Cargo.toml +++ b/crates/codecs/Cargo.toml @@ -41,3 +41,4 @@ symphonia = { version = "0.5.5", default-features = false, features = ["aac", "m [dev-dependencies] filmcraft-testkit = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/codecs/tests/mpeg_oracle.rs b/crates/codecs/tests/mpeg_oracle.rs index 5fdbc0bf..c2685d23 100644 --- a/crates/codecs/tests/mpeg_oracle.rs +++ b/crates/codecs/tests/mpeg_oracle.rs @@ -177,15 +177,64 @@ fn open(p: &Path) -> MpegSource { MpegSource::open(p.file_name().unwrap().to_str().unwrap(), bytes(p)).unwrap() } -/// ffprobe's video frames in display order: pts (90 kHz). +/// ffprobe's video frames in display order: PTS (90 kHz). A program-stream picture +/// need not carry its own PTS; use the decoder's presentation timestamp for that picture. fn ffprobe_frame_pts(file: &Path) -> Option> { - let fp = filmcraft_testkit::ffprobe()?; + let fp = filmcraft_testkit::ffprobe_or_skip("MPEG video frame presentation timestamps")?; let o = std::process::Command::new(fp) - .args(["-v", "error", "-select_streams", "v:0", "-show_entries", "frame=pts", "-of", "csv=p=0"]) + .args(["-v", "error", "-select_streams", "v:0", "-show_entries", "frame=pts,best_effort_timestamp", "-of", "json"]) .arg(file) .output() - .ok()?; - Some(String::from_utf8_lossy(&o.stdout).lines().filter_map(|l| l.trim().trim_end_matches(',').parse().ok()).collect()) + .unwrap_or_else(|error| panic!("{}: cannot run ffprobe: {error}", file.display())); + assert!(o.status.success(), "{}: ffprobe failed: {}", file.display(), String::from_utf8_lossy(&o.stderr)); + Some(parse_ffprobe_frame_pts(&o.stdout).unwrap_or_else(|error| panic!("{}: {error}", file.display()))) +} + +fn parse_ffprobe_frame_pts(bytes: &[u8]) -> Result, String> { + let data: serde_json::Value = serde_json::from_slice(bytes).map_err(|error| format!("invalid ffprobe JSON: {error}"))?; + let frames = data.get("frames").and_then(serde_json::Value::as_array).ok_or("ffprobe output is missing its frames array")?; + if frames.is_empty() { + return Err("ffprobe returned no video frames".into()); + } + frames + .iter() + .enumerate() + .map(|(index, frame)| { + let field = if frame.get("pts").is_some() { "pts" } else { "best_effort_timestamp" }; + frame.get(field).and_then(serde_json::Value::as_i64).ok_or_else(|| format!("ffprobe video frame {index} is missing an integer {field}")) + }) + .collect() +} + +#[test] +fn ffprobe_pts_preserve_missing_raw_timestamps_and_side_data() { + let output = br#"{"frames":[ + {"pts":131400,"best_effort_timestamp":131401,"side_data_list":[{},{}]}, + {"best_effort_timestamp":135000,"side_data_list":[{}]}, + {"pts":138600,"best_effort_timestamp":138600} + ]}"#; + assert_eq!(parse_ffprobe_frame_pts(output).unwrap(), [131400, 135000, 138600], "keep every display frame, with raw PTS preferred when present"); + assert_eq!(parse_ffprobe_frame_pts(br#"{"frames":[{"pts":-3600},{"pts":0},{"pts":9223372036854775807}]}"#).unwrap(), [-3600, 0, i64::MAX]); +} + +#[test] +fn malformed_ffprobe_pts_fail_instead_of_dropping_frames() { + for output in [ + "not JSON", + "{}", + r#"{"frames":{}}"#, + r#"{"frames":[]}"#, + r#"{"frames":[{}]}"#, + r#"{"frames":[null]}"#, + r#"{"frames":[{"pts":"N/A","best_effort_timestamp":135000}]}"#, + r#"{"frames":[{"pts":null,"best_effort_timestamp":135000}]}"#, + r#"{"frames":[{"pts":1.5}]}"#, + r#"{"frames":[{"pts":9223372036854775808}]}"#, + r#"{"frames":[{"best_effort_timestamp":"135000"}]}"#, + r#"{"frames":[{"pts":131400},{"best_effort_timestamp":null},{"pts":138600}]}"#, + ] { + assert!(parse_ffprobe_frame_pts(output.as_bytes()).is_err(), "malformed oracle output must fail: {output}"); + } } /// Decode every frame in order and `seeks` random frames; compare with ffmpeg within `tol`. diff --git a/crates/engine/src/shortcuts.rs b/crates/engine/src/shortcuts.rs index 4165cde8..90b1766c 100644 --- a/crates/engine/src/shortcuts.rs +++ b/crates/engine/src/shortcuts.rs @@ -24,7 +24,7 @@ //! Commands the frontend owns (tools, transport, panels, workspaces) are registered with //! [`Shortcuts::register_external`] so they can be listed, bound and resolved here too. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; @@ -593,22 +593,34 @@ impl Shortcuts { _ => return None, }; self.apply_table(&mut b, &table); + // Canonical chords can become the same physical key on this platform. Keep the first + // binding, matching resolve(), when generating a built-in preset. + let platform = Platform::current(); + let mut seen = BTreeSet::new(); + b.retain(|binding| binding.chord().is_none_or(|chord| seen.insert((binding.context().to_string(), chord.effective(platform))))); Some(b) } - /// Apply a preset table: listed commands get exactly the listed keys, and a listed key is taken - /// away from whatever else had it in that context. + /// Apply a preset table: listed commands get the listed keys, and explicit physical keys take + /// precedence over inherited bindings in the same context. Within the table, keep canonical + /// replacement order so folded aliases retain their established first-match winner. fn apply_table(&self, b: &mut Vec, table: &[Entry]) { let listed: Vec<&str> = table.iter().map(|e| e.0).filter(|c| self.known(c)).collect(); b.retain(|x| !listed.contains(&x.command.as_str())); + let platform = Platform::current(); + let mut explicit: Vec = Vec::new(); for (c, k, p) in table { if !self.known(c) { continue; } let nb = Binding::new(c, k, panel_opt(p)); - b.retain(|o| !(o.context() == nb.context() && o.chord() == nb.chord())); - b.push(nb); + let chord = nb.chord(); + let physical = chord.as_ref().map(|chord| chord.effective(platform)); + b.retain(|o| !(o.context() == nb.context() && o.chord().map(|chord| chord.effective(platform)) == physical)); + explicit.retain(|o| !(o.context() == nb.context() && o.chord() == chord)); + explicit.push(nb); } + b.extend(explicit); } fn presets_dir(&self) -> Option { diff --git a/crates/engine/src/shortcuts_tests.rs b/crates/engine/src/shortcuts_tests.rs index 0bf0b63b..520a73e5 100644 --- a/crates/engine/src/shortcuts_tests.rs +++ b/crates/engine/src/shortcuts_tests.rs @@ -218,3 +218,133 @@ fn list_searches_labels_and_keys() { let v = s.execute("shortcuts.list", json!({"panel": "History", "assigned": true})).unwrap(); assert_eq!(v.as_array().unwrap().len(), 2, "{v}"); } + +#[cfg(target_os = "linux")] +#[test] +fn engine_defaults_keep_the_existing_linux_winners_without_folded_conflicts() { + let s = Session::default(); + assert!(s.shortcuts.conflicts(Platform::Linux).is_empty()); + for (keys, command) in [("Cmd+9", "multicam.cutToCamera9"), ("Cmd+Shift+M", "markers.addRange"), ("Cmd+T", "trim.toggleType")] { + let chord = Chord::parse(keys).unwrap(); + assert_eq!(s.shortcuts.resolve(&chord, None, Platform::Linux).unwrap().command, command, "{keys} keeps its existing first-match action"); + let ctrl = keys.replacen("Cmd", "Ctrl", 1); + assert_eq!(s.shortcuts.resolve(&Chord::parse(&ctrl).unwrap(), None, Platform::Linux).unwrap().command, command); + } + assert!(!s.shortcuts.modified); + assert!(!s.shortcuts.can_undo()); + assert!(!s.shortcuts.can_redo()); +} + +#[test] +fn builtin_presets_are_unambiguous_with_engine_and_frontend_commands() { + let platform = Platform::current(); + for frontend in [false, true] { + for name in crate::shortcuts::BUILTIN_PRESETS { + let mut s = Session::default(); + if frontend { + with_ui_commands(&mut s); + } + s.execute("shortcuts.loadPreset", json!({"name": name})).unwrap(); + assert!(s.shortcuts.conflicts(platform).is_empty(), "{name}, frontend={frontend}: {:?}", s.shortcuts.conflicts(platform)); + let backspace = Chord::parse("Backspace").unwrap(); + assert_eq!(s.shortcuts.resolve(&backspace, Some("Project"), platform).unwrap().command, "project.delete", "panel overrides remain independent"); + s.execute("shortcuts.undo", json!({})).unwrap(); + assert_eq!(s.shortcuts.preset, crate::shortcuts::DEFAULT_PRESET); + assert!(s.shortcuts.conflicts(platform).is_empty()); + s.execute("shortcuts.redo", json!({})).unwrap(); + assert_eq!(s.shortcuts.preset, name); + assert!(s.shortcuts.conflicts(platform).is_empty()); + } + } +} + +#[test] +fn intentional_conflicts_survive_registration_persistence_and_shortcut_undo() { + let dir = temp_dir("intentional-conflicts"); + let platform = Platform::current(); + let mut s = Session::default(); + s.shortcuts.set_dir(&dir); + let defaults = s.shortcuts.bindings.clone(); + let result = s.execute("shortcuts.set", json!({"command": "edit.redo", "keys": "M", "keepConflicts": true})).unwrap(); + assert_eq!(result["conflicts"].as_array().unwrap().len(), 1); + let edited = s.shortcuts.bindings.clone(); + with_ui_commands(&mut s); + assert_eq!(s.shortcuts.bindings, edited, "registering the UI preserves an intentionally modified set"); + let mut restored = Session::default(); + restored.shortcuts.set_dir(&dir); + with_ui_commands(&mut restored); + assert_eq!(restored.shortcuts.bindings, edited, "a modified built-in set survives restart"); + assert!(restored.shortcuts.modified); + assert_eq!(restored.shortcuts.conflicts(platform).len(), 1); + s.execute("shortcuts.undo", json!({})).unwrap(); + assert_eq!(s.shortcuts.bindings, defaults); + assert!(!s.shortcuts.modified); + assert!(s.shortcuts.conflicts(platform).is_empty()); + s.execute("shortcuts.redo", json!({})).unwrap(); + assert_eq!(s.shortcuts.bindings, edited); + assert_eq!(s.shortcuts.conflicts(platform).len(), 1); + s.execute("shortcuts.savePreset", json!({"name": "Kept conflicts"})).unwrap(); + assert!(!s.shortcuts.modified); + with_ui_commands(&mut s); + assert_eq!(s.shortcuts.bindings, edited, "an unmodified custom preset keeps its deliberate conflicts"); + let mut custom = Session::default(); + custom.shortcuts.set_dir(&dir); + with_ui_commands(&mut custom); + assert_eq!(custom.shortcuts.preset, "Kept conflicts"); + assert_eq!(custom.shortcuts.bindings, edited); + assert_eq!(custom.shortcuts.conflicts(platform).len(), 1); + custom.execute("shortcuts.loadPreset", json!({"name": crate::shortcuts::DEFAULT_PRESET})).unwrap(); + assert!(custom.shortcuts.conflicts(platform).is_empty()); + custom.execute("shortcuts.loadPreset", json!({"name": "Kept conflicts"})).unwrap(); + assert_eq!(custom.shortcuts.bindings, edited); + assert_eq!(custom.shortcuts.conflicts(platform).len(), 1); + let _ = std::fs::remove_dir_all(&dir); +} + +#[cfg(target_os = "linux")] +#[test] +fn an_explicit_final_cut_preset_binding_overrides_inherited_linux_physical_keys() { + for frontend in [false, true] { + let mut session = Session::default(); + if frontend { + with_ui_commands(&mut session); + } + session.execute("shortcuts.loadPreset", json!({"name": crate::shortcuts::FCP_PRESET})).unwrap(); + for keys in ["Cmd+T", "Ctrl+T"] { + let resolved = session.execute("shortcuts.resolve", json!({"keys": keys})).unwrap(); + assert_eq!( + resolved["command"], "sequence.applyVideoTransition", + "the explicit Final Cut Pro table replaces inherited Trim Toggle Type: {resolved}" + ); + } + assert!(session.shortcuts.conflicts(Platform::Linux).is_empty()); + session.execute("shortcuts.undo", json!({})).unwrap(); + let resolved = session.execute("shortcuts.resolve", json!({"keys": "Cmd+T"})).unwrap(); + assert_eq!(resolved["command"], "trim.toggleType", "the FilmCraft default keeps its established Linux winner"); + session.execute("shortcuts.redo", json!({})).unwrap(); + let resolved = session.execute("shortcuts.resolve", json!({"keys": "Ctrl+T"})).unwrap(); + assert_eq!(resolved["command"], "sequence.applyVideoTransition"); + } +} + +#[cfg(target_os = "linux")] +#[test] +fn compatible_presets_keep_the_existing_first_explicit_linux_winners() { + let mut session = Session::default(); + // Export is frontend-owned; use the same definition registered by the desktop menu. + session.shortcuts.register_external(vec![CommandInfo::new("mode.export", "Export", &["File", "Export"], Some("Cmd+M"))]); + for (preset, keys, command) in [ + (crate::shortcuts::FCP_PRESET, "Cmd+R", "clip.speedDuration"), + (crate::shortcuts::PREMIERE_PRESET, "Cmd+M", "mode.export"), + (crate::shortcuts::PREMIERE_PRESET, "Cmd+Shift+M", "markers.goPrev"), + (crate::shortcuts::PREMIERE_PRESET, "Cmd+T", "trim.toggleType"), + (crate::shortcuts::PREMIERE_PRESET, "Cmd+9", "multicam.cutToCamera9"), + ] { + session.execute("shortcuts.loadPreset", json!({"name": preset})).unwrap(); + for spelling in [keys.to_string(), keys.replacen("Cmd", "Ctrl", 1)] { + let resolved = session.execute("shortcuts.resolve", json!({"keys": spelling})).unwrap(); + assert_eq!(resolved["command"], command, "{preset}: {spelling} keeps its existing first explicit action: {resolved}"); + } + assert!(session.shortcuts.conflicts(Platform::Linux).is_empty()); + } +} diff --git a/crates/frame/src/pool.rs b/crates/frame/src/pool.rs index 22f04b9e..08d30449 100644 --- a/crates/frame/src/pool.rs +++ b/crates/frame/src/pool.rs @@ -226,21 +226,37 @@ mod tests { pts: filmcraft_time::Tick::ZERO, }) }; - let before = stats().reused; - // a frame someone else still holds is left alone + let plane_ptrs = |frame: &VideoFrame| match &frame.data { + PixelData::Yuv8 { planes, .. } => planes.each_ref().map(|plane| plane.as_ptr() as usize), + _ => panic!("fixture has three byte planes"), + }; + // A frame someone else still holds is left alone. The reuse statistic is global: + // concurrent float-buffer tests can change it without touching these planes. let shared = yuv(1); let held = shared.clone(); + let held_ptrs = plane_ptrs(&held); recycle(shared); - assert!(take_u8(n).capacity() >= n); - assert_eq!(stats().reused, before); + let fresh = take_u8(n); + assert!(fresh.capacity() >= n); + assert!(!held_ptrs.contains(&(fresh.as_ptr() as usize)), "a shared plane cannot be handed out"); assert_eq!(held.byte_size(), n + 2 * c); - // an unshared frame's planes come back, empty - recycle(yuv(2)); + // An unshared frame's exact allocations come back, empty. Equal-size chroma + // planes may be taken in either order. + let unshared = yuv(2); + let returned_ptrs = plane_ptrs(&unshared); + recycle(unshared); let (y, u, v) = (take_u8(n), take_u8(c), take_u8(c)); - assert!(y.is_empty() && y.capacity() >= n && u.capacity() >= c && v.capacity() >= c); - assert_eq!(stats().reused, before + 3); - // and are not handed out twice - take_u8(n); - assert_eq!(stats().reused, before + 3); + assert!(y.is_empty() && u.is_empty() && v.is_empty()); + assert!(y.capacity() >= n && u.capacity() >= c && v.capacity() >= c); + assert_eq!(y.as_ptr() as usize, returned_ptrs[0]); + let mut expected_chroma = [returned_ptrs[1], returned_ptrs[2]]; + let mut actual_chroma = [u.as_ptr() as usize, v.as_ptr() as usize]; + expected_chroma.sort_unstable(); + actual_chroma.sort_unstable(); + assert_eq!(actual_chroma, expected_chroma); + // The returned allocations remain owned by y/u/v and cannot be handed out twice. + let another = take_u8(n); + assert!(another.capacity() >= n); + assert!(!returned_ptrs.contains(&(another.as_ptr() as usize))); } } From 892003f3d4aa7617f7e708b5f1c35b502c846f0c Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:44:39 +1000 Subject: [PATCH 08/15] GPU.EFFECT_SOURCE: preserve exact texel centers for effect inputs Draw effect sources from working pixel positions and integer decimation. Cover identity, alpha inversion and box minification without changing tolerances. Co-authored-by: Codex --- crates/gpu/src/composite.wgsl | 11 +++++- crates/gpu/src/fx.rs | 2 +- crates/gpu/src/fx_tests.rs | 71 +++++++++++++++++++++++++++++++++++ crates/gpu/src/lib.rs | 9 ++++- 4 files changed, 89 insertions(+), 4 deletions(-) diff --git a/crates/gpu/src/composite.wgsl b/crates/gpu/src/composite.wgsl index 5e897ba3..cc40e08b 100644 --- a/crates/gpu/src/composite.wgsl +++ b/crates/gpu/src/composite.wgsl @@ -14,7 +14,7 @@ struct U { p0: vec4, // opacity, kind (0 rgba8 srgb straight, 1 rgba16f premul linear, 2 yuv), taps, transfer (0 srgb, 1 linear, 2 pq, 3 hlg) p1: vec4, // y_off y_scale c_off c_scale (code units) p2: vec4, // kr kb code_scale footprint - p3: vec4, // blend mode (index into filmcraft_render::Blend::ALL), alpha-plane scale (0: none), unused ×2 + p3: vec4, // blend mode, alpha-plane scale (0: none), effect-source integer decimation, unused }; @group(0) @binding(0) var u: U; @@ -274,6 +274,15 @@ fn linear_to_srgb(v: vec3) -> vec3 { return select(hi, v * 12.92, v <= vec3(0.0031308)); } +// Effect sources are axis-aligned integer-decimated working images. Raster-interpolated source +// coordinates can mix adjacent texels at a nominal pixel center, inventing alpha near zero. +// Keep the regular transformed/minified layer path unchanged; only this source draw uses its +// exact working-pixel position and the host's integer decimation. +@fragment +fn fs_fx_source(in: VOut) -> @location(0) vec4 { + return layer_color(in.pos.xy * u.p3.z); +} + // Blend modes that need the destination: written without fixed-function blending. @fragment fn fs_blend(in: VOut) -> @location(0) vec4 { diff --git a/crates/gpu/src/fx.rs b/crates/gpu/src/fx.rs index 930a43d8..660ee2c3 100644 --- a/crates/gpu/src/fx.rs +++ b/crates/gpu/src/fx.rs @@ -254,7 +254,7 @@ impl FxStage { multisample: wgpu::MultisampleState::default(), fragment: Some(wgpu::FragmentState { module: composite, - entry_point: Some("fs"), + entry_point: Some("fs_fx_source"), compilation_options: Default::default(), targets: &[Some(wgpu::ColorTargetState { format: FX_FORMAT, blend: None, write_mask: wgpu::ColorWrites::ALL })], }), diff --git a/crates/gpu/src/fx_tests.rs b/crates/gpu/src/fx_tests.rs index 5e201d11..f47c0546 100644 --- a/crates/gpu/src/fx_tests.rs +++ b/crates/gpu/src/fx_tests.rs @@ -489,3 +489,74 @@ fn layers_with_effects_fall_back_to_the_cpu_without_a_stage() { assert!(p99 <= 6 && mean < 1.5, "stage {stage}: p99 {p99}, mean {mean}"); } } + +/// The half-float upload is lossless for this input, so a 1:1 effect source draw must be too. +/// Odd dimensions expose interpolation drift that can invent opacity before any effect runs. +#[test] +fn effect_source_identity_preserves_odd_sized_half_float_texels() { + let Some((dev, q)) = device() else { + eprintln!("no GPU adapter; skipping"); + return; + }; + let mut c = GpuCompositor::new(&dev, &q); + for (w, h) in [(67u32, 41u32), (96, 54), (64, 32)] { + let px = picture(w, h); + let frame = VideoFrame::rgba_f32(w, h, px.clone()); + for n in [0, 1] { + let (ow, oh, gpu) = c.effect_image(&frame, &LayerFx { size: (w, h), decimation: n, ops: Vec::new() }).expect("source image"); + assert_eq!((ow, oh, gpu.len()), (w, h, px.len())); + for (i, (expected, actual)) in px.iter().zip(&gpu).enumerate() { + assert_eq!(actual.to_bits(), expected.to_bits(), "{w}x{h} n{n} source component{i}: {actual} != {expected}"); + } + } + } +} + +#[test] +fn alpha_invert_does_not_amplify_invented_opacity_at_odd_source_pixels() { + let Some((dev, q)) = device() else { + eprintln!("no GPU adapter; skipping"); + return; + }; + let mut c = GpuCompositor::new(&dev, &q); + let (w, h) = (67, 41); + let px = picture(w, h); + let frame = VideoFrame::rgba_f32(w, h, px.clone()); + let op = FxOp::Invert { channel: 4, blend: 0.2 }; + let mut cpu = filmcraft_render::Image { w: w as usize, h: h as usize, px: px.clone() }; + op.apply(&mut cpu); + let (_, _, gpu) = c.effect_image(&frame, &LayerFx { size: (w, h), decimation: 1, ops: vec![op] }).expect("alpha invert image"); + assert_eq!(gpu.len(), px.len()); + let (worst, flips) = compare(&cpu.px, &gpu); + assert!(worst <= EXACT && flips == 0.0, "alpha invert: {worst}, {flips}"); + let mut transparent = 0; + for (source, result) in px.as_chunks::<4>().0.iter().zip(gpu.as_chunks::<4>().0.iter()) { + if source[3] == 0.0 { + transparent += 1; + assert_eq!(&result[..3], &[0.0; 3], "a transparent source must not acquire neighboring RGB"); + assert!((result[3] - 0.8).abs() <= f32::EPSILON); + } + } + assert_eq!(transparent, 402); +} + +/// The exact source grid keeps the existing box-minification behavior at non-unit factors. +#[test] +fn effect_source_integer_decimation_preserves_cpu_box_oracle() { + let Some((dev, q)) = device() else { + eprintln!("no GPU adapter; skipping"); + return; + }; + let mut c = GpuCompositor::new(&dev, &q); + for (w, h) in [(67u32, 41u32), (96, 54)] { + let frame = VideoFrame::rgba_f32(w, h, picture(w, h)); + for n in [2, 4, 8] { + let (ow, oh, cpu) = frame.to_linear_f32_decimated(n as usize); + let (gw, gh, gpu) = + c.effect_image(&frame, &LayerFx { size: (ow as u32, oh as u32), decimation: n, ops: Vec::new() }).expect("decimated source image"); + assert_eq!((gw as usize, gh as usize, gpu.len()), (ow, oh, cpu.len())); + let (worst, flips) = compare(&cpu, &gpu); + assert!(worst <= EXACT && flips == 0.0, "{w}x{h} n{n} source box: {worst}, {flips}"); + } + } +} diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index f71db2b6..c8442d07 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -655,7 +655,10 @@ impl GpuCompositor { Some(lfx) => { // the source drawn onto the working image (box-decimated by n, as the CPU decodes) let n = lfx.decimation.max(1) as f64; - let su = Self::uniforms(&src, &filmcraft_geom::Affine::scale(1.0 / n, 1.0 / n), 1.0, Blend::Normal, (lfx.size.0.max(1), lfx.size.1.max(1))); + let mut su = + Self::uniforms(&src, &filmcraft_geom::Affine::scale(1.0 / n, 1.0 / n), 1.0, Blend::Normal, (lfx.size.0.max(1), lfx.size.1.max(1))); + // Effect sources use the working pixel center, avoiding interpolated vertex coordinates. + su[26] = n as f32; let sbuf = self.uniform_buffer(&su); let sbg = self.device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("fx-source"), @@ -799,7 +802,9 @@ impl GpuCompositor { let views = self.uploads.get(&key)?.views.clone(); let n = fx.decimation.max(1) as f64; let (w, h) = (fx.size.0.max(1), fx.size.1.max(1)); - let su = Self::uniforms(&src, &filmcraft_geom::Affine::scale(1.0 / n, 1.0 / n), 1.0, Blend::Normal, (w, h)); + let mut su = Self::uniforms(&src, &filmcraft_geom::Affine::scale(1.0 / n, 1.0 / n), 1.0, Blend::Normal, (w, h)); + // Same integer source-grid mapping as the composited effect path. + su[26] = n as f32; let sbuf = self.uniform_buffer(&su); let sbg = self.device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("fx-source"), From 6893cb4f034472a32a68b21e6ae8323261a7e1ca Mon Sep 17 00:00:00 2001 From: WavJaby Date: Fri, 9 Oct 2026 13:47:06 +0800 Subject: [PATCH 09/15] perf(web): feed native YUV frames to the shared compositor --- apps/filmcraft-web/src/webcodecs.rs | 123 ++++++++++++++--- apps/filmcraft-web/src/webcodecs/pixels.rs | 147 +++++++++++++++++++++ 2 files changed, 251 insertions(+), 19 deletions(-) create mode 100644 apps/filmcraft-web/src/webcodecs/pixels.rs diff --git a/apps/filmcraft-web/src/webcodecs.rs b/apps/filmcraft-web/src/webcodecs.rs index 612a782b..aae035d4 100644 --- a/apps/filmcraft-web/src/webcodecs.rs +++ b/apps/filmcraft-web/src/webcodecs.rs @@ -12,8 +12,7 @@ //! the job on a later frame, when the decoder's output callback has delivered the picture. //! - A session that has not delivered the frame it was started for is not restarted by other //! requests (thumbnails and the monitor would otherwise keep resetting each other). -//! - Outputs are converted to RGBA by drawing the `VideoFrame` on an `OffscreenCanvas` (the -//! browser applies the stream's colour description). +//! - Native YUV planes feed the desktop frame/compositor contract. Unsupported browser formats or geometry use Canvas RGBA. //! - Any decoder error or unsupported configuration switches the source to our decoder. //! //! `?nowebcodecs` disables it. Support is probed at start-up with `VideoDecoder.isConfigSupported`. @@ -34,6 +33,20 @@ use wasm_bindgen::JsCast; use wasm_bindgen::prelude::*; use wasm_bindgen_futures::JsFuture; +mod pixels; + +#[derive(Default)] +struct Transfers { + native: u64, + canvas: u64, + closed: u64, + stale: u64, + bytes: u64, + native_ms: f64, + canvas_ms: f64, + reasons: BTreeMap<&'static str, u64>, +} + /// Samples fed past the wanted one (decoders hold pictures for reordering). const LOOKAHEAD: usize = 8; /// Decoded frames kept per source. @@ -52,6 +65,7 @@ thread_local! { static DECODED: Cell = const { Cell::new(0) }; static FALLBACKS: Cell = const { Cell::new(0) }; static SOURCES: Cell = const { Cell::new(0) }; + static TRANSFERS: RefCell = RefCell::new(Transfers::default()); } fn now_ms() -> f64 { @@ -160,11 +174,15 @@ pub fn stats() -> serde_json::Value { .iter() .map(|(id, s)| { json!({"id": id, "start": s.start, "next": s.next, "target": s.target, "needsKey": s.needs_key, "flushing": s.flushing.get(), - "errored": s.errored.get(), "queue": s.queue(), "idleMs": now_ms() - s.last_output.get()}) + "errored": s.errored.get(), "queue": s.queue(), "copies": s.copying.get(), "idleMs": now_ms() - s.last_output.get()}) }) .collect() }); - json!({"sources": SOURCES.with(Cell::get), "framesDecoded": DECODED.with(Cell::get), "fallbacks": FALLBACKS.with(Cell::get), "sessions": sessions}) + let transfers = TRANSFERS.with(|s| { + let s = s.borrow(); + json!({"nativeFrames":s.native,"canvasFrames":s.canvas,"closedFrames":s.closed,"staleFrames":s.stale,"nativeBytes":s.bytes,"nativeMs":s.native_ms,"canvasMs":s.canvas_ms,"canvasReasons":s.reasons}) + }); + json!({"sources": SOURCES.with(Cell::get), "framesDecoded": DECODED.with(Cell::get), "fallbacks": FALLBACKS.with(Cell::get), "sessions": sessions,"transfers":transfers}) } /// A [`SharedReader`] as the demuxer's byte source. @@ -210,6 +228,8 @@ pub fn reader_opener(name: &str, head: &[u8], reader: &SharedReader) -> Option Option>, @@ -229,6 +250,8 @@ struct Config { height: u32, /// Clockwise quarter turns from the track matrix, applied to decoded frames. rotation: u8, + color: filmcraft_color::ColorInfo, + par: (u32, u32), } #[derive(Default)] @@ -253,6 +276,9 @@ struct Session { /// Highest presentation time output since the session (re)started. out_max: Rc>, out: Rc>>, + copying: Rc>, + generation: Rc>, + alive: Rc>, _closures: (Closure, Closure), } @@ -286,17 +312,65 @@ impl Session { let errored = Rc::new(Cell::new(false)); let last_output = Rc::new(Cell::new(now_ms())); let out_max = Rc::new(Cell::new(i64::MIN)); + let copying = Rc::new(Cell::new(0u32)); + let generation = Rc::new(Cell::new(0u64)); + let alive = Rc::new(Cell::new(true)); let (o, lo, om) = (out.clone(), last_output.clone(), out_max.clone()); + let (copies, epoch, active, config, errors) = (copying.clone(), generation.clone(), alive.clone(), cfg.clone(), errored.clone()); let on_output = Closure::::new(move |frame: JsValue| { let ts = get(&frame, "timestamp").as_f64().unwrap_or(0.0) as i64; - om.set(om.get().max(ts)); - if let Some(f) = to_rgba(&frame) { - o.borrow_mut().push((ts, f)); - DECODED.with(|c| c.set(c.get() + 1)); - } - let _ = call(&frame, "close", &[]); - lo.set(now_ms()); - crate::repaint(); + let expected = epoch.get(); + let (o, lo, om, copies, epoch, active, config, errors) = + (o.clone(), lo.clone(), om.clone(), copies.clone(), epoch.clone(), active.clone(), config.clone(), errors.clone()); + copies.set(copies.get().saturating_add(1)); + wasm_bindgen_futures::spawn_local(async move { + let begin = now_ms(); + let converted = match pixels::read(&frame, &config).await { + Ok((f, bytes)) => { + TRANSFERS.with(|s| { + let mut s = s.borrow_mut(); + s.native = s.native.saturating_add(1); + s.bytes = s.bytes.saturating_add(bytes as u64); + s.native_ms += now_ms() - begin; + }); + Some(f) + } + Err(reason) => { + let begin = now_ms(); + let f = to_rgba(&frame); + TRANSFERS.with(|s| { + let mut s = s.borrow_mut(); + s.canvas = s.canvas.saturating_add(1); + s.canvas_ms += now_ms() - begin; + let count = s.reasons.entry(reason).or_default(); + *count = count.saturating_add(1); + }); + f + } + }; + let _ = call(&frame, "close", &[]); + copies.set(copies.get().saturating_sub(1)); + TRANSFERS.with(|s| { + let mut s = s.borrow_mut(); + s.closed = s.closed.saturating_add(1); + }); + if !active.get() || epoch.get() != expected { + TRANSFERS.with(|s| { + let mut s = s.borrow_mut(); + s.stale = s.stale.saturating_add(1); + }); + return; + } + if let Some(f) = converted { + o.borrow_mut().push((ts, f)); + om.set(om.get().max(ts)); + DECODED.with(|c| c.set(c.get().saturating_add(1))); + } else { + errors.set(true); + } + lo.set(now_ms()); + crate::repaint(); + }); }); let e = errored.clone(); let on_error = Closure::::new(move |err: JsValue| { @@ -317,6 +391,9 @@ impl Session { last_output, out_max, out, + copying, + generation, + alive, _closures: (on_output, on_error), }; s.configure(cfg)?; @@ -341,6 +418,14 @@ impl Session { } } +impl Drop for Session { + fn drop(&mut self) { + self.alive.set(false); + self.generation.set(self.generation.get().wrapping_add(1)); + let _ = call(&self.decoder, "close", &[]); + } +} + /// An MP4/MOV source whose video is decoded by the browser. pub struct WcSource { inner: SharedSource, @@ -389,11 +474,13 @@ impl WcSource { let covers = !s.needs_key && s.start <= i && key <= s.next && (i >= s.next || want > s.out_max.get()); if !covers { // Don't interrupt a session that is still working towards its own frame. - let busy = !s.needs_key && s.target != i64::MIN && s.out_max.get() < s.target && now_ms() - s.last_output.get() < STALE_MS; + let busy = + s.copying.get() > 0 || (!s.needs_key && s.target != i64::MIN && s.out_max.get() < s.target && now_ms() - s.last_output.get() < STALE_MS); if busy { return Drive::Waiting; } if !s.needs_key || s.next > 0 { + s.generation.set(s.generation.get().wrapping_add(1)); let _ = call(&s.decoder, "reset", &[]); if s.configure(&self.config).is_err() { return Drive::Failed; @@ -410,7 +497,7 @@ impl WcSource { s.target = want; } let upto = (i + LOOKAHEAD).min(n - 1); - while s.next <= upto && s.queue() < MAX_QUEUE { + while s.next <= upto && s.queue().saturating_add(s.copying.get()) < MAX_QUEUE { let smp = &t.samples[s.next]; let mut data = vec![0u8; smp.size as usize]; match self.reader.read_at(smp.offset, &mut data) { @@ -480,10 +567,8 @@ impl WcSource { impl Drop for WcSource { fn drop(&mut self) { SESSIONS.with(|ss| { - if let Ok(mut ss) = ss.try_borrow_mut() - && let Some(s) = ss.remove(&self.id) - { - let _ = call(&s.decoder, "close", &[]); + if let Ok(mut ss) = ss.try_borrow_mut() { + ss.remove(&self.id); } }); } diff --git a/apps/filmcraft-web/src/webcodecs/pixels.rs b/apps/filmcraft-web/src/webcodecs/pixels.rs new file mode 100644 index 00000000..7cd5eff5 --- /dev/null +++ b/apps/filmcraft-web/src/webcodecs/pixels.rs @@ -0,0 +1,147 @@ +//! Browser planes -> core frames; no color conversion or chroma reconstruction here. + +use std::sync::Arc; + +use filmcraft_color::{ColorInfo, Matrix, Primaries, Range, Transfer}; +use filmcraft_frame::{Chroma, PixelData, VideoFrame}; +use filmcraft_time::Tick; +use wasm_bindgen::{JsCast, JsValue}; +use wasm_bindgen_futures::JsFuture; + +use super::{Config, call, get, obj}; + +// One 8K 4:4:4 16-bit picture + alpha fits within 256 MiB; cap browser-driven allocations. +const MAX_COPY_BYTES: u32 = 256 * 1024 * 1024; + +fn integer(value: JsValue) -> Option { + let n = value.as_f64()?; + if n.is_finite() && n >= 0.0 && n <= u32::MAX as f64 && n.fract() == 0.0 { Some(n as u32) } else { None } +} + +fn color(frame: &JsValue, fallback: ColorInfo) -> Option { + let c = get(frame, "colorSpace"); + let matrix = match get(&c, "matrix").as_string().as_deref() { + Some("bt709") => Matrix::Bt709, + Some("bt470bg" | "smpte170m") => Matrix::Bt601, + Some("bt2020-ncl") => Matrix::Bt2020Ncl, + None => fallback.matrix, + _ => return None, + }; + let transfer = match get(&c, "transfer").as_string().as_deref() { + Some("bt709") => Transfer::Bt709, + Some("iec61966-2-1") => Transfer::Srgb, + Some("smpte2084") => Transfer::Pq, + Some("arib-std-b67") => Transfer::Hlg, + None => fallback.transfer, + _ => return None, + }; + let primaries = match get(&c, "primaries").as_string().as_deref() { + Some("bt709") => Primaries::Bt709, + Some("bt470bg") => Primaries::Bt601_625, + Some("smpte170m") => Primaries::Bt601_525, + Some("bt2020") => Primaries::Bt2020, + None => fallback.primaries, + _ => return None, + }; + let range = match get(&c, "fullRange").as_bool() { + Some(true) => Range::Full, + Some(false) => Range::Limited, + None => fallback.range, + }; + Some(ColorInfo { matrix, transfer, primaries, range }) +} + +fn plane(raw: &js_sys::Uint8Array, layout: &JsValue, width: u32, height: u32) -> Result, &'static str> { + let offset = integer(get(layout, "offset")).ok_or("layout")?; + let stride = integer(get(layout, "stride")).filter(|s| *s >= width).ok_or("layout")?; + let end = + height.checked_sub(1).and_then(|h| h.checked_mul(stride)).and_then(|n| offset.checked_add(n)).and_then(|n| n.checked_add(width)).ok_or("layout")?; + if end > raw.length() { + return Err("layout"); + } + let len = width.checked_mul(height).filter(|n| *n <= MAX_COPY_BYTES).ok_or("allocation")? as usize; + let mut out = Vec::new(); + out.try_reserve_exact(len).map_err(|_| "memory")?; + out.resize(len, 0); + if stride == width { + raw.subarray(offset, end).copy_to(&mut out); + } else { + for (y, row) in out.chunks_exact_mut(width as usize).enumerate() { + // Bounds/overflow checked for the last row above; earlier rows are smaller. + let start = offset + y as u32 * stride; + raw.subarray(start, start + width).copy_to(row); + } + } + Ok(out) +} + +/// Copy native 8-bit YUV planes; unsupported formats/geometry return a counted Canvas fallback reason. +pub(super) async fn read(frame: &JsValue, cfg: &Config) -> Result<(VideoFrame, u32), &'static str> { + let format = get(frame, "format").as_string().ok_or("format")?; + let (chroma, alpha, interleaved) = match format.as_str() { + "NV12" => (Chroma::C420, false, true), + "I420" => (Chroma::C420, false, false), + "I420A" => (Chroma::C420, true, false), + "I422" => (Chroma::C422, false, false), + "I422A" => (Chroma::C422, true, false), + "I444" => (Chroma::C444, false, false), + "I444A" => (Chroma::C444, true, false), + _ => return Err("format"), + }; + let rect = get(frame, "visibleRect"); + let w = integer(get(&rect, "width")).filter(|n| *n > 0).ok_or("geometry")?; + let h = integer(get(&rect, "height")).filter(|n| *n > 0).ok_or("geometry")?; + if integer(get(frame, "displayWidth")) != Some(w) + || integer(get(frame, "displayHeight")) != Some(h) + || get(frame, "rotation").as_f64().is_some_and(|r| r != 0.0) + || get(frame, "flip").as_bool() == Some(true) + { + return Err("geometry"); + } + let color = color(frame, cfg.color).ok_or("color")?; + let options = obj(&[("rect", rect)]); + let bytes = + integer(call(frame, "allocationSize", &[&options]).map_err(|_| "allocation")?).filter(|n| *n > 0 && *n <= MAX_COPY_BYTES).ok_or("allocation")?; + let array: js_sys::Function = get(&js_sys::global(), "Uint8Array").dyn_into().map_err(|_| "allocation")?; + let raw: js_sys::Uint8Array = + js_sys::Reflect::construct(&array, &js_sys::Array::of1(&bytes.into())).and_then(|v| v.dyn_into()).map_err(|_| "allocation")?; + let promise = call(frame, "copyTo", &[raw.as_ref(), &options]).map_err(|_| "copy")?.dyn_into::().map_err(|_| "copy")?; + let layout = js_sys::Array::from(&JsFuture::from(promise).await.map_err(|_| "copy")?); + let count = if interleaved { + 2 + } else if alpha { + 4 + } else { + 3 + }; + if layout.length() != count { + return Err("layout"); + } + let (sx, sy) = chroma.shifts(); + let (cw, ch) = (w.div_ceil(1 << sx), h.div_ceil(1 << sy)); + let y = plane(&raw, &layout.get(0), w, h)?; + let (u, v) = if interleaved { + let uv = plane(&raw, &layout.get(1), cw.checked_mul(2).ok_or("allocation")?, ch)?; + let pairs = uv.as_chunks::<2>().0; + let (mut u, mut v) = (Vec::new(), Vec::new()); + u.try_reserve_exact(pairs.len()).map_err(|_| "memory")?; + v.try_reserve_exact(pairs.len()).map_err(|_| "memory")?; + u.extend(pairs.iter().map(|p| p[0])); + v.extend(pairs.iter().map(|p| p[1])); + (u, v) + } else { + (plane(&raw, &layout.get(1), cw, ch)?, plane(&raw, &layout.get(2), cw, ch)?) + }; + let alpha = if alpha { Some(Arc::new(plane(&raw, &layout.get(3), w, h)?)) } else { None }; + Ok(( + VideoFrame { + width: w, + height: h, + data: PixelData::Yuv8 { planes: [Arc::new(y), Arc::new(u), Arc::new(v)], chroma, alpha }, + color, + par: cfg.par, + pts: Tick::ZERO, + }, + bytes, + )) +} From 43e3d26823558b6475c6584dacd1da9f9fd30332 Mon Sep 17 00:00:00 2001 From: txiki739 <111882715+txiki739@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:06:43 +0200 Subject: [PATCH 10/15] #218: System Language follows the operating system's language Settings > General > Interface Language gains System Language (`system`), now the default: a new installation shows the first of the user's preferred languages that the interface has (es-419, pt_BR.UTF-8, ja-JP...; any Portuguese gets pt-br), otherwise English. A language already chosen, or saved in preferences.json, is kept. The host supplies the preferred languages through HostHooks::system_languages: sys-locale on the desktop (LANGUAGE, LC_ALL, LC_MESSAGES, LANG on Linux; the OS lists on macOS/Windows) and navigator.languages on the web. Without the hook (tests, headless), System Language is English, so tests do not depend on the machine's locale. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 10 +++++ ROADMAP.md | 2 +- apps/filmcraft-web/src/lib.rs | 3 ++ apps/filmcraft/Cargo.toml | 1 + apps/filmcraft/src/main.rs | 2 + crates/engine/src/settings.rs | 8 ++-- crates/engine/src/settings_tests.rs | 15 +++++++ crates/ui-egui/README.md | 4 ++ crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/mod.rs | 66 +++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 11 ++++- 11 files changed, 118 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2ea9902a..9650b4c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1681,6 +1681,7 @@ dependencies = [ "objc2-foundation 0.3.2", "rfd", "serde_json", + "sys-locale", "winit", "winresource", ] @@ -5782,6 +5783,15 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + [[package]] name = "sysctl" version = "0.6.0" diff --git a/ROADMAP.md b/ROADMAP.md index 858d84e4..e77e6ee0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -205,4 +205,4 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining ### Interface localization -Edit > Language switches English/Japanese/Spanish/Portuguese (Brazilian) and persists across restarts through `general.interfaceLanguage` (also available in Settings > General). Commands `app.language.english`, `app.language.japanese`, `app.language.spanish` and `app.language.portuguese` are reachable through the control channel. Translations live in one catalog per language (`crates/ui-egui/src/i18n/.tsv`); UI code wraps its strings in `tl!("…")` / `tlf!("…{name}", name)`, and names that come from registries (effects and their parameters, settings, commands, panels, workspaces, presets) are translated where they are drawn. **Spanish covers the whole interface**: menus, panels, dialogs, the Settings pages, the Effects/Effect Controls registry, the Keyboard Shortcuts dialog and status messages; tests fail when a `tl!` literal, a menu label or a registry name has no Spanish entry. Searches match the displayed labels as well as English sources. Japanese and Brazilian Portuguese cover the core menus; untranslated labels use English. Engine error messages, the control channel, the CLI and MCP stay in English. Japanese text uses the Japanese fonts from [craft-fonts](https://github.com/storytold/craft-fonts) when FilmCraft is built with them (`CRAFT_FONTS_DIR`; all official releases, including the web build), otherwise a Japanese font already installed on the system; with neither, switching to Japanese is refused with a message. Existing vertical text support is preserved. +Edit > Language switches English/Japanese/Spanish/Portuguese (Brazilian) and persists across restarts through `general.interfaceLanguage` (also available in Settings > General). Until a language is chosen, System Language (the default) follows the operating system's or browser's preferred languages and falls back to English. Commands `app.language.english`, `app.language.japanese`, `app.language.spanish` and `app.language.portuguese` are reachable through the control channel. Translations live in one catalog per language (`crates/ui-egui/src/i18n/.tsv`); UI code wraps its strings in `tl!("…")` / `tlf!("…{name}", name)`, and names that come from registries (effects and their parameters, settings, commands, panels, workspaces, presets) are translated where they are drawn. **Spanish covers the whole interface**: menus, panels, dialogs, the Settings pages, the Effects/Effect Controls registry, the Keyboard Shortcuts dialog and status messages; tests fail when a `tl!` literal, a menu label or a registry name has no Spanish entry. Searches match the displayed labels as well as English sources. Japanese and Brazilian Portuguese cover the core menus; untranslated labels use English. Engine error messages, the control channel, the CLI and MCP stay in English. Japanese text uses the Japanese fonts from [craft-fonts](https://github.com/storytold/craft-fonts) when FilmCraft is built with them (`CRAFT_FONTS_DIR`; all official releases, including the web build), otherwise a Japanese font already installed on the system; with neither, switching to Japanese is refused with a message. Existing vertical text support is preserved. diff --git a/apps/filmcraft-web/src/lib.rs b/apps/filmcraft-web/src/lib.rs index 469d2107..beafbbd9 100644 --- a/apps/filmcraft-web/src/lib.rs +++ b/apps/filmcraft-web/src/lib.rs @@ -177,6 +177,9 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { } let mut app = FilmcraftApp::new(session); import::install_hooks(&mut app); + // Settings ▸ General ▸ Interface Language ▸ System Language (#218): the browser's languages. + app.hooks.system_languages = + Some(Box::new(|| web_sys::window().map(|w| w.navigator().languages().iter().filter_map(|v| v.as_string()).collect()).unwrap_or_default())); let (tx, rx) = std::sync::mpsc::channel(); api::set_sender(tx); app = app.with_control(rx); diff --git a/apps/filmcraft/Cargo.toml b/apps/filmcraft/Cargo.toml index 8095c787..9e43d1dc 100644 --- a/apps/filmcraft/Cargo.toml +++ b/apps/filmcraft/Cargo.toml @@ -26,6 +26,7 @@ log = { workspace = true } rfd = { workspace = true } cpal = "0.16" chrono = { version = "0.4", default-features = false, features = ["clock"] } +sys-locale = "0.3" [target.'cfg(target_os = "macos")'.dependencies] muda = "0.21" diff --git a/apps/filmcraft/src/main.rs b/apps/filmcraft/src/main.rs index 5967b98b..3b769d5d 100644 --- a/apps/filmcraft/src/main.rs +++ b/apps/filmcraft/src/main.rs @@ -196,6 +196,8 @@ fn main() -> eframe::Result { })); app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string()))); app.hooks.open_path = Some(Box::new(open_path)); + // Settings ▸ General ▸ Interface Language ▸ System Language (#218). + app.hooks.system_languages = Some(Box::new(|| sys_locale::get_locales().collect())); app.hooks.raise_without_focus = Some(Box::new(|| { window_raise::raise_without_focus(); })); diff --git a/crates/engine/src/settings.rs b/crates/engine/src/settings.rs index c3b2fbc3..cbb2b7b7 100644 --- a/crates/engine/src/settings.rs +++ b/crates/engine/src/settings.rs @@ -33,7 +33,9 @@ pub const PREFS_VERSION: u32 = 2; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct GeneralPrefs { - /// Interface language (persisted independently of projects): `en`, `ja`, `es` or `pt-br`. + /// Interface language (persisted independently of projects): `en`, `ja`, `es`, `pt-br`, or + /// `system` (the default: the operating system's preferred language when the interface has it, + /// otherwise English). pub interface_language: String, /// "At Startup": `showHome` (FilmCraft: the demo project), `openMostRecent`, `emptyProject`. pub at_startup: String, @@ -61,7 +63,7 @@ pub struct GeneralPrefs { impl Default for GeneralPrefs { fn default() -> Self { Self { - interface_language: "en".into(), + interface_language: "system".into(), at_startup: "showHome".into(), when_opening_project: "showOpenDialog".into(), bins_double_click: "openInPlace".into(), @@ -678,7 +680,7 @@ static CATEGORIES: &[Category] = &[ f( "general.interfaceLanguage", "Interface Language", - Kind::Choice(&[("en", "English"), ("ja", "日本語"), ("es", "Español"), ("pt-br", "Português (Brasil)")]), + Kind::Choice(&[("system", "System Language"), ("en", "English"), ("ja", "日本語"), ("es", "Español"), ("pt-br", "Português (Brasil)")]), true, ), f("general.atStartup", "At Startup", Kind::Choice(STARTUP), true), diff --git a/crates/engine/src/settings_tests.rs b/crates/engine/src/settings_tests.rs index b7f66291..d706e4a1 100644 --- a/crates/engine/src/settings_tests.rs +++ b/crates/engine/src/settings_tests.rs @@ -34,6 +34,21 @@ fn interface_language_persists_and_rejects_hostile_values() { assert_eq!(s.prefs.general.interface_language, "es"); } assert_eq!(Preferences::load(&path).general.interface_language, "es"); + // back to following the operating system (#218) + set(&mut s, "general.interfaceLanguage", json!("system")); + assert_eq!(Preferences::load(&path).general.interface_language, "system"); + std::fs::remove_dir_all(dir).unwrap(); +} + +/// A new installation follows the operating system's language (#218); a saved choice is kept. +#[test] +fn interface_language_defaults_to_the_system_language() { + assert_eq!(Preferences::default().general.interface_language, "system"); + let dir = tmp_dir("interface-language-default"); + let path = dir.join("preferences.json"); + assert_eq!(Preferences::load(&path).general.interface_language, "system", "no preferences file yet"); + std::fs::write(&path, r#"{"general":{"interfaceLanguage":"en"}}"#).unwrap(); + assert_eq!(Preferences::load(&path).general.interface_language, "en"); std::fs::remove_dir_all(dir).unwrap(); } diff --git a/crates/ui-egui/README.md b/crates/ui-egui/README.md index 75c11e1b..dcc629e9 100644 --- a/crates/ui-egui/README.md +++ b/crates/ui-egui/README.md @@ -7,6 +7,10 @@ Interactive controls keep stable automation ids regardless of the interface lang Edit > Language offers English, Japanese and Spanish. The language is stored in the engine's `general.interfaceLanguage` preference, restored on startup, and also available in Settings > General. +Its default, System Language (`system`), follows the operating system: the first of the user's +preferred languages that the interface has (the host supplies them through +`HostHooks::system_languages`: `sys-locale` on the desktop, `navigator.languages` on the web), +otherwise English. The `app.language.*` UI commands and `prefs.set` reach it through the control channel. Japanese requires a craft-fonts build or a suitable installed font. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 14438649..5bc68b48 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3312,6 +3312,7 @@ Zoom Tool Herramienta Zoom with: por: Interface Language Idioma de la interfaz + System Language Idioma del sistema Cinematic Cinematográfico Film Emulation Emulación de película Technical Técnico diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 750d5fe7..c04416c6 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -62,6 +62,19 @@ impl Language { } } + /// Interface Language ▸ System Language (#218): the first of the user's preferred languages + /// (BCP 47 or POSIX locale tags such as `es-419`, `pt_BR.UTF-8`, most preferred first) that the + /// interface has, else English. Any Portuguese gets the Brazilian catalog, the only one there is. + pub fn from_locales(tags: &[String]) -> Self { + const PRIMARY: [(&str, Language); 4] = [("en", Language::En), ("ja", Language::Ja), ("es", Language::Es), ("pt", Language::PtBr)]; + tags.iter() + .find_map(|tag| { + let primary = tag.split(['-', '_', '.', '@']).next().unwrap_or_default(); + PRIMARY.iter().find(|(code, _)| primary.eq_ignore_ascii_case(code)).map(|(_, l)| *l) + }) + .unwrap_or_default() + } + /// Stable code: the `general.interfaceLanguage` preference value (`Language::parse` reads it back). pub fn code(self) -> &'static str { match self { @@ -616,6 +629,59 @@ mod tests { set_current(Language::En); } + /// #218: System Language picks the first of the user's preferred languages the interface has. + #[test] + fn system_language_follows_the_preferred_locales() { + let l = |tags: &[&str]| Language::from_locales(&tags.iter().map(|t| t.to_string()).collect::>()); + assert_eq!(l(&["es_ES.UTF-8"]), Language::Es); + assert_eq!(l(&["es-419"]), Language::Es); + assert_eq!(l(&["ES"]), Language::Es); + assert_eq!(l(&["ja-JP"]), Language::Ja); + assert_eq!(l(&["pt-BR"]), Language::PtBr); + assert_eq!(l(&["pt_PT.UTF-8@euro"]), Language::PtBr); + assert_eq!(l(&["fr-FR", "de", "es-MX", "ja"]), Language::Es, "the first one the interface has"); + assert_eq!(l(&["en-GB", "es"]), Language::En); + for none in [&[][..], &["fr"], &["C"], &["POSIX"], &[""], &["e"], &["esp"], &["-es"]] { + assert_eq!(l(none), Language::En, "{none:?}"); + } + assert_eq!(l(&[&"x".repeat(1 << 20), "es"]), Language::Es); + } + + /// #218: the `system` preference (the default) asks the host for the system's languages; an + /// explicit choice does not, and without a host hook System Language is English. + #[test] + fn system_language_preference_uses_the_host_languages() { + let ctx = egui::Context::default(); + let session = filmcraft_engine::Session::default(); + assert_eq!(session.prefs.general.interface_language, "system"); + let mut app = crate::FilmcraftApp::new(session); + app.apply_prefs(&ctx); + assert_eq!(app.ui.language, Language::En, "no host hook"); + + let mut app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); + let asked = std::rc::Rc::new(std::cell::Cell::new(0)); + let count = asked.clone(); + app.hooks.system_languages = Some(Box::new(move || { + count.set(count.get() + 1); + vec!["fr-FR".into(), "es-ES".into()] + })); + app.apply_prefs(&ctx); + assert_eq!(app.ui.language, Language::Es); + assert_eq!(current(), Language::Es); + crate::menus::invoke(&mut app, &ctx, "app.language.english", serde_json::json!({})).unwrap(); + app.apply_prefs(&ctx); + assert_eq!((app.ui.language, app.session.prefs.general.interface_language.as_str()), (Language::En, "en")); + let asked_before = asked.get(); + app.session.execute("prefs.set", serde_json::json!({"key": "general.interfaceLanguage", "value": "ja"})).unwrap(); + app.apply_prefs(&ctx); + assert_eq!(asked.get(), asked_before, "an explicit language does not ask the system"); + app.session.execute("prefs.set", serde_json::json!({"key": "general.interfaceLanguage", "value": "system"})).unwrap(); + app.apply_prefs(&ctx); + assert_eq!(app.ui.language, Language::Es); + assert_eq!(asked.get(), asked_before + 1); + set_current(Language::En); + } + #[test] fn japanese_needs_an_installed_font() { let mut app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8e25a7a7..76f1f28c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -108,6 +108,9 @@ pub struct HostHooks { pub pick_save_as: Option Option>>, /// The active keyboard shortcuts changed: update native menu key equivalents. pub shortcuts_changed: Option>, + /// The user's preferred languages (locale tags, most preferred first) for Interface Language ▸ + /// System Language. Without it, System Language is English. + pub system_languages: Option Vec>>, /// Open dialog for a JSON file (shortcut preset import): filter name, extensions → path. pub pick_open_file: Option Option>>, /// Folder picker (Link Media search, proxy and Project Manager destinations). @@ -501,7 +504,13 @@ impl FilmcraftApp { self.set_theme(ctx, ThemeKind::from_pref(&p.appearance.color_theme)); } if prev.as_ref().is_none_or(|q| q.general.interface_language != p.general.interface_language) { - let language = i18n::Language::parse(&p.general.interface_language).unwrap_or_default(); + let language = match i18n::Language::parse(&p.general.interface_language) { + Some(l) => l, + None if p.general.interface_language == "system" => { + i18n::Language::from_locales(&self.hooks.system_languages.as_ref().map(|f| f()).unwrap_or_default()) + } + None => i18n::Language::default(), + }; if language == i18n::Language::Ja && !i18n::install_japanese_font(ctx) { self.ui.language = i18n::Language::En; self.ui.status = tl!("no Japanese font is installed on this system; the interface stays in English").into(); From cc5e3c10654967cf207d901a341b5e0b0bbd7e4b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 9 Oct 2026 09:40:15 -0400 Subject: [PATCH 11/15] Revert shortcut-preset changes in favour of #337's approach The maintainers chose #337's handling of Ctrl/Cmd shortcut collisions off macOS (drop_control_collisions / audit_for / builtin_for), which audits the collisions and keeps documented winners rather than keeping the first listed binding. This reverts crates/engine/src/shortcuts.rs and its tests to main so the two approaches don't overlap; the MPEG-oracle ffprobe-JSON change and the frame-pool test rewrite from this PR are kept. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/shortcuts.rs | 22 ++--- crates/engine/src/shortcuts_tests.rs | 130 --------------------------- 2 files changed, 5 insertions(+), 147 deletions(-) diff --git a/crates/engine/src/shortcuts.rs b/crates/engine/src/shortcuts.rs index 90b1766c..4165cde8 100644 --- a/crates/engine/src/shortcuts.rs +++ b/crates/engine/src/shortcuts.rs @@ -24,7 +24,7 @@ //! Commands the frontend owns (tools, transport, panels, workspaces) are registered with //! [`Shortcuts::register_external`] so they can be listed, bound and resolved here too. -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; @@ -593,34 +593,22 @@ impl Shortcuts { _ => return None, }; self.apply_table(&mut b, &table); - // Canonical chords can become the same physical key on this platform. Keep the first - // binding, matching resolve(), when generating a built-in preset. - let platform = Platform::current(); - let mut seen = BTreeSet::new(); - b.retain(|binding| binding.chord().is_none_or(|chord| seen.insert((binding.context().to_string(), chord.effective(platform))))); Some(b) } - /// Apply a preset table: listed commands get the listed keys, and explicit physical keys take - /// precedence over inherited bindings in the same context. Within the table, keep canonical - /// replacement order so folded aliases retain their established first-match winner. + /// Apply a preset table: listed commands get exactly the listed keys, and a listed key is taken + /// away from whatever else had it in that context. fn apply_table(&self, b: &mut Vec, table: &[Entry]) { let listed: Vec<&str> = table.iter().map(|e| e.0).filter(|c| self.known(c)).collect(); b.retain(|x| !listed.contains(&x.command.as_str())); - let platform = Platform::current(); - let mut explicit: Vec = Vec::new(); for (c, k, p) in table { if !self.known(c) { continue; } let nb = Binding::new(c, k, panel_opt(p)); - let chord = nb.chord(); - let physical = chord.as_ref().map(|chord| chord.effective(platform)); - b.retain(|o| !(o.context() == nb.context() && o.chord().map(|chord| chord.effective(platform)) == physical)); - explicit.retain(|o| !(o.context() == nb.context() && o.chord() == chord)); - explicit.push(nb); + b.retain(|o| !(o.context() == nb.context() && o.chord() == nb.chord())); + b.push(nb); } - b.extend(explicit); } fn presets_dir(&self) -> Option { diff --git a/crates/engine/src/shortcuts_tests.rs b/crates/engine/src/shortcuts_tests.rs index 1f98b8ec..d1224233 100644 --- a/crates/engine/src/shortcuts_tests.rs +++ b/crates/engine/src/shortcuts_tests.rs @@ -231,133 +231,3 @@ fn list_searches_labels_and_keys() { let v = s.execute("shortcuts.list", json!({"panel": "History", "assigned": true})).unwrap(); assert_eq!(v.as_array().unwrap().len(), 2, "{v}"); } - -#[cfg(target_os = "linux")] -#[test] -fn engine_defaults_keep_the_existing_linux_winners_without_folded_conflicts() { - let s = Session::default(); - assert!(s.shortcuts.conflicts(Platform::Linux).is_empty()); - for (keys, command) in [("Cmd+9", "multicam.cutToCamera9"), ("Cmd+Shift+M", "markers.addRange"), ("Cmd+T", "trim.toggleType")] { - let chord = Chord::parse(keys).unwrap(); - assert_eq!(s.shortcuts.resolve(&chord, None, Platform::Linux).unwrap().command, command, "{keys} keeps its existing first-match action"); - let ctrl = keys.replacen("Cmd", "Ctrl", 1); - assert_eq!(s.shortcuts.resolve(&Chord::parse(&ctrl).unwrap(), None, Platform::Linux).unwrap().command, command); - } - assert!(!s.shortcuts.modified); - assert!(!s.shortcuts.can_undo()); - assert!(!s.shortcuts.can_redo()); -} - -#[test] -fn builtin_presets_are_unambiguous_with_engine_and_frontend_commands() { - let platform = Platform::current(); - for frontend in [false, true] { - for name in crate::shortcuts::BUILTIN_PRESETS { - let mut s = Session::default(); - if frontend { - with_ui_commands(&mut s); - } - s.execute("shortcuts.loadPreset", json!({"name": name})).unwrap(); - assert!(s.shortcuts.conflicts(platform).is_empty(), "{name}, frontend={frontend}: {:?}", s.shortcuts.conflicts(platform)); - let backspace = Chord::parse("Backspace").unwrap(); - assert_eq!(s.shortcuts.resolve(&backspace, Some("Project"), platform).unwrap().command, "project.delete", "panel overrides remain independent"); - s.execute("shortcuts.undo", json!({})).unwrap(); - assert_eq!(s.shortcuts.preset, crate::shortcuts::DEFAULT_PRESET); - assert!(s.shortcuts.conflicts(platform).is_empty()); - s.execute("shortcuts.redo", json!({})).unwrap(); - assert_eq!(s.shortcuts.preset, name); - assert!(s.shortcuts.conflicts(platform).is_empty()); - } - } -} - -#[test] -fn intentional_conflicts_survive_registration_persistence_and_shortcut_undo() { - let dir = temp_dir("intentional-conflicts"); - let platform = Platform::current(); - let mut s = Session::default(); - s.shortcuts.set_dir(&dir); - let defaults = s.shortcuts.bindings.clone(); - let result = s.execute("shortcuts.set", json!({"command": "edit.redo", "keys": "M", "keepConflicts": true})).unwrap(); - assert_eq!(result["conflicts"].as_array().unwrap().len(), 1); - let edited = s.shortcuts.bindings.clone(); - with_ui_commands(&mut s); - assert_eq!(s.shortcuts.bindings, edited, "registering the UI preserves an intentionally modified set"); - let mut restored = Session::default(); - restored.shortcuts.set_dir(&dir); - with_ui_commands(&mut restored); - assert_eq!(restored.shortcuts.bindings, edited, "a modified built-in set survives restart"); - assert!(restored.shortcuts.modified); - assert_eq!(restored.shortcuts.conflicts(platform).len(), 1); - s.execute("shortcuts.undo", json!({})).unwrap(); - assert_eq!(s.shortcuts.bindings, defaults); - assert!(!s.shortcuts.modified); - assert!(s.shortcuts.conflicts(platform).is_empty()); - s.execute("shortcuts.redo", json!({})).unwrap(); - assert_eq!(s.shortcuts.bindings, edited); - assert_eq!(s.shortcuts.conflicts(platform).len(), 1); - s.execute("shortcuts.savePreset", json!({"name": "Kept conflicts"})).unwrap(); - assert!(!s.shortcuts.modified); - with_ui_commands(&mut s); - assert_eq!(s.shortcuts.bindings, edited, "an unmodified custom preset keeps its deliberate conflicts"); - let mut custom = Session::default(); - custom.shortcuts.set_dir(&dir); - with_ui_commands(&mut custom); - assert_eq!(custom.shortcuts.preset, "Kept conflicts"); - assert_eq!(custom.shortcuts.bindings, edited); - assert_eq!(custom.shortcuts.conflicts(platform).len(), 1); - custom.execute("shortcuts.loadPreset", json!({"name": crate::shortcuts::DEFAULT_PRESET})).unwrap(); - assert!(custom.shortcuts.conflicts(platform).is_empty()); - custom.execute("shortcuts.loadPreset", json!({"name": "Kept conflicts"})).unwrap(); - assert_eq!(custom.shortcuts.bindings, edited); - assert_eq!(custom.shortcuts.conflicts(platform).len(), 1); - let _ = std::fs::remove_dir_all(&dir); -} - -#[cfg(target_os = "linux")] -#[test] -fn an_explicit_final_cut_preset_binding_overrides_inherited_linux_physical_keys() { - for frontend in [false, true] { - let mut session = Session::default(); - if frontend { - with_ui_commands(&mut session); - } - session.execute("shortcuts.loadPreset", json!({"name": crate::shortcuts::FCP_PRESET})).unwrap(); - for keys in ["Cmd+T", "Ctrl+T"] { - let resolved = session.execute("shortcuts.resolve", json!({"keys": keys})).unwrap(); - assert_eq!( - resolved["command"], "sequence.applyVideoTransition", - "the explicit Final Cut Pro table replaces inherited Trim Toggle Type: {resolved}" - ); - } - assert!(session.shortcuts.conflicts(Platform::Linux).is_empty()); - session.execute("shortcuts.undo", json!({})).unwrap(); - let resolved = session.execute("shortcuts.resolve", json!({"keys": "Cmd+T"})).unwrap(); - assert_eq!(resolved["command"], "trim.toggleType", "the FilmCraft default keeps its established Linux winner"); - session.execute("shortcuts.redo", json!({})).unwrap(); - let resolved = session.execute("shortcuts.resolve", json!({"keys": "Ctrl+T"})).unwrap(); - assert_eq!(resolved["command"], "sequence.applyVideoTransition"); - } -} - -#[cfg(target_os = "linux")] -#[test] -fn compatible_presets_keep_the_existing_first_explicit_linux_winners() { - let mut session = Session::default(); - // Export is frontend-owned; use the same definition registered by the desktop menu. - session.shortcuts.register_external(vec![CommandInfo::new("mode.export", "Export", &["File", "Export"], Some("Cmd+M"))]); - for (preset, keys, command) in [ - (crate::shortcuts::FCP_PRESET, "Cmd+R", "clip.speedDuration"), - (crate::shortcuts::PREMIERE_PRESET, "Cmd+M", "mode.export"), - (crate::shortcuts::PREMIERE_PRESET, "Cmd+Shift+M", "markers.goPrev"), - (crate::shortcuts::PREMIERE_PRESET, "Cmd+T", "trim.toggleType"), - (crate::shortcuts::PREMIERE_PRESET, "Cmd+9", "multicam.cutToCamera9"), - ] { - session.execute("shortcuts.loadPreset", json!({"name": preset})).unwrap(); - for spelling in [keys.to_string(), keys.replacen("Cmd", "Ctrl", 1)] { - let resolved = session.execute("shortcuts.resolve", json!({"keys": spelling})).unwrap(); - assert_eq!(resolved["command"], command, "{preset}: {spelling} keeps its existing first explicit action: {resolved}"); - } - assert!(session.shortcuts.conflicts(Platform::Linux).is_empty()); - } -} From 7050055a58ebf4d59f852158aecee53726557d99 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 9 Oct 2026 09:54:46 -0400 Subject: [PATCH 12/15] README: move the Gentoo overlay into Downloads > Linux The Gentoo paragraph sat after the build-from-source and control-protocol notes; it belongs with the other Linux install options. Mark it clearly as community-maintained. Co-Authored-By: Claude Opus 5.5 --- README.md | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index d7f466a4..f1ffce86 100644 --- a/README.md +++ b/README.md @@ -262,15 +262,6 @@ CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo run --release -p filmcraft The control protocol is documented in [docs/control-protocol.md](docs/control-protocol.md). Stuck, or want to show what you made? Ask in [Discord](https://discord.gg/artcraft). -Each [GitHub release](https://github.com/storytold/filmcraft/releases) has ready-made builds, on Linux as an AppImage, a `.deb`, an `.rpm` and a tarball. On Gentoo, the community [::snakebyte overlay](https://github.com/switch87/snakebyte-overlay) packages the Linux release as `media-video/filmcraft-bin` (not maintained by the FilmCraft team): - -```sh -eselect repository add snakebyte git https://github.com/switch87/snakebyte-overlay.git -emaint sync -r snakebyte -echo 'media-video/filmcraft-bin ~amd64' >> /etc/portage/package.accept_keywords/filmcraft -emerge --ask media-video/filmcraft-bin -``` - ## Documentation | | | @@ -372,6 +363,17 @@ The tarball installer places FilmCraft, its command-line tool and desktop integr `~/.local`, without administrator permissions. For all users, run `sudo ./install.sh --prefix /usr/local` instead. Running the installer again updates the installation. +**Gentoo (community-maintained):** the [::snakebyte overlay](https://github.com/switch87/snakebyte-overlay) +packages the Linux release as `media-video/filmcraft-bin`. It is maintained by the community, not by the +FilmCraft team, so report packaging problems to the overlay: + +```sh +eselect repository add snakebyte git https://github.com/switch87/snakebyte-overlay.git +emaint sync -r snakebyte +echo 'media-video/filmcraft-bin ~amd64' >> /etc/portage/package.accept_keywords/filmcraft +emerge --ask media-video/filmcraft-bin +``` + ### FreeBSD | Build | File | From 79a3b623fe7c0c26b3ec2dc8d158c46cb1c4ec6c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 9 Oct 2026 09:55:47 -0400 Subject: [PATCH 13/15] Pool trim: free only the float shelf, and only after a standalone export pool::trim() from Pipeline::drop flushed the whole process-wide pool after every render-preview segment and proxy, and threw away playback's U8/U16 planes too. Now Pipeline::drop calls pool::trim_f32() only when !settings.part_of_batch. PoolStats gains f32_idle_bytes so the tests can check the float shelf alone. Co-Authored-By: Claude Opus 5.5 --- crates/export/src/pipeline.rs | 15 +++++++++---- crates/export/tests/pool_trim.rs | 19 +++++++++++------ crates/frame/src/pool.rs | 23 ++++++++++---------- crates/frame/tests/pool_trim.rs | 36 +++++++++++++++++--------------- docs/architecture.md | 5 +++-- docs/performance.md | 7 ++++--- 6 files changed, 61 insertions(+), 44 deletions(-) diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index b9ed4fec..e88f7b2c 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -68,15 +68,21 @@ pub(crate) struct Pipeline { overlay: Option, start_tc: i64, drop_frame: bool, + /// Free the pool's float images when the export ends: a standalone export, not one part of a + /// batch (`ExportSettings::part_of_batch`). + trim_pool: bool, } impl Drop for Pipeline { /// The export is over, however it ended (done, failed, cancelled, dropped half way). The float - /// images its frames left on the shelves of `filmcraft_frame::pool` are the size of its frames, - /// which nothing else asks for, so free them instead of holding up to 320 MiB idle. A job still - /// rendering at that moment allocates a few images again; the pool is only a cache. + /// images its frames left on the `filmcraft_frame::pool` float shelf are the size of its + /// frames, which nothing else asks for, so free them instead of holding up to 320 MiB idle. A + /// job still rendering at that moment allocates a few images again; the pool is only a cache. + /// Parts of a larger job (render-preview segments, proxies) keep them for the next part. fn drop(&mut self) { - filmcraft_frame::pool::trim(); + if self.trim_pool { + filmcraft_frame::pool::trim_f32(); + } } } @@ -110,6 +116,7 @@ impl Pipeline { seq_rate: q.settings.frame_rate, start_tc: q.start_timecode, drop_frame: q.settings.drop_frame, + trim_pool: !settings.part_of_batch, project: project.clone(), seq, rate: r.rate, diff --git a/crates/export/tests/pool_trim.rs b/crates/export/tests/pool_trim.rs index 678a995c..832888e7 100644 --- a/crates/export/tests/pool_trim.rs +++ b/crates/export/tests/pool_trim.rs @@ -1,5 +1,5 @@ -//! An export gives back what it left on the frame pool (`filmcraft_frame::pool`) when it ends, -//! however it ends. One test in this file on purpose: the pool is shared by every test of a binary, +//! A standalone export gives back the float images it left on the frame pool +//! (`filmcraft_frame::pool`) when it ends, however it ends; one part of a batch keeps them. One test in this file on purpose: the pool is shared by every test of a binary, //! and its numbers mean something only while nothing else renders. use std::sync::Arc; @@ -40,11 +40,11 @@ fn project() -> (Arc, ItemId, SourceMap) { } fn idle_mb() -> f64 { - pool::stats().idle_bytes as f64 / 1e6 + pool::stats().f32_idle_bytes as f64 / 1e6 } #[test] -fn the_pool_is_empty_when_an_export_ends_however_it_ended() { +fn the_float_shelf_is_empty_when_a_standalone_export_ends_however_it_ended() { let (p, seq, sources) = project(); let dir = std::env::temp_dir().join(format!("fc-pool-trim-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); @@ -54,9 +54,9 @@ fn the_pool_is_empty_when_an_export_ends_however_it_ended() { // themselves do too: the checks below fail without the trim) let leave_something = || { pool::recycle_f32(vec![0.0; 100_000]); - assert!(pool::stats().idle_bytes > 0); + assert!(pool::stats().f32_idle_bytes > 0); }; - let check = |what: &str| assert_eq!(pool::stats().idle_bytes, 0, "after {what}: {:.1} MB idle", idle_mb()); + let check = |what: &str| assert_eq!(pool::stats().f32_idle_bytes, 0, "after {what}: {:.1} MB idle", idle_mb()); // images, which run in `export` itself leave_something(); @@ -91,5 +91,12 @@ fn the_pool_is_empty_when_an_export_ends_however_it_ended() { drop(ex); check("an export dropped half way"); + // one part of a batch (a render-preview segment, a proxy): the next part reuses the images + leave_something(); + let part = ExportSettings { part_of_batch: true, ..settings(Format::ProRes, "e.mov") }; + export(&p, seq, &part, &sources, &Progress::default()).unwrap(); + assert!(pool::stats().f32_idle_bytes > 0, "a part of a batch keeps the float images"); + pool::trim_f32(); + let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/frame/src/pool.rs b/crates/frame/src/pool.rs index 8b778418..d5c0ba99 100644 --- a/crates/frame/src/pool.rs +++ b/crates/frame/src/pool.rs @@ -11,7 +11,7 @@ //! frame they cost a zero-fill and fresh page faults every time. //! //! The shelves are sized for the busiest work, so they are a lot to keep once it is over: whoever -//! finishes such work (an export) calls [`trim`]. +//! finishes such work (a standalone export) calls [`trim_f32`]. use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, PoisonError}; @@ -141,23 +141,22 @@ pub fn recycle(frame: Arc) { } } -/// Free every idle buffer. An export leaves the shelves full (up to [`F32_MAX_BYTES`] of float -/// images, each the size of that export's frames, and [`MAX_BYTES`] of each plane type) and -/// nothing else asks for those sizes again, so they would sit idle until the next export. What -/// is still in use is not touched, and the next job starts cold, for the price of its first -/// allocations. -pub fn trim() { - // taken under the locks, freed after them: giving back hundreds of megabytes takes a moment +/// Free the idle float images. A standalone export leaves that shelf full (up to [`F32_MAX_BYTES`] +/// of images, each the size of that export's frames) and nothing else asks for those sizes again, +/// so they would sit idle until the next export. Images still in use are not touched. The 8- and +/// 16-bit plane shelves are left alone: playback recycles decoded frames through them all the time. +pub fn trim_f32() { + // taken under the lock, freed after it: giving back hundreds of megabytes takes a moment let floats = std::mem::take(&mut *F32.lock().unwrap_or_else(PoisonError::into_inner)); - let planes8 = std::mem::replace(&mut *U8.lock().unwrap_or_else(PoisonError::into_inner), Shelf::new()); - let planes16 = std::mem::replace(&mut *U16.lock().unwrap_or_else(PoisonError::into_inner), Shelf::new()); - drop((floats, planes8, planes16)); + drop(floats); } /// Idle buffers held and buffers handed out again so far (`perf.stats`). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct PoolStats { pub idle_bytes: usize, + /// The part of `idle_bytes` held as float images ([`recycle_f32`]). + pub f32_idle_bytes: usize, pub reused: u64, } @@ -166,7 +165,7 @@ pub fn stats() -> PoolStats { let a = U8.lock().unwrap_or_else(PoisonError::into_inner).bytes; let b = U16.lock().unwrap_or_else(PoisonError::into_inner).bytes; let c: usize = F32.lock().unwrap_or_else(PoisonError::into_inner).iter().map(|b| b.len().saturating_mul(4)).sum(); - PoolStats { idle_bytes: idle(idle(a, b), c), reused: REUSED.load(Ordering::Relaxed) } + PoolStats { idle_bytes: idle(idle(a, b), c), f32_idle_bytes: c, reused: REUSED.load(Ordering::Relaxed) } } #[cfg(test)] diff --git a/crates/frame/tests/pool_trim.rs b/crates/frame/tests/pool_trim.rs index 64c4b67e..c019568c 100644 --- a/crates/frame/tests/pool_trim.rs +++ b/crates/frame/tests/pool_trim.rs @@ -1,6 +1,7 @@ -//! `pool::trim` frees every idle buffer of the process-wide pool. This file holds one test on -//! purpose: the pool is shared by every test of a binary, and another test recycling or taking -//! buffers while this one counts them would make the counts meaningless. +//! `pool::trim_f32` frees the idle float images of the process-wide pool and leaves the plane +//! shelves alone. This file holds one test on purpose: the pool is shared by every test of a binary, +//! and another test recycling or taking buffers while this one counts them would make the counts +//! meaningless. use std::sync::Arc; @@ -23,31 +24,32 @@ fn recycle_planes() { } #[test] -fn trim_frees_every_idle_buffer_and_the_pool_keeps_working() { +fn trim_f32_frees_the_float_images_keeps_the_planes_and_the_pool_keeps_working() { assert_eq!(pool::stats().idle_bytes, 0, "nothing has been recycled yet"); // one idle buffer on each of the three shelves pool::recycle_f32(vec![f32::NAN; FLOATS]); recycle_planes(); - let held = FLOATS * 4 + BYTES + WORDS * 2; - assert!(pool::stats().idle_bytes >= held, "idle {} of {held}", pool::stats().idle_bytes); + let planes = BYTES + WORDS * 2; + assert_eq!(pool::stats().f32_idle_bytes, FLOATS * 4); + assert!(pool::stats().idle_bytes >= FLOATS * 4 + planes, "idle {}", pool::stats().idle_bytes); - pool::trim(); - assert_eq!(pool::stats().idle_bytes, 0, "every shelf is empty"); + pool::trim_f32(); + assert_eq!(pool::stats().f32_idle_bytes, 0, "the float shelf is empty"); + assert!(pool::stats().idle_bytes >= planes, "the plane shelves are untouched"); - // nothing comes back from the shelves: the float image is a fresh (zeroed) one and no take - // counts as a reuse + // the float image is a fresh (zeroed) one and doesn't count as a reuse; the planes come back let reused = pool::stats().reused; let image = pool::take_f32_overwritten(FLOATS); assert!(image.iter().all(|v| *v == 0.0), "the recycled image was freed"); - drop((pool::take_u8(BYTES), pool::take_u16(WORDS))); assert_eq!(pool::stats().reused, reused); + drop((pool::take_u8(BYTES), pool::take_u16(WORDS))); + assert!(pool::stats().reused > reused, "the planes were kept"); - // the pool works after a trim, and trimming twice (or an empty pool) is fine + // the pool works after a trim, and trimming twice (or an empty shelf) is fine pool::recycle_f32(image); - recycle_planes(); - assert!(pool::stats().idle_bytes >= held); - pool::trim(); - pool::trim(); - assert_eq!(pool::stats().idle_bytes, 0); + assert_eq!(pool::stats().f32_idle_bytes, FLOATS * 4); + pool::trim_f32(); + pool::trim_f32(); + assert_eq!(pool::stats().f32_idle_bytes, 0); } diff --git a/docs/architecture.md b/docs/architecture.md index d56c33d9..9a20486c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -306,8 +306,9 @@ file ──► codecs (MP4/MOV, MKV, audio) demux + decode, GOP-aware see not zero (`VideoFrame::alpha_region`, `to_linear_f32_region`, `blend::composite_at`), the rest being transparent anyway; and working images are recycled through `filmcraft_frame::pool` (`take_f32_overwritten` / `recycle_f32`: the buffer comes back with its old contents, so there is - no zero-fill) by the export pipeline once a frame is converted to 8 bits; the pipeline trims the - pool (`pool::trim`) when it goes away, so a finished export leaves no idle images behind. A clip + no zero-fill) by the export pipeline once a frame is converted to 8 bits; a standalone export's + pipeline frees the idle float images (`pool::trim_f32`) when it goes away, so a finished export + leaves none behind (parts of a batch, such as render-preview segments, keep them for the next part). A clip with effects, opacity masks, frame blending, colour management, or a picture that is not placed one to one on the output takes the general path. - **Frame scheduling.** `crates/ui-egui/src/frames.rs` runs a small pool of worker threads with diff --git a/docs/performance.md b/docs/performance.md index 4b9c1229..017c51dc 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -99,9 +99,10 @@ picture (4.5 ms in the synthetic case) and the conversion to 8 bits (2 ms). The recycled images stay on the shelves of `filmcraft_frame::pool` (up to 320 MiB of them, and 192 MiB of each plane type), and nothing else asks for images of an export's size, so a finished -export left them idle. The export pipeline now trims the pool when it goes away, however the -export ended (done, failed, cancelled, dropped half way): `perf.stats` reads `decode.planePoolMB` -0 afterwards. A process that exported a synthetic 3 s 1080p24 matte as PNG frames three times +export left them idle. A standalone export's pipeline now frees the idle float images when it goes +away, however the export ended (done, failed, cancelled, dropped half way). The plane shelves are +kept (playback recycles decoded frames through them), and so are the float images after one part +of a batch (a render-preview segment, a proxy), which the next part reuses. A process that exported a synthetic 3 s 1080p24 matte as PNG frames three times (Apple M1, `footprint`, 10 s after the last export) rested at **575 MB** with the pool holding 332 MB idle and at **258 MB** with the trim. The memory does not come back at once: macOS returns freed pages over a few seconds (892 MB right after the export in both cases, 726 MB after 2 s). From 82cb6fbc74fe50352159e13d4dbcac317919013e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 9 Oct 2026 09:57:13 -0400 Subject: [PATCH 14/15] i18n: Spanish for strings added on main (Sequence Settings, keyframe navigator, export) Translate the new Sequence Settings dialog (labels, sections, tabs, values, hints), the shared keyframe navigator's tooltips, the non-macOS tool and mask hints, Include Alpha Channel and the export status. Update the hardware decoding description to main's wording. Automation ids and labels stay English. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/es.tsv | 38 ++++++++- crates/ui-egui/src/lib.rs | 6 +- crates/ui-egui/src/panels/export_mode.rs | 4 +- .../ui-egui/src/panels/sequence_settings.rs | 84 ++++++++++--------- 4 files changed, 87 insertions(+), 45 deletions(-) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 756808f0..8b2ee6b6 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1808,7 +1808,7 @@ Hard Limiter Limitador duro Hard Mix Mezcla definida Hardware decoding Descodificación por hardware - Hardware decoding: Auto uses the system's video decoder (VideoToolbox on macOS) for the H.264 and HEVC streams it supports, and FilmCraft's own decoder for everything else or if the hardware fails. Media that is already open keeps its decoder until it is reopened. Descodificación por hardware: Automático usa el descodificador de video del sistema (VideoToolbox en macOS) para los flujos H.264 y HEVC que admite, y el descodificador propio de FilmCraft para todo lo demás o si falla el hardware. Los medios ya abiertos mantienen su descodificador hasta que se vuelven a abrir. + Hardware decoding: Auto uses the system's video decoder (VideoToolbox on macOS, Media Foundation on Windows, VA-API for H.264 and HEVC on Linux) for the streams it supports, and FilmCraft's own decoder for everything else or if the hardware fails. Media that is already open keeps its decoder until it is reopened. Descodificación por hardware: Automático usa el descodificador de video del sistema (VideoToolbox en macOS, Media Foundation en Windows, VA-API para H.264 y HEVC en Linux) para los flujos que admite, y el descodificador propio de FilmCraft para todo lo demás o si falla el hardware. Los medios ya abiertos mantienen su descodificador hasta que se vuelven a abrir. Head Size Tamaño de la cabeza Heart Corazón Height Altura @@ -3435,3 +3435,39 @@ Add keyframe Añadir fotograma clave Remove keyframe Quitar fotograma clave Turn on animation and add keyframe Activar la animación y añadir un fotograma clave + Click to select, or click in empty space and drag to marquee select. Use Shift, Alt, and Ctrl for other options. Haz clic para seleccionar, o haz clic en un espacio vacío y arrastra para seleccionar con un marco. Usa Mayús, Alt y Ctrl para otras opciones. + Click to zoom in; Alt-click to zoom out. Haz clic para acercar; Alt+clic para alejar. + Drag: move · Alt-click: smooth/corner · Ctrl-click: delete Arrastrar: mover · Alt+clic: suave/esquina · Ctrl+clic: eliminar + Include Alpha Channel Incluir canal alfa + Sequence Settings Ajustes de secuencia + Color Management Gestión del color + VR Properties Propiedades de RV + Editing Mode: Modo de edición: + Timebase: Base de tiempo: + Frame Size: Tamaño del fotograma: + Pixel Aspect Ratio: Proporción de aspecto de píxeles: + Fields: Campos: + Channel Format: Formato de canales: + Number of Channels: Número de canales: + Preview File Format: Formato de archivo de previsualización: + Working Color Space: Espacio de color de trabajo: + Projection: Proyección: + Layout: Disposición: + Captured View: Vista capturada: + Video Previews Previsualizaciones de video + No Fields (Progressive Scan) Sin campos (exploración progresiva) + Custom ({ratio}) Personalizado ({ratio}) + {rate} frames/second {rate} fotogramas/segundo + {rate} fps Drop-Frame Timecode Código de tiempo con omisión de fotogramas a {rate} fps + {rate} fps Non Drop-Frame Timecode Código de tiempo sin omisión de fotogramas a {rate} fps + {rate} fps Timecode Código de tiempo a {rate} fps + horizontal horizontal + vertical vertical + Scale motion effects proportionally when changing frame size Escalar los efectos de movimiento proporcionalmente al cambiar el tamaño del fotograma + Maximum Bit Depth Profundidad de bits máxima + Maximum Render Quality Calidad de procesamiento máxima + Composite in Linear Color Componer en color lineal + Auto Tone Map Media (HDR and log into SDR) Mapeo de tonos automático de medios (HDR y log a SDR) + Configure… Configurar… + Not supported in FilmCraft yet FilmCraft aún no lo admite + FilmCraft always composites in linear light FilmCraft siempre compone en luz lineal diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3498925d..b8d2e23a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1119,7 +1119,7 @@ impl FilmcraftApp { p.insert("path".into(), json!(path)); let r = self.session.execute(id, Value::Object(p)).map_err(|e| e.to_string()); self.ui.status = match &r { - Ok(_) => format!("Exported {path}"), + Ok(_) => tlf!("Exported {path}", path), Err(e) => e.clone(), }; r @@ -1453,7 +1453,9 @@ impl FilmcraftApp { fn hint_text(&self) -> String { let mac = cfg!(target_os = "macos"); match self.ui.tool { - state::Tool::Selection if mac => tl!("Click to select, or click in empty space and drag to marquee select. Use Shift, Opt, and Cmd for other options."), + state::Tool::Selection if mac => { + tl!("Click to select, or click in empty space and drag to marquee select. Use Shift, Opt, and Cmd for other options.") + } state::Tool::Selection => tl!("Click to select, or click in empty space and drag to marquee select. Use Shift, Alt, and Ctrl for other options."), state::Tool::TrackSelectForward => tl!("Click to select all clips to the right in all tracks. Shift-click for a single track."), state::Tool::TrackSelectBackward => tl!("Click to select all clips to the left in all tracks. Shift-click for a single track."), diff --git a/crates/ui-egui/src/panels/export_mode.rs b/crates/ui-egui/src/panels/export_mode.rs index 00a6a00c..4f5e6dcb 100644 --- a/crates/ui-egui/src/panels/export_mode.rs +++ b/crates/ui-egui/src/panels/export_mode.rs @@ -605,8 +605,8 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T }); } if matches!(s.format, Format::PngSequence | Format::TiffSequence) { - row(ui, t, "Alpha", |ui| { - check(ui, reg, "export.video.alpha", &mut s.alpha, "Include Alpha Channel"); + row(ui, t, tl!("Alpha"), |ui| { + check(ui, reg, "export.video.alpha", &mut s.alpha, tl!("Include Alpha Channel")); }); } match s.video_format() { diff --git a/crates/ui-egui/src/panels/sequence_settings.rs b/crates/ui-egui/src/panels/sequence_settings.rs index 06000805..e87150cf 100644 --- a/crates/ui-egui/src/panels/sequence_settings.rs +++ b/crates/ui-egui/src/panels/sequence_settings.rs @@ -28,6 +28,7 @@ use filmcraft_time::FrameRate; use serde_json::{Map, Value, json}; use crate::FilmcraftApp; +use crate::i18n::t; use crate::state::SequenceSettingsDraft; const NOT_YET: &str = "Not supported in FilmCraft yet"; @@ -110,26 +111,26 @@ fn gcd(mut a: u32, mut b: u32) -> u32 { } fn timebase_label(r: FrameRate) -> String { - format!("{} frames/second", r.label()) + tlf!("{rate} frames/second", rate = r.label()) } /// The Video Display Format choices for a timebase: drop-frame / non-drop-frame timecode for the /// NTSC rates, plain timecode otherwise (`(id, label)`). fn timecode_formats(r: FrameRate) -> Vec<(&'static str, String)> { if r.supports_drop_frame() { - vec![("df", format!("{} fps Drop-Frame Timecode", r.label())), ("ndf", format!("{} fps Non Drop-Frame Timecode", r.label()))] + vec![("df", tlf!("{rate} fps Drop-Frame Timecode", rate = r.label())), ("ndf", tlf!("{rate} fps Non Drop-Frame Timecode", rate = r.label()))] } else { - vec![("tc", format!("{} fps Timecode", r.label()))] + vec![("tc", tlf!("{rate} fps Timecode", rate = r.label()))] } } fn par_label(par: (u32, u32)) -> String { if par.0 == par.1 { - "Square Pixels (1.0)".into() + tl!("Square Pixels (1.0)").into() } else if par.1 > 0 { - format!("Custom ({:.4})", f64::from(par.0) / f64::from(par.1)) + tlf!("Custom ({ratio})", ratio = format!("{:.4}", f64::from(par.0) / f64::from(par.1))) } else { - "Custom".into() + tl!("Custom").into() } } @@ -177,7 +178,7 @@ fn row(ui: &mut egui::Ui, label_w: f32, label: &str, enabled: bool, add: impl ui.horizontal(|ui| { let h = ui.spacing().interact_size.y; ui.allocate_ui_with_layout(egui::vec2(label_w, h), egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.add_enabled(enabled, egui::Label::new(label)); + ui.add_enabled(enabled, egui::Label::new(t(label))); }); add(ui) }) @@ -187,21 +188,21 @@ fn row(ui: &mut egui::Ui, label_w: f32, label: &str, enabled: bool, add: impl /// A greyed-out list showing `value`, with the "not supported yet" hint (or `hint`). fn fixed_combo(ui: &mut egui::Ui, elems: &mut Elems, id: &str, value: &str, width: f32, hint: &str) { let r = ui - .add_enabled_ui(false, |ui| egui::ComboBox::from_id_salt(("seq-settings-fixed", id)).selected_text(value).width(width).show_ui(ui, |_| {}).response) + .add_enabled_ui(false, |ui| egui::ComboBox::from_id_salt(("seq-settings-fixed", id)).selected_text(t(value)).width(width).show_ui(ui, |_| {}).response) .inner - .on_disabled_hover_text(hint); + .on_disabled_hover_text(t(hint)); elems.push((format!("sequenceSettings.{id}"), r.rect, value.to_string())); } /// A greyed-out read-only value (a number field in Premiere), with the hint. fn fixed_value(ui: &mut egui::Ui, elems: &mut Elems, id: &str, value: &str) { - let r = ui.add_enabled(false, egui::Button::new(value).min_size(egui::vec2(64.0, 0.0))).on_disabled_hover_text(NOT_YET); + let r = ui.add_enabled(false, egui::Button::new(value).min_size(egui::vec2(64.0, 0.0))).on_disabled_hover_text(t(NOT_YET)); elems.push((format!("sequenceSettings.{id}"), r.rect, value.to_string())); } fn section(ui: &mut egui::Ui, title: &str, add: impl FnOnce(&mut egui::Ui)) { ui.add_space(6.0); - ui.label(egui::RichText::new(title).strong()); + ui.label(egui::RichText::new(t(title)).strong()); ui.group(|ui| { ui.set_width(ui.available_width()); add(ui); @@ -212,7 +213,7 @@ fn general(ui: &mut egui::Ui, d: &mut SequenceSettingsDraft, cur: &SequenceSetti let font = egui::TextStyle::Body.resolve(ui.style()); let label_w = ["Preview File Format:", "Pixel Aspect Ratio:", "Number of Channels:"] .iter() - .map(|l| ui.painter().layout_no_wrap(l.to_string(), font.clone(), egui::Color32::WHITE).size().x) + .map(|l| ui.painter().layout_no_wrap(t(l).to_string(), font.clone(), egui::Color32::WHITE).size().x) .fold(0.0, f32::max); let list_w = 260.0; row(ui, label_w, "Editing Mode:", false, |ui| fixed_combo(ui, elems, "editingMode", "Custom", list_w, NOT_YET)); @@ -246,15 +247,15 @@ fn general(ui: &mut egui::Ui, d: &mut SequenceSettingsDraft, cur: &SequenceSetti row(ui, label_w, "Frame Size:", true, |ui| { let w = ui.add(egui::DragValue::new(&mut d.width).range(1..=filmcraft_project::MAX_FRAME_SIDE).speed(1.0)); elems.push(("sequenceSettings.width".into(), w.rect, d.width.to_string())); - ui.label("horizontal"); + ui.label(tl!("horizontal")); let h = ui.add(egui::DragValue::new(&mut d.height).range(1..=filmcraft_project::MAX_FRAME_SIDE).speed(1.0)); elems.push(("sequenceSettings.height".into(), h.rect, d.height.to_string())); - ui.label("vertical"); + ui.label(tl!("vertical")); let a = aspect(d.width, d.height); let ar = ui.label(&a); elems.push(("sequenceSettings.aspect".into(), ar.rect, a)); }); - let r = ui.checkbox(&mut d.scale_motion, "Scale motion effects proportionally when changing frame size"); + let r = ui.checkbox(&mut d.scale_motion, tl!("Scale motion effects proportionally when changing frame size")); elems.push(("sequenceSettings.scaleMotion".into(), r.rect, d.scale_motion.to_string())); row(ui, label_w, "Pixel Aspect Ratio:", false, |ui| fixed_combo(ui, elems, "par", &par_label(cur.par), list_w, NOT_YET)); row(ui, label_w, "Fields:", false, |ui| fixed_combo(ui, elems, "fields", "No Fields (Progressive Scan)", list_w, NOT_YET)); @@ -272,7 +273,7 @@ fn general(ui: &mut egui::Ui, d: &mut SequenceSettingsDraft, cur: &SequenceSetti elems.push((format!("sequenceSettings.videoDisplay.option.{id}"), o.rect, label.clone())); } for label in ["Feet + Frames 16mm", "Feet + Frames 35mm", "Frames"] { - ui.add_enabled(false, egui::Button::selectable(false, label)).on_disabled_hover_text(NOT_YET); + ui.add_enabled(false, egui::Button::selectable(false, t(label))).on_disabled_hover_text(t(NOT_YET)); } }); elems.push(("sequenceSettings.videoDisplay".into(), resp.response.rect, shown)); @@ -281,14 +282,14 @@ fn general(ui: &mut egui::Ui, d: &mut SequenceSettingsDraft, cur: &SequenceSetti section(ui, "Audio", |ui| { row(ui, label_w, "Channel Format:", true, |ui| { let shown = d.mix.clone(); - let resp = egui::ComboBox::from_id_salt("seq-settings-mix").selected_text(&shown).width(120.0).show_ui(ui, |ui| { + let resp = egui::ComboBox::from_id_salt("seq-settings-mix").selected_text(t(&shown)).width(120.0).show_ui(ui, |ui| { for m in MIXES { - let o = ui.selectable_value(&mut d.mix, m.to_string(), m); + let o = ui.selectable_value(&mut d.mix, m.to_string(), t(m)); elems.push((format!("sequenceSettings.channelFormat.option.{m}"), o.rect, m.to_string())); } }); elems.push(("sequenceSettings.channelFormat".into(), resp.response.rect, shown)); - ui.add_enabled(false, egui::Label::new("Number of Channels:")); + ui.add_enabled(false, egui::Label::new(tl!("Number of Channels:"))); let n = match d.mix.as_str() { "Mono" => "1", "5.1" => "6", @@ -318,57 +319,57 @@ fn general(ui: &mut egui::Ui, d: &mut SequenceSettingsDraft, cur: &SequenceSetti section(ui, "Video Previews", |ui| { row(ui, label_w, "Preview File Format:", false, |ui| { fixed_combo(ui, elems, "previewFormat", "QuickTime", list_w, NOT_YET); - ui.add_enabled(false, egui::Button::new("Configure…")).on_disabled_hover_text(NOT_YET); + ui.add_enabled(false, egui::Button::new(tl!("Configure…"))).on_disabled_hover_text(t(NOT_YET)); }); row(ui, label_w, "Codec:", false, |ui| fixed_combo(ui, elems, "previewCodec", &cur.preview_codec, list_w, NOT_YET)); row(ui, label_w, "Width:", false, |ui| fixed_value(ui, elems, "previewWidth", &d.width.to_string())); row(ui, label_w, "Height:", false, |ui| { fixed_value(ui, elems, "previewHeight", &d.height.to_string()); - ui.add_enabled(false, egui::Button::new("Reset")).on_disabled_hover_text(NOT_YET); + ui.add_enabled(false, egui::Button::new(tl!("Reset"))).on_disabled_hover_text(t(NOT_YET)); }); ui.horizontal(|ui| { let mut bit_depth = cur.max_bit_depth; - let r = ui.add_enabled(false, egui::Checkbox::new(&mut bit_depth, "Maximum Bit Depth")).on_disabled_hover_text(NOT_YET); + let r = ui.add_enabled(false, egui::Checkbox::new(&mut bit_depth, tl!("Maximum Bit Depth"))).on_disabled_hover_text(t(NOT_YET)); elems.push(("sequenceSettings.maxBitDepth".into(), r.rect, bit_depth.to_string())); - let r = ui.checkbox(&mut d.max_render_quality, "Maximum Render Quality"); + let r = ui.checkbox(&mut d.max_render_quality, tl!("Maximum Render Quality")); elems.push(("sequenceSettings.maxRenderQuality".into(), r.rect, d.max_render_quality.to_string())); }); let mut linear = true; - let r = ui.add_enabled(false, egui::Checkbox::new(&mut linear, "Composite in Linear Color")).on_disabled_hover_text(LINEAR); + let r = ui.add_enabled(false, egui::Checkbox::new(&mut linear, tl!("Composite in Linear Color"))).on_disabled_hover_text(t(LINEAR)); elems.push(("sequenceSettings.linearColor".into(), r.rect, "true".into())); }); } fn color(ui: &mut egui::Ui, d: &mut SequenceSettingsDraft, elems: &mut Elems) { let font = egui::TextStyle::Body.resolve(ui.style()); - let label_w = ui.painter().layout_no_wrap("Working Color Space:".to_string(), font, egui::Color32::WHITE).size().x; + let label_w = ui.painter().layout_no_wrap(tl!("Working Color Space:").to_string(), font, egui::Color32::WHITE).size().x; row(ui, label_w, "Working Color Space:", true, |ui| { - let shown = WorkingSpace::parse(&d.working_space).map_or(d.working_space.clone(), |w| w.label().to_string()); + let shown = WorkingSpace::parse(&d.working_space).map_or(d.working_space.clone(), |w| t(w.label()).to_string()); let resp = egui::ComboBox::from_id_salt("seq-settings-working-space").selected_text(&shown).width(220.0).show_ui(ui, |ui| { for w in WorkingSpace::ALL { - let o = ui.selectable_value(&mut d.working_space, w.id().to_string(), w.label()); + let o = ui.selectable_value(&mut d.working_space, w.id().to_string(), t(w.label())); elems.push((format!("sequenceSettings.workingSpace.option.{}", w.id()), o.rect, w.label().to_string())); } }); elems.push(("sequenceSettings.workingSpace".into(), resp.response.rect, shown)); }); ui.add_space(4.0); - let r = ui.checkbox(&mut d.wide_gamut, "Wide gamut color (composite in BT.2020)"); + let r = ui.checkbox(&mut d.wide_gamut, tl!("Wide gamut color (composite in BT.2020)")); elems.push(("sequenceSettings.wideGamut".into(), r.rect, d.wide_gamut.to_string())); - let r = ui.checkbox(&mut d.auto_tone_map, "Auto Tone Map Media (HDR and log into SDR)"); + let r = ui.checkbox(&mut d.auto_tone_map, tl!("Auto Tone Map Media (HDR and log into SDR)")); elems.push(("sequenceSettings.autoToneMap".into(), r.rect, d.auto_tone_map.to_string())); } fn vr(ui: &mut egui::Ui, elems: &mut Elems) { let font = egui::TextStyle::Body.resolve(ui.style()); - let label_w = ui.painter().layout_no_wrap("Captured View:".to_string(), font, egui::Color32::WHITE).size().x; + let label_w = ui.painter().layout_no_wrap(tl!("Captured View:").to_string(), font, egui::Color32::WHITE).size().x; row(ui, label_w, "Projection:", false, |ui| fixed_combo(ui, elems, "vr.projection", "None", 200.0, NOT_YET)); row(ui, label_w, "Layout:", false, |ui| fixed_combo(ui, elems, "vr.layout", "Monoscopic", 200.0, NOT_YET)); row(ui, label_w, "Captured View:", false, |ui| { fixed_value(ui, elems, "vr.horizontal", "0°"); - ui.add_enabled(false, egui::Label::new("Horizontal")); + ui.add_enabled(false, egui::Label::new(tl!("Horizontal"))); fixed_value(ui, elems, "vr.vertical", "0°"); - ui.add_enabled(false, egui::Label::new("Vertical")); + ui.add_enabled(false, egui::Label::new(tl!("Vertical"))); }); } @@ -383,13 +384,17 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { let mut apply = false; let mut elems: Elems = Vec::new(); let max_h = ctx.content_rect().height() * 0.8; - egui::Window::new("Sequence Settings").collapsible(false).resizable(false).default_width(620.0).anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]).show( - ctx, - |ui| { + egui::Window::new(tl!("Sequence Settings")) + .id(egui::Id::new("Sequence Settings")) + .collapsible(false) + .resizable(false) + .default_width(620.0) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { ui.set_width(620.0); ui.horizontal(|ui| { for (id, label) in [("general", "General"), ("color", "Color Management"), ("vr", "VR Properties")] { - let r = ui.selectable_label(d.tab == id, label); + let r = ui.selectable_label(d.tab == id, t(label)); if r.clicked() { d.tab = id.to_string(); } @@ -404,19 +409,18 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { }); ui.add_space(10.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let o = ui.add(egui::Button::new(egui::RichText::new("OK").color(egui::Color32::WHITE)).fill(accent)); + let o = ui.add(egui::Button::new(egui::RichText::new(tl!("OK")).color(egui::Color32::WHITE)).fill(accent)); elems.push(("sequenceSettings.ok".into(), o.rect, "OK".into())); if o.clicked() { apply = true; } - let c = ui.button("Cancel"); + let c = ui.button(tl!("Cancel")); elems.push(("sequenceSettings.cancel".into(), c.rect, "Cancel".into())); if c.clicked() { keep = false; } }); - }, - ); + }); for (id, r, l) in elems { app.auto.add(&id, r, &l); } From 183884fd34a0f45ca58c32d0fbb589197152ca0d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 9 Oct 2026 09:58:06 -0400 Subject: [PATCH 15/15] web: WebCodecs busy check honours STALE_MS; checked row offsets in plane() A copyTo that never settles kept copying > 0 and bypassed the stale timeout, wedging the session; now busy requires recent output. plane() computes each row's start/end with checked arithmetic and bounds it by the validated end. Co-Authored-By: Claude Opus 5.5 --- apps/filmcraft-web/src/webcodecs.rs | 7 ++++--- apps/filmcraft-web/src/webcodecs/pixels.rs | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/filmcraft-web/src/webcodecs.rs b/apps/filmcraft-web/src/webcodecs.rs index aae035d4..99b3df3b 100644 --- a/apps/filmcraft-web/src/webcodecs.rs +++ b/apps/filmcraft-web/src/webcodecs.rs @@ -473,9 +473,10 @@ impl WcSource { // already output but the cache dropped needs a restart). let covers = !s.needs_key && s.start <= i && key <= s.next && (i >= s.next || want > s.out_max.get()); if !covers { - // Don't interrupt a session that is still working towards its own frame. - let busy = - s.copying.get() > 0 || (!s.needs_key && s.target != i64::MIN && s.out_max.get() < s.target && now_ms() - s.last_output.get() < STALE_MS); + // Don't interrupt a session that is still working towards its own frame, unless it + // has gone quiet for STALE_MS (a copyTo that never settles must not wedge it). + let fresh = now_ms() - s.last_output.get() < STALE_MS; + let busy = fresh && (s.copying.get() > 0 || (!s.needs_key && s.target != i64::MIN && s.out_max.get() < s.target)); if busy { return Drive::Waiting; } diff --git a/apps/filmcraft-web/src/webcodecs/pixels.rs b/apps/filmcraft-web/src/webcodecs/pixels.rs index 7cd5eff5..dc99e771 100644 --- a/apps/filmcraft-web/src/webcodecs/pixels.rs +++ b/apps/filmcraft-web/src/webcodecs/pixels.rs @@ -67,9 +67,10 @@ fn plane(raw: &js_sys::Uint8Array, layout: &JsValue, width: u32, height: u32) -> raw.subarray(offset, end).copy_to(&mut out); } else { for (y, row) in out.chunks_exact_mut(width as usize).enumerate() { - // Bounds/overflow checked for the last row above; earlier rows are smaller. - let start = offset + y as u32 * stride; - raw.subarray(start, start + width).copy_to(row); + // Checked for the last row above; earlier rows are smaller, but stay checked anyway. + let start = u32::try_from(y).ok().and_then(|y| y.checked_mul(stride)).and_then(|n| offset.checked_add(n)).ok_or("layout")?; + let row_end = start.checked_add(width).filter(|e| *e <= end).ok_or("layout")?; + raw.subarray(start, row_end).copy_to(row); } } Ok(out)