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:
+52
-2
@@ -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
@@ -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
@@ -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 ---
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user