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:
+28
-7
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
÷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<ProxyFootageRow> {
|
||||
|
||||
+18
-2
@@ -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",
|
||||
÷r.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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user