multicam: no-split switch skips full rebuild, angle refresh stride, proxy in node-graph decode

- multicam_switch_to: a NO-SPLIT switch only changes current source —
  push snapshot + invalidate preview windows, skip the full
  apply_edit rebuild (the 'switch once then everything grinds' hit)
- MulticamPanel: playback angle refresh runs one cycle per 3 ticks
  instead of re-requesting every source every tick (each angle decode
  is a keyframe-scanning FFmpeg seek)
- footage value(): a ready proxy stands in for the original media in
  the node-graph decode request (proxy enabled but 4K decoded report)
- proxy_resolution made public (engine's proxy-first preview size)
This commit is contained in:
2026-09-01 21:19:54 +08:00
parent 1b0a15192e
commit 3bcf4e0b1a
4 changed files with 139 additions and 9 deletions
+80 -1
View File
@@ -1643,6 +1643,12 @@ impl RealEngine {
/// stays tiny to keep the block short; the background job renders the
/// full-resolution frame off-thread at the divider-scaled size (M12
/// P5a, see [`RealEngine::schedule_full_res`]).
///
/// When an active proxy covers the program playhead, its OWN size is
/// the preview default ("预览分辨率默认跟代理"): a 720p proxy renders
/// as 720p instead of the 480/divider guess; the playback divider
/// (菜单 Playback Resolution) still scales it down when the user
/// lowers it. Without a proxy the old 480/divider policy holds.
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));
@@ -1651,12 +1657,65 @@ impl RealEngine {
// 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 mut scale = max_long_edge as f64 / w.max(h) as f64;
// Proxy-first default: the active proxy's own resolution sets the
// preview ceiling (never upscale below the sequence's own size).
if let Some(proxy) = self.active_proxy_resolution() {
let proxy_long = proxy.0.max(proxy.1) as f64;
// at least the proxy's long edge, divided when the user cut it
let proxy_edge = (proxy_long / divider as f64).max(1.0);
let proxy_scale = proxy_edge / w.max(h) as f64;
scale = scale.max(proxy_scale);
}
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))
}
/// The proxy resolution of the clip covering the program playhead on
/// the topmost video track, WHEN that clip actually renders through
/// its proxy (the same application checks as [`proxy_limits_at`]:
/// UseProxyMedia on, footage proxy enabled + Ready). `None` when no
/// proxy governs the current frame.
fn active_proxy_resolution(&self) -> Option<(i32, i32)> {
let (project, seq) = (self.project_ref()?, self.sequence?);
let playhead = {
let g = graphops::lock(project);
graphops::sequence_playhead(&g.graph, seq)
};
let g = graphops::lock(project);
let tracks = graphops::track_ids(&g.graph, seq, oak_node::track::TrackType::Video);
for &track_id in tracks.iter().rev() {
let Some(track) = graphops::track_behavior(&g.graph, track_id) else {
continue;
};
for &block_id in &track.blocks {
let Some(clip) = graphops::clip_behavior(&g.graph, block_id) else {
continue;
};
if playhead < clip.core.in_() || playhead >= clip.core.out() {
continue;
}
let Some(footage_id) =
super::graphops::find_input_footage(&g.graph, block_id)
else {
continue;
};
let Some(f) = super::graphops::footage_behavior(&g.graph, footage_id) else {
continue;
};
// Only when the proxy actually stands in (same test as the
// montage path — otherwise a stale proxy flag would bind
// the render to a file that is not used).
if super::renderops::preview_footage_media(f, true).0 != f.proxy {
continue;
}
return super::renderops::proxy_resolution(f);
}
}
None
}
/// Renders one program-monitor frame through the oakrender ticket
/// arena: builds the sequence's montage at `frame`, renders at the
/// proxy geometry, and produces the viewer display image plus the
@@ -6344,6 +6403,26 @@ impl AppEngine for RealEngine {
}
_ => graphops::push_command(cmd, oak_timeline::multicam::SWITCH_LABEL),
};
if let Err(e) = &result {
println!("[real engine] multicam switch failed: {e}");
}
// A NO-SPLIT switch only changes the multicam node's current source
// (the timeline structure is untouched): the lightweight path skips
// the shared full rebuild — `apply_edit` re-snapshots every track,
// invalidates the whole pre-render window and (150 ms later) pushes
// a full 192 KB graph upload, which is exactly the "switch once,
// everything grinds" hit. The pre-render window refresh below keeps
// the graph snapshot + invalidation (the worker's picture must
// follow the new source), but nothing else churns.
if !split_clip {
// The current-source change is an undoable edit: snapshot +
// invalidate the preview windows so the workers re-render the
// new source.
self.push_graph_snapshot();
self.invalidate_rendered_frames();
cx.notify();
return;
}
self.apply_edit(result, "multicam switch", cx);
}
+1 -1
View File
@@ -664,7 +664,7 @@ pub fn full_res_render_size(seq_width: u32, seq_height: u32, divider: u32) -> (i
/// configured, otherwise the source video resolution divided by the
/// proxy divider. `None` when there is no probed video stream to base a
/// divider mode on.
fn proxy_resolution(f: &oak_node::footage::FootageBehavior) -> Option<(i32, i32)> {
pub fn proxy_resolution(f: &oak_node::footage::FootageBehavior) -> Option<(i32, i32)> {
let params = f
.custom_proxy_params
.clone()
+28 -5
View File
@@ -61,6 +61,14 @@ use crate::panels::chip;
/// The number of sources refreshed per tick during playback (the rest keep
/// their last frame until their turn — the task's round-robin throttle).
const PLAYBACK_REFRESH_PER_TICK: i32 = 2;
/// A refresh cycle runs once every this many ticks (a tick is one frame
/// at the sequence rate). Decoding one angle is an FFmpeg keyframe-
/// scanning seek (~10-50 ms even on a 720p proxy); re-requesting ALL
/// sources EVERY tick made those seek costs compound — the "switch once,
/// then everything grinds" report's persistent half. The grid still
/// refreshes each source in turn, just at a third of the frame rate;
/// resting playheads and just-after-switch still do a full refresh.
const PLAYBACK_REFRESH_STRIDE: i32 = 3;
/// One pending switch (the C++ `MulticamWidget`'s `play_queue_`): the
/// switch applies once the program playhead reaches `target`.
@@ -92,6 +100,10 @@ pub struct MulticamPanel<E: AppEngine> {
play_queue: VecDeque<QueuedSwitch>,
/// Round-robin cursor over the sources (playback refresh throttle).
refresh_cursor: i32,
/// Tick counter for the playback refresh cycle (stride-scaled).
refresh_tick_toggle: u64,
/// The refresh cycle seen last (dedups re-requests within a cycle).
last_refresh_cycle: i64,
/// The last program playhead (detects a jump / rest).
last_playhead: i64,
/// Set when a switch cleared the frames: the next refresh pass covers
@@ -121,6 +133,8 @@ impl<E: AppEngine> MulticamPanel<E> {
frames: HashMap::new(),
play_queue: VecDeque::new(),
refresh_cursor: 0,
refresh_tick_toggle: 0,
last_refresh_cycle: -1,
last_playhead: i64::MIN,
full_refresh: true,
focus: _cx.focus_handle(),
@@ -177,12 +191,21 @@ impl<E: AppEngine> MulticamPanel<E> {
}
self.refresh_cursor = 0;
} else {
// Playback: round-robin a couple of sources per tick.
for _ in 0..state.source_count.min(PLAYBACK_REFRESH_PER_TICK) {
let source = self.refresh_cursor % state.source_count;
self.refresh_cursor += 1;
self.request_angle(source, cx);
// Playback: one refresh cycle per PLAYBACK_REFRESH_STRIDE
// ticks, round-robin a couple of sources per cycle (the old
// per-tick round-robin requested decodes faster than they can
// complete — each is a keyframe-scanning seek).
let cycle = (self.refresh_tick_toggle / PLAYBACK_REFRESH_STRIDE as u64) as i64;
if cycle != self.last_refresh_cycle {
self.last_refresh_cycle = cycle;
self.refresh_cursor += 0;
for _ in 0..state.source_count.min(PLAYBACK_REFRESH_PER_TICK) {
let source = self.refresh_cursor % state.source_count;
self.refresh_cursor += 1;
self.request_angle(source, cx);
}
}
self.refresh_tick_toggle = self.refresh_tick_toggle.wrapping_add(1);
}
self.full_refresh = false;
self.last_playhead = playhead;
+30 -2
View File
@@ -25,6 +25,13 @@ use crate::input::Input;
use crate::node::{Category, NodeBehavior, NodeCore};
use crate::value::{AudioParams, NodeValue, NodeValueRow, NodeValueTable, ValueType, VideoParams};
/// Whether the footage's proxy state value means "the proxy file is
/// ready on disk" (the C++ `ProxyState::Ready` = 2; the footage behavior
/// stores it raw to avoid a codec-crate dependency here).
pub(crate) fn proxymanager_proxy_state_ready(state: i32) -> bool {
state == 2
}
/// One media stream inside a footage file.
#[derive(Clone, Debug)]
pub struct StreamInfo {
@@ -351,6 +358,13 @@ impl NodeBehavior for FootageBehavior {
/// boxed [`crate::nodes::jobs::FootageJobPayload`] the render hooks
/// resolve to the decoded frame. A footage with no probed video stream
/// (or no filename) outputs nothing.
///
/// A ready proxy stands in for the original media (the node-graph
/// renderer's counterpart of the montage path's proxy selection): the
/// payload then names the proxy file/stream instead of the 4K
/// original — without this the node-graph preview decodes full
/// resolution even with proxies enabled (the "proxy on but preview is
/// still 4K and the GPU crawls" report).
fn value(
&self,
_core: &NodeCore,
@@ -364,9 +378,23 @@ impl NodeBehavior for FootageBehavior {
let Some(stream) = self.streams.iter().find(|s| s.is_video) else {
return;
};
// Proxy selection mirrors the montage path's
// `preview_footage_media`: enabled + Ready state, and the proxy
// replaces the first video stream only (C++ matches the proxy's
// video stream index against the footage's first video stream).
let proxy_ready = proxymanager_proxy_state_ready(self.proxy_state);
let use_proxy = self.proxy_enabled
&& !self.proxy.is_empty()
&& proxy_ready
&& self.proxy_video_stream_index == stream.index;
let (filename, stream_index) = if use_proxy {
(self.proxy.clone(), 0)
} else {
(self.filename.clone(), stream.index)
};
let payload = crate::nodes::jobs::FootageJobPayload {
filename: self.filename.clone(),
stream_index: stream.index,
filename,
stream_index,
time,
};
table.push(