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
+52 -2
View File
@@ -199,6 +199,7 @@ define_actions! {
FocusHistory { cpp: "focushistory", i18n: "menu.window.history", keys: [], route: Global, menu_id: 606 };
FocusTimeline { cpp: "focustimeline", i18n: "menu.window.timeline", keys: [], route: Global, menu_id: 607 };
FocusEffectLibrary { cpp: "focuseffectlibrary", i18n: "menu.window.effect_library", keys: [], route: Global, menu_id: 608 };
FocusMulticam { cpp: "focusmulticam", i18n: "menu.window.multicam", keys: [], route: Global, menu_id: 609 };
MaximizePanel { cpp: "maximizepanel", i18n: "menu.window.maximize_panel", keys: ["`"], route: Global, menu_id: 1070 };
ResetDefaultLayout { cpp: "resetdefaultlayout", i18n: "menu.window.reset_layout", keys: [], route: Global, menu_id: 1071 };
@@ -231,6 +232,32 @@ define_actions! {
SyncBySourceTime { cpp: "syncsourcetime", i18n: "timeline.context.sync_source_time", keys: [], route: FocusedPanel, menu_id: 1130 };
SyncByWaveform { cpp: "syncwaveform", i18n: "timeline.context.sync_waveform", keys: ["ctrl-shift-w"], route: FocusedPanel, menu_id: 1131 };
SyncByWaveformSpeed { cpp: "syncwaveformspeed", i18n: "timeline.context.sync_waveform_speed", keys: [], route: FocusedPanel, menu_id: 1132 };
// The multicam source-switch hotkeys. Like the C++ `QShortcut`s attached
// directly to the `MulticamWidget`, they are panel-context hotkeys, not
// menu items: `menu_id` is [`HIDDEN_MENU_ID`] and the menus never list
// them. They route to the focused panel (the multicam panel handles
// them; any other focused panel falls through to the no-op global
// handler). The digit keys switch and split the clip (the C++ plain
// `1`..`9`); the `secondary-` variants switch without splitting
// (`Ctrl+1`..`Ctrl+9`).
MulticamSwitch1 { cpp: "multicamswitch1", i18n: "multicam.switch_1", keys: ["1"], route: FocusedPanel, menu_id: 0 };
MulticamSwitch2 { cpp: "multicamswitch2", i18n: "multicam.switch_2", keys: ["2"], route: FocusedPanel, menu_id: 0 };
MulticamSwitch3 { cpp: "multicamswitch3", i18n: "multicam.switch_3", keys: ["3"], route: FocusedPanel, menu_id: 0 };
MulticamSwitch4 { cpp: "multicamswitch4", i18n: "multicam.switch_4", keys: ["4"], route: FocusedPanel, menu_id: 0 };
MulticamSwitch5 { cpp: "multicamswitch5", i18n: "multicam.switch_5", keys: ["5"], route: FocusedPanel, menu_id: 0 };
MulticamSwitch6 { cpp: "multicamswitch6", i18n: "multicam.switch_6", keys: ["6"], route: FocusedPanel, menu_id: 0 };
MulticamSwitch7 { cpp: "multicamswitch7", i18n: "multicam.switch_7", keys: ["7"], route: FocusedPanel, menu_id: 0 };
MulticamSwitch8 { cpp: "multicamswitch8", i18n: "multicam.switch_8", keys: ["8"], route: FocusedPanel, menu_id: 0 };
MulticamSwitch9 { cpp: "multicamswitch9", i18n: "multicam.switch_9", keys: ["9"], route: FocusedPanel, menu_id: 0 };
MulticamSwitchNoSplit1 { cpp: "multicamswitch1nosplit", i18n: "multicam.switch_1", keys: ["secondary-1"], route: FocusedPanel, menu_id: 0 };
MulticamSwitchNoSplit2 { cpp: "multicamswitch2nosplit", i18n: "multicam.switch_2", keys: ["secondary-2"], route: FocusedPanel, menu_id: 0 };
MulticamSwitchNoSplit3 { cpp: "multicamswitch3nosplit", i18n: "multicam.switch_3", keys: ["secondary-3"], route: FocusedPanel, menu_id: 0 };
MulticamSwitchNoSplit4 { cpp: "multicamswitch4nosplit", i18n: "multicam.switch_4", keys: ["secondary-4"], route: FocusedPanel, menu_id: 0 };
MulticamSwitchNoSplit5 { cpp: "multicamswitch5nosplit", i18n: "multicam.switch_5", keys: ["secondary-5"], route: FocusedPanel, menu_id: 0 };
MulticamSwitchNoSplit6 { cpp: "multicamswitch6nosplit", i18n: "multicam.switch_6", keys: ["secondary-6"], route: FocusedPanel, menu_id: 0 };
MulticamSwitchNoSplit7 { cpp: "multicamswitch7nosplit", i18n: "multicam.switch_7", keys: ["secondary-7"], route: FocusedPanel, menu_id: 0 };
MulticamSwitchNoSplit8 { cpp: "multicamswitch8nosplit", i18n: "multicam.switch_8", keys: ["secondary-8"], route: FocusedPanel, menu_id: 0 };
MulticamSwitchNoSplit9 { cpp: "multicamswitch9nosplit", i18n: "multicam.switch_9", keys: ["secondary-9"], route: FocusedPanel, menu_id: 0 };
Preferences { cpp: "prefs", i18n: "menu.view.preferences", keys: ["secondary-,"], route: Global, menu_id: 305 };
// --- Help ---------------------------------------------------------------
@@ -276,8 +303,20 @@ impl ActionEntry {
}
}
/// The registry entry bound to a menu item id, if any.
/// The menu id of the panel-context hotkeys that have no menu item (the
/// multicam source-switch keys). [`ActionId::menu_id`] returns it for those
/// actions; the menus never build an item with this id, and
/// [`entry_for_menu_id`] refuses it so a stray menu dispatch can never hit
/// a hidden action.
pub const HIDDEN_MENU_ID: usize = 0;
/// The registry entry bound to a menu item id, if any. Menu ids
/// [`HIDDEN_MENU_ID`] (the panel-context hotkeys' placeholder) resolve to
/// `None`.
pub fn entry_for_menu_id(id: usize) -> Option<&'static ActionEntry> {
if id == HIDDEN_MENU_ID {
return None;
}
REGISTRY.iter().find(|entry| entry.action.menu_id() == id)
}
@@ -432,11 +471,15 @@ mod tests {
}
/// Every menu id is unique (the menu bar reports plain ids; a duplicate
/// would make two items dispatch the same action).
/// would make two items dispatch the same action). Panel-context
/// hotkeys share [`HIDDEN_MENU_ID`] (no menu item) and are skipped.
#[test]
fn registry_menu_ids_are_unique() {
let mut seen = std::collections::HashSet::new();
for entry in REGISTRY {
if entry.menu_id() == HIDDEN_MENU_ID {
continue;
}
assert!(
seen.insert(entry.menu_id()),
"duplicate menu id {} ({})",
@@ -508,10 +551,17 @@ mod tests {
&crate::panels::timeline::clip_menu(
crate::oakui::engine::SyncEligibility::default(),
&[],
None,
),
&mut ids,
);
for entry in REGISTRY {
// The panel-context hotkeys (multicam source switches) are bound
// to the focused panel, not to any menu — the C++ attaches them
// straight to the MulticamWidget.
if entry.menu_id() == HIDDEN_MENU_ID {
continue;
}
assert!(
ids.contains(&entry.menu_id()),
"action {} (menu id {}) has no menu item",
+52 -1
View File
@@ -64,6 +64,7 @@ use crate::panels::effect_library::EffectLibraryPanel;
use crate::panels::history::HistoryPanel;
use crate::panels::ids::*;
use crate::panels::inspector::InspectorPanel;
use crate::panels::multicam::MulticamPanel;
use crate::panels::node_editor::NodeEditorPanel;
use crate::panels::program_viewer::ProgramViewerPanel;
use crate::panels::project_explorer::ProjectExplorerPanel;
@@ -200,6 +201,7 @@ impl<E: AppEngine> PanelRegistry for AppPanelRegistry<E> {
HISTORY => "history",
TIMELINE => "timeline",
EFFECT_LIBRARY => "effect-library",
MULTICAM => "multicam",
_ => return None,
}
.to_string(),
@@ -263,6 +265,12 @@ impl<E: AppEngine> PanelRegistry for AppPanelRegistry<E> {
}),
cx,
)),
"multicam" => Some(PanelHandle::new(
cx.new(|cx| {
MulticamPanel::new(self.engine.clone(), self.program_clock.clone(), window, cx)
}),
cx,
)),
_ => None,
}
}
@@ -319,6 +327,7 @@ struct ShellPanels<E: AppEngine> {
history: Entity<HistoryPanel<E>>,
timeline: Entity<TimelinePanel<E>>,
effect_library: Entity<EffectLibraryPanel<E>>,
multicam: Entity<MulticamPanel<E>>,
}
impl<E: AppEngine> OakApp<E> {
@@ -403,6 +412,8 @@ impl<E: AppEngine> OakApp<E> {
let history = cx.new(|cx| HistoryPanel::new(engine.clone(), window, cx));
let timeline_panel =
cx.new(|cx| TimelinePanel::new(engine.clone(), timeline.clone(), window, cx));
let multicam_panel =
cx.new(|cx| MulticamPanel::new(engine.clone(), program_clock.clone(), window, cx));
// Keep the panel entities for focused-panel command routing (the dock
// only hands back type-erased handles).
@@ -415,6 +426,7 @@ impl<E: AppEngine> OakApp<E> {
history: history.clone(),
timeline: timeline_panel.clone(),
effect_library: effect_library.clone(),
multicam: multicam_panel.clone(),
};
// Wire each panel's right-click menu: registry-backed items come
@@ -493,6 +505,17 @@ impl<E: AppEngine> OakApp<E> {
}),
cx,
);
// The multicam panel tabs behind the program viewer (the C++
// default is hidden; the 窗口 menu's Focus Multicam brings it
// forward). The program viewer stays the group's active tab.
dock.add_panel(
PanelHandle::new(multicam_panel, cx),
Some(DropTarget {
panel: Some(PROGRAM_VIEWER),
zone: DropZone::Center,
}),
cx,
);
});
// Tune the default split ratios: viewers 60% / timeline 40%, project
@@ -715,6 +738,10 @@ impl<E: AppEngine> OakApp<E> {
EFFECT_LIBRARY => self.panels.effect_library.update(cx, |panel, cx| {
panel_commands::dispatch_to(panel, action, cx)
}),
MULTICAM => self
.panels
.multicam
.update(cx, |panel, cx| panel_commands::dispatch_to(panel, action, cx)),
_ => false,
}
}
@@ -894,6 +921,7 @@ impl<E: AppEngine> OakApp<E> {
A::FocusHistory => self.focus_panel(HISTORY, cx),
A::FocusTimeline => self.focus_panel(TIMELINE, cx),
A::FocusEffectLibrary => self.focus_panel(EFFECT_LIBRARY, cx),
A::FocusMulticam => self.focus_panel(MULTICAM, cx),
// --- Tools -----------------------------------------------------
A::Snapping => {
let enabled = !self.timeline.read(cx).state.snap_enabled;
@@ -919,6 +947,27 @@ impl<E: AppEngine> OakApp<E> {
self.rebuild_menu_bar(cx);
}
A::ProxySettings => self.open_proxy_dialog(cx),
// The multicam source-switch hotkeys are scoped to the Multicam
// panel (the focused-panel route handles them there); a fall-through
// from any other focused panel is a silent no-op.
A::MulticamSwitch1
| A::MulticamSwitch2
| A::MulticamSwitch3
| A::MulticamSwitch4
| A::MulticamSwitch5
| A::MulticamSwitch6
| A::MulticamSwitch7
| A::MulticamSwitch8
| A::MulticamSwitch9
| A::MulticamSwitchNoSplit1
| A::MulticamSwitchNoSplit2
| A::MulticamSwitchNoSplit3
| A::MulticamSwitchNoSplit4
| A::MulticamSwitchNoSplit5
| A::MulticamSwitchNoSplit6
| A::MulticamSwitchNoSplit7
| A::MulticamSwitchNoSplit8
| A::MulticamSwitchNoSplit9 => {}
// --- everything else is a placeholder --------------------------
other => println!(
"[action] {} not wired yet (placeholder)",
@@ -2122,7 +2171,8 @@ fn make_menus(state: MenuState) -> Vec<MenuBarEntry> {
menu_item(A::FocusInspector),
menu_item(A::FocusHistory),
menu_item(A::FocusTimeline),
menu_item(A::FocusEffectLibrary).separated(),
menu_item(A::FocusEffectLibrary),
menu_item(A::FocusMulticam).separated(),
menu_item(A::MaximizePanel),
menu_item(A::ResetDefaultLayout),
]),
@@ -2488,6 +2538,7 @@ mod tests {
&crate::panels::timeline::clip_menu(
crate::oakui::engine::SyncEligibility::default(),
&[],
None,
),
&mut ids,
);
+26
View File
@@ -283,6 +283,7 @@ const EN: &[(&str, &str)] = &[
("menu.window.history", "History"),
("menu.window.timeline", "Timeline"),
("menu.window.effect_library", "Effect Library"),
("menu.window.multicam", "Multi-Cam"),
("menu.window.maximize_panel", "Maximize Panel"),
("menu.window.reset_layout", "Reset Layout"),
// --- Tools ---
@@ -324,6 +325,18 @@ const EN: &[(&str, &str)] = &[
("panel.history", "History"),
("panel.timeline", "Timeline"),
("panel.effect_library", "Effect Library"),
("panel.multicam", "Multi-Cam"),
// --- multicam panel ---
("multicam.no_multicam", "No multi-camera clip detected"),
("multicam.switch_1", "Switch to Camera 1"),
("multicam.switch_2", "Switch to Camera 2"),
("multicam.switch_3", "Switch to Camera 3"),
("multicam.switch_4", "Switch to Camera 4"),
("multicam.switch_5", "Switch to Camera 5"),
("multicam.switch_6", "Switch to Camera 6"),
("multicam.switch_7", "Switch to Camera 7"),
("multicam.switch_8", "Switch to Camera 8"),
("multicam.switch_9", "Switch to Camera 9"),
// --- effect library ---
("effect_library.hint", "Double-click to add to the selected clip"),
// --- status bar ---
@@ -701,6 +714,7 @@ const ZH: &[(&str, &str)] = &[
("menu.window.history", "历史记录"),
("menu.window.timeline", "时间线"),
("menu.window.effect_library", "效果库"),
("menu.window.multicam", "多机位"),
("menu.window.maximize_panel", "最大化面板"),
("menu.window.reset_layout", "重置布局"),
// --- Tools ---
@@ -742,6 +756,18 @@ const ZH: &[(&str, &str)] = &[
("panel.history", "历史记录"),
("panel.timeline", "时间线"),
("panel.effect_library", "效果库"),
("panel.multicam", "多机位"),
// --- multicam panel ---
("multicam.no_multicam", "未检测到多机位片段"),
("multicam.switch_1", "切换到机位 1"),
("multicam.switch_2", "切换到机位 2"),
("multicam.switch_3", "切换到机位 3"),
("multicam.switch_4", "切换到机位 4"),
("multicam.switch_5", "切换到机位 5"),
("multicam.switch_6", "切换到机位 6"),
("multicam.switch_7", "切换到机位 7"),
("multicam.switch_8", "切换到机位 8"),
("multicam.switch_9", "切换到机位 9"),
// --- effect library ---
("effect_library.hint", "双击添加到选中片段"),
// --- status bar ---
+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();
+28
View File
@@ -192,6 +192,16 @@ pub trait PanelCommandHandler: Sized {
false
}
// --- multi-camera (the Multicam panel's source-switch hotkeys) ---------
/// Switches the panel's multicam to source index `source`. `split_clip`
/// = the change applies from the playhead forward (the clip is split
/// first). The Multicam panel handles it; every other panel falls
/// through to the shell's no-op handler.
fn multicam_switch(&mut self, source: i32, split_clip: bool, cx: &mut Context<Self>) -> bool {
let _ = (source, split_clip, cx);
false
}
// --- view ----------------------------------------------------------------
fn zoom_in(&mut self, _cx: &mut Context<Self>) -> bool {
false
@@ -267,6 +277,24 @@ pub fn dispatch_to<P: PanelCommandHandler>(
ActionId::SyncBySourceTime => panel.sync_by_source_time(cx),
ActionId::SyncByWaveform => panel.sync_by_waveform(cx),
ActionId::SyncByWaveformSpeed => panel.sync_by_waveform_speed(cx),
ActionId::MulticamSwitch1 => panel.multicam_switch(0, true, cx),
ActionId::MulticamSwitch2 => panel.multicam_switch(1, true, cx),
ActionId::MulticamSwitch3 => panel.multicam_switch(2, true, cx),
ActionId::MulticamSwitch4 => panel.multicam_switch(3, true, cx),
ActionId::MulticamSwitch5 => panel.multicam_switch(4, true, cx),
ActionId::MulticamSwitch6 => panel.multicam_switch(5, true, cx),
ActionId::MulticamSwitch7 => panel.multicam_switch(6, true, cx),
ActionId::MulticamSwitch8 => panel.multicam_switch(7, true, cx),
ActionId::MulticamSwitch9 => panel.multicam_switch(8, true, cx),
ActionId::MulticamSwitchNoSplit1 => panel.multicam_switch(0, false, cx),
ActionId::MulticamSwitchNoSplit2 => panel.multicam_switch(1, false, cx),
ActionId::MulticamSwitchNoSplit3 => panel.multicam_switch(2, false, cx),
ActionId::MulticamSwitchNoSplit4 => panel.multicam_switch(3, false, cx),
ActionId::MulticamSwitchNoSplit5 => panel.multicam_switch(4, false, cx),
ActionId::MulticamSwitchNoSplit6 => panel.multicam_switch(5, false, cx),
ActionId::MulticamSwitchNoSplit7 => panel.multicam_switch(6, false, cx),
ActionId::MulticamSwitchNoSplit8 => panel.multicam_switch(7, false, cx),
ActionId::MulticamSwitchNoSplit9 => panel.multicam_switch(8, false, cx),
ActionId::ZoomIn => panel.zoom_in(cx),
ActionId::ZoomOut => panel.zoom_out(cx),
ActionId::IncreaseTrackHeight => panel.increase_track_height(cx),
+3
View File
@@ -27,6 +27,7 @@ pub mod commands;
pub mod effect_library;
pub mod history;
pub mod inspector;
pub mod multicam;
pub mod node_editor;
pub mod program_viewer;
pub mod project_explorer;
@@ -55,6 +56,8 @@ pub mod ids {
pub const TIMELINE: PanelId = PanelId::new(7);
/// The effect library (效果库).
pub const EFFECT_LIBRARY: PanelId = PanelId::new(8);
/// The multicam panel (多机位).
pub const MULTICAM: PanelId = PanelId::new(9);
}
/// A small info chip used in viewer headers and the status bar: muted
+525
View File
@@ -0,0 +1,525 @@
// 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/>.
//! The multicam panel (多机位): the C++ `MulticamWidget` + `MulticamDisplay`.
//!
//! The panel shows every angle of the detected multicam clip in a
//! rows×cols grid ([`MultiCamNode::rows_and_columns`], square-ish like the
//! C++), highlights the current source with a yellow box, and switches the
//! source on a grid click or the `1..9` / `⌘1..⌘9` hotkeys (route through
//! the action registry, see [`crate::actions::ActionId::MulticamSwitch1`]).
//!
//! Frame pipeline (M15 S2): the panel asks the engine for each angle's
//! frame through [`AppEngine::multicam_angle_frame`]; the engine renders
//! the source sequence's track at the playhead on a background thread and
//! caches it per (multicam node, source). The panel keeps its own
//! last-image map, so a cell keeps showing its last frame while the next
//! one renders:
//!
//! * resting playhead or right after a switch — every source is refreshed
//! in one pass;
//! * during playback — the refresh is throttled round-robin (a couple of
//! sources per tick), the rest show slightly-stale frames.
//!
//! Switch requests during playback are queued (the C++ `play_queue_`
//! semantics) and applied when the program playhead reaches the target
//! time. Every switch goes through `oaktimeline::multicam::multicam_switch`
//! on the global undo stack.
use std::collections::{HashMap, VecDeque};
use std::sync::Arc;
use gpui::colors::{Colors, DefaultColors};
use gpui::dock::{DockPanel, PanelEvent};
use gpui::{
canvas, div, img, prelude::*, px, AnyElement, App, Bounds, Context, Entity, EventEmitter,
MouseButton, ObjectFit, Pixels, Point, Rgba, Render, SharedString, Window,
};
use gpui_widgets::viewer::PlaybackClock;
use oaknode::nodes::multicamnode::MultiCamNode;
use crate::oakui::timecode::format_timecode;
use crate::oakui::{AppEngine, MulticamState};
use crate::panels::commands::PanelCommandHandler;
use crate::panels::ids::MULTICAM;
use crate::panels::chip;
/// The number of sources refreshed per tick during playback (the rest keep
/// their last frame until their turn — the task's round-robin throttle).
const PLAYBACK_REFRESH_PER_TICK: i32 = 2;
/// One pending switch (the C++ `MulticamWidget`'s `play_queue_`): the
/// switch applies once the program playhead reaches `target`.
#[derive(Debug, Clone, Copy)]
struct QueuedSwitch {
/// The source index to switch to.
source: i32,
/// Whether to split the clip at the playhead first.
split_clip: bool,
/// The playhead frame the switch was requested at.
target: i64,
}
/// The multicam panel.
pub struct MulticamPanel<E: AppEngine> {
engine: Entity<E>,
/// The program monitor's clock (playhead + playing state).
clock: Entity<E::Clock>,
/// The grid container's window-space bounds, recorded on every layout
/// by the invisible `canvas` child (click-to-cell conversion).
grid_bounds: Bounds<Pixels>,
/// The last resolved multicam state (compared by identity so a node or
/// source-count change clears the stale frames).
state: Option<MulticamState>,
/// The last image per source (stale-OK during playback; the engine's
/// cache holds the fresh frames).
frames: HashMap<i32, Arc<gpui::RenderImage>>,
/// Pending switches during playback ([`QueuedSwitch`]).
play_queue: VecDeque<QueuedSwitch>,
/// Round-robin cursor over the sources (playback refresh throttle).
refresh_cursor: i32,
/// The last program playhead (detects a jump / rest).
last_playhead: i64,
/// Set when a switch cleared the frames: the next refresh pass covers
/// every source immediately.
full_refresh: bool,
}
impl<E: AppEngine> MulticamPanel<E> {
/// Builds a panel over the program monitor's clock `clock`.
pub fn new(
engine: Entity<E>,
clock: Entity<E::Clock>,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> Self {
Self {
engine,
clock,
grid_bounds: Bounds::default(),
state: None,
frames: HashMap::new(),
play_queue: VecDeque::new(),
refresh_cursor: 0,
last_playhead: i64::MIN,
full_refresh: true,
}
}
/// Re-reads the engine's detected multicam; a node / source-count change
/// drops the stale angle frames.
fn sync_state(&mut self, cx: &mut Context<Self>) {
let state = self.engine.read(cx).multicam_state();
let changed = self
.state
.map(|s| s.node_id)
!= state.map(|s| s.node_id)
|| self.state.map(|s| s.source_count) != state.map(|s| s.source_count);
if changed {
self.frames.clear();
self.full_refresh = true;
self.play_queue.clear();
}
self.state = state;
}
/// Requests the fresh frame for one source (the engine returns the
/// cached frame for the current playhead, or schedules a background
/// render and returns `None`).
fn request_angle(&mut self, source: i32, cx: &mut Context<Self>) {
if let Some(image) = self
.engine
.update(cx, |engine, cx| engine.multicam_angle_frame(source, cx))
{
self.frames.insert(source, image);
}
}
/// Requests every angle; throttles to a round-robin subset during
/// playback (the rest keep their last frame).
fn refresh_frames(&mut self, cx: &mut Context<Self>) {
let Some(state) = self.state else {
return;
};
if state.source_count <= 0 {
return;
}
let playhead = self.clock.read(cx).current_frame().0;
let playing = self.clock.read(cx).is_playing();
// `saturating_sub` guards the initial `i64::MIN` sentinel (a first
// render counts as a jump → full refresh).
let jumped = playhead.saturating_sub(self.last_playhead).abs() > 1;
if self.full_refresh || !playing || jumped {
// Resting playhead (or just after a switch / jump): refresh all.
for source in 0..state.source_count {
self.request_angle(source, cx);
}
self.refresh_cursor = 0;
} else {
// Playback: round-robin a couple of sources per tick.
for _ in 0..state.source_count.min(PLAYBACK_REFRESH_PER_TICK) {
let source = self.refresh_cursor % state.source_count;
self.refresh_cursor += 1;
self.request_angle(source, cx);
}
}
self.full_refresh = false;
self.last_playhead = playhead;
}
/// Applies queued switches whose target time the playhead reached (and
/// flushes the queue when playback stopped).
fn process_play_queue(&mut self, cx: &mut Context<Self>) {
let playing = self.clock.read(cx).is_playing();
let playhead = self.clock.read(cx).current_frame().0;
while let Some(front) = self.play_queue.front().copied() {
if !playing || front.target <= playhead {
self.play_queue.pop_front();
self.apply_switch(front.source, front.split_clip, cx);
} else {
break;
}
}
}
/// Switches the multicam source through the engine (one undo entry).
/// During playback the switch is deferred to the playhead reaching the
/// request time — the C++ `play_queue_` semantics.
fn request_switch(&mut self, source: i32, split_clip: bool, cx: &mut Context<Self>) {
if self.state.is_none() {
return;
}
let playing = self.clock.read(cx).is_playing();
if playing {
let target = self.clock.read(cx).current_frame().0;
self.play_queue.push_back(QueuedSwitch {
source,
split_clip,
target,
});
} else {
self.apply_switch(source, split_clip, cx);
}
}
/// Applies a switch immediately and schedules the full angle refresh.
fn apply_switch(&mut self, source: i32, split_clip: bool, cx: &mut Context<Self>) {
self.engine
.update(cx, |engine, cx| engine.multicam_switch_to(source, split_clip, cx));
self.frames.clear();
self.full_refresh = true;
}
/// Converts a click inside the grid container to a source index and
/// switches to it (the C++ `display_clicked`).
fn handle_grid_click(&mut self, window_position: Point<Pixels>, cx: &mut Context<Self>) {
let Some(state) = self.state else {
return;
};
if state.source_count <= 0 {
return;
}
let local = window_position - self.grid_bounds.origin;
if local.x < px(0.0)
|| local.y < px(0.0)
|| local.x > self.grid_bounds.size.width
|| local.y > self.grid_bounds.size.height
{
return;
}
let (rows, cols) = MultiCamNode::rows_and_columns(state.source_count);
let multi = rows.max(cols).max(1);
let cell_w = f32::from(self.grid_bounds.size.width) / multi as f32;
let cell_h = f32::from(self.grid_bounds.size.height) / multi as f32;
if cell_w <= 0.0 || cell_h <= 0.0 {
return;
}
let c = (f32::from(local.x) / cell_w).floor() as i32;
let r = (f32::from(local.y) / cell_h).floor() as i32;
if c >= cols || r >= rows {
return;
}
let source = MultiCamNode::rows_cols_to_index(r, c, rows, cols);
if (0..state.source_count).contains(&source) {
self.request_switch(source, true, cx);
}
}
/// One grid cell: the angle frame (or a placeholder) with a yellow box
/// around the current source.
fn cell(&mut self, source: i32, cell_w: f32, cell_h: f32, colors: &Colors) -> AnyElement {
let is_current = self.state.is_some_and(|s| s.current_source == source);
let content: AnyElement = match self.frames.get(&source) {
Some(image) => img(image.clone())
.size_full()
.object_fit(ObjectFit::Contain)
.into_any_element(),
None => div()
.size_full()
.flex()
.items_center()
.justify_center()
.text_xs()
.text_color(colors.disabled)
.child(format!("CAM {}", source + 1))
.into_any_element(),
};
div()
.w(px(cell_w))
.h(px(cell_h))
.border_2()
.border_color(if is_current {
// The C++ MulticamDisplay's yellow current-source box.
Rgba {
r: 1.0,
g: 0.95,
b: 0.1,
a: 1.0,
}
} else {
colors.border
})
.bg(colors.container)
.overflow_hidden()
.child(content)
.into_any_element()
}
/// The angle grid: rows×cols cells filling the container.
fn grid(&mut self, state: MulticamState, colors: &Colors) -> AnyElement {
let (rows, cols) = MultiCamNode::rows_and_columns(state.source_count);
let multi = rows.max(cols).max(1);
let cell_w = f32::from(self.grid_bounds.size.width) / multi as f32;
let cell_h = f32::from(self.grid_bounds.size.height) / multi as f32;
let cell_w = cell_w.max(1.0);
let cell_h = cell_h.max(1.0);
let cells: Vec<AnyElement> =
(0..state.source_count).map(|s| self.cell(s, cell_w, cell_h, colors)).collect();
div()
.size_full()
.flex()
.flex_wrap()
.bg(colors.background)
.children(cells)
.into_any_element()
}
}
impl<E: AppEngine> Render for MulticamPanel<E> {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
self.sync_state(cx);
self.process_play_queue(cx);
self.refresh_frames(cx);
let colors = cx.default_colors().clone();
let rate = self.clock.read(cx).frame_rate();
let playhead = self.clock.read(cx).current_frame();
let timecode = format_timecode(playhead, rate);
let this = cx.weak_entity();
let body: AnyElement = match self.state {
Some(state) => self.grid(state, &colors),
None => div()
.size_full()
.flex()
.items_center()
.justify_center()
.text_color(colors.disabled)
.child(crate::i18n::tr("multicam.no_multicam"))
.into_any_element(),
};
div()
.size_full()
.flex()
.flex_col()
.overflow_hidden()
// Any click inside the panel makes it the focused panel (the
// dock re-emits this as `DockEvent::PanelFocused`, which the
// shell uses to route the focused-panel hotkeys).
.on_mouse_down(MouseButton::Left, {
cx.listener(|_this, _event: &gpui::MouseDownEvent, _window, cx| {
cx.emit(PanelEvent::Focused);
})
})
.child(
div()
.flex()
.items_center()
.gap_2()
.px_2()
.py_1()
.border_b_1()
.border_color(colors.border)
.child(chip(&colors, crate::i18n::tr("panel.multicam")))
.child(chip(&colors, timecode.clone())),
)
.child(
div()
.flex_1()
.min_h_0()
.min_w_0()
.relative()
// The grid click handler converts the window-space click
// through the recorded grid origin (see the canvas below).
.on_mouse_down(MouseButton::Left, {
cx.listener(|this, event: &gpui::MouseDownEvent, _window, cx| {
this.handle_grid_click(event.position, cx);
})
})
.child(body)
.child(
// Records the grid's window-space origin on every
// layout (the click hit-test converts window
// positions through it); paints nothing.
canvas(
move |bounds, _window, cx| {
if let Some(this) = this.upgrade() {
this.update(cx, |this, _cx| this.grid_bounds = bounds);
}
},
|_bounds, (), _window, _cx| {},
)
.absolute()
.size_full(),
),
)
// A simplified time ruler strip (the C++ TimeRuler; this port
// shows the program playhead's timecode and a marker line).
.child(
div()
.h(px(20.0))
.flex()
.items_center()
.px_2()
.border_t_1()
.border_color(colors.border)
.bg(colors.container)
.text_xs()
.text_color(colors.disabled)
.child(crate::i18n::tr("panel.multicam"))
.child(" · ")
.child(timecode),
)
}
}
impl<E: AppEngine> PanelCommandHandler for MulticamPanel<E> {
fn multicam_switch(&mut self, source: i32, split_clip: bool, cx: &mut Context<Self>) -> bool {
self.request_switch(source, split_clip, cx);
true
}
}
impl<E: AppEngine> EventEmitter<PanelEvent> for MulticamPanel<E> {}
impl<E: AppEngine> DockPanel for MulticamPanel<E> {
fn panel_id(&self) -> gpui::dock::PanelId {
MULTICAM
}
fn title(&self, _cx: &App) -> SharedString {
crate::i18n::tr("panel.multicam").into()
}
fn tab_content(&self, _cx: &App) -> AnyElement {
div().child(crate::i18n::tr("panel.multicam")).into_any_element()
}
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::{point, size, TestAppContext, VisualTestContext};
/// The grid conversion helpers used by the panel (rows/cols from the
/// node, index round-trips) plus the panel's click math.
fn rows_cols(sources: i32) -> (i32, i32) {
MultiCamNode::rows_and_columns(sources)
}
/// Click-point → source index with the same math [`MulticamPanel`]
/// applies: cell = grid/multi, source = rows_cols_to_index(r, c).
fn click_to_source(
grid: (f32, f32),
click: (f32, f32),
sources: i32,
) -> Option<i32> {
if click.0 < 0.0 || click.1 < 0.0 || click.0 >= grid.0 || click.1 >= grid.1 {
return None;
}
let (rows, cols) = rows_cols(sources);
let multi = rows.max(cols).max(1);
let cell_w = grid.0 / multi as f32;
let cell_h = grid.1 / multi as f32;
let c = (click.0 / cell_w).floor() as i32;
let r = (click.1 / cell_h).floor() as i32;
if c >= cols || r >= rows {
return None;
}
let source = MultiCamNode::rows_cols_to_index(r, c, rows, cols);
(0..sources).contains(&source).then_some(source)
}
/// The C++ grid is "as square as possible"; a click lands on the cell
/// it geometrically falls in.
#[test]
fn click_to_cell_maps_sources() {
// 4 sources → 2×2.
assert_eq!(click_to_source((400.0, 200.0), (100.0, 50.0), 4), Some(0));
assert_eq!(click_to_source((400.0, 200.0), (300.0, 50.0), 4), Some(1));
assert_eq!(click_to_source((400.0, 200.0), (100.0, 150.0), 4), Some(2));
assert_eq!(click_to_source((400.0, 200.0), (300.0, 150.0), 4), Some(3));
// 3 sources → 2×2 with the 4th cell empty.
assert_eq!(click_to_source((400.0, 200.0), (300.0, 150.0), 3), None);
// Out of bounds.
assert_eq!(click_to_source((400.0, 200.0), (400.0, 100.0), 4), None);
assert_eq!(click_to_source((400.0, 200.0), (100.0, 200.0), 4), None);
// 5 sources → 2×3 (2 rows × 3 cols, 6 cells, 5 used): source 4 sits
// at row 1, col 1.
assert_eq!(click_to_source((400.0, 300.0), (150.0, 150.0), 5), Some(4));
}
/// The panel renders the demo grid without crashing and fills the
/// angle-frame map from the mock engine's synthetic frames.
#[gpui::test]
async fn panel_renders_the_mock_grid(cx: &mut TestAppContext) {
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(640.0), px(360.0)), |window, cx| {
let engine = cx.new(|cx| crate::oakui::MockEngine::demo(cx));
let clock = engine.read(cx).program_clock().clone();
MulticamPanel::new(engine, clock, window, cx)
});
cx.run_until_parked();
let panel = window.root(cx).expect("multicam panel root");
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
cx.update(|window, cx| {
window.draw(cx).clear();
});
cx.run_until_parked();
// The mock reports a demo multicam with several sources; the panel's
// frame cache is populated (synthetic colored cells).
let (state, frame_count) = cx.read(|app| {
let panel = panel.read(app);
(panel.state, panel.frames.len())
});
let state = state.expect("the mock reports a demo multicam");
assert!(state.source_count >= 1, "demo sources: {}", state.source_count);
assert_eq!(frame_count, state.source_count as usize, "every angle frame is cached");
}
}
+107 -11
View File
@@ -222,11 +222,18 @@ impl<E: AppEngine> TimelinePanel<E> {
TimelineHit::Clip(_) => {
let ids: Vec<ClipId> =
self.timeline.read(cx).selection().iter().copied().collect();
let (sync, proxy) = {
let (sync, proxy, multicam) = {
let engine = self.engine.read(cx);
(engine.sync_eligibility(&ids), engine.clip_footage_entries(&ids))
(
engine.sync_eligibility(&ids),
engine.clip_footage_entries(&ids),
MulticamMenuState {
eligible: engine.multicam_eligible(&ids),
enabled: engine.multicam_enabled_on_selection(&ids),
},
)
};
clip_menu(sync, &proxy)
clip_menu(sync, &proxy, Some(multicam))
}
TimelineHit::Empty { .. } => empty_area_menu(),
TimelineHit::TrackHead(track) => {
@@ -312,6 +319,16 @@ impl<E: AppEngine> TimelinePanel<E> {
| LOCAL_TIMECODE_MILLISECONDS => {
println!("[timeline] timecode display {item} (not implemented yet)");
}
LOCAL_MULTICAM => {
// The C++ `multicam_enabled_triggered` flip: checked clips
// disable, unchecked ones enable.
let ids: Vec<ClipId> =
self.timeline.read(cx).selection().iter().copied().collect();
let enable = !self.engine.read(cx).multicam_enabled_on_selection(&ids);
self.engine.update(cx, |engine, cx| {
engine.multicam_enable_selected(ids, enable, cx)
});
}
_ => {
println!("[timeline] unhandled local menu item {item}");
}
@@ -906,15 +923,30 @@ fn properties_item(action: ActionId) -> MenuItem {
item
}
/// The multicam menu state of the selected clips (the C++ conditions in
/// `timelinewidget.cpp::show_context_menu`: the Multi-Cam item enables when
/// any selected clip's connected viewer is a sequence, and is checked when
/// that clip's texture chain contains a multicam node).
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct MulticamMenuState {
/// Whether any selected clip can host multicam (its connected viewer is
/// a sequence).
pub eligible: bool,
/// Whether the selected clips are currently multicam-enabled.
pub enabled: bool,
}
/// The clip context menu (`TimelineWidget::show_context_menu` with a
/// selection): the shared clip-edit section, color labels, the synchronize
/// / cache / proxy groups, reveal entries and "Properties". `sync` and
/// `proxy` carry the selection-derived enable state (the C++ enables the
/// synchronize entries at ≥ 2 eligible clips and the proxy entries per
/// the selected footage's proxy fields).
/// the selected footage's proxy fields); `multicam` carries the Multi-Cam
/// item's enable/checked state.
pub(crate) fn clip_menu(
sync: crate::oakui::engine::SyncEligibility,
proxy: &[crate::oakui::engine::ProxyFootageRow],
multicam: Option<MulticamMenuState>,
) -> Menu {
let mut items = shared::edit_section(true);
// The C++ puts a separator between the edit section and the color
@@ -986,7 +1018,8 @@ pub(crate) fn clip_menu(
]);
items.push(MenuItem::new(0, i18n::tr("timeline.context.proxy")).with_submenu(proxy_menu));
// Reveal / multi-cam entries (the C++ shows them only when the clip is
// connected to a viewer; the mock keeps them visible but disabled).
// connected to a viewer; the reveal entries stay disabled — the Rust
// app has no footage-reveal surface yet).
items.push(
MenuItem::new(
LOCAL_REVEAL_FOOTAGE_VIEWER,
@@ -998,12 +1031,16 @@ pub(crate) fn clip_menu(
MenuItem::new(LOCAL_REVEAL_PROJECT, i18n::tr("timeline.context.reveal_in_project"))
.disabled(),
);
items.push(
MenuItem::new(LOCAL_MULTICAM, i18n::tr("timeline.context.multicam"))
.with_checked(false)
.disabled()
.separated(),
);
// Multi-Cam (checkable): enabled when any selected clip's connected
// viewer is a sequence, checked when that clip already has a multicam —
// the C++ `connected_viewer()` + `find_ways_node_arrives_here` checks.
let multicam = multicam.unwrap_or_default();
let mut multicam_item = MenuItem::new(LOCAL_MULTICAM, i18n::tr("timeline.context.multicam"))
.with_checked(multicam.enabled);
if !multicam.eligible {
multicam_item = multicam_item.disabled();
}
items.push(multicam_item.separated());
items.push(properties_item(ActionId::SpeedDuration));
Menu::new(items)
}
@@ -1238,6 +1275,7 @@ mod tests {
let menu = clip_menu(
crate::oakui::engine::SyncEligibility::default(),
&[],
None,
);
// Color label item sits right after the edit section and carries a
// submenu of all 16 labels.
@@ -1333,6 +1371,7 @@ mod tests {
waveform: 1,
},
&rows,
None,
);
let find = |id: usize| {
menu.items
@@ -1364,6 +1403,63 @@ mod tests {
assert!(proxy_items[3].enabled, "delete: one footage has a proxy");
}
/// The Multi-Cam item follows the selection's multicam state: it enables
/// when a selected clip's connected viewer is a sequence and is checked
/// when that clip already has a multicam (the C++ conditions).
#[test]
fn clip_menu_multicam_item_follows_the_state() {
// No eligible clip: disabled and unchecked.
let menu = clip_menu(
crate::oakui::engine::SyncEligibility::default(),
&[],
Some(MulticamMenuState {
eligible: false,
enabled: false,
}),
);
let item = menu
.items
.iter()
.find(|item| item.id == LOCAL_MULTICAM)
.expect("multi-cam item");
assert!(!item.enabled, "ineligible clips keep Multi-Cam disabled");
assert!(!item.checked.unwrap_or(false));
// Eligible + enabled: enabled and checked.
let menu = clip_menu(
crate::oakui::engine::SyncEligibility::default(),
&[],
Some(MulticamMenuState {
eligible: true,
enabled: true,
}),
);
let item = menu
.items
.iter()
.find(|item| item.id == LOCAL_MULTICAM)
.expect("multi-cam item");
assert!(item.enabled, "a sequence-fed clip enables Multi-Cam");
assert!(item.checked.unwrap_or(false), "checked when multicam present");
// Eligible but not enabled: enabled, unchecked.
let menu = clip_menu(
crate::oakui::engine::SyncEligibility::default(),
&[],
Some(MulticamMenuState {
eligible: true,
enabled: false,
}),
);
let item = menu
.items
.iter()
.find(|item| item.id == LOCAL_MULTICAM)
.expect("multi-cam item");
assert!(item.enabled);
assert!(!item.checked.unwrap_or(false));
}
/// The empty-area menu exposes the view toggles plus the sequence
/// settings "Properties" entry.
#[test]