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).
This commit is contained in:
2026-08-19 13:11:02 +08:00
parent 46e43b51d9
commit 4dd4ceb08a
5 changed files with 109 additions and 19 deletions
+28 -7
View File
@@ -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");
}
}
+21
View File
@@ -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<Self>) {
oakcommon::configstore::ConfigStore::instance().set(
None,
"PlaybackDivider",
&divider.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<ProxyFootageRow> {
+18 -2
View File
@@ -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<Self>) {
oakcommon::configstore::ConfigStore::instance().set(
None,
"PlaybackDivider",
&divider.clamp(1, 8).to_string(),
);
self.invalidate_preview_frames(cx);
}
fn proxy_rows(&self) -> Vec<super::engine::ProxyFootageRow> {
let Some(project) = self.project.as_ref() else {
return Vec::new();
+21 -5
View File
@@ -146,9 +146,21 @@ impl<E: AppEngine> ProgramViewerPanel<E> {
}
}
/// 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<Self>) {
/// Handles the viewer's local (non-registry) context-menu items.
fn on_local_menu_item(&mut self, item: usize, cx: &mut Context<Self>) {
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<E: AppEngine> Render for ProgramViewerPanel<E> {
// 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(
+21 -5
View File
@@ -80,9 +80,21 @@ impl<E: AppEngine> SourceViewerPanel<E> {
}
}
/// 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<Self>) {
/// Handles the viewer's local (non-registry) context-menu items.
fn on_local_menu_item(&mut self, item: usize, cx: &mut Context<Self>) {
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<E: AppEngine> Render for SourceViewerPanel<E> {
// 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(