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