From 4dd4ceb08af239df532233864e42bef5ba5108ed Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Wed, 19 Aug 2026 13:11:02 +0800 Subject: [PATCH] feat(app): playback resolution divider (Full/Half/Quarter/Eighth) The C++ viewer Playback Resolution menu, wired end to end: the radio in the viewer context menu reflects and sets the PlaybackDivider config, proxy_render_size renders the preview at 480/divider long edge, and changing the divider invalidates the cached and in-flight preview frames. This is the escape hatch for machines that cannot keep up with playback (measured: a debug build of the worker pool reaches only 11 fps vs 152 fps in release on 1080p H.264, which no amount of scheduling can make realtime). --- src/menus/shared.rs | 35 ++++++++++++++++++++++++++++------- src/oakui/engine.rs | 21 +++++++++++++++++++++ src/oakui/real.rs | 20 ++++++++++++++++++-- src/panels/program_viewer.rs | 26 +++++++++++++++++++++----- src/panels/source_viewer.rs | 26 +++++++++++++++++++++----- 5 files changed, 109 insertions(+), 19 deletions(-) diff --git a/src/menus/shared.rs b/src/menus/shared.rs index f66bc0297..1caa77878 100644 --- a/src/menus/shared.rs +++ b/src/menus/shared.rs @@ -202,7 +202,7 @@ pub fn viewer_zoom_level_id(index: usize) -> usize { /// subtitle block the engine does not surface yet). Zoom / playback /// resolution / safe margins / waveform / FPS are placeholders until the /// viewer widget grows those controls. -pub fn viewer_menu() -> Menu { +pub fn viewer_menu(playback_divider: i64) -> Menu { use crate::i18n::tr; // Zoom: Fit + one entry per zoom level. let mut zoom_items = vec![MenuItem::new(LOCAL_VIEWER_ZOOM_FIT, tr("viewer.context.zoom_fit"))]; @@ -213,12 +213,17 @@ pub fn viewer_menu() -> Menu { )); } // Playback resolution radio group. + // Playback Resolution radio group (the C++ `PlaybackDivider` config): + // the checked entry reflects the current divider. let resolution_menu = Menu::new(vec![ - MenuItem::new(LOCAL_VIEWER_RES_FULL, tr("viewer.context.res_full")).with_checked(true), - MenuItem::new(LOCAL_VIEWER_RES_HALF, tr("viewer.context.res_half")).with_checked(false), + MenuItem::new(LOCAL_VIEWER_RES_FULL, tr("viewer.context.res_full")) + .with_checked(playback_divider <= 1), + MenuItem::new(LOCAL_VIEWER_RES_HALF, tr("viewer.context.res_half")) + .with_checked(playback_divider == 2), MenuItem::new(LOCAL_VIEWER_RES_QUARTER, tr("viewer.context.res_quarter")) - .with_checked(false), - MenuItem::new(LOCAL_VIEWER_RES_EIGHTH, tr("viewer.context.res_eighth")).with_checked(false), + .with_checked(playback_divider == 4), + MenuItem::new(LOCAL_VIEWER_RES_EIGHTH, tr("viewer.context.res_eighth")) + .with_checked(playback_divider >= 8), ]); // Safe margins radio group. let safe_menu = Menu::new(vec![ @@ -370,7 +375,7 @@ mod tests { /// defaults each radio group to its first entry. #[test] fn viewer_menu_offers_every_zoom_level() { - let menu = viewer_menu(); + let menu = viewer_menu(1); let zoom = menu .items .iter() @@ -389,7 +394,7 @@ mod tests { /// (default) entry only. #[test] fn viewer_menu_radio_groups_default_to_the_first_entry() { - let menu = viewer_menu(); + let menu = viewer_menu(1); for label_key in [ "viewer.context.playback_resolution", "viewer.context.safe_margins", @@ -408,4 +413,20 @@ mod tests { ); } } + + /// The resolution radio follows the current playback divider. + #[test] + fn viewer_menu_resolution_radio_follows_the_divider() { + let menu = viewer_menu(4); + let item = menu + .items + .iter() + .find(|item| item.label == crate::i18n::tr("viewer.context.playback_resolution")) + .expect("resolution submenu"); + let sub = &item.submenu.as_ref().unwrap().items; + assert_eq!(sub[0].checked, Some(false), "full unchecked at /4"); + assert_eq!(sub[1].checked, Some(false), "half unchecked at /4"); + assert_eq!(sub[2].checked, Some(true), "quarter checked at /4"); + assert_eq!(sub[3].checked, Some(false), "eighth unchecked at /4"); + } } diff --git a/src/oakui/engine.rs b/src/oakui/engine.rs index 1c914b821..463655fd6 100644 --- a/src/oakui/engine.rs +++ b/src/oakui/engine.rs @@ -740,6 +740,27 @@ pub trait AppEngine: let _ = cx; } + /// The playback resolution divider (the C++ viewer `Playback + /// Resolution ▸` menu / `PlaybackDivider` config): 1 = full preview + /// size, 2/4/8 = progressively smaller preview renders for machines + /// that cannot keep up. Preview-only; exports always render native. + fn playback_divider(&self) -> i64 { + oakcommon::configstore::ConfigStore::instance() + .get_int(None, "PlaybackDivider", 1) + .clamp(1, 8) as i64 + } + + /// Sets the playback resolution divider and invalidates the rendered + /// frames so the next pull re-renders at the new geometry. + fn set_playback_divider(&mut self, divider: i64, cx: &mut Context) { + oakcommon::configstore::ConfigStore::instance().set( + None, + "PlaybackDivider", + ÷r.clamp(1, 8).to_string(), + ); + let _ = cx; + } + /// The footage rows the proxy dialog's footage mode lists (every /// footage node in the open project). fn proxy_rows(&self) -> Vec { diff --git a/src/oakui/real.rs b/src/oakui/real.rs index 117f0956b..1147cef50 100644 --- a/src/oakui/real.rs +++ b/src/oakui/real.rs @@ -1231,8 +1231,12 @@ impl RealEngine { fn proxy_render_size(&self) -> Option<(i32, i32)> { let info = self.sequence_info.as_ref()?; let (w, h) = (info.format.width.max(1), info.format.height.max(1)); - const MAX_LONG_EDGE: u32 = 480; - let scale = MAX_LONG_EDGE as f64 / w.max(h) as f64; + // The playback resolution divider (the C++ viewer `Playback + // Resolution ▸` menu): render the preview at 480/divider long edge + // so slower machines (or debug builds) can still play in real time. + let divider = self.playback_divider().max(1) as u32; + let max_long_edge = 480 / divider; + let scale = max_long_edge as f64 / w.max(h) as f64; let width = ((w as f64 * scale).round() as u32).max(2); let height = ((h as f64 * scale).round() as u32).max(2); Some((width as i32, height as i32)) @@ -4007,6 +4011,18 @@ impl AppEngine for RealEngine { self.invalidate_preview_frames(cx); } + /// Sets the playback resolution divider (the viewer `Playback + /// Resolution ▸` menu): the preview geometry changes, so every cached + /// and in-flight preview frame is stale. + fn set_playback_divider(&mut self, divider: i64, cx: &mut Context) { + oakcommon::configstore::ConfigStore::instance().set( + None, + "PlaybackDivider", + ÷r.clamp(1, 8).to_string(), + ); + self.invalidate_preview_frames(cx); + } + fn proxy_rows(&self) -> Vec { let Some(project) = self.project.as_ref() else { return Vec::new(); diff --git a/src/panels/program_viewer.rs b/src/panels/program_viewer.rs index fa81e6dc6..0dde89d23 100644 --- a/src/panels/program_viewer.rs +++ b/src/panels/program_viewer.rs @@ -146,9 +146,21 @@ impl ProgramViewerPanel { } } - /// Handles the viewer's local (non-registry) context-menu items — all - /// placeholders until the viewer widget grows the matching controls. - fn on_local_menu_item(&mut self, item: usize, _cx: &mut Context) { + /// Handles the viewer's local (non-registry) context-menu items. + fn on_local_menu_item(&mut self, item: usize, cx: &mut Context) { + use crate::menus::shared as shared_menu; + let divider = match item { + shared_menu::LOCAL_VIEWER_RES_FULL => Some(1), + shared_menu::LOCAL_VIEWER_RES_HALF => Some(2), + shared_menu::LOCAL_VIEWER_RES_QUARTER => Some(4), + shared_menu::LOCAL_VIEWER_RES_EIGHTH => Some(8), + _ => None, + }; + if let Some(divider) = divider { + let engine = self.engine.clone(); + engine.update(cx, |engine, cx| engine.set_playback_divider(divider, cx)); + return; + } println!("[program viewer] context-menu item {item} (not implemented yet)"); } @@ -309,8 +321,12 @@ impl Render for ProgramViewerPanel { // the panel opens the shared viewer menu here. .on_mouse_down(MouseButton::Right, { cx.listener(|this, event: &gpui::MouseDownEvent, _window, cx| { - this.context_menu - .show(event.position, crate::menus::shared::viewer_menu(), cx); + let divider = this.engine.read(cx).playback_divider(); + this.context_menu.show( + event.position, + crate::menus::shared::viewer_menu(divider), + cx, + ); }) }) .child( diff --git a/src/panels/source_viewer.rs b/src/panels/source_viewer.rs index 386377e4c..06364663b 100644 --- a/src/panels/source_viewer.rs +++ b/src/panels/source_viewer.rs @@ -80,9 +80,21 @@ impl SourceViewerPanel { } } - /// Handles the viewer's local (non-registry) context-menu items — all - /// placeholders until the viewer widget grows the matching controls. - fn on_local_menu_item(&mut self, item: usize, _cx: &mut Context) { + /// Handles the viewer's local (non-registry) context-menu items. + fn on_local_menu_item(&mut self, item: usize, cx: &mut Context) { + use crate::menus::shared as shared_menu; + let divider = match item { + shared_menu::LOCAL_VIEWER_RES_FULL => Some(1), + shared_menu::LOCAL_VIEWER_RES_HALF => Some(2), + shared_menu::LOCAL_VIEWER_RES_QUARTER => Some(4), + shared_menu::LOCAL_VIEWER_RES_EIGHTH => Some(8), + _ => None, + }; + if let Some(divider) = divider { + let engine = self.engine.clone(); + engine.update(cx, |engine, cx| engine.set_playback_divider(divider, cx)); + return; + } println!("[source viewer] context-menu item {item} (not implemented yet)"); } @@ -142,8 +154,12 @@ impl Render for SourceViewerPanel { // the panel opens the shared viewer menu here. .on_mouse_down(MouseButton::Right, { cx.listener(|this, event: &gpui::MouseDownEvent, _window, cx| { - this.context_menu - .show(event.position, crate::menus::shared::viewer_menu(), cx); + let divider = this.engine.read(cx).playback_divider(); + this.context_menu.show( + event.position, + crate::menus::shared::viewer_menu(divider), + cx, + ); }) }) .child(