diff --git a/crates/oak-app/src/oakui/real.rs b/crates/oak-app/src/oakui/real.rs index 4a2b477c2..f1e1bfa00 100644 --- a/crates/oak-app/src/oakui/real.rs +++ b/crates/oak-app/src/oakui/real.rs @@ -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); } diff --git a/crates/oak-app/src/oakui/renderops.rs b/crates/oak-app/src/oakui/renderops.rs index 0b738a175..6bba41a23 100644 --- a/crates/oak-app/src/oakui/renderops.rs +++ b/crates/oak-app/src/oakui/renderops.rs @@ -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() diff --git a/crates/oak-app/src/panels/multicam.rs b/crates/oak-app/src/panels/multicam.rs index 7cb10782f..502e23c69 100644 --- a/crates/oak-app/src/panels/multicam.rs +++ b/crates/oak-app/src/panels/multicam.rs @@ -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 { play_queue: VecDeque, /// 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 MulticamPanel { 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 MulticamPanel { } 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; diff --git a/crates/oak-node/src/footage.rs b/crates/oak-node/src/footage.rs index 6b2971f83..5cb6cc1a7 100644 --- a/crates/oak-node/src/footage.rs +++ b/crates/oak-node/src/footage.rs @@ -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(