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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user