feat(app): multicam panel with live angle grid, switching, timeline enable

- New MulticamPanel: rows/cols angle grid with the current angle
  highlighted, click-to-switch, 1-9 switch-and-split and cmd-1-9
  switch-only shortcuts (focused-panel routed), deferred switch queue
  during playback.
- src/oakui/multicam.rs: clip->connected-sequence resolution, multicam
  state detection (selection then playhead fallbacks), per-angle frame
  requests rendered through the process backend into an LRU cache.
- Timeline clip context menu Multi-Cam checkable item wired to
  oaktimeline::multicam enable/disable with undo.
- Engine trait extended (real + mock); mock drives the real command
  path with synthesized angle frames.
This commit is contained in:
2026-08-18 21:40:00 +08:00
parent cad1d93544
commit cf459d7e4c
132 changed files with 2276 additions and 422 deletions
+84
View File
@@ -792,11 +792,95 @@ pub trait AppEngine:
let _ = (clips, adjust_speed, cx);
}
// -------------------------------------------------------------------
// Multi-camera (the C++ MulticamWidget / timeline Multi-Cam menu):
// detection state for the panel, angle-frame rendering, the timeline
// menu's enable/disable and the source switch. Defaults degrade to "no
// multicam", so engines without a multicam surface keep compiling.
// -------------------------------------------------------------------
/// The currently detected multicam state (the panel's grid), or `None`
/// when there is nothing to display. The backend performs the
/// detection on demand (selected clip → `find_multicam`, falling back
/// to the clip at the program playhead), so the panel always reads a
/// fresh answer.
fn multicam_state(&self) -> Option<MulticamState> {
None
}
/// The rendered frame of one multicam angle, when a frame for the
/// current playhead is cached. The panel calls this for every source it
/// draws; `None` means the frame is not ready (the engine schedules a
/// background render and notifies when it lands). The backend caches
/// per (multicam node, source) with an LRU cap, so a paused panel never
/// re-renders a cell.
fn multicam_angle_frame(&mut self, source: i32, cx: &mut Context<Self>) -> Option<Arc<RenderImage>> {
let _ = (source, cx);
None
}
/// Whether any of `clips` can host multicam — the timeline clip menu's
/// enable condition (the C++ `connected_viewer()` of the clip is a
/// sequence).
fn multicam_eligible(&self, clips: &[ClipId]) -> bool {
let _ = clips;
false
}
/// Whether the selected clips are currently multicam-enabled — the
/// timeline menu's checked state.
fn multicam_enabled_on_selection(&self, clips: &[ClipId]) -> bool {
let _ = clips;
false
}
/// Enables / disables multicam on `clips` (the timeline menu's checkable
/// item), as ONE undo entry (`Multi-Cam Enabled On %1 Clip(s)` /
/// `Multi-Cam Disabled On %1 Clip(s)`). Clips whose connected viewer is
/// not a sequence are skipped.
fn multicam_enable_selected(
&mut self,
clips: Vec<ClipId>,
enabled: bool,
cx: &mut Context<Self>,
) {
let _ = (clips, enabled, cx);
}
/// Switches the currently detected multicam to `source` (the digit
/// keys and grid clicks), as ONE undo entry (`Switched Multi-Camera
/// Source`). `split_clip` = the change applies from the playhead
/// forward (the clip is split first).
fn multicam_switch_to(&mut self, source: i32, split_clip: bool, cx: &mut Context<Self>) {
let _ = (source, split_clip, cx);
}
/// The display name of the engine backend ("mock" / "real"), shown in
/// the status bar.
fn backend_name(&self) -> &'static str;
}
/// The detected multicam state the Multicam panel displays (the C++
/// `MulticamWidget`'s `node_` / `clip_` plus the resolved source count /
/// current source). `None` in the engine means there is no multicam to
/// show — the panel falls back to its empty state.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct MulticamState {
/// The source sequence node identity (the multicam's `sequence_in` edge
/// target; its track list supplies the angles).
pub sequence_id: u64,
/// The multicam node identity.
pub node_id: u64,
/// The timeline clip node identity whose texture input the multicam
/// feeds.
pub clip_id: u64,
/// The number of angle sources (the source sequence's track count of
/// the multicam's `sequence_type_in` kind).
pub source_count: i32,
/// The currently selected source index (`current_in`).
pub current_source: i32,
}
/// The lifecycle state of one footage's proxy (the UI mirror of
/// `oakcodec::proxymanager::ProxyState`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+299 -2
View File
@@ -61,10 +61,16 @@ use gpui_widgets::audio_meter::AudioMeterDataSource;
use gpui_widgets::project_explorer::{ProjectDataSource, ProjectEntry};
use gpui_widgets::viewer::PlaybackClock;
use oakcore_rs::Rational;
use oaknode::block::clip_input;
use oaknode::track::TrackType;
use oaktimeline::util::{block_clip_create, track_append_block};
use super::engine::{
AppEngine, EngineGateway, ExportEvent, ExportSession, LibraryProject, Monitor, Project,
ScopeData, Sequence, VideoFormat,
AppEngine, EngineGateway, ExportEvent, ExportSession, LibraryProject, Monitor, MulticamState,
Project, ScopeData, Sequence, VideoFormat,
};
use super::graphops;
use super::transport::TransportState;
/// The demo sequence length: 00:04:18:18 at 25 fps.
@@ -533,6 +539,15 @@ pub struct MockEngine {
proxy_custom: HashMap<u64, crate::oakui::engine::ProxyParamsUi>,
/// The demo's global "Use Proxy Media" switch.
use_proxy: bool,
/// The demo multicam graph: a real oaknode project whose source
/// sequence's video tracks are the angles. Created lazily so the demo
/// panel shows a genuine graph behind its synthetic frames — and the
/// switch / enable / disable commands run on the real command path
/// (`oaktimeline::multicam` + the global undo stack).
multicam_graph: Mutex<Option<DemoMulticamGraph>>,
/// The demo multicam angle-frame cache: source → (playhead, image), so
/// a paused cell never regenerates its picture.
multicam_frames: Mutex<HashMap<i32, (i64, Arc<RenderImage>)>>,
}
impl MockEngine {
@@ -791,6 +806,8 @@ impl MockEngine {
proxy_enabled: HashMap::new(),
proxy_custom: HashMap::new(),
use_proxy: true,
multicam_graph: Mutex::new(None),
multicam_frames: Mutex::new(HashMap::new()),
};
// The demo graph is born connected: derive every port's `connected`
// flag from the edge list.
@@ -1952,6 +1969,86 @@ impl AppEngine for MockEngine {
.collect()
}
fn multicam_state(&self) -> Option<MulticamState> {
self.mock_multicam_state()
}
fn multicam_angle_frame(&mut self, source: i32, cx: &mut Context<Self>) -> Option<Arc<RenderImage>> {
let playhead = self.clock_frame(Monitor::Program, cx).0;
self.mock_multicam_angle_frame(source, playhead)
}
fn multicam_eligible(&self, _clips: &[ClipId]) -> bool {
// The demo always exposes a multicam setup, so the timeline menu
// item is enabled (the mock has no clip→viewer wiring to judge).
self.mock_multicam_state().is_some()
}
fn multicam_enabled_on_selection(&self, _clips: &[ClipId]) -> bool {
self.mock_multicam_state().is_some()
}
fn multicam_enable_selected(&mut self, _clips: Vec<ClipId>, enabled: bool, cx: &mut Context<Self>) {
// Run the real enable/disable commands on the demo graph (one undo
// entry each, like the real engine).
let mut guard = self.ensure_demo_multicam();
let Some(demo) = guard.as_mut() else {
return;
};
let clip = demo.clip.clone();
if enabled {
if demo.multicam.is_some() {
return; // The demo starts enabled; enabling again is a no-op.
}
let sequence = demo.sequence.clone();
let cmd = oaktimeline::multicam::multicam_enable(vec![clip], sequence);
let label = oaktimeline::multicam::enable_label(1);
if let Err(e) = super::graphops::push_command(cmd, &label) {
println!("[mock] multicam enable failed: {e}");
}
} else {
let cmd = oaktimeline::multicam::multicam_disable(vec![clip]);
let label = oaktimeline::multicam::disable_label(1);
if let Err(e) = super::graphops::push_command(cmd, &label) {
println!("[mock] multicam disable failed: {e}");
}
}
// Re-resolve the multicam node after the command.
demo.multicam = oaktimeline::multicam::clip_find_multicam(&demo.clip);
self.multicam_frames.lock().unwrap().clear();
cx.notify();
}
fn multicam_switch_to(&mut self, source: i32, split_clip: bool, cx: &mut Context<Self>) {
let guard = self.ensure_demo_multicam();
let Some(demo) = guard.as_ref() else {
return;
};
let Some(state) = crate::oakui::multicam::multicam_state_for_clip(&demo.project, demo.clip.id)
else {
return;
};
if source < 0 || source >= state.source_count {
return;
}
let playhead_frame = self.clock_frame(Monitor::Program, cx).0;
let playhead = Rational::new(playhead_frame.max(0), 25);
let cmd = oaktimeline::multicam::multicam_switch(
demo.clip.clone(),
source,
split_clip,
playhead,
);
if let Err(e) =
super::graphops::push_command(cmd, oaktimeline::multicam::SWITCH_LABEL)
{
println!("[mock] multicam switch failed: {e}");
}
drop(guard);
self.multicam_frames.lock().unwrap().clear();
cx.notify();
}
fn backend_name(&self) -> &'static str {
"mock"
}
@@ -2078,6 +2175,206 @@ impl AudioMeterDataSource for MockEngine {
}
}
// ---------------------------------------------------------------------------
// Demo multicam (the mock's multicam panel grid)
// ---------------------------------------------------------------------------
/// The mock's demo multicam: a real oaknode graph whose source sequence's
/// video tracks are the angles. The panel's frames are synthetic colored
/// cells, but the switch / enable / disable commands run on the REAL command
/// path (`oaktimeline::multicam` + the global undo stack), so the demo
/// exercises the same machinery the real engine uses.
struct DemoMulticamGraph {
/// The project holding the graph.
project: graphops::ProjectRef,
/// The clip whose texture input the multicam feeds.
clip: oaktimeline::util::NodeRef,
/// The source sequence (its video tracks are the angles).
sequence: oaktimeline::util::NodeRef,
/// The multicam node (present while enabled).
multicam: Option<oaktimeline::util::NodeRef>,
}
impl DemoMulticamGraph {
/// Builds the demo graph: a sequence with four video tracks, one clip on
/// the top track fed by the sequence, multicam already enabled. The
/// tracks are built directly in the graph (no `Add Track` undo entries —
/// the demo's initial state is not a user edit).
fn build() -> Self {
use oaknode::node::NodeCore;
use oaknode::sequence::SequenceBehavior;
use oaknode::track::{TrackBehavior, TrackListBehavior};
let project = graphops::create_project();
let sequence = graphops::create_sequence(&project, "Multicam Demo");
// A video track list with four tracks, wired into the sequence.
{
let mut g = graphops::lock(&project);
let (core, behavior) = TrackListBehavior::create();
let mut behavior = behavior;
let list = behavior
.as_any_mut()
.unwrap()
.downcast_mut::<TrackListBehavior>()
.unwrap();
list.kind = TrackType::Video;
list.sequence = Some(sequence);
let list_id = g.graph.add_node(core, behavior);
for _ in 0..4 {
let (core, behavior) =
(NodeCore::new(), Box::new(TrackBehavior::new(TrackType::Video)));
let track_id = g.graph.add_node(core, behavior);
let t = g
.graph
.get_mut(track_id)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::<TrackBehavior>()
.unwrap();
t.kind = TrackType::Video;
t.track_list = Some(list_id);
let l = g
.graph
.get_mut(list_id)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::<TrackListBehavior>()
.unwrap();
l.tracks.push(track_id);
}
let s = g
.graph
.get_mut(sequence)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::<SequenceBehavior>()
.unwrap();
s.track_lists.push(list_id);
}
let clip = block_clip_create(&project);
{
let mut g = graphops::lock(&project);
let c = g
.graph
.get_mut(clip.id)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::<oaknode::block::ClipBlockBehavior>()
.unwrap();
c.core.range = oakcore_rs::TimeRange::new(Rational::new(0, 1), Rational::new(200, 1));
c.core.media_in = Rational::new(0, 1);
}
let track0 = {
let g = graphops::lock(&project);
graphops::track_ids(&g.graph, sequence, TrackType::Video)[0]
};
let track0 = oaktimeline::util::NodeRef::new(project.clone(), track0);
track_append_block(&track0, &clip);
{
let mut g = graphops::lock(&project);
g.graph
.connect(sequence, clip.id, clip_input::TEXTURE_INPUT, -1)
.unwrap();
}
// Enable multicam through the real command (kept out of the undo
// stack — it is the demo's initial state, not a user edit).
let mut enable = oaktimeline::multicam::MultiCamEnableCommand::new(
vec![clip.clone()],
oaktimeline::util::NodeRef::new(project.clone(), sequence),
);
enable.redo();
let multicam = oaktimeline::multicam::clip_find_multicam(&clip);
let sequence = oaktimeline::util::NodeRef::new(project.clone(), sequence);
DemoMulticamGraph {
project,
clip,
sequence,
multicam,
}
}
}
impl MockEngine {
/// The demo multicam graph, built on first access.
fn ensure_demo_multicam(&self) -> std::sync::MutexGuard<'_, Option<DemoMulticamGraph>> {
let mut guard = self.multicam_graph.lock().unwrap();
if guard.is_none() {
*guard = Some(DemoMulticamGraph::build());
}
guard
}
/// The demo multicam state (the panel's grid): source count = the demo
/// sequence's video track count, current source read from the multicam
/// node.
fn mock_multicam_state(&self) -> Option<MulticamState> {
let guard = self.ensure_demo_multicam();
let demo = guard.as_ref()?;
let state = crate::oakui::multicam::multicam_state_for_clip(&demo.project, demo.clip.id)?;
Some(state)
}
/// The demo angle frame: a solid colored cell per source with a moving
/// white stripe (the mock cannot decode media; the cells are synthetic
/// but the grid geometry and the switch commands are real).
fn demo_angle_image(source: i32, playhead: i64) -> Option<Arc<RenderImage>> {
const W: u32 = 160;
const H: u32 = 90;
let palette: [(u8, u8, u8); 9] = [
(255, 0, 0),
(0, 255, 0),
(0, 0, 255),
(255, 255, 0),
(255, 0, 255),
(0, 255, 255),
(255, 128, 0),
(128, 0, 255),
(0, 128, 255),
];
let (pr, pg, pb) = palette[source.rem_euclid(9) as usize];
let stripe = (playhead * 6) % W as i64;
let mut bytes = Vec::with_capacity((W * H * 4) as usize);
for y in 0..H {
for x in 0..W {
let (r, g, b) = if (x as i64 - stripe).abs() < 6 {
(255, 255, 255)
} else if (y as i64) < 18 {
(pr, pg, pb)
} else {
// Darken below the "label" band so the cells read as
// distinct angles.
(pr / 2, pg / 2, pb / 2)
};
// BGRA8 display order.
bytes.extend_from_slice(&[b, g, r, 255]);
}
}
crate::oakui::frames::bgra_bytes_to_render_image(W, H, &bytes).map(Arc::new)
}
/// The demo angle frame for `source` at the current program playhead,
/// cached per (source, playhead) so a paused cell never regenerates.
fn mock_multicam_angle_frame(&self, source: i32, playhead: i64) -> Option<Arc<RenderImage>> {
let mut cache = self.multicam_frames.lock().unwrap();
if let Some((cached_playhead, image)) = cache.get(&source) {
if *cached_playhead == playhead {
return Some(image.clone());
}
}
let image = Self::demo_angle_image(source, playhead)?;
cache.insert(source, (playhead, image.clone()));
Some(image)
}
}
/// Convenience accessors used by panels and the status bar.
impl MockEngine {
/// The paths imported via [`AppEngine::import_footage`] so far (mock state;
+3 -1
View File
@@ -46,6 +46,7 @@ pub mod frames;
pub mod graphops;
pub mod icons;
pub mod mock;
pub mod multicam;
pub mod nodegraph;
pub mod projectbrowser;
pub mod real;
@@ -58,7 +59,8 @@ pub mod waveformsync;
pub use engine::{
AppEngine, EngineClock, EngineGateway, ExportEvent, ExportSession, HistoryEntry,
LibraryProject, Monitor, NodeLibraryEntry, Project, ScopeData, Sequence, VideoFormat,
LibraryProject, Monitor, MulticamState, NodeLibraryEntry, Project, ScopeData, Sequence,
VideoFormat,
};
pub use mock::{MockClock, MockEngine};
pub use real::{RealClock, RealEngine};
+427
View File
@@ -0,0 +1,427 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! App-side multicam resolution: the bridge between the
//! [`oaktimeline::multicam`](oaktimeline::multicam) commands and the
//! UI's detection / menu needs.
//!
//! The C++ `MulticamWidget` detects a multicam by asking the viewer, then
//! walks the clip's texture chain for a `MultiCamNode` (`viewer.cpp`'s
//! `detect_multicam_node`). The Rust engine mirrors that here:
//!
//! * [`clip_connected_sequence`] is the C++ `ClipBlock::connected_viewer()`
//! (a `ViewerOutput` = sequence feeding the clip's `buffer_in`), the
//! timeline menu's enable condition;
//! * [`multicam_state_for_clip`] resolves a clip to its multicam node, the
//! source sequence, the source count and the current source — the panel's
//! grid state;
//! * [`clip_at_playhead_with_multicam`] is the third detection level (the
//! clip under the playhead on the video tracks), used when nothing is
//! selected.
use oakcore_rs::Rational;
use oaknode::block::clip_input;
use oaknode::graph::Graph;
use oaknode::id::NodeId;
use oaknode::nodes::multicamnode::{SEQUENCE_INPUT, SEQUENCE_TYPE_INPUT};
use oaknode::sequence::SequenceBehavior;
use oaknode::track::TrackType;
use super::engine::MulticamState;
use super::graphops::{self, lock, ProjectRef};
/// Whether `id` names a sequence node (C++ `dynamic_cast<Sequence*>` /
/// the facade's `oakengine_node_is_sequence`).
pub fn is_sequence(g: &Graph, id: NodeId) -> bool {
g.get(id)
.and_then(|e| e.behavior.as_any())
.and_then(|a| a.downcast_ref::<SequenceBehavior>())
.is_some()
}
/// The node feeding `node.input[input][element]`, if any.
fn connected_output(g: &Graph, node: NodeId, input: &str, element: i32) -> Option<NodeId> {
g.connected_output(node, input, element)
}
/// The C++ `find_input_node_internal` walk: check `node`'s input
/// connections for a match, recursing into each source. Collects the first
/// sequence found (stopping at `maximum` matches, `0` = unlimited).
fn find_sequence_internal(
g: &Graph,
node: NodeId,
maximum: usize,
list: &mut Vec<NodeId>,
) {
for (from, _input, _element) in g.input_connections(node) {
if is_sequence(g, from) {
list.push(from);
if maximum != 0 && list.len() == maximum {
return;
}
}
find_sequence_internal(g, from, maximum, list);
if maximum != 0 && list.len() == maximum {
return;
}
}
}
/// The C++ `find_input_nodes_connected_to_input<ViewerOutput>(input, 1)`:
/// the first sequence feeding the clip's texture input (`buffer_in`), depth
/// 1 and then along the dependency chain — the clip's "connected viewer".
/// This is the timeline Multi-Cam menu's enable condition (a clip whose
/// source is a sequence can host a multicam).
pub fn clip_connected_sequence(g: &Graph, clip: NodeId) -> Option<NodeId> {
let source = connected_output(g, clip, clip_input::TEXTURE_INPUT, -1)?;
if is_sequence(g, source) {
return Some(source);
}
let mut list = Vec::new();
find_sequence_internal(g, source, 1, &mut list);
list.first().copied()
}
/// The sequence a multicam node pulls its angles from (the `sequence_in`
/// edge target).
pub fn multicam_sequence(p: &ProjectRef, mc: NodeId) -> Option<NodeId> {
let g = lock(p);
connected_output(&g.graph, mc, SEQUENCE_INPUT, -1)
}
/// The source sequence's track for angle `source` (the track whose clip
/// makes up that angle), or `None` when the source is out of range.
pub fn multicam_source_track(p: &ProjectRef, mc: NodeId, source: i32) -> Option<NodeId> {
let g = lock(p);
let seq = connected_output(&g.graph, mc, SEQUENCE_INPUT, -1)?;
let kind = multicam_sequence_type(&g.graph, mc);
graphops::track_ids(&g.graph, seq, kind).get(source as usize).copied()
}
/// The track-type selector of a multicam node (`sequence_type_in`):
/// [`TrackType::Video`] (0) or [`TrackType::Audio`] (1); defaults to video
/// when the node is stale.
pub fn multicam_sequence_type(g: &Graph, mc: NodeId) -> TrackType {
g.get(mc)
.map(|e| {
let v = e.core.standard_value(SEQUENCE_TYPE_INPUT, -1).to_double() as i32;
TrackType::from_c(v).unwrap_or(TrackType::Video)
})
.unwrap_or(TrackType::Video)
}
/// The number of angle sources of a multicam node: the connected source
/// sequence's track count of the node's `sequence_type_in` kind (the C++
/// `get_source_count()` resolves the same way when a sequence is set).
pub fn multicam_source_count(p: &ProjectRef, mc: NodeId) -> i32 {
let g = lock(p);
let Some(seq) = connected_output(&g.graph, mc, SEQUENCE_INPUT, -1) else {
return 0;
};
let kind = multicam_sequence_type(&g.graph, mc);
graphops::track_list_of(&g.graph, seq, kind)
.and_then(|list| graphops::track_list_behavior(&g.graph, list))
.map(|l| l.tracks.len() as i32)
.unwrap_or(0)
}
/// The currently selected source of a multicam node (`current_in` as int),
/// `-1` when stale.
pub fn multicam_current_source(p: &ProjectRef, mc: NodeId) -> i32 {
let g = lock(p);
g.graph
.get(mc)
.map(|e| e.core.standard_value(oaknode::nodes::multicamnode::CURRENT_INPUT, -1).to_double() as i32)
.unwrap_or(-1)
}
/// Resolve a clip to the full multicam state the panel displays: its
/// multicam node, the source sequence, the source count and the current
/// source. `None` when the clip has no multicam or the multicam has no
/// connected sequence.
pub fn multicam_state_for_clip(p: &ProjectRef, clip: NodeId) -> Option<MulticamState> {
let clip_ref = oaktimeline::util::NodeRef::new(p.clone(), clip);
let mc = oaktimeline::multicam::clip_find_multicam(&clip_ref)?;
let sequence_id = multicam_sequence(p, mc.id)?;
let source_count = multicam_source_count(p, mc.id);
Some(MulticamState {
sequence_id: sequence_id.identity(),
node_id: mc.id.identity(),
clip_id: clip.identity(),
source_count,
current_source: multicam_current_source(p, mc.id),
})
}
/// The clip covering `time` on the sequence's video tracks whose texture
/// chain contains a multicam node — the C++ detection's third level (the
/// playhead's nearest clip). `None` when no such clip exists.
pub fn clip_at_playhead_with_multicam(p: &ProjectRef, seq: NodeId, time: Rational) -> Option<NodeId> {
// Collect the candidates under the lock, then re-lock per clip via
// `clip_find_multicam` (which takes the project lock itself) — holding
// the guard across it would deadlock.
let candidates: Vec<NodeId> = {
let g = lock(p);
let mut out = Vec::new();
for track_id in graphops::track_ids(&g.graph, seq, TrackType::Video) {
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 time < clip.core.in_() || time >= clip.core.out() {
continue;
}
out.push(block_id);
}
}
out
};
candidates.into_iter().find(|block_id| {
let clip_ref = oaktimeline::util::NodeRef::new(p.clone(), *block_id);
oaktimeline::multicam::clip_find_multicam(&clip_ref).is_some()
})
}
#[cfg(test)]
mod tests {
use super::*;
use oaknode::block::ClipBlockBehavior;
use oaknode::node::NodeCore;
use oaknode::project::Project;
use oaknode::sequence::SequenceBehavior;
use oaknode::track::{TrackBehavior, TrackListBehavior};
use std::sync::{Arc, Mutex};
use oaktimeline::util::{
block_clip_create, block_in, track_append_block, NodeRef,
};
/// Project fixture: a sequence owning one video track list with one
/// video track.
struct Fixture {
project: Arc<Mutex<oaknode::project::Project>>,
seq: NodeRef,
track: NodeRef,
}
fn fixture() -> Fixture {
let project = Project::new();
let (seq_id, _list_id, track_id) = {
let mut p = project.lock().unwrap();
let (core, behavior) = SequenceBehavior::create();
let seq_id = p.graph.add_node(core, behavior);
let (core, behavior) = TrackListBehavior::create();
let list_id = p.graph.add_node(core, behavior);
let (core, behavior) = (NodeCore::new(), Box::new(TrackBehavior::new(TrackType::Video)));
let track_id = p.graph.add_node(core, behavior);
{
let seq = p
.graph
.get_mut(seq_id)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::<SequenceBehavior>()
.unwrap();
seq.track_lists.push(list_id);
}
let list = p.graph.get_mut(list_id).unwrap();
let l = list
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::<TrackListBehavior>()
.unwrap();
l.sequence = Some(seq_id);
l.tracks.push(track_id);
let track = p.graph.get_mut(track_id).unwrap();
let t = track
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::<TrackBehavior>()
.unwrap();
t.kind = TrackType::Video;
t.track_list = Some(list_id);
(seq_id, list_id, track_id)
};
let _ = _list_id;
Fixture {
project: project.clone(),
seq: NodeRef::new(project.clone(), seq_id),
track: NodeRef::new(project, track_id),
}
}
/// A clip spanning `[0, 50)` on the fixture's video track.
fn add_clip(fx: &Fixture) -> NodeRef {
let clip = block_clip_create(&fx.project);
{
let mut p = fx.project.lock().unwrap();
let c = p
.graph
.get_mut(clip.id)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::<ClipBlockBehavior>()
.unwrap();
c.core.range = oakcore_rs::TimeRange::new(Rational::new(0, 1), Rational::new(50, 1));
}
track_append_block(&fx.track, &clip);
clip
}
/// A plain footage-fed clip has no connected sequence (the C++ viewer
/// check); a sequence-fed clip resolves its source.
#[test]
fn connected_sequence_resolves_the_source() {
let fx = fixture();
let clip = add_clip(&fx);
let g = lock(&fx.project);
// No source at all: no connected sequence.
assert!(clip_connected_sequence(&g.graph, clip.id).is_none());
drop(g);
// Feed the clip from a sequence (a nested-sequence clip).
{
let mut p = fx.project.lock().unwrap();
p.graph
.connect(fx.seq.id, clip.id, clip_input::TEXTURE_INPUT, -1)
.unwrap();
}
let g = lock(&fx.project);
assert_eq!(clip_connected_sequence(&g.graph, clip.id), Some(fx.seq.id));
}
/// `multicam_state_for_clip` resolves the multicam node, its source
/// sequence and the source count; `None` without a multicam.
#[test]
fn multicam_state_resolves_node_sequence_and_count() {
let fx = fixture();
let clip = add_clip(&fx);
{
let mut p = fx.project.lock().unwrap();
p.graph
.connect(fx.seq.id, clip.id, clip_input::TEXTURE_INPUT, -1)
.unwrap();
}
// No multicam yet.
assert!(multicam_state_for_clip(&fx.project, clip.id).is_none());
// Enable multicam through the real command, then resolve.
let mut cmd = oaktimeline::multicam::MultiCamEnableCommand::new(
vec![clip.clone()],
fx.seq.clone(),
);
cmd.redo();
let state = multicam_state_for_clip(&fx.project, clip.id).expect("multicam enabled");
assert_eq!(state.sequence_id, fx.seq.id.identity());
assert_eq!(state.source_count, 1, "one video track = one source");
assert_eq!(state.current_source, 0);
// The connected sequence still resolves through the multicam.
let mc = oaktimeline::multicam::clip_find_multicam(&clip).unwrap();
assert_eq!(multicam_sequence(&fx.project, mc.id), Some(fx.seq.id));
}
/// `clip_at_playhead_with_multicam` finds the clip under the playhead on
/// the video tracks; a non-multicam clip under the playhead is skipped.
#[test]
fn playhead_clip_detection_prefers_multicam_clips() {
let fx = fixture();
let plain = add_clip(&fx);
// A second track with a multicam-enabled clip.
let (core, behavior) = (NodeCore::new(), Box::new(TrackBehavior::new(TrackType::Video)));
let track2 = {
let mut p = fx.project.lock().unwrap();
let id = p.graph.add_node(core, behavior);
let list = {
let seq = p
.graph
.get(fx.seq.id)
.unwrap()
.behavior
.as_any()
.unwrap()
.downcast_ref::<SequenceBehavior>()
.unwrap();
seq.track_lists[0]
};
let l = p
.graph
.get_mut(list)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::<TrackListBehavior>()
.unwrap();
l.tracks.push(id);
let t = p
.graph
.get_mut(id)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::<TrackBehavior>()
.unwrap();
t.kind = TrackType::Video;
t.track_list = Some(list);
id
};
let track2 = NodeRef::new(fx.project.clone(), track2);
let mc_clip = block_clip_create(&fx.project);
{
let mut p = fx.project.lock().unwrap();
let c = p
.graph
.get_mut(mc_clip.id)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::<ClipBlockBehavior>()
.unwrap();
c.core.range = oakcore_rs::TimeRange::new(Rational::new(0, 1), Rational::new(50, 1));
}
track_append_block(&track2, &mc_clip);
{
let mut p = fx.project.lock().unwrap();
p.graph
.connect(fx.seq.id, mc_clip.id, clip_input::TEXTURE_INPUT, -1)
.unwrap();
}
let mut cmd = oaktimeline::multicam::MultiCamEnableCommand::new(
vec![mc_clip.clone()],
fx.seq.clone(),
);
cmd.redo();
// At frame 25 the multicam clip (topmost video track) wins over the
// plain clip below it.
let found = clip_at_playhead_with_multicam(&fx.project, fx.seq.id, Rational::new(25, 1));
assert_eq!(found, Some(mc_clip.id));
let _ = plain;
assert_eq!(block_in(&mc_clip), Rational::new(0, 1));
}
}
+523 -2
View File
@@ -83,10 +83,11 @@ use oaknode::track::TrackType;
use oakrender::manager::RenderManager;
use oakrender::procpool::{bgra8_to_rgba8, ShmFrameRef};
use oaktimeline::handle::CHandle;
use oaktimeline::util::NodeRef;
use super::engine::{
AppEngine, EngineGateway, ExportSession, LibraryProject, Monitor, Project, ScopeData, Sequence,
VideoFormat,
AppEngine, EngineGateway, ExportSession, LibraryProject, Monitor, MulticamState, Project,
ScopeData, Sequence, VideoFormat,
};
use super::frames::{bgra_bytes_to_render_image, f32_rgba_to_bgra_image, synthetic_frame_samples};
use super::graphops::{self, ProjectRef};
@@ -357,6 +358,105 @@ fn thumbnail_path(filename: &str) -> PathBuf {
thumbnail_dir().join(format!("{h:016x}.png"))
}
// ---------------------------------------------------------------------------
// Multicam angle frames (M15 S2)
// ---------------------------------------------------------------------------
//
// The Multicam panel draws one cell per angle (= the source sequence's
// track `i` at the playhead). The engine renders those frames on background
// threads — the same ticket path as the viewers, one single-track montage
// per angle — and caches them keyed by (multicam node, source) with an LRU
// cap, so a paused panel never re-renders a cell and playback refreshes
// cells round-robin (the panel throttles its requests; the engine only ever
// has one in-flight render per source).
/// The grid cell render size: the sequence's aspect scaled to a 320px long
/// edge (the panel grid cells are roughly this size; keeping the tickets
/// small bounds the 9-angle burst cost).
const MULTICAM_ANGLE_LONG_EDGE: u32 = 320;
/// A completed multicam angle frame, delivered through the completion
/// channel (drained on the app tick, like full-res frames). `None` image =
/// the render failed; the drain still clears the in-flight marker so the
/// cell can be retried on the next invalidation.
struct MulticamAngleEvent {
/// The multicam node identity the frame belongs to.
node_id: u64,
/// The source index rendered.
source: i32,
/// The playhead frame the frame was rendered for.
playhead: i64,
/// The rendered display image (`None` when the render failed).
image: Option<Arc<RenderImage>>,
}
/// One background multicam angle render request (UI-thread-built; the
/// worker thread owns it from there).
struct MulticamAngleRequest {
/// The multicam node identity (the cache key's node half).
node_id: u64,
/// The source index.
source: i32,
/// The playhead frame to render.
playhead: i64,
/// The project (keeps the graph alive while the worker renders).
project: ProjectRef,
/// The source sequence node.
seq: NodeId,
/// The track whose clip makes up this angle.
track: NodeId,
/// Output width.
width: i32,
/// Output height.
height: i32,
/// The sequence's timebase.
tb: (i64, i64),
}
/// The multicam angle-frame cache: rendered frames keyed by
/// `(multicam node, source)` with the playhead they were rendered for,
/// LRU-capped, plus the in-flight sources per node.
#[derive(Default)]
struct MulticamFrameCache {
/// `(node_id, source) -> (rendered playhead, image)`, insertion-ordered
/// (the LRU eviction drops the head).
frames: Vec<((u64, i32), (i64, Arc<RenderImage>))>,
/// `(node_id, source)` renders currently in flight (never re-scheduled).
pending: HashSet<(u64, i32)>,
}
impl MulticamFrameCache {
/// The cached image for `(node, source)` rendered at exactly `playhead`
/// (a playhead change makes the frame stale).
fn lookup(&self, node: u64, source: i32, playhead: i64) -> Option<Arc<RenderImage>> {
self.frames
.iter()
.find(|(k, v)| k == &(node, source) && v.0 == playhead)
.map(|(_, (_, img))| img.clone())
}
/// The most recent image for `(node, source)` regardless of playhead
/// (the panel's stale-OK fallback during playback).
fn last(&self, node: u64, source: i32) -> Option<Arc<RenderImage>> {
self.frames
.iter()
.rev()
.find(|(k, _)| k == &(node, source))
.map(|(_, (_, img))| img.clone())
}
/// Store a freshly rendered frame, evicting the LRU head past the cap.
fn insert(&mut self, node: u64, source: i32, playhead: i64, image: Arc<RenderImage>) {
self.frames.retain(|(k, _)| k != &(node, source));
self.frames.push(((node, source), (playhead, image)));
const CAP: usize = 24;
if self.frames.len() > CAP {
let excess = self.frames.len() - CAP;
self.frames.drain(0..excess);
}
}
}
// ---------------------------------------------------------------------------
// Frame conversion
// ---------------------------------------------------------------------------
@@ -789,6 +889,16 @@ pub struct RealEngine {
/// The sending half of `thumb_rx` (cloned into every job).
thumb_tx: Mutex<mpsc::Sender<ThumbEvent>>,
proxy_runs: Vec<ProxyRun>,
/// The multicam angle-frame cache (rendered grid cells keyed by
/// (multicam node, source), LRU-capped). An `Arc` so the background
/// angle workers' completions can reach it; the mutex keeps the engine
/// `Sync`.
multicam_frames: Arc<Mutex<MulticamFrameCache>>,
/// The channel background multicam angle workers report finished frames
/// through; drained on the app tick.
multicam_rx: Mutex<mpsc::Receiver<MulticamAngleEvent>>,
/// The sending half of `multicam_rx` (cloned into every worker).
multicam_tx: Mutex<mpsc::Sender<MulticamAngleEvent>>,
}
impl RealEngine {
@@ -836,6 +946,7 @@ impl RealEngine {
let rate = VideoFormat::hd_1080p25().rate;
let (full_res_tx, full_res_rx) = mpsc::channel::<FullResEvent>();
let (thumb_tx, thumb_rx) = mpsc::channel::<ThumbEvent>();
let (multicam_tx, multicam_rx) = mpsc::channel::<MulticamAngleEvent>();
Self {
project: None,
sequence: None,
@@ -869,6 +980,9 @@ impl RealEngine {
thumb_rx: Mutex::new(thumb_rx),
thumb_tx: Mutex::new(thumb_tx),
proxy_runs: Vec::new(),
multicam_frames: Arc::new(Mutex::new(MulticamFrameCache::default())),
multicam_rx: Mutex::new(multicam_rx),
multicam_tx: Mutex::new(multicam_tx),
}
}
@@ -1140,6 +1254,156 @@ impl RealEngine {
}
}
// -----------------------------------------------------------------------
// Multi-camera (the Multicam panel grid + the timeline Multi-Cam menu)
// -----------------------------------------------------------------------
/// The program playhead as a sequence-frame timestamp (the angle render
/// time; 0 without a sequence). The sequence's stored playhead is
/// mirrored from the program clock on every seek/tick.
fn program_playhead_ts(&self) -> i64 {
let Some(project) = self.project_ref() else { return 0 };
let Some(seq) = self.sequence else { return 0 };
let Some(tb) = self.time_base() else { return 0 };
let time = graphops::sequence_playhead(&graphops::lock(project).graph, seq);
graphops::rational_to_ts(time, tb)
}
/// The multicam state the panel displays (the C++ viewer's
/// `detect_multicam_node`): the selected clip's multicam, falling back
/// to the clip under the program playhead on the video tracks. The
/// detection runs on demand, so the panel always reads a fresh answer;
/// the node-graph-selection level of the C++ is not ported (the Rust
/// node editor has no multicam selection).
fn multicam_state_internal(&self) -> Option<MulticamState> {
let project = self.project_ref()?;
let seq = self.sequence?;
if let Some(clip) = self.selected_clip_node() {
if let Some(state) = super::multicam::multicam_state_for_clip(project, clip) {
return Some(state);
}
}
let time = graphops::sequence_playhead(&graphops::lock(project).graph, seq);
let clip = super::multicam::clip_at_playhead_with_multicam(project, seq, time)?;
super::multicam::multicam_state_for_clip(project, clip)
}
/// Renders one multicam angle on a background thread (the same ticket
/// path as the viewers, one single-track montage per angle) and reports
/// the finished frame through `tx`.
fn multicam_angle_worker(request: MulticamAngleRequest, tx: mpsc::Sender<MulticamAngleEvent>) {
let MulticamAngleRequest {
node_id,
source,
playhead,
project,
seq,
track,
width,
height,
tb,
} = request;
let mut image = None;
if super::renderops::ensure_render_manager() {
if let Ok(rendered) = super::renderops::render_multicam_angle_frame(
&project, seq, track, playhead, tb, width, height,
) {
image = rendered_to_owned_image(&rendered);
release_rendered_frame(&rendered);
}
}
// Always report (also on failure) so the in-flight marker clears.
let _ = tx.send(MulticamAngleEvent {
node_id,
source,
playhead,
image,
});
}
/// The engine's [`AppEngine::multicam_angle_frame`]: returns the cached
/// angle frame for the current playhead when present, otherwise
/// schedules a background render (deduplicated per source) and returns
/// `None`. The panel shows its last image until the frame lands.
fn multicam_angle_frame_internal(&mut self, source: i32) -> Option<Arc<RenderImage>> {
let Some(state) = self.multicam_state_internal() else {
return None;
};
if source < 0 || source >= state.source_count {
return None;
}
let Some(project) = self.project.clone() else { return None };
let Some(seq) = self.sequence else { return None };
let Some(tb) = self.time_base() else { return None };
let playhead = self.program_playhead_ts();
// Exact-playhead cache hit.
if let Some(img) = self
.multicam_frames
.lock()
.unwrap()
.lookup(state.node_id, source, playhead)
{
return Some(img);
}
let Some(mc) = graphops::id_of(state.node_id) else {
return None;
};
let Some(track) = super::multicam::multicam_source_track(&project, mc, source) else {
return None;
};
// The panel may outlive a stale node (a multicam removed under it):
// treat a node mismatch as a fresh cache.
let mut cache = self.multicam_frames.lock().unwrap();
if cache.pending.contains(&(state.node_id, source)) {
return cache.last(state.node_id, source);
}
let (width, height) = {
let info = self.sequence_info.as_ref()?;
let (w, h) = (info.format.width.max(1), info.format.height.max(1));
let scale = MULTICAM_ANGLE_LONG_EDGE as f64 / w.max(h) as f64;
(((w as f64 * scale).round() as u32).max(2) as i32, ((h as f64 * scale).round() as u32).max(2) as i32)
};
cache.pending.insert((state.node_id, source));
let request = MulticamAngleRequest {
node_id: state.node_id,
source,
playhead,
project,
seq,
track,
width,
height,
tb,
};
let tx = self.multicam_tx.lock().unwrap().clone();
std::thread::spawn(move || Self::multicam_angle_worker(request, tx));
cache.last(state.node_id, source)
}
/// Installs completed multicam angle frames into the cache and repaints
/// (the panel re-reads the fresh cell images on the next render).
fn drain_multicam_frames(&mut self, cx: &mut Context<Self>) {
let rx = self.multicam_rx.lock().unwrap();
let mut any = false;
while let Ok(event) = rx.try_recv() {
any = true;
let mut cache = self.multicam_frames.lock().unwrap();
cache.pending.remove(&(event.node_id, event.source));
if let Some(image) = event.image {
cache.insert(event.node_id, event.source, event.playhead, image);
}
}
if any {
cx.notify();
}
}
/// Clears the multicam angle cache (project drop / edit invalidation).
fn clear_multicam_frames(&mut self) {
self.multicam_frames.lock().unwrap().frames.clear();
self.multicam_frames.lock().unwrap().pending.clear();
}
// -----------------------------------------------------------------------
// M15 S2: playback pre-render window
// -----------------------------------------------------------------------
@@ -1348,6 +1612,7 @@ impl RealEngine {
self.full_res_generation = self.full_res_generation.wrapping_add(1);
self.preview_generation = self.preview_generation.wrapping_add(1);
self.cancel_preview_windows();
self.clear_multicam_frames();
}
/// Attaches cached thumbnails to the bin entries, spawning a background
@@ -2417,6 +2682,7 @@ impl EngineGateway for RealEngine {
self.drain_full_res();
self.drain_thumbnails();
self.drain_proxy_runs(cx);
self.drain_multicam_frames(cx);
self.schedule_full_res(Monitor::Source, cx);
self.schedule_full_res(Monitor::Program, cx);
cx.notify();
@@ -3834,6 +4100,142 @@ impl AppEngine for RealEngine {
)?;
Ok(super::renderops::spawn_export(&project, seq, params))
}
fn multicam_state(&self) -> Option<MulticamState> {
self.multicam_state_internal()
}
fn multicam_angle_frame(&mut self, source: i32, _cx: &mut Context<Self>) -> Option<Arc<RenderImage>> {
self.multicam_angle_frame_internal(source)
}
fn multicam_eligible(&self, clips: &[ClipId]) -> bool {
let Some(project) = self.project_ref() else {
return false;
};
let g = graphops::lock(project);
for id in clips {
let Some(block) = graphops::id_of(id.0) else {
continue;
};
if graphops::clip_behavior(&g.graph, block).is_none() {
continue;
}
if super::multicam::clip_connected_sequence(&g.graph, block).is_some() {
return true;
}
}
false
}
fn multicam_enabled_on_selection(&self, clips: &[ClipId]) -> bool {
let Some(project) = self.project_ref() else {
return false;
};
for id in clips {
let Some(block) = graphops::id_of(id.0) else {
continue;
};
if graphops::clip_behavior(&graphops::lock(project).graph, block).is_none() {
continue;
}
let clip_ref = NodeRef::new(project.clone(), block);
if oaktimeline::multicam::clip_find_multicam(&clip_ref).is_some() {
return true;
}
}
false
}
fn multicam_enable_selected(&mut self, clips: Vec<ClipId>, enabled: bool, cx: &mut Context<Self>) {
let Some(project) = self.project.clone() else {
return;
};
// Resolve the selected clips' block nodes. Enable additionally needs
// each clip's connected sequence (the clip's source must be a
// sequence — the C++ `connected_viewer()` check); disable just walks
// every selected clip (`multicam_disable` skips clips without a
// multicam itself).
let mut all_clips: Vec<NodeId> = Vec::new();
let mut eligible: Vec<(NodeId, NodeId)> = Vec::new();
{
let g = graphops::lock(&project);
for id in &clips {
let Some(block) = graphops::id_of(id.0) else {
continue;
};
if graphops::clip_behavior(&g.graph, block).is_none() {
continue;
}
all_clips.push(block);
if let Some(seq) = super::multicam::clip_connected_sequence(&g.graph, block) {
eligible.push((block, seq));
}
}
}
let result = if enabled {
if eligible.is_empty() {
return;
}
// Group the clips by their connected sequence (one enable
// command per sequence; the common case is a single sequence).
let mut by_seq: HashMap<NodeId, Vec<NodeRef>> = HashMap::new();
for (block, seq) in &eligible {
by_seq
.entry(*seq)
.or_default()
.push(NodeRef::new(project.clone(), *block));
}
let children: Vec<_> = by_seq
.into_iter()
.map(|(seq, clips)| {
oaktimeline::multicam::multicam_enable(
clips,
NodeRef::new(project.clone(), seq),
)
})
.collect();
let label = oaktimeline::multicam::enable_label(eligible.len());
graphops::push_multi_command(children, &label)
} else {
let clip_refs: Vec<NodeRef> = all_clips
.iter()
.map(|block| NodeRef::new(project.clone(), *block))
.collect();
let label = oaktimeline::multicam::disable_label(all_clips.len());
graphops::push_command(oaktimeline::multicam::multicam_disable(clip_refs), &label)
};
self.apply_edit(result, "multicam enable/disable", cx);
}
fn multicam_switch_to(&mut self, source: i32, split_clip: bool, cx: &mut Context<Self>) {
let Some(project) = self.project.clone() else {
return;
};
let Some(seq) = self.sequence else {
return;
};
let Some(state) = self.multicam_state_internal() else {
return;
};
if source < 0 || source >= state.source_count {
return;
}
let Some(clip) = graphops::id_of(state.clip_id) else {
return;
};
let playhead = graphops::sequence_playhead(&graphops::lock(&project).graph, seq);
let cmd = oaktimeline::multicam::multicam_switch(
NodeRef::new(project.clone(), clip),
source,
split_clip,
playhead,
);
let result =
graphops::push_command(cmd, oaktimeline::multicam::SWITCH_LABEL);
self.apply_edit(result, "multicam switch", cx);
}
fn backend_name(&self) -> &'static str {
"real"
}
@@ -4864,6 +5266,125 @@ mod tests {
oakundo::global::clear().unwrap();
}
/// The multicam switch through the UI path (`multicam_switch_to`): it
/// lands on the global undo stack as ONE entry and the engine's
/// undo/redo round-trip it — the digit keys, the `⌘` variants and the
/// grid clicks all run this exact path. Also covers the timeline menu's
/// eligibility/checked state and the enable/disable detection.
#[gpui::test]
async fn real_engine_multicam_switch_round_trips_through_undo(
cx: &mut gpui::TestAppContext,
) {
use oaknode::block::clip_input::TEXTURE_INPUT;
let _media = media_lock();
let engine = cx.update(|cx| cx.new(|cx| RealEngine::create(cx)));
// A project whose clip is fed by a sequence (the multicam host).
let clip_id = cx.update(|app| {
engine.update(app, |engine, cx| {
let project = graphops::create_project();
let seq = graphops::create_sequence(&project, "Multicam Test");
graphops::add_track(&project, seq, TrackType::Video).unwrap();
graphops::add_track(&project, seq, TrackType::Video).unwrap();
let clip = oaktimeline::util::block_clip_create(&project);
{
let mut g = graphops::lock(&project);
let c = g
.graph
.get_mut(clip.id)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::<oaknode::block::ClipBlockBehavior>()
.unwrap();
c.core.range = oakcore_rs::TimeRange::new(
oakcore_rs::Rational::new(0, 1),
oakcore_rs::Rational::new(100, 1),
);
c.core.media_in = oakcore_rs::Rational::new(0, 1);
}
let track0 = {
let g = graphops::lock(&project);
graphops::track_ids(&g.graph, seq, TrackType::Video)[0]
};
oaktimeline::util::track_append_block(
&oaktimeline::util::NodeRef::new(project.clone(), track0),
&clip,
);
{
let mut g = graphops::lock(&project);
g.graph.connect(seq, clip.id, TEXTURE_INPUT, -1).unwrap();
}
let clip_id = ClipId(clip.id.identity());
engine.adopt_project(project, cx);
clip_id
})
});
// Select the clip and enable multicam through the UI path.
cx.update(|app| {
engine.update(app, |engine, cx| {
engine.set_selected_clips(vec![clip_id], cx)
})
});
cx.update(|app| {
engine.update(app, |engine, cx| {
engine.multicam_enable_selected(vec![clip_id], true, cx)
})
});
// The timeline menu's enable + checked state reflect the clip.
assert!(cx.read(|app| engine.read(app).multicam_eligible(&[clip_id])));
assert!(cx.read(|app| engine.read(app).multicam_enabled_on_selection(&[clip_id])));
// The detection (selection → clip → find_multicam) resolves the
// source count from the source sequence's video tracks.
let state = cx
.read(|app| engine.read(app).multicam_state())
.expect("a selected multicam clip is detected");
assert_eq!(state.source_count, 2, "two video tracks = two angles");
assert_eq!(state.current_source, 0);
// Switch through the UI path (no split: the playhead sits at the
// clip's in point, so the switch is a plain current_in write).
cx.update(|app| {
engine.update(app, |engine, cx| engine.multicam_switch_to(1, false, cx))
});
let state = cx
.read(|app| engine.read(app).multicam_state())
.expect("still detected after the switch");
assert_eq!(state.current_source, 1);
// ONE undo entry restores the previous source; redo re-applies it.
cx.update(|app| engine.update(app, |engine, cx| engine.undo(cx)));
assert_eq!(
cx.read(|app| engine.read(app).multicam_state()).unwrap().current_source,
0,
"undo restores the pre-switch source"
);
cx.update(|app| engine.update(app, |engine, cx| engine.redo(cx)));
assert_eq!(
cx.read(|app| engine.read(app).multicam_state()).unwrap().current_source,
1,
"redo re-applies the switched source"
);
// Disabling through the UI path clears the detection (the panel
// falls back to its empty state).
cx.update(|app| {
engine.update(app, |engine, cx| {
engine.multicam_enable_selected(vec![clip_id], false, cx)
})
});
assert!(
cx.read(|app| engine.read(app).multicam_state()).is_none(),
"disabling multicam clears the detection"
);
oakundo::global::clear().unwrap();
}
/// M12 P2 acceptance: a real project with a sequence + footage clip
/// builds a NON-EMPTY node graph with the wires the node editor shows:
/// the footage feeds the clip's `tex_in` (a real edge), and every clip
+147
View File
@@ -186,6 +186,103 @@ pub fn video_montage(p: &ProjectRef, seq: NodeId, time: Rational) -> Vec<Montage
clips
}
/// The video montage at sequence time `time` containing ONLY the clip on
/// `track` (a track of the sequence's video track list), if any covers
/// `time`. This is the multicam angle render: each angle is the source
/// sequence's track `i` at the playhead, so the montage carries just that
/// track's clip instead of the whole stack.
pub fn single_track_video_montage(
p: &ProjectRef,
seq: NodeId,
track: NodeId,
time: Rational,
) -> Vec<MontageClip> {
let g = lock(p);
let mut clips = Vec::new();
let Some(s) = sequence_behavior(&g.graph, seq) else {
return clips;
};
// The track must belong to the sequence's video track list.
let in_list = s.track_lists.iter().any(|&list_id| {
track_list_behavior(&g.graph, list_id)
.map(|l| l.kind == TrackType::Video && l.tracks.contains(&track))
.unwrap_or(false)
});
if !in_list {
return clips;
}
let Some(track) = track_behavior(&g.graph, track) else {
return clips;
};
if track.muted {
return clips;
}
for &block_id in &track.blocks {
let Some(clip) = clip_behavior(&g.graph, block_id) else {
continue;
};
let in_ = clip.core.in_();
let out = clip.core.out();
if time < in_ || time >= out {
continue;
}
let Some((filename, stream_index)) = clip_preview_media(&g.graph, block_id, true) else {
continue;
};
clips.push(MontageClip {
filename,
stream_index,
in_time: in_,
out_time: out,
media_in: clip.core.media_in,
gain: 1.0,
});
}
clips
}
/// Build the video ticket params for one multicam angle: the clip on
/// `track` (a video track of `seq`, the multicam's source sequence) at the
/// playhead timestamp `frame_ts`.
pub fn multicam_angle_frame_params(
p: &ProjectRef,
seq: NodeId,
track: NodeId,
frame_ts: i64,
tb: (i64, i64),
width: i32,
height: i32,
) -> Result<VideoTicketParams, String> {
validate_geometry(width, height, tb)?;
let time = Rational::new(frame_ts * tb.0, tb.1);
Ok(VideoTicketParams {
viewer: seq.identity(),
time,
force_size: Some((width, height)),
force_format: None,
cache: None,
cache_dir: None,
cache_id: None,
cache_timebase: None,
footage: None,
montage: single_track_video_montage(p, seq, track, time),
})
}
/// Render one multicam angle frame (the clip on `track` of the source
/// sequence at `frame_ts`) into a `(width, height)` frame.
pub fn render_multicam_angle_frame(
p: &ProjectRef,
seq: NodeId,
track: NodeId,
frame_ts: i64,
tb: (i64, i64),
width: i32,
height: i32,
) -> Result<RenderedFrame, String> {
render_video(multicam_angle_frame_params(p, seq, track, frame_ts, tb, width, height)?)
}
/// The audio montage over `range`: every audio clip overlapping the
/// range, media times resolved from the clip ranges, audio stream 1.
/// Muted tracks are silenced (skipped entirely).
@@ -707,6 +804,56 @@ mod tests {
let _ = std::fs::remove_file(&media);
}
/// The multicam angle montage ([`single_track_video_montage`]) carries
/// ONLY the clip on the requested track — the whole-stack `video_montage`
/// is the parity reference. This is the montage the angle-frame ticket
/// renders for each grid cell.
#[test]
fn single_track_montage_isolates_its_track() {
let _media = media_lock();
let media =
std::env::temp_dir().join(format!("oakapp_montage_ang_{}.mp4", std::process::id()));
oakcodec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("generate test media");
let (project, seq, footage) = project_with_clip(&media);
graphops::add_track(&project, seq, TrackType::Video).expect("add a second video track");
// A second clip on track 1 overlapping the same time.
graphops::place_footage_clip(&project, seq, footage, TrackType::Video, 1, 0, 10, 0)
.expect("place the second clip");
let tb = graphops::sequence_time_base(&lock(&project).graph, seq).unwrap();
let at = |frame: i64| graphops::ts_to_rational(frame, tb);
let tracks = {
let g = lock(&project);
graphops::track_ids(&g.graph, seq, TrackType::Video)
};
assert_eq!(tracks.len(), 2, "two video tracks");
// The whole stack sees both clips; the single-track montage sees only
// its own track's clip.
assert_eq!(video_montage(&project, seq, at(0)).len(), 2);
let track0_only = single_track_video_montage(&project, seq, tracks[0], at(0));
assert_eq!(track0_only.len(), 1, "track 0 contributes its own clip");
assert_eq!(track0_only[0].filename, media.to_string_lossy());
let track1_only = single_track_video_montage(&project, seq, tracks[1], at(0));
assert_eq!(track1_only.len(), 1, "track 1 contributes its own clip");
// A hidden video track contributes nothing (the angle's track is
// skipped, matching the full montage's muted-track rule).
graphops::set_track_muted(&project, tracks[0], true).expect("hide track 0");
assert!(
single_track_video_montage(&project, seq, tracks[0], at(0)).is_empty(),
"a hidden angle track renders nothing"
);
assert_eq!(
single_track_video_montage(&project, seq, tracks[1], at(0)).len(),
1,
"the other track is unaffected"
);
oakundo::global::clear().unwrap();
let _ = std::fs::remove_file(&media);
}
#[test]
fn audio_montage_overlaps_the_range() {
let _media = media_lock();