diff --git a/.gitignore b/.gitignore index d6cea1550..2eefd58ba 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,10 @@ CmakeSettings.json # clangd's index and likely other things that need not be in the repository .cache/ +# Generated packaging assets (icons/icon.png is produced from Oak_Icon.svg +# by rsvg-convert in the CD workflow; see .github/workflows/cd.yml) +/icons/ + # macOS General .DS_Store .AppleDouble @@ -114,4 +118,6 @@ otio-install/ # Rust **/target/ tarpaulin-out/ -.env \ No newline at end of file +.env +# CD packaging artifacts +/*.dmg diff --git a/crates/oakaudio/src/manager.rs b/crates/oakaudio/src/manager.rs index 68d9d1ee1..c506d4b3e 100644 --- a/crates/oakaudio/src/manager.rs +++ b/crates/oakaudio/src/manager.rs @@ -244,7 +244,11 @@ impl ManagerInner { self.output_device = device; self.output_started = false; self.output_buffer.clear(); - // The stream reopens with the new device on the next push. + // The stream reopens with the new device on the next push. Drop the + // cached params too: `push_to_output` only re-opens the stream on a + // params change, so without this the switch would stay silent until + // the format changed. + self.output_params = None; self.output_device_stream.close(); Ok(()) } @@ -440,6 +444,48 @@ pub fn find_device_by_name_s_or_default(name: &String, _is_output_device: bool) } +/// The host's output device names in enumeration order; the list index is +/// the device index [`ManagerInner::set_output_device`] takes. The default +/// device is NOT marked — callers prepend their own "system default" entry +/// (index -1). Static (needs no manager instance). +pub fn output_device_names() -> Vec { + device_names(true) +} + +/// The host's input device names in enumeration order (see +/// [`output_device_names`]). +pub fn input_device_names() -> Vec { + device_names(false) +} + +/// Enumerates the default host's devices, keeping only the ones that +/// support the requested direction; unnamed devices are skipped. +fn device_names(output: bool) -> Vec { + let host = cpal::default_host(); + let devices = if output { + host.output_devices() + } else { + host.input_devices() + }; + let Ok(devices) = devices else { + return Vec::new(); + }; + devices + .filter_map(|d| d.id().ok().map(|id| id.id().to_string())) + .collect() +} + +/// The enumeration index of the device named `name` (`None` when absent); +/// the inverse of [`output_device_names`]/[`input_device_names`]. +pub fn device_index_by_name(name: &str, output: bool) -> Option { + let names = if output { + output_device_names() + } else { + input_device_names() + }; + names.iter().position(|n| n == name).map(|i| i as i32) +} + #[cfg(test)] mod tests { diff --git a/crates/oakcommon/src/filefunctions.rs b/crates/oakcommon/src/filefunctions.rs index a50d8e390..2b0b79e1a 100644 --- a/crates/oakcommon/src/filefunctions.rs +++ b/crates/oakcommon/src/filefunctions.rs @@ -926,10 +926,20 @@ mod tests { /// The default disk cache directory (C++ `DiskManager:: /// get_default_disk_cache_path`): `/mediacache`. /// +/// The `DiskCachePath` config key overrides the location when set (the +/// preferences dialog's cache-directory setting); an empty/absent value +/// keeps the default. +/// /// Single-lib unification: this used to live in the oakrender crate's /// `bridge::common` fallback (see `docs/zh/plans/riir/single-lib.md`); /// oaknode and oakrender both call it directly now. pub fn default_disk_cache_path() -> String { + // A configured override wins (whitespace-only counts as absent). + if let Ok(custom) = crate::configstore::ConfigStore::instance().get(None, "DiskCachePath") { + if !custom.trim().is_empty() { + return custom; + } + } Path::new( &FileFunctions::new() .get_configuration_location() diff --git a/docs/screenshot-manager-en.png b/docs/screenshot-manager-en.png index 780c6835c..b8c0e5f8b 100644 Binary files a/docs/screenshot-manager-en.png and b/docs/screenshot-manager-en.png differ diff --git a/docs/screenshot-manager.png b/docs/screenshot-manager.png index ac732c86b..f0ebe05d3 100644 Binary files a/docs/screenshot-manager.png and b/docs/screenshot-manager.png differ diff --git a/docs/screenshot-preferences-en.png b/docs/screenshot-preferences-en.png new file mode 100644 index 000000000..8243c41ce Binary files /dev/null and b/docs/screenshot-preferences-en.png differ diff --git a/docs/screenshot-preferences.png b/docs/screenshot-preferences.png new file mode 100644 index 000000000..ee0e1bc6b Binary files /dev/null and b/docs/screenshot-preferences.png differ diff --git a/docs/zh/plans/riir/M12-app.md b/docs/zh/plans/riir/M12-app.md index b4fb50735..60b3c2f12 100644 --- a/docs/zh/plans/riir/M12-app.md +++ b/docs/zh/plans/riir/M12-app.md @@ -25,9 +25,10 @@ - **扁平化现代 UI**,图标用 C++ 版素材(assets/icons/{dark,light}, 16px 网格),双主题,中英双语(src/i18n.rs + gpui i18n 钩子)。 - **平台**:macOS(Apple Silicon)优先;Linux 链接已通(build.rs - rpath+--export-dynamic);Windows 卡在 DLL 不允许未定义符号 - (oakcore_* 宿主导入),需 stub import lib 或 delay-load(见 - §5 风险表)。 + rpath+--export-dynamic);Windows 的 DLL 未定义符号问题已解决 + (M12 P5:oakcore_audioparams_* 收编进 dylib,DLL 无未定义符号, + oakengine/cli/worker 可链接;app 的 win32 支持仍待 gpui,见 §5 + 风险表)。 - **插件 GUI**(OFX Interact/Dialog、第三方语言插件自绘窗口)走 方案 2(已立项时的决议):引擎侧离屏渲染,app 侧贴图;窗口类 需求由插件自建窗口、app 不嵌套。对应 M11 第 4 期(UI 类 suite)。 @@ -64,7 +65,7 @@ | G5 | 全分辨率异步渲染(facade worker 进程面)未绑 | 代理分辨率外的画质 | oakengine::worker NDJSON | | G6 | 时间线音频波形未显示 | 波形提取已是真实现(oakaudio/ffmpeg-next) | src/panels/timeline.rs | | G7 | move_clip 跨轨 | facade 签名冻结无目标轨参数(需新增导出) | crates/oakengine | -| G8 | Windows 构建 | DLL 未定义符号问题 | 根 build.rs 注释 | +| G8 | Windows 构建 | ~~DLL 未定义符号~~ 已解决(M12 P5:oakcore_audioparams_* 收编进 dylib);剩余为 app 侧 win32(gpui) | 根 build.rs 注释 + §5 风险表 | ## 2. 分期 @@ -127,8 +128,8 @@ 2. 偏好设置完整化(缓存目录、代理策略、自动保存间隔、默认 过渡等,全部落 oakengine_config_*)。 3. 键盘快捷键表(对齐 C++ 版快捷键,i18n 无关)。 -4. Windows 支持:解决 DLL 未定义符号(stub import lib 或把 - oakcore_* 收进 dylib 本体),CI windows job 转绿。 +4. Windows 支持:oakcore_* 已收编进 dylib(M12 P5,DLL 无未定义符号, + oakengine/cli/worker 可链接);CI windows job 转绿待工具链到位。 5. CD 实跑验证六个包(deb/rpm/pkg.tar.zst/AppImage/NSIS/dmg) 能装能起。 @@ -153,7 +154,7 @@ | 风险 | 影响 | 缓解 | |------|------|------| -| Windows DLL 未定义符号 | Windows 无法出包 | oakcore_* 收编进 dylib 或生成 stub import lib;CI 先保 Linux/macOS | +| Windows DLL 未定义符号(oakcore_* 宿主导入) | Windows 无法出包 | 已解决(M12 P5):oakcore_audioparams_* 收编进 dylib,DLL 无未定义符号;CI 先保 Linux/macOS | | gpui fork 与上游分叉扩大 | 维护成本 | 只在 gpui_widgets 层扩展;上游同步按季度评审 | | 音频回调实时性(PortAudio 回调里禁锁/分配) | 爆音 | 回调只读写无锁环形缓冲;oakaudio PreviewAudioDevice 已是此形态 | | OFX 插件 GUI(方案 2)交互延迟 | 插件调参手感 | P5 后单独立项评审(M11 第 4 期) | diff --git a/examples/screenshot.rs b/examples/screenshot.rs index 863b705aa..7c86f53fd 100644 --- a/examples/screenshot.rs +++ b/examples/screenshot.rs @@ -50,6 +50,8 @@ const OUT_ZH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/docs/screenshot-windo const OUT_EN: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/docs/screenshot-window-en.png"); const OUT_MGR_ZH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/docs/screenshot-manager.png"); const OUT_MGR_EN: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/docs/screenshot-manager-en.png"); +const OUT_PREF_ZH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/docs/screenshot-preferences.png"); +const OUT_PREF_EN: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/docs/screenshot-preferences-en.png"); /// Logical y of the timeline toolbar row, which sits at the top of the /// bottom dock panel in the default layout: the dock starts at y 27.5 (the @@ -86,6 +88,7 @@ fn main() -> Result<()> { image.save(OUT_ZH)?; println!("wrote {OUT_ZH} ({}×{})", image.width(), image.height()); assert_toolbar(&image, "zh-CN"); + capture_preferences(&mut cx, handle, &root, OUT_PREF_ZH)?; capture_manager(&mut cx, handle, &root, OUT_MGR_ZH)?; } i18n::set_language(Language::EnUs); @@ -96,6 +99,7 @@ fn main() -> Result<()> { image.save(OUT_EN)?; println!("wrote {OUT_EN} ({}×{})", image.width(), image.height()); assert_toolbar(&image, "en-US"); + capture_preferences(&mut cx, handle, &root, OUT_PREF_EN)?; capture_manager(&mut cx, handle, &root, OUT_MGR_EN)?; } i18n::set_language(original); @@ -155,6 +159,28 @@ fn settle(cx: &mut VisualTestAppContext, handle: AnyWindowHandle) { cx.run_until_parked(); } +/// Opens the preferences dialog on the shell (M12 P5b) and captures it: the +/// modal lists the grouped settings (general / rendering / cache / proxy / +/// project / audio). Drives the root ENTITY (not the window handle — see +/// [`capture_manager`]). The dialog closes afterwards so the manager +/// capture starts from a clean shell. +fn capture_preferences( + cx: &mut VisualTestAppContext, + handle: gpui::WindowHandle>, + root: &Entity>, + out: &str, +) -> Result<()> { + root.update(cx, |app, cx| app.open_preferences(cx)); + settle(cx, handle.into()); + let image = cx.capture_screenshot(handle.into())?; + image.save(out)?; + println!("wrote {out} ({}×{})", image.width(), image.height()); + // Close the dialog (the Close button path: commit + dismiss). + root.update(cx, |app, cx| app.close_modal(cx)); + cx.run_until_parked(); + Ok(()) +} + /// Opens the project manager on the shell (M13 D4) and captures it: the /// modal card lists the mock library with its per-project stats. Drives the /// root ENTITY (not the window handle — a window update borrows the window, diff --git a/src/app.rs b/src/app.rs index 3ca13ef47..2dff639cf 100644 --- a/src/app.rs +++ b/src/app.rs @@ -68,7 +68,7 @@ use crate::panels::status_bar::StatusBar; use crate::panels::timeline::TimelinePanel; // Menu item ids (unique per menu). -mod menu_ids { +pub(crate) mod menu_ids { pub const NEW_PROJECT: usize = 101; pub const OPEN_PROJECT: usize = 102; pub const EXPORT_PROJECT: usize = 103; @@ -86,17 +86,24 @@ mod menu_ids { pub const PASTE: usize = 205; pub const DELETE: usize = 206; pub const RIPPLE_DELETE: usize = 207; + pub const SELECT_ALL: usize = 208; pub const THEME_DARK: usize = 301; pub const THEME_LIGHT: usize = 302; pub const LANG_ZH: usize = 303; pub const LANG_EN: usize = 304; pub const PREFERENCES: usize = 305; + pub const ZOOM_IN: usize = 306; + pub const ZOOM_OUT: usize = 307; pub const PLAY_PAUSE: usize = 401; pub const PREV_FRAME: usize = 402; pub const NEXT_FRAME: usize = 403; pub const TO_START: usize = 404; + pub const PLAY: usize = 405; + pub const PAUSE: usize = 406; + pub const SET_IN_POINT: usize = 407; + pub const SET_OUT_POINT: usize = 408; pub const ADD_VIDEO_TRACK: usize = 501; pub const ADD_AUDIO_TRACK: usize = 502; @@ -289,6 +296,10 @@ pub struct OakApp { dark: bool, /// The modal currently shown on top of the shell, if any. modal: ModalState, + /// The shell's own focus handle: the root element tracks it so the + /// keyboard shortcut layer (the root's `on_key_down`) sits on the + /// dispatch path even before any panel takes focus. + shell_focus: gpui::FocusHandle, /// The running export session, if any. export: Option, /// The library row pending an export save dialog (manager 导出). @@ -299,7 +310,13 @@ impl OakApp { /// Builds the whole shell. `initial_path` (a CLI argument) is opened /// after the layout is up. pub fn new(window: &mut Window, initial_path: Option, cx: &mut Context) -> Self { - apply_theme(cx, &OakTheme::olive_dark()); + // The persisted theme (config `Theme`) wins; the app defaults to dark. + let dark = crate::oakui::real::theme_is_dark(); + if dark { + apply_theme(cx, &OakTheme::olive_dark()); + } else { + apply_theme(cx, &OakTheme::olive_light()); + } crate::oakui::icons::init(cx); // --- engine and shared state --------------------------------------- @@ -322,7 +339,7 @@ impl OakApp { }); // --- menu bar ------------------------------------------------------ - let menu_bar = cx.new(|cx| MenuBar::new(1, make_menus(true), window, cx)); + let menu_bar = cx.new(|cx| MenuBar::new(1, make_menus(dark), window, cx)); cx.subscribe( &menu_bar, |this, _menu: Entity, event: &MenuBarEvent, cx| { @@ -483,6 +500,10 @@ impl OakApp { }) .detach(); + let shell_focus = cx.focus_handle(); + // The shell starts focused so the shortcut layer works before any + // panel grabs focus. + window.focus(&shell_focus, cx); let shell = Self { engine, program_clock, @@ -491,8 +512,9 @@ impl OakApp { menu_bar, dock, status_bar, - dark: true, + dark, modal: ModalState::None, + shell_focus, export: None, pending_export: None, }; @@ -548,22 +570,21 @@ impl OakApp { REDO => self.engine.update(cx, |engine, cx| engine.redo(cx)), DELETE => self.delete_timeline_selection(false, cx), RIPPLE_DELETE => self.delete_timeline_selection(true, cx), + SELECT_ALL => self.select_all_clips(cx), CUT | COPY | PASTE => { println!("[menu] clipboard action {item} not wired yet"); } // --- View ------------------------------------------------------ THEME_DARK => { - self.dark = true; - apply_theme(cx, &OakTheme::olive_dark()); - self.rebuild_menu_bar(cx); - cx.notify(); + crate::oakui::real::set_theme_dark(true); + self.apply_dark(true, cx); } THEME_LIGHT => { - self.dark = false; - apply_theme(cx, &OakTheme::olive_light()); - self.rebuild_menu_bar(cx); - cx.notify(); + crate::oakui::real::set_theme_dark(false); + self.apply_dark(false, cx); } + ZOOM_IN => self.zoom_timeline(1.25, cx), + ZOOM_OUT => self.zoom_timeline(0.8, cx), LANG_ZH => self.switch_language(crate::i18n::Language::ZhCN, cx), LANG_EN => self.switch_language(crate::i18n::Language::EnUs, cx), PREFERENCES => self.open_preferences(cx), @@ -589,12 +610,24 @@ impl OakApp { self.engine .update(cx, |engine, cx| engine.step(monitor, 1, cx)); } + PLAY => { + let monitor = Monitor::Program; + self.engine + .update(cx, |engine, cx| engine.play(monitor, cx)); + } + PAUSE => { + let monitor = Monitor::Program; + self.engine + .update(cx, |engine, cx| engine.pause(monitor, cx)); + } TO_START => { let monitor = Monitor::Program; self.engine.update(cx, |engine, cx| { engine.request_frame(monitor, Frame::ZERO, cx) }); } + SET_IN_POINT => self.set_point_at_playhead(true, cx), + SET_OUT_POINT => self.set_point_at_playhead(false, cx), // --- Sequence -------------------------------------------------- ADD_VIDEO_TRACK => { let kind = gpui::timeline::TrackKind::Video; @@ -661,6 +694,98 @@ impl OakApp { } } + /// 编辑 → 全选: selects every timeline clip and forwards the selection + /// to the engine (the effect stack's target), mirroring what the + /// timeline's own `SelectionChanged` subscription does. + fn select_all_clips(&mut self, cx: &mut Context) { + let ids: Vec = { + let engine = self.engine.read(cx); + let mut ids = Vec::new(); + for index in 0..engine.track_count() { + if let Some(track) = engine.track(index) { + ids.extend(track.clips().iter().map(|clip| clip.id())); + } + } + ids + }; + self.timeline.update(cx, |view, cx| { + view.state.select_range(ids.iter().copied()); + cx.notify(); + }); + self.engine + .update(cx, |engine, cx| engine.set_selected_clips(ids, cx)); + } + + /// 视图 → 放大/缩小: scales the timeline zoom around its left edge + /// (`TimelineState::set_zoom` clamps to the widget's zoom range). + fn zoom_timeline(&mut self, factor: f32, cx: &mut Context) { + self.timeline.update(cx, |view, cx| { + let zoom = view.state.zoom * factor; + view.state.set_zoom(zoom, px(0.)); + cx.notify(); + }); + } + + /// 回放 → 设置入点/出点: moves the work area's start (`in_point`) or end + /// to the program playhead as ONE undoable entry (the same commit the + /// ruler drag and 序列 → 设置工作区 use). Without an existing work area + /// the far edge falls back to the sequence bounds. + fn set_point_at_playhead(&mut self, in_point: bool, cx: &mut Context) { + let playhead = self.program_clock.read(cx).current_frame(); + let seq_len = self + .engine + .read(cx) + .current_sequence() + .map(|s| s.length) + .unwrap_or(Frame(playhead.0 + 1)); + let (old_start, old_end) = self + .engine + .read(cx) + .workarea() + .unwrap_or((Frame::ZERO, seq_len)); + let (start, end) = if in_point { + (playhead, old_end.max(Frame(playhead.0 + 1))) + } else { + (old_start.min(Frame((playhead.0 - 1).max(0))), playhead) + }; + if end.0 <= start.0 { + println!("[playback] set in/out point: empty range, ignored"); + return; + } + self.engine.update(cx, |engine, cx| { + engine.commit_workarea(old_start, old_end, start, end, cx); + }); + } + + /// Applies the dark/light theme and rebuilds the menu bar (the theme + /// checkmark moves). The caller persists the choice through + /// [`crate::oakui::real::set_theme_dark`]. + fn apply_dark(&mut self, dark: bool, cx: &mut Context) { + self.dark = dark; + if dark { + apply_theme(cx, &OakTheme::olive_dark()); + } else { + apply_theme(cx, &OakTheme::olive_light()); + } + self.rebuild_menu_bar(cx); + cx.notify(); + } + + /// The shell's keyboard shortcut entry point: maps the keystroke through + /// [`crate::shortcuts`] and dispatches the matched menu action. While a + /// modal dialog is open the shell stays keyboard-quiet, so the dialogs' + /// text fields never trigger editing actions. + fn on_shortcut(&mut self, keystroke: &gpui::Keystroke, cx: &mut Context) { + if !matches!(self.modal, ModalState::None) { + return; + } + let Some(action) = crate::shortcuts::action_for(keystroke) else { + return; + }; + cx.stop_propagation(); + self.on_menu(action, cx); + } + /// Removes the first track selected in the timeline header. fn remove_selected_track(&mut self, cx: &mut Context) { let Some(&index) = self.timeline.read(cx).selected_tracks().iter().next() else { @@ -986,7 +1111,7 @@ impl OakApp { // ----------------------------------------------------------------------- /// Closes the current modal. - fn close_modal(&mut self, cx: &mut Context) { + pub fn close_modal(&mut self, cx: &mut Context) { self.modal = ModalState::None; cx.notify(); } @@ -1158,14 +1283,16 @@ impl OakApp { } } - /// Opens the preferences dialog. - fn open_preferences(&mut self, cx: &mut Context) { + /// Opens the preferences dialog. Theme/language selections emit + /// [`crate::dialogs::PreferencesEvent`]s, applied to the shell chrome + /// immediately; the typed cache directory commits when the dialog closes. + pub fn open_preferences(&mut self, cx: &mut Context) { self.spawn_modal(cx, |window, app| { let content = app.new(|cx| PreferencesContent::new(window, cx)); let modal = app.new(|cx| { Modal::new( modal_ids::PREFERENCES, - ModalOptions::new(crate::i18n::tr("preferences.title"), px(380.0)) + ModalOptions::new(crate::i18n::tr("preferences.title"), px(480.0)) .with_button(DialogButton::primary(crate::i18n::tr("dialog.close"))), window, cx, @@ -1174,6 +1301,30 @@ impl OakApp { }); ModalState::Preferences { modal, content } }); + if let ModalState::Preferences { content, .. } = &self.modal { + let content = content.clone(); + cx.subscribe( + &content, + |this, _content, event: &crate::dialogs::PreferencesEvent, cx| match *event { + crate::dialogs::PreferencesEvent::ThemeChanged(dark) => { + this.apply_dark(dark, cx); + } + crate::dialogs::PreferencesEvent::LanguageChanged => { + this.rebuild_menu_bar(cx); + cx.notify(); + } + }, + ) + .detach(); + } + } + + /// Commits the preferences dialog's free-text fields (the cache + /// directory path) before the modal closes. + fn commit_preferences(&mut self, cx: &mut Context) { + if let ModalState::Preferences { content, .. } = &self.modal { + content.update(cx, |content, cx| content.commit_cache_dir(cx)); + } } /// Opens the export dialog. @@ -1328,7 +1479,10 @@ impl OakApp { self.cancel_export(cx); } } - modal_ids::PREFERENCES => self.close_modal(cx), + modal_ids::PREFERENCES => { + self.commit_preferences(cx); + self.close_modal(cx); + } modal_ids::MANAGER => self.close_modal(cx), modal_ids::MANAGER_RENAME => { if *button == 0 { @@ -1356,6 +1510,10 @@ impl OakApp { modal_ids::MANAGER_RENAME | modal_ids::MANAGER_DELETE => { self.back_to_manager(cx); } + modal_ids::PREFERENCES => { + self.commit_preferences(cx); + self.close_modal(cx); + } _ => self.close_modal(cx), }, } @@ -1363,11 +1521,18 @@ impl OakApp { } impl Render for OakApp { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let mut root = div() .size_full() .flex() .flex_col() + .track_focus(&self.shell_focus) + // The shell's keyboard shortcut layer: keys not consumed by a + // focused widget bubble up here and dispatch through the + // shortcut table (see `crate::shortcuts`). + .on_key_down(cx.listener(|this, event: &gpui::KeyDownEvent, _window, cx| { + this.on_shortcut(&event.keystroke, cx); + })) .child(self.menu_bar.clone()) .child(div().flex_1().min_h_0().child(self.dock.clone())) .child(self.status_bar.clone()); @@ -1431,10 +1596,11 @@ fn make_menus(dark: bool) -> Vec { MenuItem::new(CUT, tr("menu.edit.cut")).with_shortcut("⌘X"), MenuItem::new(COPY, tr("menu.edit.copy")).with_shortcut("⌘C"), MenuItem::new(PASTE, tr("menu.edit.paste")).with_shortcut("⌘V"), - MenuItem::new(DELETE, tr("menu.edit.delete")) - .with_shortcut("⌫") + MenuItem::new(DELETE, tr("menu.edit.delete")).with_shortcut("⌫"), + MenuItem::new(RIPPLE_DELETE, tr("menu.edit.ripple_delete")) + .with_shortcut("⇧⌫") .separated(), - MenuItem::new(RIPPLE_DELETE, tr("menu.edit.ripple_delete")), + MenuItem::new(SELECT_ALL, tr("menu.edit.select_all")).with_shortcut("A"), ]), ), MenuBarEntry::new( @@ -1442,18 +1608,28 @@ fn make_menus(dark: bool) -> Vec { Menu::new(vec![ MenuItem::new(THEME_DARK, tr("menu.view.theme")).with_submenu(theme_submenu), MenuItem::new(LANG_ZH, tr("menu.view.language")).with_submenu(language_submenu), - MenuItem::new(PREFERENCES, tr("menu.view.preferences")).separated(), + MenuItem::new(ZOOM_IN, tr("menu.view.zoom_in")) + .with_shortcut("+") + .separated(), + MenuItem::new(ZOOM_OUT, tr("menu.view.zoom_out")).with_shortcut("-"), + MenuItem::new(PREFERENCES, tr("menu.view.preferences")) + .with_shortcut("⌘,") + .separated(), ]), ), MenuBarEntry::new( tr("menu.playback"), Menu::new(vec![ MenuItem::new(PLAY_PAUSE, tr("menu.playback.play_pause")).with_shortcut("空格"), + MenuItem::new(PLAY, tr("menu.playback.play")).with_shortcut("L"), + MenuItem::new(PAUSE, tr("menu.playback.pause")).with_shortcut("K"), MenuItem::new(PREV_FRAME, tr("menu.playback.prev_frame")).with_shortcut("←"), MenuItem::new(NEXT_FRAME, tr("menu.playback.next_frame")) .with_shortcut("→") .separated(), MenuItem::new(TO_START, tr("menu.playback.to_start")).with_shortcut("Home"), + MenuItem::new(SET_IN_POINT, tr("menu.playback.set_in_point")).with_shortcut("I"), + MenuItem::new(SET_OUT_POINT, tr("menu.playback.set_out_point")).with_shortcut("O"), ]), ), MenuBarEntry::new( @@ -1462,7 +1638,8 @@ fn make_menus(dark: bool) -> Vec { MenuItem::new(ADD_VIDEO_TRACK, tr("menu.sequence.add_video_track")), MenuItem::new(ADD_AUDIO_TRACK, tr("menu.sequence.add_audio_track")), MenuItem::new(REMOVE_TRACK, tr("menu.sequence.remove_track")).separated(), - MenuItem::new(SPLIT_AT_PLAYHEAD, tr("menu.sequence.split_at_playhead")), + MenuItem::new(SPLIT_AT_PLAYHEAD, tr("menu.sequence.split_at_playhead")) + .with_shortcut("S"), MenuItem::new(ADD_MARKER, tr("menu.sequence.add_marker")).with_shortcut("M"), MenuItem::new(REMOVE_MARKER, tr("menu.sequence.remove_marker")).separated(), MenuItem::new(SET_WORKAREA, tr("menu.sequence.set_workarea")), @@ -1561,6 +1738,10 @@ pub fn run() { fn run_with(args: AppArgs) { let initial = args.project.clone(); gpui_platform::application().run(move |cx: &mut App| { + // Load the persisted preferences (config.ini) before anything reads + // them — the language, the theme, the storage backend and the audio + // devices all come from the config store. + crate::oakui::real::config_load(); // Restore the persisted UI language (config `Language` key) before the // first window renders. crate::i18n::init(); @@ -1568,6 +1749,9 @@ fn run_with(args: AppArgs) { // default location) unless the user configured the backend // explicitly. crate::oakui::real::configure_storage(); + // Bring up the audio manager and apply the persisted device choices + // (without an instance, playback pushes fail silently). + crate::oakui::real::audio_init_from_config(); cx.init_colors(); let bounds = Bounds::centered(None, size(px(1600.0), px(900.0)), cx); let initial = initial.clone(); @@ -1607,9 +1791,10 @@ fn run_with(args: AppArgs) { cx.activate(true); cx.on_window_closed(|cx, _| { if cx.windows().is_empty() { - // Exit path (plan M13 §2): drain the write-through backlog - // (save + snapshot of every still-bound project) and stop - // the facade's snapshot thread before quitting. + // Persist the preferences (config.ini), then drain the + // write-through backlog (save + snapshot of every still-bound + // project) and stop the facade's snapshot thread. + crate::oakui::real::config_save(); crate::oakui::real::storage_flush(); cx.quit(); } @@ -1622,6 +1807,7 @@ fn run_with(args: AppArgs) { mod tests { use super::*; use crate::oakui::EngineGateway as _; + use gpui::timeline::TimelineDataSource as _; use gpui::{px, size, ExternalPaths, FileDropEvent, TestAppContext, VisualTestContext}; /// The 视图/View menu carries a 语言/Language submenu whose items are @@ -1801,6 +1987,190 @@ mod tests { ); } + // ------------------------------------------------------------------- + // Keyboard shortcuts (M12 P5c) + // ------------------------------------------------------------------- + + /// Every shortcut-table action exists as a menu item (recursing into + /// submenus), so a key press can never dispatch a dead action. + #[test] + fn every_shortcut_maps_to_a_menu_item() { + let _guard = crate::i18n::lang_test_lock().lock().unwrap(); + crate::i18n::set_language(crate::i18n::Language::EnUs); + + fn collect(menu: &Menu, out: &mut Vec) { + for item in &menu.items { + out.push(item.id); + if let Some(sub) = &item.submenu { + collect(sub, out); + } + } + } + let mut ids = Vec::new(); + for entry in make_menus(true) { + collect(&entry.menu, &mut ids); + } + for shortcut in crate::shortcuts::SHORTCUTS { + assert!( + ids.contains(&shortcut.action), + "shortcut {} → action {} has no menu item", + shortcut.keystroke, + shortcut.action + ); + } + } + + /// The menu's shortcut labels mirror the shortcut table's display + /// strings (a drift between them would show the user the wrong key). + #[test] + fn menu_shortcut_labels_match_the_table() { + let _guard = crate::i18n::lang_test_lock().lock().unwrap(); + crate::i18n::set_language(crate::i18n::Language::EnUs); + + fn walk(menu: &Menu) -> Vec<(usize, Option)> { + let mut out = Vec::new(); + for item in &menu.items { + out.push((item.id, item.shortcut.clone())); + if let Some(sub) = &item.submenu { + out.extend(walk(sub)); + } + } + out + } + for entry in make_menus(true) { + for (id, label) in walk(&entry.menu) { + let Some(label) = label else { continue }; + let expected = crate::shortcuts::display_for(id) + .unwrap_or_else(|| panic!("menu item {id} shows {label} but has no shortcut")); + assert_eq!( + label.as_ref(), + expected, + "shortcut label drift on menu item {id}" + ); + } + } + } + + /// Pressing space on the shell toggles program playback (the keystroke + /// bubbles to the shell's key listener and dispatches 回放 → 播放/暂停). + #[gpui::test] + async fn space_toggles_program_playback(cx: &mut TestAppContext) { + let _guard = crate::i18n::lang_test_lock().lock().unwrap(); + let (window, root) = mock_shell(cx); + + let playing = cx.read(|app| root.read(app).program_clock.read(app).is_playing()); + assert!(!playing, "the shell starts paused"); + + cx.dispatch_keystroke( + window.into(), + gpui::Keystroke::parse("space").unwrap(), + ); + cx.run_until_parked(); + let playing = cx.read(|app| root.read(app).program_clock.read(app).is_playing()); + assert!(playing, "space starts program playback"); + + cx.dispatch_keystroke( + window.into(), + gpui::Keystroke::parse("space").unwrap(), + ); + cx.run_until_parked(); + let playing = cx.read(|app| root.read(app).program_clock.read(app).is_playing()); + assert!(!playing, "space again pauses program playback"); + } + + /// ⌘Z on the shell dispatches 编辑 → 撤销 to the engine, and "s" splits + /// at the playhead. + #[gpui::test] + async fn edit_shortcuts_dispatch_to_the_engine(cx: &mut TestAppContext) { + let _guard = crate::i18n::lang_test_lock().lock().unwrap(); + let (window, root) = mock_shell(cx); + + // Move the playhead inside the first clip (the → shortcut steps one + // frame), then split with "s": the mock sequence grows by one clip. + for _ in 0..10 { + cx.dispatch_keystroke(window.into(), gpui::Keystroke::parse("right").unwrap()); + } + cx.run_until_parked(); + let playhead = cx.read(|app| root.read(app).program_clock.read(app).current_frame()); + assert_eq!(playhead, Frame(10), "→ steps the playhead"); + + let clips_before: usize = cx.read(|app| { + let engine = root.read(app).engine.read(app); + (0..engine.track_count()) + .filter_map(|i| engine.track(i)) + .map(|t| t.clips().len()) + .sum() + }); + cx.dispatch_keystroke(window.into(), gpui::Keystroke::parse("s").unwrap()); + cx.run_until_parked(); + let clips_after: usize = cx.read(|app| { + let engine = root.read(app).engine.read(app); + (0..engine.track_count()) + .filter_map(|i| engine.track(i)) + .map(|t| t.clips().len()) + .sum() + }); + assert!( + clips_after > clips_before, + "split at the playhead adds a clip ({clips_before} → {clips_after})" + ); + + // ⌘Z / ⌘⇧Z reach the engine's undo/redo (the mock counts the calls). + cx.dispatch_keystroke( + window.into(), + gpui::Keystroke::parse("secondary-z").unwrap(), + ); + cx.dispatch_keystroke( + window.into(), + gpui::Keystroke::parse("secondary-shift-z").unwrap(), + ); + cx.run_until_parked(); + let (undo, redo) = cx.read(|app| root.read(app).engine.read(app).undo_redo_calls()); + assert_eq!((undo, redo), (1, 1), "⌘Z/⌘⇧Z dispatch undo/redo"); + } + + /// While a modal dialog is open the shell's shortcuts are inert (the + /// dialog's text fields must never trigger editing actions). + #[gpui::test] + async fn shortcuts_are_suppressed_while_a_modal_is_open(cx: &mut TestAppContext) { + let _guard = crate::i18n::lang_test_lock().lock().unwrap(); + let (window, root) = mock_shell(cx); + + cx.update(|app| root.update(app, |app, cx| app.on_menu(menu_ids::PREFERENCES, cx))); + cx.run_until_parked(); + + // "s" would split at the playhead without the modal guard. + let clips_before: usize = cx.read(|app| { + let engine = root.read(app).engine.read(app); + (0..engine.track_count()) + .filter_map(|i| engine.track(i)) + .map(|t| t.clips().len()) + .sum() + }); + cx.dispatch_keystroke(window.into(), gpui::Keystroke::parse("s").unwrap()); + cx.dispatch_keystroke( + window.into(), + gpui::Keystroke::parse("space").unwrap(), + ); + cx.run_until_parked(); + + let (clips_after, playing) = cx.read(|app| { + let root = root.read(app); + let clips: usize = (0..root.engine.read(app).track_count()) + .filter_map(|i| root.engine.read(app).track(i)) + .map(|t| t.clips().len()) + .sum(); + (clips, root.program_clock.read(app).is_playing()) + }); + assert_eq!(clips_after, clips_before, "no split while the modal is open"); + assert!(!playing, "no playback toggle while the modal is open"); + assert!( + cx.read(|app| matches!(root.read(app).modal, ModalState::Preferences { .. })), + "the modal is still open" + ); + } + + /// 文件 → 导入素材… opens the *platform* path picker (not the in-window /// file dialog) and routes the picked path to the engine's import; the /// mock engine records it, so the async round trip is observable. diff --git a/src/dialogs.rs b/src/dialogs.rs index 1dbfa1d41..b05bbf899 100644 --- a/src/dialogs.rs +++ b/src/dialogs.rs @@ -14,44 +14,98 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -//! The content views of the app's modal dialogs: preferences (renderer -//! backend + language) and export (format + output path). +//! The content views of the app's modal dialogs: preferences (the settings +//! panel mirroring the C++ tabbed preferences: general / rendering / cache +//! / proxy / project / audio) and export (format + output path). //! //! Each view owns its widgets and emits nothing itself — the host //! (`crate::app::OakApp`) reads the state (format / path) when a dialog //! button is clicked, and the preferences view writes its choices straight -//! through the config C ABI on selection. +//! through the config C ABI on selection. Theme/language changes +//! additionally emit a [`PreferencesEvent`] so the host can re-apply the +//! shell chrome immediately. use gpui::colors::DefaultColors; use gpui::prelude::*; -use gpui::{div, App, Context, Entity, Render, SharedString, Window}; +use gpui::{div, px, App, Context, Entity, Render, SharedString, Window}; use gpui_elements::editable_text::{text_input, EditableTextState, StringStorage}; +use gpui_widgets::checkbox::{CheckBox, CheckBoxEvent, CheckState}; use gpui_widgets::combo_box::{ComboBox, ComboBoxEvent, ComboBoxOption}; +use gpui_widgets::slider::SliderModel; +use gpui_widgets::spinbox::{SpinBox, SpinBoxEvent}; +use gpui_widgets::value::{SliderValue, ValueKind}; use crate::i18n; use crate::oakui::real::{ - config_get_string, config_set_string, encoding_formats, renderer_backends, - CONFIG_KEY_RENDERER_BACKEND, EXPORT_FORMAT_MP4, + audio_input_device, audio_input_devices, audio_output_device, audio_output_devices, + config_get_bool, config_get_int, config_get_string, config_set_bool, config_set_int, + config_set_string, encoding_formats, proxy_dividers, renderer_backends, + set_audio_input_device, set_audio_output_device, set_theme_dark, theme_is_dark, + CONFIG_KEY_DEFAULT_TRANSITION_SEC, CONFIG_KEY_DISK_CACHE_PATH, CONFIG_KEY_PROXY_DIVIDER, + CONFIG_KEY_RENDERER_BACKEND, CONFIG_KEY_SNAPSHOT_INTERVAL_SEC, CONFIG_KEY_USE_PROXY, + DEFAULT_SNAPSHOT_INTERVAL_SEC, DEFAULT_TRANSITION_SEC, EXPORT_FORMAT_MP4, }; // --------------------------------------------------------------------------- // Preferences // --------------------------------------------------------------------------- -/// The preferences dialog content: the renderer backend and the language -/// dropdowns. Both write through the config C ABI on selection, so the -/// choices survive restarts. +/// A request the preferences dialog emits for the host shell (the settings +/// themselves are written through the config C ABI directly; these need +/// shell chrome — the menu bar / theme — to re-render). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PreferencesEvent { + /// The theme dropdown changed (the payload is the new dark flag). + ThemeChanged(bool), + /// The language dropdown changed (already applied to the i18n global). + LanguageChanged, +} + +impl gpui::EventEmitter for PreferencesContent {} + +/// The preferences dialog content, mirroring the C++ tabbed preferences as +/// one grouped panel: +/// +/// * **常规 General** — language, theme. +/// * **渲染 Rendering** — the renderer backend. +/// * **缓存 Cache** — the disk cache directory (`DiskCachePath`). +/// * **代理 Proxy** — use proxy media (`UseProxyMedia`), proxy resolution +/// divider (`ProxyDivider`). +/// * **项目 Project** — the snapshot interval (`Storage/SnapshotIntervalSec`, +/// the write-through era's auto-save interval) and the default transition +/// length (`DefaultTransitionLength`). +/// * **音频 Audio** — the output / input devices (`AudioOutput` / +/// `AudioInput`, applied live through the audio facade). +/// +/// Every row writes through the config C ABI on selection, so the choices +/// survive restarts (the app loads the config at startup and saves it on +/// exit). pub struct PreferencesContent { backend: Entity, language: Entity, + theme: Entity, + cache_dir: Entity, + use_proxy: Entity, + proxy_divider: Entity, + snapshot_interval: Entity, + transition_length: Entity, + audio_output: Entity, + audio_input: Entity, /// The backend options, in display order. backends: Vec<&'static str>, + /// The proxy divider options, in display order (1 = full resolution). + dividers: Vec, + /// The output device names (dropdown order; index 0 is system default). + output_devices: Vec, + /// The input device names (dropdown order; index 0 is system default). + input_devices: Vec, } impl PreferencesContent { - /// Builds the content: reads the current config values and seeds the - /// dropdowns. + /// Builds the content: reads the current config values and seeds every + /// widget. pub fn new(window: &mut Window, cx: &mut Context) -> Self { + // --- 渲染 Rendering: the renderer backend -------------------------- let backends = renderer_backends(); let current_backend = config_get_string(CONFIG_KEY_RENDERER_BACKEND); let backend_selected = backends @@ -81,6 +135,7 @@ impl PreferencesContent { combo.set_selected(Some(backend_selected), cx) }); + // --- 常规 General: language + theme -------------------------------- let language_options = vec![ ComboBoxOption::new(0, "English (en-US)"), ComboBoxOption::new(1, "简体中文 (zh-CN)"), @@ -100,20 +155,272 @@ impl PreferencesContent { _ => crate::i18n::Language::EnUs, }; crate::i18n::set_language(language); + cx.emit(PreferencesEvent::LanguageChanged); } - let _ = cx; }) .detach(); language.update(cx, |combo, cx| { combo.set_selected(Some(language_selected), cx) }); + let theme_options = vec![ + ComboBoxOption::new(0, i18n::tr("preferences.theme.dark")), + ComboBoxOption::new(1, i18n::tr("preferences.theme.light")), + ]; + let theme = cx.new(|cx| ComboBox::new(3, theme_options, window, cx)); + cx.subscribe(&theme, |_this, _combo, event: &ComboBoxEvent, cx| { + if let ComboBoxEvent::Selected { value, .. } = event { + let dark = *value == 0; + set_theme_dark(dark); + cx.emit(PreferencesEvent::ThemeChanged(dark)); + } + }) + .detach(); + theme.update(cx, |combo, cx| { + combo.set_selected(Some(if theme_is_dark() { 0 } else { 1 }), cx) + }); + + // --- 缓存 Cache: the disk cache directory -------------------------- + let cache_dir = cx.new(|cx| { + let editor = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx)); + PathField { editor } + }); + let configured_cache = config_get_string(CONFIG_KEY_DISK_CACHE_PATH); + cache_dir.update(cx, |field, cx| field.set_path(configured_cache, cx)); + + // --- 代理 Proxy ----------------------------------------------------- + let use_proxy = cx.new(|cx| { + CheckBox::new( + 7, + if config_get_bool(CONFIG_KEY_USE_PROXY, true) { + CheckState::Checked + } else { + CheckState::Unchecked + }, + window, + cx, + ) + .with_label(i18n::tr("preferences.proxy.enable")) + }); + cx.subscribe(&use_proxy, |_this, check, event: &CheckBoxEvent, cx| { + if let CheckBoxEvent::Toggled { state, .. } = event { + let enabled = *state == CheckState::Checked; + config_set_bool(CONFIG_KEY_USE_PROXY, enabled); + check.update(cx, |check, cx| check.set_state(*state, cx)); + } + }) + .detach(); + + let dividers = proxy_dividers(); + let divider_options = dividers + .iter() + .enumerate() + .map(|(i, d)| { + if *d <= 1 { + ComboBoxOption::new(i, i18n::tr("preferences.proxy.full")) + } else { + ComboBoxOption::new(i, format!("1/{d}")) + } + }) + .collect(); + let proxy_divider = cx.new(|cx| ComboBox::new(4, divider_options, window, cx)); + cx.subscribe(&proxy_divider, |this, _combo, event: &ComboBoxEvent, cx| { + if let ComboBoxEvent::Selected { value, .. } = event { + if let Some(divider) = this.dividers.get(*value) { + config_set_int(CONFIG_KEY_PROXY_DIVIDER, *divider); + } + } + let _ = cx; + }) + .detach(); + let current_divider = config_get_int(CONFIG_KEY_PROXY_DIVIDER, 1); + let divider_selected = dividers + .iter() + .position(|d| *d == current_divider) + .unwrap_or(0); + proxy_divider.update(cx, |combo, cx| { + combo.set_selected(Some(divider_selected), cx) + }); + + // --- 项目 Project: snapshot interval + default transition ---------- + let snapshot_interval = cx.new(|cx| { + let current = + config_get_int(CONFIG_KEY_SNAPSHOT_INTERVAL_SEC, DEFAULT_SNAPSHOT_INTERVAL_SEC); + SpinBox::new( + 8, + SliderModel::new(ValueKind::Integer, 0.0, 86400.0, 10.0, current as f64), + window, + cx, + ) + }); + cx.subscribe( + &snapshot_interval, + |_this, _spin, event: &SpinBoxEvent, cx| { + let value = match event { + SpinBoxEvent::ValueChanged { value, .. } + | SpinBoxEvent::EditCommitted { value, .. } => value.to_f64() as i64, + _ => return, + }; + config_set_int(CONFIG_KEY_SNAPSHOT_INTERVAL_SEC, value); + let _ = cx; + }, + ) + .detach(); + + let transition_length = cx.new(|cx| { + let current = config_get_string(CONFIG_KEY_DEFAULT_TRANSITION_SEC) + .parse::() + .ok() + .filter(|v| *v >= 0.0) + .unwrap_or_else(|| DEFAULT_TRANSITION_SEC.parse().unwrap()); + SpinBox::new( + 9, + SliderModel::new(ValueKind::Float, 0.0, 60.0, 0.5, current), + window, + cx, + ) + }); + cx.subscribe( + &transition_length, + |_this, _spin, event: &SpinBoxEvent, cx| { + let value = match event { + SpinBoxEvent::ValueChanged { value, .. } + | SpinBoxEvent::EditCommitted { value, .. } => value.to_f64(), + _ => return, + }; + config_set_string(CONFIG_KEY_DEFAULT_TRANSITION_SEC, &format!("{value}")); + let _ = cx; + }, + ) + .detach(); + + // --- 音频 Audio: output / input devices ----------------------------- + // The enumeration goes through the facade even on the mock engine; + // the config choice applies the moment the device dropdown changes. + let (audio_output, output_devices) = + device_combo(5, true, window, cx); + let (audio_input, input_devices) = + device_combo(6, false, window, cx); + cx.subscribe(&audio_output, |this, _combo, event: &ComboBoxEvent, cx| { + if let ComboBoxEvent::Selected { value, .. } = event { + // Option 0 is the system default; the devices start at 1. + let name = value + .checked_sub(1) + .and_then(|i| this.output_devices.get(i)) + .cloned() + .unwrap_or_default(); + set_audio_output_device(&name); + } + let _ = cx; + }) + .detach(); + cx.subscribe(&audio_input, |this, _combo, event: &ComboBoxEvent, cx| { + if let ComboBoxEvent::Selected { value, .. } = event { + let name = value + .checked_sub(1) + .and_then(|i| this.input_devices.get(i)) + .cloned() + .unwrap_or_default(); + set_audio_input_device(&name); + } + let _ = cx; + }) + .detach(); + Self { backend, language, + theme, + cache_dir, + use_proxy, + proxy_divider, + snapshot_interval, + transition_length, + audio_output, + audio_input, backends, + dividers, + output_devices, + input_devices, } } + + /// The cache directory currently entered. + pub fn cache_dir(&self, cx: &App) -> SharedString { + self.cache_dir.read(cx).path(cx) + } + + /// Commits the cache directory field to the config (called by the host + /// when the dialog closes, so a typed-but-unbrowsed path still lands). + pub fn commit_cache_dir(&self, cx: &App) { + let path = self.cache_dir(cx).trim().to_string(); + config_set_string(CONFIG_KEY_DISK_CACHE_PATH, &path); + } + + /// Opens the platform directory picker and lands the choice in the cache + /// directory field (committed with the field, on dialog close). + fn browse_cache_dir(&mut self, cx: &mut Context) { + let receiver = cx.prompt_for_paths(gpui::PathPromptOptions { + files: false, + directories: true, + multiple: false, + prompt: Some(i18n::tr("preferences.cache.browse").into()), + }); + cx.spawn(async move |this, cx| { + let Ok(Ok(Some(paths))) = receiver.await else { + return; + }; + let Some(path) = paths.first() else { + return; + }; + this.update(cx, |this, cx| { + this.cache_dir.update(cx, |field, cx| { + field.set_path(path.to_string_lossy().into_owned(), cx) + }); + cx.notify(); + }); + }) + .detach(); + } +} + +/// Builds a device dropdown for the output (`output = true`) or input side: +/// option 0 is the system default, the rest are the enumerated devices, and +/// the current config value (validated against the enumeration) is +/// preselected. Returns the combo and the option→device-name list. +fn device_combo( + control: usize, + output: bool, + window: &mut Window, + cx: &mut Context, +) -> (Entity, Vec) { + let devices = if output { + audio_output_devices() + } else { + audio_input_devices() + }; + let current = if output { + audio_output_device() + } else { + audio_input_device() + }; + let mut options = vec![ComboBoxOption::new(0, i18n::tr("preferences.audio.default"))]; + for (i, name) in devices.iter().enumerate() { + options.push(ComboBoxOption::new(i + 1, name.clone())); + } + let selected = devices + .iter() + .position(|n| *n == current) + .map(|i| i + 1) + .unwrap_or(0); + let placeholder = if output { + i18n::tr("preferences.audio.output.placeholder") + } else { + i18n::tr("preferences.audio.input.placeholder") + }; + let combo = cx.new(|cx| ComboBox::new(control, options, window, cx).with_placeholder(placeholder)); + combo.update(cx, |combo, cx| combo.set_selected(Some(selected), cx)); + (combo, devices) } /// A display label for a renderer backend id. @@ -142,23 +449,104 @@ fn form_row( .child(widget) } +/// A group header separating the preference sections. +fn section_header(colors: &gpui::colors::Colors, label: SharedString) -> gpui::Div { + div() + .pt_2() + .text_color(colors.disabled) + .text_xs() + .child(label) +} + impl Render for PreferencesContent { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let colors = cx.default_colors().clone(); + // The full settings list is taller than a 900px window at the design + // density, so the content scrolls inside the modal card. div() + .id("preferences-content") .flex() .flex_col() .gap_3() .w_full() + .max_h(px(720.0)) + .overflow_y_scroll() + // 常规 General + .child(section_header(&colors, i18n::tr("preferences.section.general").into())) + .child(form_row( + &colors, + i18n::tr("preferences.language").into(), + self.language.clone(), + )) + .child(form_row( + &colors, + i18n::tr("preferences.theme").into(), + self.theme.clone(), + )) + // 渲染 Rendering + .child(section_header(&colors, i18n::tr("preferences.section.render").into())) .child(form_row( &colors, i18n::tr("preferences.backend").into(), self.backend.clone(), )) + // 缓存 Cache + .child(section_header(&colors, i18n::tr("preferences.section.cache").into())) .child(form_row( &colors, - i18n::tr("preferences.language").into(), - self.language.clone(), + i18n::tr("preferences.cache.dir").into(), + div() + .flex() + .gap_2() + .child(div().flex_1().child(self.cache_dir.clone())) + .child( + div() + .id("preferences-cache-browse") + .px_3() + .py_1() + .rounded_md() + .bg(colors.background) + .border_1() + .border_color(colors.border) + .text_color(colors.text) + .cursor_pointer() + .child(i18n::tr("preferences.cache.browse")) + .on_click(cx.listener(|this, _event, _window, cx| { + this.browse_cache_dir(cx); + })), + ), + )) + // 代理 Proxy + .child(section_header(&colors, i18n::tr("preferences.section.proxy").into())) + .child(self.use_proxy.clone()) + .child(form_row( + &colors, + i18n::tr("preferences.proxy.resolution").into(), + self.proxy_divider.clone(), + )) + // 项目 Project + .child(section_header(&colors, i18n::tr("preferences.section.project").into())) + .child(form_row( + &colors, + i18n::tr("preferences.snapshot.interval").into(), + self.snapshot_interval.clone(), + )) + .child(form_row( + &colors, + i18n::tr("preferences.transition.default").into(), + self.transition_length.clone(), + )) + // 音频 Audio + .child(section_header(&colors, i18n::tr("preferences.section.audio").into())) + .child(form_row( + &colors, + i18n::tr("preferences.audio.output").into(), + self.audio_output.clone(), + )) + .child(form_row( + &colors, + i18n::tr("preferences.audio.input").into(), + self.audio_input.clone(), )) .child( div() @@ -169,6 +557,7 @@ impl Render for PreferencesContent { } } + // --------------------------------------------------------------------------- // Export // --------------------------------------------------------------------------- diff --git a/src/i18n.rs b/src/i18n.rs index b61713282..accb24a2d 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -196,6 +196,7 @@ const EN: &[(&str, &str)] = &[ ("menu.edit.paste", "Paste"), ("menu.edit.delete", "Delete"), ("menu.edit.ripple_delete", "Ripple Delete"), + ("menu.edit.select_all", "Select All"), // --- View --- ("menu.view.theme", "Theme"), ("menu.view.theme.dark", "Olive Dark"), @@ -203,12 +204,18 @@ const EN: &[(&str, &str)] = &[ ("menu.view.language", "Language"), ("menu.view.language.en", "English"), ("menu.view.language.zh", "简体中文"), + ("menu.view.zoom_in", "Zoom In"), + ("menu.view.zoom_out", "Zoom Out"), ("menu.view.preferences", "Preferences…"), // --- Playback --- ("menu.playback.play_pause", "Play/Pause"), + ("menu.playback.play", "Play"), + ("menu.playback.pause", "Pause"), ("menu.playback.prev_frame", "Previous Frame"), ("menu.playback.next_frame", "Next Frame"), ("menu.playback.to_start", "Jump to Sequence Start"), + ("menu.playback.set_in_point", "Set In Point"), + ("menu.playback.set_out_point", "Set Out Point"), // --- Sequence --- ("menu.sequence.add_video_track", "Add Video Track"), ("menu.sequence.add_audio_track", "Add Audio Track"), @@ -328,11 +335,32 @@ const EN: &[(&str, &str)] = &[ ("file.open.title", "Open Project"), ("file.import_footage.title", "Import Footage"), ("preferences.title", "Preferences"), + ("preferences.section.general", "General"), + ("preferences.section.render", "Rendering"), + ("preferences.section.cache", "Cache"), + ("preferences.section.proxy", "Proxy"), + ("preferences.section.project", "Project"), + ("preferences.section.audio", "Audio"), ("preferences.backend", "Renderer backend"), ("preferences.backend.placeholder", "Select a backend…"), ("preferences.language", "Language"), ("preferences.language.placeholder", "Select a language…"), - ("preferences.hint", "The renderer backend applies to the render worker at the next launch; the language switches immediately."), + ("preferences.theme", "Theme"), + ("preferences.theme.dark", "Olive Dark"), + ("preferences.theme.light", "Olive Light"), + ("preferences.cache.dir", "Disk cache directory"), + ("preferences.cache.browse", "Browse…"), + ("preferences.proxy.enable", "Use proxy media"), + ("preferences.proxy.resolution", "Proxy resolution"), + ("preferences.proxy.full", "Full resolution"), + ("preferences.snapshot.interval", "Auto-save (snapshot) interval, seconds"), + ("preferences.transition.default", "Default transition length, seconds"), + ("preferences.audio.output", "Audio output device"), + ("preferences.audio.output.placeholder", "Select an output device…"), + ("preferences.audio.input", "Audio input device"), + ("preferences.audio.input.placeholder", "Select an input device…"), + ("preferences.audio.default", "System Default"), + ("preferences.hint", "The renderer backend applies to the render worker at the next launch; every other setting takes effect immediately and is saved on exit."), ("export.title", "Export Sequence"), ("export.format", "Format"), ("export.format.placeholder", "Select a format…"), @@ -372,6 +400,7 @@ const ZH: &[(&str, &str)] = &[ ("menu.edit.paste", "粘贴"), ("menu.edit.delete", "删除"), ("menu.edit.ripple_delete", "波纹删除"), + ("menu.edit.select_all", "全选"), // --- View --- ("menu.view.theme", "主题"), ("menu.view.theme.dark", "Olive Dark"), @@ -379,12 +408,18 @@ const ZH: &[(&str, &str)] = &[ ("menu.view.language", "语言"), ("menu.view.language.en", "English"), ("menu.view.language.zh", "简体中文"), + ("menu.view.zoom_in", "放大"), + ("menu.view.zoom_out", "缩小"), ("menu.view.preferences", "偏好设置…"), // --- Playback --- ("menu.playback.play_pause", "播放/暂停"), + ("menu.playback.play", "播放"), + ("menu.playback.pause", "暂停"), ("menu.playback.prev_frame", "上一帧"), ("menu.playback.next_frame", "下一帧"), ("menu.playback.to_start", "跳到序列起点"), + ("menu.playback.set_in_point", "设置入点"), + ("menu.playback.set_out_point", "设置出点"), // --- Sequence --- ("menu.sequence.add_video_track", "添加视频轨道"), ("menu.sequence.add_audio_track", "添加音频轨道"), @@ -507,13 +542,34 @@ const ZH: &[(&str, &str)] = &[ ("file.open.title", "打开项目"), ("file.import_footage.title", "导入素材"), ("preferences.title", "偏好设置"), + ("preferences.section.general", "常规"), + ("preferences.section.render", "渲染"), + ("preferences.section.cache", "缓存"), + ("preferences.section.proxy", "代理"), + ("preferences.section.project", "项目"), + ("preferences.section.audio", "音频"), ("preferences.backend", "渲染后端"), ("preferences.backend.placeholder", "选择一个后端…"), ("preferences.language", "语言"), ("preferences.language.placeholder", "选择语言…"), + ("preferences.theme", "主题"), + ("preferences.theme.dark", "Olive Dark"), + ("preferences.theme.light", "Olive Light"), + ("preferences.cache.dir", "磁盘缓存目录"), + ("preferences.cache.browse", "浏览…"), + ("preferences.proxy.enable", "使用代理媒体"), + ("preferences.proxy.resolution", "代理分辨率"), + ("preferences.proxy.full", "原始分辨率"), + ("preferences.snapshot.interval", "自动保存(快照)间隔(秒)"), + ("preferences.transition.default", "默认过渡时长(秒)"), + ("preferences.audio.output", "音频输出设备"), + ("preferences.audio.output.placeholder", "选择输出设备…"), + ("preferences.audio.input", "音频输入设备"), + ("preferences.audio.input.placeholder", "选择输入设备…"), + ("preferences.audio.default", "系统默认"), ( "preferences.hint", - "渲染后端在下次启动渲染工作进程时生效;语言立即切换。", + "渲染后端在下次启动渲染工作进程时生效;其余设置立即生效,并在退出时保存。", ), ("export.title", "导出序列"), ("export.format", "格式"), diff --git a/src/lib.rs b/src/lib.rs index 6396c7a54..fd752aeef 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -31,6 +31,7 @@ //! * [`app`] — the window shell: menu bar, dock layout, status bar, modal //! dialogs (file open/save-as, preferences, export), tick loop. //! * [`dialogs`] — the preferences and export dialog content views. +//! * [`shortcuts`] — the keyboard shortcut table (keystroke → menu action). //! * [`manager`] — the project manager window (M13 D4): the library browser //! with new / open / rename / duplicate / delete / import / export. //! * [`panels`] — the dockable panels (viewers, timeline, inspector, ...). @@ -56,6 +57,7 @@ pub mod i18n; pub mod manager; pub mod oakui; pub mod panels; +pub mod shortcuts; /// The application entry point (called from `main.rs`). pub fn run() { diff --git a/src/oakui/ffi.rs b/src/oakui/ffi.rs index 9855e458c..d2589d6fd 100644 --- a/src/oakui/ffi.rs +++ b/src/oakui/ffi.rs @@ -290,6 +290,17 @@ unsafe extern "C" { ) -> c_int; /// `oakengine_config_set_string` — write a string value. pub fn oakengine_config_set_string(key: *const c_char, value: *const c_char) -> c_int; + /// `oakengine_config_get_int` — read an integer value (fallback when the + /// key is missing or not convertible). + pub fn oakengine_config_get_int(key: *const c_char, default_value: i64) -> i64; + /// `oakengine_config_set_int` — write an integer value. + pub fn oakengine_config_set_int(key: *const c_char, value: i64) -> c_int; + /// `oakengine_config_load` — load the configuration from disk (the app + /// calls it once at startup, before reading any preference). + pub fn oakengine_config_load() -> c_int; + /// `oakengine_config_save` — persist the configuration to disk (the app + /// calls it on exit). + pub fn oakengine_config_save() -> c_int; // -- oakengine::storage (write-through session state) -- @@ -1011,6 +1022,39 @@ unsafe extern "C" { /// buffered output into `peaks` (up to `capacity` entries); returns /// the channel count (0 = nothing buffered), negative on error. pub fn oakengine_audio_output_levels(peaks: *mut f32, capacity: c_int) -> c_int; + /// `oakengine_audio_create_instance` — create the AudioManager singleton + /// (no-op when it exists). The app calls it once at startup so playback + /// can open an output stream. + pub fn oakengine_audio_create_instance() -> c_int; + /// `oakengine_audio_get_output_device` — the output device index + /// (-1 = none/default). + pub fn oakengine_audio_get_output_device() -> i64; + /// `oakengine_audio_set_output_device` — set the output device index; + /// the stream reopens on the next pushed samples. + pub fn oakengine_audio_set_output_device(device: i64) -> c_int; + /// `oakengine_audio_get_input_device` — the input device index. + pub fn oakengine_audio_get_input_device() -> i64; + /// `oakengine_audio_set_input_device` — set the input device index. + pub fn oakengine_audio_set_input_device(device: i64) -> c_int; + /// `oakengine_audio_output_device_count` — the host's output device + /// count (enumeration order == device index). + pub fn oakengine_audio_output_device_count() -> c_int; + /// `oakengine_audio_output_device_name` — the name of output device + /// `index` (buf/size; the length excludes the NUL). + pub fn oakengine_audio_output_device_name( + index: c_int, + buf: *mut c_char, + buf_size: c_int, + ) -> c_int; + /// `oakengine_audio_input_device_count` — the host's input device count. + pub fn oakengine_audio_input_device_count() -> c_int; + /// `oakengine_audio_input_device_name` — the name of input device + /// `index` (buf/size). + pub fn oakengine_audio_input_device_name( + index: c_int, + buf: *mut c_char, + buf_size: c_int, + ) -> c_int; // -- oakengine::render (manager lifecycle) -- diff --git a/src/oakui/mock.rs b/src/oakui/mock.rs index 261e2f365..a25793fe2 100644 --- a/src/oakui/mock.rs +++ b/src/oakui/mock.rs @@ -495,6 +495,11 @@ pub struct MockEngine { /// The enabled work area (render/export in/out range) of the demo /// sequence, in sequence frames (M12 P4). `None` = disabled. workarea: Option<(Frame, Frame)>, + /// Undo/redo call counts (test observability; the mock keeps no undo + /// stack, so the counters are the only way to see the dispatch landed). + undo_calls: u64, + /// See [`MockEngine::undo_calls`]. + redo_calls: u64, } impl MockEngine { @@ -744,6 +749,8 @@ impl MockEngine { library_exported: Vec::new(), markers: Vec::new(), workarea: None, + undo_calls: 0, + redo_calls: 0, }; // The demo graph is born connected: derive every port's `connected` // flag from the edge list. @@ -1312,11 +1319,13 @@ impl AppEngine for MockEngine { fn undo(&mut self, cx: &mut Context) { println!("[mock engine] undo: no undo stack in mock mode"); + self.undo_calls += 1; cx.notify(); } fn redo(&mut self, cx: &mut Context) { println!("[mock engine] redo: no undo stack in mock mode"); + self.redo_calls += 1; cx.notify(); } @@ -1650,6 +1659,11 @@ impl MockEngine { &self.imported_footage } + /// The undo/redo call counts (test observability; see the fields). + pub fn undo_redo_calls(&self) -> (u64, u64) { + (self.undo_calls, self.redo_calls) + } + /// The uuids opened via [`AppEngine::library_open_project`] so far /// (mock state; drives app-level tests of the manager's open flow). pub fn library_opened(&self) -> &[String] { diff --git a/src/oakui/real.rs b/src/oakui/real.rs index b3004e3c6..21bcf5ad6 100644 --- a/src/oakui/real.rs +++ b/src/oakui/real.rs @@ -36,8 +36,11 @@ //! * **Export** — the oaktask export task, driven on a background thread, //! with progress events and cancel wired to the module task's event //! callback and cancel atom. -//! * **Config** — renderer backend + language keys round-trip through -//! `oakengine_config_*`. +//! * **Config** — the preferences (renderer backend, language, theme, +//! cache dir, proxy policy, snapshot interval, default transition, +//! audio devices) round-trip through `oakengine_config_*`; the audio +//! device selection additionally applies live through +//! `oakengine_audio_*_device`. //! //! # What is still mock/stub //! @@ -49,8 +52,16 @@ //! ([`RealEngine::render_program_frame`]). Actual media *decode* is //! still a module gap (the oakrender eval's footage hook is deferred), //! so both viewers show the pipeline's generated frame, not the file's -//! pixels; the full-resolution async render worker (the facade's worker -//! module) is a separate process surface not bound yet. +//! pixels. +//! * **Full-resolution rendering is in-process (M12 P5a):** the proxy +//! frame (a 480px long edge) is rendered synchronously for immediate +//! display; when the playhead rests, a background thread renders the +//! same frame at the sequence's native size through its own dedicated +//! facade renderer and the cache swaps it in when it lands (see +//! [`RealEngine::schedule_full_res`]). The facade's worker *process* +//! module (`oakengine_worker_*`, NDJSON control plane) remains unbound: +//! its `load_graph`/`render_frame` are documented stubs, so there is no +//! render-capable process transport to bind. //! * Effect stack — the selected clip's effect chain is bound: the stack //! reads the chain through the facade (see //! [`EffectStackDataSource`](EffectStackDataSource) for `RealEngine`) @@ -262,6 +273,156 @@ enum RendererSlot { Unavailable, } +// --------------------------------------------------------------------------- +// Full-resolution frame cache (M12 P5a) +// --------------------------------------------------------------------------- +// +// Each monitor displays the small proxy frame immediately and lets a +// background thread fill the same frame at the sequence's native size; +// when the fill lands it replaces the proxy in the display path. The cache +// below is the UI-thread-owned state of that schedule: the two cached +// frames plus the one in-flight background job per monitor. + +/// The proxy frame cached for one monitor: the image plus the scope samples +/// analyzed in the same render pass (a paused viewer never regenerates +/// either). +struct ProxyEntry { + /// The playhead frame that produced the frame. + frame: i64, + /// The viewer image. + image: Arc, + /// The scope samples of the same render. + scope: ScopeData, +} + +/// A full-resolution frame the background worker filled in. No scope data: +/// the scopes keep reading the proxy pass (same content, lower resolution), +/// so a full-res fill costs no extra analysis and the scopes tab behaves +/// exactly as before. +struct FullResEntry { + /// The playhead frame the frame was rendered for. + frame: i64, + /// The viewer image at the sequence's native size. + image: Arc, +} + +/// One monitor's display cache: the proxy frame plus the full-resolution +/// fill, and the identity of the in-flight background job. +#[derive(Default)] +struct MonitorFrameCache { + /// The last proxy render (immediate display path). + proxy: Option, + /// The last full-resolution fill (replaces the proxy when its frame + /// matches the playhead). + full: Option, + /// The in-flight background job's `(frame, generation)`, or None. Only + /// one job runs per monitor; the drain re-schedules when the playhead + /// moves while a job is in flight. + pending: Option<(i64, u64)>, +} + +impl MonitorFrameCache { + /// The image to display for `frame`: the full-resolution fill when the + /// worker has landed it, else the proxy frame, else None. + fn image_for(&self, frame: i64) -> Option<&Arc> { + if let Some(full) = &self.full { + if full.frame == frame { + return Some(&full.image); + } + } + if let Some(proxy) = &self.proxy { + if proxy.frame == frame { + return Some(&proxy.image); + } + } + None + } + + /// The scope samples matching [`MonitorFrameCache::image_for`] (always + /// the proxy pass; see [`FullResEntry`]). + fn scope_for(&self, frame: i64) -> Option<&ScopeData> { + let proxy = self.proxy.as_ref()?; + (proxy.frame == frame).then_some(&proxy.scope) + } + + /// Whether a background full-resolution render should be started for + /// `frame`: the playhead is not moving (`playing`), the frame is not + /// already cached full-res, and no job is in flight for this monitor. + fn needs_full_res(&self, frame: i64, playing: bool) -> bool { + // Proxy stays primary while playing (a full-res render would fight + // the moving playhead); a cached fill or an in-flight job mean no + // new job (the drain re-schedules once the job lands). + !playing + && self.pending.is_none() + && !self.full.as_ref().is_some_and(|f| f.frame == frame) + } + + /// Installs a completed full-res frame. Returns false — and keeps the + /// cache untouched — when the completion is stale (the pending job it + /// belongs to no longer matches, i.e. an edit, a selection change or a + /// project drop happened while it was in flight). + fn install_full_res( + &mut self, + frame: i64, + generation: u64, + image: Arc, + ) -> bool { + if self.pending != Some((frame, generation)) { + return false; + } + self.pending = None; + self.full = Some(FullResEntry { frame, image }); + true + } +} + +/// What a background full-res job renders: the program monitor's sequence +/// or the source monitor's selected footage node. The boxed handle is +/// owned by the job and freed by the worker thread. +#[derive(Clone, Copy)] +enum FullResTarget { + /// An addref'd sequence box (released with [`free_box`], last, after + /// the renderer so the sequence outlives the renderer's borrowed view). + Sequence(SendPtr), + /// A boxed footage node (freed with `oakengine_node_free` once the + /// renderer has resolved its footage spec). + Node(SendPtr), +} + +/// One background full-resolution render request (built on the UI thread +/// at schedule time; the worker thread owns it from there). +struct FullResRequest { + /// The monitor the frame belongs to. + monitor: Monitor, + /// The playhead frame to render. + frame: i64, + /// The engine's full-res generation when the job was scheduled (stale + /// completions are discarded by the drain). + generation: u64, + /// The sequence or footage node to render. + target: FullResTarget, + /// Output width (the sequence's native size). + width: c_int, + /// Output height. + height: c_int, + /// Frame-rate numerator. + rate_num: c_int, + /// Frame-rate denominator. + rate_den: c_int, +} + +/// A completed full-res frame, delivered through the completion channel. +struct FullResEvent { + /// The monitor the frame belongs to. + monitor: Monitor, + /// The playhead frame that was rendered. + frame: i64, + /// The job's generation (see [`FullResRequest`]). + generation: u64, + /// The rendered viewer image. + image: Arc, +} + // --------------------------------------------------------------------------- // FFI helpers // --------------------------------------------------------------------------- @@ -576,13 +737,25 @@ pub struct RealEngine { /// Phase counter driving the (silent) audio levels. meter_phase: u32, /// Cache of the CPU frames handed to the viewers, keyed by monitor. - /// Entries are the playhead frame that produced the image plus the scope - /// samples analyzed in the same pass, so a paused viewer never - /// regenerates its picture (or its scopes). Both monitors hold real - /// rendered frames (see [`RealEngine::render_program_frame`] / + /// Each monitor holds its proxy frame (rendered synchronously on the UI + /// thread, with the scope samples analyzed in the same pass) plus the + /// full-resolution fill the background worker lands when the playhead + /// rests (see [`MonitorFrameCache`]). Both monitors hold real rendered + /// frames (see [`RealEngine::render_program_frame`] / /// [`RealEngine::render_source_frame`]); the synthetic pattern is only /// the failure fallback. - cpu_frame_cache: Mutex, ScopeData)>>, + cpu_frame_cache: Mutex>, + /// Bumped whenever the rendered content can change underneath an + /// in-flight background full-res job (an edit, a selection change or a + /// project drop); completions tagged with a stale generation are + /// discarded by the drain. + full_res_generation: u64, + /// The channel background full-res jobs report finished frames through; + /// drained on the app tick. The mutex keeps the engine `Sync` (the + /// channel is only ever touched on the UI thread). + full_res_rx: Mutex>, + /// The sending half of `full_res_rx` (cloned into every job). + full_res_tx: Mutex>, /// The program monitor's cached renderer, created lazily from the /// current sequence at a proxy resolution. The mutex both provides the /// interior mutability `cpu_frame` (a `&self` read) needs and serializes @@ -649,6 +822,7 @@ impl RealEngine { /// Builds an engine with no project open. pub fn new(cx: &mut Context) -> Self { let rate = VideoFormat::hd_1080p25().rate; + let (full_res_tx, full_res_rx) = mpsc::channel::(); Self { project: None, sequence: None, @@ -667,6 +841,9 @@ impl RealEngine { program_playing: false, meter_phase: 0, cpu_frame_cache: Mutex::new(HashMap::new()), + full_res_generation: 0, + full_res_rx: Mutex::new(full_res_rx), + full_res_tx: Mutex::new(full_res_tx), renderer: Mutex::new(RendererSlot::Untried), source_renderer: Mutex::new(RendererSlot::Untried), } @@ -724,9 +901,9 @@ impl RealEngine { /// The proxy resolution the viewer renderer runs at: the sequence's /// aspect scaled to a small long edge. Rendering is a synchronous call /// made from `cpu_frame` (a `&self` read on the UI thread), so the - /// geometry stays tiny to keep the block short; the async render worker - /// (full-resolution, off-thread) is a separate transport surface not - /// bound yet. + /// geometry stays tiny to keep the block short; the full-resolution + /// frame is rendered off-thread at the sequence's native size by the + /// background job (M12 P5a, see [`RealEngine::schedule_full_res`]). fn proxy_render_size(&self) -> Option<(c_int, c_int)> { let info = self.sequence_info.as_ref()?; let (w, h) = (info.format.width.max(1), info.format.height.max(1)); @@ -979,6 +1156,226 @@ impl RealEngine { image } + /// Repacks one F32 RGBA facade frame (rows padded to linesize) into + /// tightly packed samples. Returns `(width, height, samples)` when the + /// frame is well-formed (positive geometry, the pipeline's F32 format, + /// non-null data). + fn read_f32_frame(frame_ptr: *mut OakEngineFrame) -> Option<(u32, u32, Vec)> { + // SAFETY: `frame_ptr` is a live facade frame box. + let (width, height, linesize, format) = unsafe { + ( + oakengine_frame_width(frame_ptr), + oakengine_frame_height(frame_ptr), + oakengine_frame_linesize_bytes(frame_ptr), + oakengine_frame_format(frame_ptr), + ) + }; + let data = unsafe { oakengine_frame_data(frame_ptr) }; + if width <= 0 || height <= 0 || format != PIXEL_FORMAT_F32 || data.is_null() { + return None; + } + let row_bytes = (width * 4 * 4) as usize; + let linesize = (linesize as usize).max(row_bytes); + let mut samples = vec![0.0f32; (width * height * 4) as usize]; + for y in 0..height as usize { + // SAFETY: the facade frame holds `height` rows of at least + // `linesize` bytes; `samples` holds tightly packed rows. + unsafe { + std::ptr::copy_nonoverlapping( + (data as *const u8).add(y * linesize), + samples.as_mut_ptr().add(y * row_bytes / 4) as *mut u8, + row_bytes, + ); + } + } + Some((width as u32, height as u32, samples)) + } + + /// An addref'd copy of the sequence handle, boxed for the background + /// worker. The copy keeps the sequence alive even when the project is + /// dropped while a full-res job is in flight; the worker frees it last + /// (after the renderer, whose view of the sequence is borrowed). + fn sequence_copy(&self) -> Option<*mut OakEngineSequence> { + let seq = self.seq_ptr()?; + // SAFETY: `seq` is the engine's live sequence box. + let handle = unsafe { unbox(seq) }?; + let addref = handle.addref?; + // SAFETY: `handle` is a live module handle; addref takes a new + // reference the copy releases. + unsafe { addref(handle.ctx) }; + Some(unsafe { box_handle::(handle) }) + } + + /// Builds the background full-res job for `monitor` at `frame` (the + /// program monitor's sequence via an addref'd copy, the source + /// monitor's selected footage node) at the sequence's native size. + /// Returns None when there is nothing to render (no sequence open, no + /// footage selected). + fn build_full_res_request(&self, monitor: Monitor, frame: i64) -> Option { + let info = self.sequence_info.as_ref()?; + let rate = info.format.rate; + let width = info.format.width.max(1) as c_int; + let height = info.format.height.max(1) as c_int; + let target = match monitor { + Monitor::Program => FullResTarget::Sequence(SendPtr(self.sequence_copy()?)), + Monitor::Source => FullResTarget::Node(SendPtr(self.selected_footage_node()?)), + }; + Some(FullResRequest { + monitor, + frame, + generation: self.full_res_generation, + target, + width, + height, + rate_num: rate.num as c_int, + rate_den: rate.den as c_int, + }) + } + + /// Runs one background full-resolution render (the worker thread of the + /// full-res path). The dedicated full-size renderer is created, used + /// and freed on this thread only, so it never aliases the UI thread's + /// proxy renderer; the target box is freed here too (the sequence copy + /// last, keeping the sequence alive for the renderer's borrowed view). + /// Reports the finished frame through `tx`. + fn full_res_worker(request: FullResRequest, tx: mpsc::Sender) { + let FullResRequest { + monitor, + frame, + generation, + target, + width, + height, + rate_num, + rate_den, + } = request; + if !Self::ensure_render_manager() { + Self::release_full_res_target(target); + return; + } + let renderer = unsafe { + match target { + FullResTarget::Sequence(seq) => { + // The sequence copy stays alive until after the + // renderer is freed below (the renderer's view is + // borrowed), so the box must not be freed here. + oakengine_renderer_create( + seq.0, + width, + height, + PIXEL_FORMAT_F32, + rate_num, + rate_den, + std::ptr::null(), + ) + } + FullResTarget::Node(node) => { + // SAFETY: the renderer resolves its own footage spec at + // creation; the node box is no longer needed after it. + let renderer = oakengine_renderer_create_for_node( + node.0, + width, + height, + PIXEL_FORMAT_F32, + rate_num, + rate_den, + std::ptr::null(), + ); + oakengine_node_free(node.0); + renderer + } + } + }; + if renderer.is_null() { + // SAFETY: the sequence copy is still owned by us (a node was + // freed right after creation above). + Self::release_full_res_target(target); + return; + } + // SAFETY: `renderer` is the live box created above. + let frame_ptr = unsafe { oakengine_renderer_render_frame(renderer, frame) }; + let mut event = None; + if !frame_ptr.is_null() { + if let Some((width, height, samples)) = Self::read_f32_frame(frame_ptr) { + let image = Arc::new(f32_rgba_to_bgra_image(width, height, &samples)); + event = Some(FullResEvent { + monitor, + frame, + generation, + image, + }); + } + // SAFETY: `frame_ptr` is a live frame box from render_frame. + unsafe { oakengine_frame_free(frame_ptr) }; + } + // SAFETY: `renderer` is a live renderer box. + unsafe { oakengine_renderer_free(renderer) }; + // SAFETY: the sequence copy outlived the renderer (its borrowed + // view was dropped above). + Self::release_full_res_target(target); + if let Some(event) = event { + let _ = tx.send(event); + } + } + + /// Frees the box a full-res job owns: the sequence copy with + /// [`free_box`], the footage node with `oakengine_node_free`. + /// + /// # Safety + /// `target` must be a live box owned by the calling job. + fn release_full_res_target(target: FullResTarget) { + unsafe { + match target { + FullResTarget::Sequence(seq) => free_box(seq.0), + FullResTarget::Node(node) => oakengine_node_free(node.0), + } + } + } + + /// Schedules a background full-resolution render for `monitor`'s current + /// playhead when the policy says so: the playhead is resting, the frame + /// is not already cached full-res, and no job is in flight for this + /// monitor (M12 P5a). The job runs on its own thread with a dedicated + /// renderer, so the UI thread never blocks. + fn schedule_full_res(&mut self, monitor: Monitor, cx: &mut Context) { + let frame = self.clock_frame(monitor, cx).0; + if frame < 0 { + return; + } + let clock = self.clock(monitor).clone(); + let playing = clock.read(cx).transport.is_playing(); + let schedule = { + let cache = self.cpu_frame_cache.lock().unwrap(); + cache + .get(&monitor) + .is_none_or(|entry| entry.needs_full_res(frame, playing)) + }; + if !schedule { + return; + } + let Some(request) = self.build_full_res_request(monitor, frame) else { + return; + }; + self.cpu_frame_cache.lock().unwrap().entry(monitor).or_default().pending = + Some((frame, request.generation)); + let tx = self.full_res_tx.lock().unwrap().clone(); + std::thread::spawn(move || Self::full_res_worker(request, tx)); + } + + /// Installs completed full-res frames into the cache, discarding stale + /// completions (a job that outlived an edit, a selection change or a + /// project drop — its generation no longer matches the pending job). + fn drain_full_res(&mut self) { + let mut cache = self.cpu_frame_cache.lock().unwrap(); + let rx = self.full_res_rx.lock().unwrap(); + while let Ok(event) = rx.try_recv() { + cache + .entry(event.monitor) + .or_default() + .install_full_res(event.frame, event.generation, event.image); + } + } + /// Adopts a newly created/loaded facade project, freeing any previous /// one, and rebuilds every snapshot. `blank` projects get a default /// sequence; loaded ones use the first sequence. @@ -1027,6 +1424,10 @@ impl RealEngine { drop(self.sequence.take()); drop(self.project.take()); self.cpu_frame_cache.lock().unwrap().clear(); + // The sequence an in-flight full-res job may still be rendering is + // gone (the job holds its own addref'd copy, so it stays valid, but + // its frame belongs to the dropped project): mark it stale. + self.full_res_generation = self.full_res_generation.wrapping_add(1); self.tracks.clear(); self.sequence_info = None; @@ -1406,8 +1807,10 @@ impl RealEngine { } self.refresh_sequence_info(); self.rebuild_timeline(); - // The sequence content changed: cached rendered frames are stale. + // The sequence content changed: cached rendered frames are stale, + // and so are any in-flight full-res renders (M12 P5a). self.cpu_frame_cache.lock().unwrap().clear(); + self.full_res_generation = self.full_res_generation.wrapping_add(1); cx.notify(); } @@ -1510,6 +1913,12 @@ impl EngineGateway for RealEngine { if self.program_playing { self.pull_audio_tick(cx); } + // M12 P5a: install finished full-resolution frames and schedule + // the next fills for the resting playheads (the schedule skips + // playing monitors, so playback keeps the proxy path). + self.drain_full_res(); + self.schedule_full_res(Monitor::Source, cx); + self.schedule_full_res(Monitor::Program, cx); cx.notify(); } } @@ -1690,10 +2099,12 @@ impl AppEngine for RealEngine { fn cpu_frame(&self, monitor: Monitor, cx: &App) -> Arc { let frame = self.clock_frame(monitor, cx); let mut cache = self.cpu_frame_cache.lock().unwrap(); - if let Some((cached_frame, image, _)) = cache.get(&monitor) { - if *cached_frame == frame.0 { - return image.clone(); - } + // The full-resolution fill replaces the proxy when its frame matches + // the playhead; otherwise the proxy frame is displayed (rendered + // synchronously below on a cache miss, filled by the background + // worker once the playhead rests). + if let Some(image) = cache.entry(monitor).or_default().image_for(frame.0) { + return image.clone(); } // Both monitors render through the facade CPU renderer (falling // back to the synthetic pattern when rendering is unavailable): the @@ -1715,7 +2126,11 @@ impl AppEngine for RealEngine { ) } }; - cache.insert(monitor, (frame.0, image.clone(), scope)); + cache.entry(monitor).or_default().proxy = Some(ProxyEntry { + frame: frame.0, + image: image.clone(), + scope, + }); image } @@ -1723,10 +2138,12 @@ impl AppEngine for RealEngine { // Ensure the cache holds the current playhead frame (the analysis // runs inside that render pass, so this never re-walks a frame). let _ = self.cpu_frame(monitor, cx); + let frame = self.clock_frame(monitor, cx); let cache = self.cpu_frame_cache.lock().unwrap(); cache .get(&monitor) - .map(|(_, _, scope)| scope.clone()) + .and_then(|entry| entry.scope_for(frame.0)) + .cloned() .unwrap_or_default() } @@ -1781,9 +2198,11 @@ impl AppEngine for RealEngine { if changed { // The source monitor renders the selected footage node: a new // selection must rebind the renderer and drop the stale cached - // frame (the cache key only tracks the playhead frame). + // frame (the cache key only tracks the playhead frame), and any + // in-flight full-res job for the old selection is stale. *self.source_renderer.lock().unwrap() = RendererSlot::Untried; self.cpu_frame_cache.lock().unwrap().remove(&Monitor::Source); + self.full_res_generation = self.full_res_generation.wrapping_add(1); } cx.notify(); } @@ -2305,6 +2724,7 @@ impl AppEngine for RealEngine { self.refresh_sequence_info(); self.rebuild_timeline(); self.cpu_frame_cache.lock().unwrap().clear(); + self.full_res_generation = self.full_res_generation.wrapping_add(1); cx.notify(); } } @@ -2317,6 +2737,7 @@ impl AppEngine for RealEngine { self.refresh_sequence_info(); self.rebuild_timeline(); self.cpu_frame_cache.lock().unwrap().clear(); + self.full_res_generation = self.full_res_generation.wrapping_add(1); cx.notify(); } } @@ -2851,12 +3272,60 @@ pub fn encoding_formats() -> Vec<(c_int, String, String)> { pub const EXPORT_FORMAT_MP4: c_int = 2; // --------------------------------------------------------------------------- -// Config C ABI (renderer backend + language) +// Config C ABI (preferences) // --------------------------------------------------------------------------- /// The config key selecting the renderer backend (worker `create_renderer` /// backend id). pub const CONFIG_KEY_RENDERER_BACKEND: &str = "GraphicsBackend"; +/// The config key holding the UI theme (`"dark"` / `"light"`; the app +/// defaults to dark when the key is absent). +pub const CONFIG_KEY_THEME: &str = "Theme"; +/// The config key overriding the disk cache directory (empty = the +/// platform default `/mediacache`; honored by oakcommon's +/// `default_disk_cache_path`, so oakrender/oaknode caches follow it). +pub const CONFIG_KEY_DISK_CACHE_PATH: &str = "DiskCachePath"; +/// The config key toggling proxy media use (`UseProxyMedia`, bool). +pub const CONFIG_KEY_USE_PROXY: &str = "UseProxyMedia"; +/// The config key holding the proxy resolution divider (`ProxyDivider`, +/// int; 1 = full resolution, 2/4/8/16 = 1/2 … 1/16). oakcodec's +/// `ProxyManager::proxy_params_from_config` reads it for generation. +pub const CONFIG_KEY_PROXY_DIVIDER: &str = "ProxyDivider"; +/// The config key holding the project snapshot interval in seconds +/// (`Storage/SnapshotIntervalSec`; the write-through era's "auto-save +/// interval" — the facade's snapshot thread reads it every pass, default +/// 600, ≤ 0 snapshots every dirty save). +pub const CONFIG_KEY_SNAPSHOT_INTERVAL_SEC: &str = "Storage/SnapshotIntervalSec"; +/// The config key holding the default transition length in seconds +/// (`DefaultTransitionLength`, decimal string; consumed by the engine's +/// add-default-transition command — currently a facade stub). +pub const CONFIG_KEY_DEFAULT_TRANSITION_SEC: &str = "DefaultTransitionLength"; +/// The config key holding the audio output device NAME (empty = system +/// default; C++ parity `AudioOutput`). +pub const CONFIG_KEY_AUDIO_OUTPUT: &str = "AudioOutput"; +/// The config key holding the audio input device NAME (`AudioInput`). +pub const CONFIG_KEY_AUDIO_INPUT: &str = "AudioInput"; + +/// The default snapshot interval (seconds), mirroring the facade's +/// compiled-in default. +pub const DEFAULT_SNAPSHOT_INTERVAL_SEC: i64 = 600; +/// The default transition length (seconds). +pub const DEFAULT_TRANSITION_SEC: &str = "0.5"; + +/// Loads the persisted configuration from disk (once at startup, before +/// any preference is read). +pub fn config_load() { + unsafe { + oakengine_config_load(); + } +} + +/// Persists the configuration to disk (the app calls it on exit). +pub fn config_save() { + unsafe { + oakengine_config_save(); + } +} /// Reads a config string through the facade config C ABI (empty when /// missing). @@ -2877,12 +3346,166 @@ pub fn config_set_string(key: &str, value: &str) { } } +/// Reads a config integer through the facade config C ABI (`default` when +/// the key is missing or not an integer). +pub fn config_get_int(key: &str, default: i64) -> i64 { + let Ok(key_c) = CString::new(key) else { + return default; + }; + unsafe { oakengine_config_get_int(key_c.as_ptr(), default) } +} + +/// Writes a config integer through the facade config C ABI. +pub fn config_set_int(key: &str, value: i64) { + let Ok(key_c) = CString::new(key) else { + return; + }; + unsafe { + oakengine_config_set_int(key_c.as_ptr(), value); + } +} + +/// Reads a config boolean through the string accessor (the store parses +/// `"true"`/`"false"` for registered bool keys). +pub fn config_get_bool(key: &str, default: bool) -> bool { + match config_get_string(key).as_str() { + "true" => true, + "false" => false, + _ => default, + } +} + +/// Writes a config boolean (see [`config_get_bool`]). +pub fn config_set_bool(key: &str, value: bool) { + config_set_string(key, if value { "true" } else { "false" }); +} + /// The renderer backends offered in the preferences dialog, in display /// order. The first entry is the built-in default. pub fn renderer_backends() -> Vec<&'static str> { vec!["opengl", "metal", "vulkan", "none"] } +/// The proxy resolution dividers offered in the preferences dialog, in +/// display order (1 = full resolution). +pub fn proxy_dividers() -> Vec { + vec![1, 2, 4, 8, 16] +} + +/// Whether the persisted theme is dark (the default). +pub fn theme_is_dark() -> bool { + config_get_string(CONFIG_KEY_THEME) != "light" +} + +/// Persists the theme choice (applied live by the caller). +pub fn set_theme_dark(dark: bool) { + config_set_string(CONFIG_KEY_THEME, if dark { "dark" } else { "light" }); +} + +// --------------------------------------------------------------------------- +// Audio devices (preferences + startup wiring) +// --------------------------------------------------------------------------- + +/// The host's audio output device names in enumeration order (the index is +/// what `oakengine_audio_set_output_device` takes). +pub fn audio_output_devices() -> Vec { + let count = unsafe { oakengine_audio_output_device_count() }; + let mut out = Vec::new(); + for i in 0..count.max(0) { + out.push(read_string(|buf, size| unsafe { + oakengine_audio_output_device_name(i, buf, size) + })); + } + out +} + +/// The host's audio input device names (see [`audio_output_devices`]). +pub fn audio_input_devices() -> Vec { + let count = unsafe { oakengine_audio_input_device_count() }; + let mut out = Vec::new(); + for i in 0..count.max(0) { + out.push(read_string(|buf, size| unsafe { + oakengine_audio_input_device_name(i, buf, size) + })); + } + out +} + +/// Selects the output device by NAME (empty = system default), persists the +/// choice to `AudioOutput`, and applies it live: the output stream reopens +/// on the device with the next pushed samples. +pub fn set_audio_output_device(name: &str) { + config_set_string(CONFIG_KEY_AUDIO_OUTPUT, name); + let index = if name.is_empty() { + -1 + } else { + audio_output_devices() + .iter() + .position(|n| n == name) + .map(|i| i as i64) + .unwrap_or(-1) + }; + unsafe { + oakengine_audio_set_output_device(index); + } +} + +/// Selects the input device by NAME (empty = system default) and persists +/// the choice to `AudioInput` (used by recording). +pub fn set_audio_input_device(name: &str) { + config_set_string(CONFIG_KEY_AUDIO_INPUT, name); + let index = if name.is_empty() { + -1 + } else { + audio_input_devices() + .iter() + .position(|n| n == name) + .map(|i| i as i64) + .unwrap_or(-1) + }; + unsafe { + oakengine_audio_set_input_device(index); + } +} + +/// The configured output device name, validated against the live +/// enumeration (empty when the configured device is gone). +pub fn audio_output_device() -> String { + let name = config_get_string(CONFIG_KEY_AUDIO_OUTPUT); + if name.is_empty() || audio_output_devices().iter().any(|n| *n == name) { + name + } else { + String::new() + } +} + +/// The configured input device name (see [`audio_output_device`]). +pub fn audio_input_device() -> String { + let name = config_get_string(CONFIG_KEY_AUDIO_INPUT); + if name.is_empty() || audio_input_devices().iter().any(|n| *n == name) { + name + } else { + String::new() + } +} + +/// Brings up the AudioManager singleton and applies the persisted device +/// choices. Called once at startup; without an instance the facade's +/// `push_to_output` fails silently and playback stays video-only. +pub fn audio_init_from_config() { + unsafe { + oakengine_audio_create_instance(); + } + let output = config_get_string(CONFIG_KEY_AUDIO_OUTPUT); + if !output.is_empty() { + set_audio_output_device(&output); + } + let input = config_get_string(CONFIG_KEY_AUDIO_INPUT); + if !input.is_empty() { + set_audio_input_device(&input); + } +} + // --------------------------------------------------------------------------- // Project library (M13 D4: the write-through database the manager browses) // --------------------------------------------------------------------------- @@ -3001,6 +3624,8 @@ impl RealEngine { #[cfg(test)] mod tests { use super::*; + use std::sync::mpsc; + use std::time::Duration; /// Serializes the media/FFmpeg-heavy tests: the engine dylib's static /// FFmpeg is not thread-safe against concurrent decode sessions. @@ -3048,6 +3673,115 @@ mod tests { ); } + /// Serializes the config round-trip tests: the facade config is a + /// process-global store, so tests mutating the same keys must not run + /// concurrently. + fn config_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + LOCK.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// Restores `key`'s original value when dropped, so a round-trip test + /// never leaks a preference into the rest of the test process. + struct ConfigRestore(&'static str, String); + + impl ConfigRestore { + fn of(key: &'static str) -> Self { + ConfigRestore(key, config_get_string(key)) + } + } + + impl Drop for ConfigRestore { + fn drop(&mut self) { + config_set_string(self.0, &self.1); + } + } + + /// Every preferences-dialog key round-trips through the facade config + /// C ABI: the value written is the value read back. + #[test] + fn preferences_keys_round_trip_through_the_config() { + let _guard = config_lock(); + + // String-valued keys (theme, cache dir, transition seconds, audio + // device names, renderer backend). + let _theme = ConfigRestore::of(CONFIG_KEY_THEME); + config_set_string(CONFIG_KEY_THEME, "light"); + assert_eq!(config_get_string(CONFIG_KEY_THEME), "light"); + assert!(!theme_is_dark()); + config_set_string(CONFIG_KEY_THEME, "dark"); + assert!(theme_is_dark()); + + let _cache = ConfigRestore::of(CONFIG_KEY_DISK_CACHE_PATH); + config_set_string(CONFIG_KEY_DISK_CACHE_PATH, "/tmp/oak-test-cache"); + assert_eq!( + config_get_string(CONFIG_KEY_DISK_CACHE_PATH), + "/tmp/oak-test-cache" + ); + + let _transition = ConfigRestore::of(CONFIG_KEY_DEFAULT_TRANSITION_SEC); + config_set_string(CONFIG_KEY_DEFAULT_TRANSITION_SEC, "1.5"); + assert_eq!( + config_get_string(CONFIG_KEY_DEFAULT_TRANSITION_SEC), + "1.5" + ); + + let _output = ConfigRestore::of(CONFIG_KEY_AUDIO_OUTPUT); + config_set_string(CONFIG_KEY_AUDIO_OUTPUT, "Test Speakers"); + assert_eq!(config_get_string(CONFIG_KEY_AUDIO_OUTPUT), "Test Speakers"); + + let _backend = ConfigRestore::of(CONFIG_KEY_RENDERER_BACKEND); + config_set_string(CONFIG_KEY_RENDERER_BACKEND, "metal"); + assert_eq!(config_get_string(CONFIG_KEY_RENDERER_BACKEND), "metal"); + + // The bool key parses through the string accessor (the store's + // registered bool entry accepts "true"/"false"). + let _proxy = ConfigRestore::of(CONFIG_KEY_USE_PROXY); + config_set_bool(CONFIG_KEY_USE_PROXY, false); + assert!(!config_get_bool(CONFIG_KEY_USE_PROXY, true)); + config_set_bool(CONFIG_KEY_USE_PROXY, true); + assert!(config_get_bool(CONFIG_KEY_USE_PROXY, false)); + + // The int keys round-trip through the int accessors (and read back + // as strings too — the store serializes typed values). + config_set_int(CONFIG_KEY_PROXY_DIVIDER, 4); + assert_eq!(config_get_int(CONFIG_KEY_PROXY_DIVIDER, 1), 4); + assert_eq!(config_get_string(CONFIG_KEY_PROXY_DIVIDER), "4"); + config_set_int(CONFIG_KEY_PROXY_DIVIDER, 1); + + config_set_int(CONFIG_KEY_SNAPSHOT_INTERVAL_SEC, 120); + assert_eq!(config_get_int(CONFIG_KEY_SNAPSHOT_INTERVAL_SEC, 600), 120); + config_set_int( + CONFIG_KEY_SNAPSHOT_INTERVAL_SEC, + DEFAULT_SNAPSHOT_INTERVAL_SEC, + ); + } + + /// The audio device enumeration crosses the facade without crashing; + /// the output and input lists are independent (either may be empty on a + /// headless box). + #[test] + fn audio_device_enumeration_is_stable() { + let outputs = audio_output_devices(); + let inputs = audio_input_devices(); + assert!(outputs.iter().all(|n| !n.is_empty())); + assert!(inputs.iter().all(|n| !n.is_empty())); + // An unknown device name validates to "system default" (empty). + let _guard = config_lock(); + let _output = ConfigRestore::of(CONFIG_KEY_AUDIO_OUTPUT); + config_set_string(CONFIG_KEY_AUDIO_OUTPUT, "No Such Device"); + assert_eq!(audio_output_device(), ""); + } + + /// The snapshot-interval key is the one the facade's snapshot thread + /// reads (`Storage/SnapshotIntervalSec`, see crates/oakengine/src/ + /// storage.rs) — a rename here would silently disconnect the dialog. + #[test] + fn snapshot_interval_key_matches_the_facade() { + assert_eq!(CONFIG_KEY_SNAPSHOT_INTERVAL_SEC, "Storage/SnapshotIntervalSec"); + } + + /// End-to-end through the facade: a project the engine itself writes /// (save → load round-trip) keeps its identity, and the in-memory /// sequence the app drives (created with `oakengine_sequence_new`) carries @@ -3459,4 +4193,273 @@ mod tests { unsafe { oakengine_project_free(project) }; let _ = std::fs::remove_file(&media); } + + // ----------------------------------------------------------------------- + // M12 P5a: the full-resolution fill — scheduling logic (pure) + // ----------------------------------------------------------------------- + + /// A tiny 2x2 viewer image for cache tests. + fn test_image() -> Arc { + let samples = [ + 1.0, 0.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, + ]; + Arc::new(f32_rgba_to_bgra_image(2, 2, &samples)) + } + + /// Scope samples matching the test image's pixel count. + fn test_scope() -> ScopeData { + ScopeData { + luma: Arc::new(vec![0.5; 4]), + chroma: Arc::new(vec![(0.5, 0.5); 4]), + } + } + + #[test] + fn full_res_fill_replaces_the_proxy_on_display() { + let mut cache = MonitorFrameCache::default(); + cache.proxy = Some(ProxyEntry { + frame: 5, + image: test_image(), + scope: test_scope(), + }); + // The proxy is displayed until the fill lands... + let proxy = cache.proxy.as_ref().unwrap().image.clone(); + assert!(Arc::ptr_eq(cache.image_for(5).unwrap(), &proxy)); + // ...and the fill wins once it does (the same playhead frame). + let fill = test_image(); + cache.pending = Some((5, 1)); + assert!(cache.install_full_res(5, 1, fill.clone())); + assert!(Arc::ptr_eq(cache.image_for(5).unwrap(), &fill)); + // The proxy stays cached (the scopes keep reading it). + assert!(cache.proxy.is_some()); + } + + #[test] + fn full_res_fill_does_not_cover_other_frames() { + let mut cache = MonitorFrameCache::default(); + cache.proxy = Some(ProxyEntry { + frame: 5, + image: test_image(), + scope: test_scope(), + }); + let fill = test_image(); + cache.full = Some(FullResEntry { + frame: 5, + image: fill.clone(), + }); + // The fill covers exactly its own frame. + assert!(Arc::ptr_eq(cache.image_for(5).unwrap(), &fill)); + assert!(cache.image_for(6).is_none(), "other frames are unrendered"); + // A later proxy render for another frame displays alongside the fill. + let proxy6 = test_image(); + cache.proxy = Some(ProxyEntry { + frame: 6, + image: proxy6.clone(), + scope: test_scope(), + }); + assert!(Arc::ptr_eq(cache.image_for(6).unwrap(), &proxy6)); + // The scopes always read the proxy pass (the fill carries none). + assert_eq!(cache.scope_for(6).unwrap().luma.len(), 4); + assert!(cache.scope_for(5).is_none()); + } + + #[test] + fn full_res_policy_skips_playing_cached_and_in_flight_frames() { + let cache = MonitorFrameCache::default(); + assert!(cache.needs_full_res(0, false), "resting playhead schedules"); + assert!( + !cache.needs_full_res(0, true), + "playback keeps the proxy path (smoothness)" + ); + + let mut cached = MonitorFrameCache::default(); + cached.full = Some(FullResEntry { + frame: 5, + image: test_image(), + }); + assert!(!cached.needs_full_res(5, false), "already filled"); + + let mut in_flight = MonitorFrameCache::default(); + in_flight.pending = Some((5, 1)); + assert!( + !in_flight.needs_full_res(5, false), + "one job in flight per monitor" + ); + assert!( + !in_flight.needs_full_res(7, false), + "the drain re-schedules the moved playhead when the job lands" + ); + } + + #[test] + fn full_res_install_accepts_only_the_pending_job() { + // A job that outlived an invalidation must be discarded: the cache + // entry was recreated with a fresh pending marker (new frame and + // generation), so the old completion does not install. + let mut stale = MonitorFrameCache::default(); + stale.pending = Some((7, 2)); + assert!( + !stale.install_full_res(5, 1, test_image()), + "stale generation is discarded" + ); + assert_eq!(stale.pending, Some((7, 2)), "the new job stays pending"); + assert!(stale.full.is_none(), "nothing is installed"); + + // The matching job installs, clears the pending marker and lets the + // moved playhead schedule again. + let mut current = MonitorFrameCache::default(); + current.pending = Some((5, 1)); + assert!(current.install_full_res(5, 1, test_image())); + assert!(current.pending.is_none()); + assert!(current.full.as_ref().is_some_and(|f| f.frame == 5)); + assert!(current.needs_full_res(7, false)); + } + + /// M12 P5a end-to-end through the facade: a full-res job (the exact + /// request [`RealEngine::build_full_res_request`] builds) renders a real + /// frame on a background thread — the dedicated sequence renderer is + /// created on that thread, the footage clip is decoded, and the frame is + /// delivered through the completion channel with the renderer and the + /// sequence copy freed by the worker. + #[test] + fn full_res_worker_renders_real_frame() { + let _media = media_lock(); + if !RealEngine::ensure_render_manager() { + panic!("the render manager failed to start"); + } + + let project = unsafe { oakengine_project_create() }; + assert!(!project.is_null()); + assert_eq!(unsafe { oakengine_project_new(project) }, 0); + let name = CString::new("Full Res E2E").unwrap(); + let sequence = unsafe { oakengine_sequence_new(project, name.as_ptr()) }; + assert!(!sequence.is_null()); + assert_eq!( + unsafe { oakengine_sequence_add_track(sequence, TRACK_TYPE_VIDEO) }, + 0 + ); + + let media = std::env::temp_dir().join(format!( + "oakapp_fullres_{}.mp4", + std::process::id() + )); + let media_c = CString::new(media.to_string_lossy().into_owned()).unwrap(); + assert_eq!( + unsafe { oakengine_testmedia_write_clip(media_c.as_ptr(), 64, 64, 10, 10) }, + 0 + ); + let footage = unsafe { oakengine_project_import_footage(project, media_c.as_ptr()) }; + assert!(!footage.is_null(), "import must succeed"); + let clip = unsafe { + oakengine_sequence_add_footage_clip_ex( + sequence, + footage, + TRACK_TYPE_VIDEO, + 0, + 0, + 10, + 0, + ) + }; + assert!(!clip.is_null(), "clip placement must succeed"); + unsafe { oakengine_footage_free(footage) }; + unsafe { free_box(clip) }; + + // The addref'd sequence copy the scheduler hands the worker. + let handle = unsafe { unbox(sequence) }.expect("sequence handle"); + let addref = handle.addref.expect("module handle addref"); + unsafe { addref(handle.ctx) }; + let copy = unsafe { box_handle::(handle) }; + + let (tx, rx) = mpsc::channel(); + let request = FullResRequest { + monitor: Monitor::Program, + frame: 0, + generation: 1, + target: FullResTarget::Sequence(SendPtr(copy)), + width: 320, + height: 180, + rate_num: 25, + rate_den: 1, + }; + std::thread::spawn(move || RealEngine::full_res_worker(request, tx)); + + let event = rx + .recv_timeout(Duration::from_secs(20)) + .expect("the worker delivers the full-res frame"); + assert_eq!(event.monitor, Monitor::Program); + assert_eq!(event.frame, 0); + assert_eq!(event.generation, 1); + let bytes = event.image.as_bytes(0).expect("one frame"); + assert_eq!(bytes.len(), 320 * 180 * 4, "full-res geometry"); + // The test clip's left half is red on frame 0: the decoded footage + // must be visible (non-black), like the proxy e2e test asserts. + let nonzero = bytes + .chunks(4) + .filter(|px| px[..3].iter().any(|&c| c != 0)) + .count(); + assert!(nonzero > 0, "the footage clip renders non-black pixels"); + + unsafe { free_box(sequence) }; + unsafe { oakengine_project_free(project) }; + let _ = std::fs::remove_file(&media); + } + + /// M12 P5a acceptance through the app seam: a `RealEngine` built exactly + /// as the app builds it renders the program frame at the proxy size on + /// demand, and the background full-res job fills the sequence's native + /// size once the playhead rests (the tick loop schedules, the worker + /// renders, the drain installs, `cpu_frame` hands the fill to the + /// viewer). + #[gpui::test] + async fn real_engine_fills_full_res_behind_the_proxy(cx: &mut gpui::TestAppContext) { + let _media = media_lock(); + let engine = cx.update(|cx| cx.new(|cx| RealEngine::create(cx))); + cx.update(|app| engine.update(app, |engine, cx| engine.new_project(cx))); + + // The sequence's native size is the fill geometry. + let (width, height) = cx.read(|app| { + let format = engine.read(app).current_sequence().unwrap().format; + (format.width, format.height) + }); + + // The immediate path is the proxy: the first read renders the small + // proxy synchronously (no fill has landed yet). + let proxy_len = cx.read(|app| { + engine + .read(app) + .cpu_frame(Monitor::Program, app) + .as_bytes(0) + .unwrap() + .len() + }); + assert!( + proxy_len < (width * height * 4) as usize, + "the immediate display is the proxy (got {proxy_len} bytes)" + ); + + // Drive the tick loop until the background fill lands and replaces + // the proxy in the display path. + let full_len = (width * height * 4) as usize; + let deadline = std::time::Instant::now() + Duration::from_secs(20); + loop { + cx.update(|app| engine.update(app, |engine, cx| engine.tick(cx))); + let len = cx.read(|app| { + engine + .read(app) + .cpu_frame(Monitor::Program, app) + .as_bytes(0) + .unwrap() + .len() + }); + if len == full_len { + break; + } + assert!( + std::time::Instant::now() < deadline, + "the full-res fill lands within the deadline (got {len} bytes)" + ); + std::thread::sleep(Duration::from_millis(10)); + } + } } diff --git a/src/shortcuts.rs b/src/shortcuts.rs new file mode 100644 index 000000000..6a09f0aab --- /dev/null +++ b/src/shortcuts.rs @@ -0,0 +1,198 @@ +// Oak Video Editor - Non-Linear Video Editor +// Copyright (C) 2026 Oak Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! The keyboard shortcut table (M12 P5c): a flat keystroke → menu-action +//! map, so a key press and a menu click dispatch the SAME action id through +//! `OakApp::on_menu` (the menu bar's `with_shortcut` labels mirror the +//! `display` strings here). +//! +//! The set follows the C++ Olive layout: space toggles playback, J/K/L form +//! the shuttle (J steps back — true reverse playback is an engine transport +//! gap), I/O set the work-area in/out points at the playhead, S splits, A +//! selects all, Delete / Shift-Delete delete (gap / ripple), and the +//! platform-modifier file/edit shortcuts use `secondary` (⌘ on macOS, Ctrl +//! elsewhere). +//! +//! The table is plain data (no gpui keymap contexts): `action_for` matches +//! a [`gpui::Keystroke`] against it, and the shell's root key listener does +//! the dispatch. While a modal dialog is open the shell skips the table +//! entirely, so the dialogs' text fields never trigger editing actions. + +use gpui::Keystroke; + +use crate::app::menu_ids; + +/// One shortcut entry: the gpui keystroke pattern (the +/// [`Keystroke::parse`] syntax), the menu action it dispatches, and the +/// label the menus show. +pub struct Shortcut { + /// The keystroke pattern, e.g. `"secondary-z"` or `"space"`. + pub keystroke: &'static str, + /// The dispatched menu action id (`crate::app::menu_ids`). + pub action: usize, + /// The menu label for the keystroke (display only). + pub display: &'static str, +} + +/// The shortcut table, in menu order. `secondary` is the platform command +/// modifier (⌘ on macOS, Ctrl on Windows/Linux). +pub const SHORTCUTS: &[Shortcut] = &[ + // --- File --- + Shortcut { keystroke: "secondary-n", action: menu_ids::NEW_PROJECT, display: "⌘N" }, + Shortcut { keystroke: "secondary-o", action: menu_ids::OPEN_PROJECT, display: "⌘O" }, + Shortcut { keystroke: "secondary-s", action: menu_ids::EXPORT_PROJECT, display: "⌘S" }, + Shortcut { keystroke: "secondary-e", action: menu_ids::EXPORT, display: "⌘E" }, + Shortcut { keystroke: "secondary-q", action: menu_ids::QUIT, display: "⌘Q" }, + // --- Edit --- + Shortcut { keystroke: "secondary-z", action: menu_ids::UNDO, display: "⌘Z" }, + Shortcut { keystroke: "secondary-shift-z", action: menu_ids::REDO, display: "⇧⌘Z" }, + Shortcut { keystroke: "secondary-x", action: menu_ids::CUT, display: "⌘X" }, + Shortcut { keystroke: "secondary-c", action: menu_ids::COPY, display: "⌘C" }, + Shortcut { keystroke: "secondary-v", action: menu_ids::PASTE, display: "⌘V" }, + Shortcut { keystroke: "backspace", action: menu_ids::DELETE, display: "⌫" }, + Shortcut { keystroke: "delete", action: menu_ids::DELETE, display: "⌫" }, + Shortcut { keystroke: "shift-backspace", action: menu_ids::RIPPLE_DELETE, display: "⇧⌫" }, + Shortcut { keystroke: "shift-delete", action: menu_ids::RIPPLE_DELETE, display: "⇧⌫" }, + Shortcut { keystroke: "a", action: menu_ids::SELECT_ALL, display: "A" }, + Shortcut { keystroke: "secondary-a", action: menu_ids::SELECT_ALL, display: "⌘A" }, + // --- View --- + // Zoom-in covers both the unshifted "=" key and the shifted "+" (gpui + // reports the base key with the shift modifier on most layouts; the + // bare "+" catches layouts that report the shifted character). + Shortcut { keystroke: "=", action: menu_ids::ZOOM_IN, display: "+" }, + Shortcut { keystroke: "shift-=", action: menu_ids::ZOOM_IN, display: "+" }, + Shortcut { keystroke: "+", action: menu_ids::ZOOM_IN, display: "+" }, + Shortcut { keystroke: "-", action: menu_ids::ZOOM_OUT, display: "-" }, + Shortcut { keystroke: "secondary-,", action: menu_ids::PREFERENCES, display: "⌘," }, + // --- Playback --- + Shortcut { keystroke: "space", action: menu_ids::PLAY_PAUSE, display: "空格" }, + Shortcut { keystroke: "left", action: menu_ids::PREV_FRAME, display: "←" }, + Shortcut { keystroke: "right", action: menu_ids::NEXT_FRAME, display: "→" }, + Shortcut { keystroke: "home", action: menu_ids::TO_START, display: "Home" }, + // The J/K/L shuttle: J steps back (true reverse playback is an engine + // transport gap), K pauses, L plays. + Shortcut { keystroke: "j", action: menu_ids::PREV_FRAME, display: "J" }, + Shortcut { keystroke: "k", action: menu_ids::PAUSE, display: "K" }, + Shortcut { keystroke: "l", action: menu_ids::PLAY, display: "L" }, + // --- Sequence --- + Shortcut { keystroke: "s", action: menu_ids::SPLIT_AT_PLAYHEAD, display: "S" }, + Shortcut { keystroke: "i", action: menu_ids::SET_IN_POINT, display: "I" }, + Shortcut { keystroke: "o", action: menu_ids::SET_OUT_POINT, display: "O" }, + Shortcut { keystroke: "m", action: menu_ids::ADD_MARKER, display: "M" }, +]; + +/// The parsed table (lazily built once; every pattern is a compile-time +/// constant, so a parse failure is a bug the tests below catch). +fn parsed() -> &'static Vec<(Keystroke, usize)> { + static TABLE: std::sync::OnceLock> = std::sync::OnceLock::new(); + TABLE.get_or_init(|| { + SHORTCUTS + .iter() + .map(|s| { + ( + Keystroke::parse(s.keystroke) + .unwrap_or_else(|_| panic!("invalid shortcut keystroke {:?}", s.keystroke)), + s.action, + ) + }) + .collect() + }) +} + +/// The menu action bound to `keystroke`, if any. Matching is exact on the +/// key and the full modifier set (a shortcut with no modifiers does not +/// fire when shift is held, so shifted typing never triggers edits). +pub fn action_for(keystroke: &Keystroke) -> Option { + parsed() + .iter() + .find(|(k, _)| k.key == keystroke.key && k.modifiers == keystroke.modifiers) + .map(|(_, action)| *action) +} + +/// The display label for an action's first shortcut (the menus' source). +pub fn display_for(action: usize) -> Option<&'static str> { + SHORTCUTS + .iter() + .find(|s| s.action == action) + .map(|s| s.display) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Every keystroke in the table parses (the table is static data, so a + /// typo would otherwise only surface as a dead shortcut at runtime). + #[test] + fn every_shortcut_keystroke_parses() { + for shortcut in SHORTCUTS { + assert!( + Keystroke::parse(shortcut.keystroke).is_ok(), + "invalid keystroke {:?}", + shortcut.keystroke + ); + } + } + + /// The main key bindings map to the documented actions (space playback, + /// J/K/L shuttle, I/O points, S split, A select-all, delete flavors, + /// undo/redo, the file shortcuts and the track zoom). + #[test] + fn main_keys_dispatch_their_actions() { + let action = |pattern: &str| action_for(&Keystroke::parse(pattern).unwrap()); + + assert_eq!(action("space"), Some(menu_ids::PLAY_PAUSE)); + assert_eq!(action("j"), Some(menu_ids::PREV_FRAME)); + assert_eq!(action("k"), Some(menu_ids::PAUSE)); + assert_eq!(action("l"), Some(menu_ids::PLAY)); + assert_eq!(action("i"), Some(menu_ids::SET_IN_POINT)); + assert_eq!(action("o"), Some(menu_ids::SET_OUT_POINT)); + assert_eq!(action("s"), Some(menu_ids::SPLIT_AT_PLAYHEAD)); + assert_eq!(action("a"), Some(menu_ids::SELECT_ALL)); + assert_eq!(action("secondary-a"), Some(menu_ids::SELECT_ALL)); + assert_eq!(action("backspace"), Some(menu_ids::DELETE)); + assert_eq!(action("shift-backspace"), Some(menu_ids::RIPPLE_DELETE)); + assert_eq!(action("secondary-z"), Some(menu_ids::UNDO)); + assert_eq!(action("secondary-shift-z"), Some(menu_ids::REDO)); + assert_eq!(action("secondary-n"), Some(menu_ids::NEW_PROJECT)); + assert_eq!(action("secondary-o"), Some(menu_ids::OPEN_PROJECT)); + assert_eq!(action("secondary-s"), Some(menu_ids::EXPORT_PROJECT)); + assert_eq!(action("="), Some(menu_ids::ZOOM_IN)); + assert_eq!(action("-"), Some(menu_ids::ZOOM_OUT)); + assert_eq!(action("m"), Some(menu_ids::ADD_MARKER)); + assert_eq!(action("left"), Some(menu_ids::PREV_FRAME)); + assert_eq!(action("right"), Some(menu_ids::NEXT_FRAME)); + assert_eq!(action("home"), Some(menu_ids::TO_START)); + assert_eq!(action("secondary-,"), Some(menu_ids::PREFERENCES)); + } + + /// A shortcut without modifiers must not fire while shift is held (so + /// shifted keys — e.g. typing capitals — never trigger edits). + #[test] + fn unmodified_shortcuts_ignore_extra_modifiers() { + let shifted = Keystroke::parse("shift-s").unwrap(); + assert_eq!(action_for(&shifted), None); + let cmd = Keystroke::parse("secondary-i").unwrap(); + assert_eq!(action_for(&cmd), None); + } + + /// Unbound keys map to nothing. + #[test] + fn unbound_keys_map_to_nothing() { + assert_eq!(action_for(&Keystroke::parse("f1").unwrap()), None); + assert_eq!(action_for(&Keystroke::parse("x").unwrap()), None); + } +}