From cf459d7e4cb54ae2e4e731fb8fb0b04e09703156 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Tue, 18 Aug 2026 21:40:00 +0800 Subject: [PATCH] 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. --- crates/oakcommon/src/handle.rs | 402 -------------- crates/{oakengine => oakengine.bk}/Cargo.lock | 0 crates/{oakengine => oakengine.bk}/Cargo.toml | 0 crates/{oakengine => oakengine.bk}/README.md | 0 crates/{oakengine => oakengine.bk}/build.rs | 0 .../include/audio/error.h | 0 .../include/audio/levelmeter.h | 0 .../include/audio/manager.h | 0 .../include/audio/processor.h | 0 .../include/audio/sync.h | 0 .../include/audio/waveform.h | 0 .../include/codec/conform.h | 0 .../include/codec/decoder.h | 0 .../include/codec/encoder.h | 0 .../include/codec/error.h | 0 .../include/codec/format.h | 0 .../include/codec/frame.h | 0 .../include/codec/proxy.h | 0 .../include/codec/task.h | 0 .../include/common/colortransform.h | 0 .../include/common/commandlineparser.h | 0 .../include/common/config.h | 0 .../include/common/current.h | 0 .../include/common/debug.h | 0 .../include/common/dropworkflowbehavior.h | 0 .../include/common/error.h | 0 .../include/common/ffmpegutils.h | 0 .../include/common/filefunctions.h | 0 .../include/common/handle.h | 0 .../include/common/loopmode.h | 0 .../include/common/miscutils.h | 0 .../include/common/ocioutils.h | 0 .../include/common/oiioutils.h | 0 .../include/common/power.h | 0 .../include/common/qtutils.h | 0 .../include/common/subtitleparams.h | 0 .../include/common/videoparams.h | 0 .../include/common/xmlutils.h | 0 .../include/node/block.h | 0 .../include/node/colormanager.h | 0 .../include/node/dragger.h | 0 .../include/node/error.h | 0 .../include/node/factory.h | 0 .../include/node/folder.h | 0 .../include/node/footage.h | 0 .../include/node/group.h | 0 .../include/node/keyframe.h | 0 .../include/node/multicam.h | 0 .../include/node/node.h | 0 .../include/node/project.h | 0 .../include/node/sequence.h | 0 .../include/node/serializer.h | 0 .../include/node/track.h | 0 .../include/node/traverser.h | 0 .../include/plugin/error.h | 0 .../include/plugin/host.h | 0 .../include/plugin/instance.h | 0 .../include/render/cache.h | 0 .../include/render/cancelatom.h | 0 .../include/render/color.h | 0 .../include/render/copier.h | 0 .../include/render/error.h | 0 .../include/render/manager.h | 0 .../include/render/renderer.h | 0 .../include/render/ticket.h | 0 .../include/task/error.h | 0 .../include/task/manager.h | 0 .../include/task/project.h | 0 .../include/task/task.h | 0 .../include/timeline/displaymode.h | 0 .../include/timeline/edit.h | 0 .../include/timeline/error.h | 0 .../include/timeline/marker.h | 0 .../include/timeline/workarea.h | 0 .../include/undo/error.h | 0 .../include/undo/undocommand.h | 0 .../include/undo/undostack.h | 0 .../{oakengine => oakengine.bk}/src/audio.rs | 0 .../{oakengine => oakengine.bk}/src/codec.rs | 0 .../{oakengine => oakengine.bk}/src/common.rs | 0 .../src/deferred.rs | 0 .../{oakengine => oakengine.bk}/src/error.rs | 0 .../{oakengine => oakengine.bk}/src/handle.rs | 0 crates/{oakengine => oakengine.bk}/src/ipc.rs | 0 crates/{oakengine => oakengine.bk}/src/lib.rs | 0 .../src/library.rs | 0 .../src/linkage.rs | 0 .../{oakengine => oakengine.bk}/src/node.rs | 0 .../{oakengine => oakengine.bk}/src/plugin.rs | 0 .../{oakengine => oakengine.bk}/src/pods.rs | 0 .../{oakengine => oakengine.bk}/src/render.rs | 0 .../src/storage.rs | 0 .../{oakengine => oakengine.bk}/src/stubs.rs | 1 - .../{oakengine => oakengine.bk}/src/task.rs | 0 .../src/test_support/audio.rs | 0 .../src/test_support/codec.rs | 0 .../src/test_support/common/mod.rs | 0 .../src/test_support/common_smoke.rs | 0 .../src/test_support/it_audio.rs | 0 .../src/test_support/it_codec.rs | 0 .../src/test_support/it_common.rs | 0 .../src/test_support/it_export.rs | 0 .../src/test_support/it_library.rs | 0 .../src/test_support/it_plugin.rs | 0 .../src/test_support/it_storage.rs | 0 .../src/test_support/it_task.rs | 0 .../src/test_support/it_timeline.rs | 0 .../src/test_support/it_undo.rs | 0 .../src/test_support/linkage.rs | 0 .../src/test_support/mod.rs | 0 .../src/test_support/node.rs | 0 .../src/test_support/plugin.rs | 0 .../src/test_support/render.rs | 0 .../src/test_support/task.rs | 0 .../src/test_support/undo.rs | 0 .../src/testmedia.rs | 0 .../src/timeline.rs | 0 .../{oakengine => oakengine.bk}/src/undo.rs | 0 .../{oakengine => oakengine.bk}/src/worker.rs | 0 src/actions.rs | 54 +- src/app.rs | 53 +- src/i18n.rs | 26 + src/oakui/engine.rs | 84 +++ src/oakui/mock.rs | 301 +++++++++- src/oakui/mod.rs | 4 +- src/oakui/multicam.rs | 427 ++++++++++++++ src/oakui/real.rs | 525 +++++++++++++++++- src/oakui/renderops.rs | 147 +++++ src/panels/commands.rs | 28 + src/panels/mod.rs | 3 + src/panels/multicam.rs | 525 ++++++++++++++++++ src/panels/timeline.rs | 118 +++- 132 files changed, 2276 insertions(+), 422 deletions(-) delete mode 100644 crates/oakcommon/src/handle.rs rename crates/{oakengine => oakengine.bk}/Cargo.lock (100%) rename crates/{oakengine => oakengine.bk}/Cargo.toml (100%) rename crates/{oakengine => oakengine.bk}/README.md (100%) rename crates/{oakengine => oakengine.bk}/build.rs (100%) rename crates/{oakengine => oakengine.bk}/include/audio/error.h (100%) rename crates/{oakengine => oakengine.bk}/include/audio/levelmeter.h (100%) rename crates/{oakengine => oakengine.bk}/include/audio/manager.h (100%) rename crates/{oakengine => oakengine.bk}/include/audio/processor.h (100%) rename crates/{oakengine => oakengine.bk}/include/audio/sync.h (100%) rename crates/{oakengine => oakengine.bk}/include/audio/waveform.h (100%) rename crates/{oakengine => oakengine.bk}/include/codec/conform.h (100%) rename crates/{oakengine => oakengine.bk}/include/codec/decoder.h (100%) rename crates/{oakengine => oakengine.bk}/include/codec/encoder.h (100%) rename crates/{oakengine => oakengine.bk}/include/codec/error.h (100%) rename crates/{oakengine => oakengine.bk}/include/codec/format.h (100%) rename crates/{oakengine => oakengine.bk}/include/codec/frame.h (100%) rename crates/{oakengine => oakengine.bk}/include/codec/proxy.h (100%) rename crates/{oakengine => oakengine.bk}/include/codec/task.h (100%) rename crates/{oakengine => oakengine.bk}/include/common/colortransform.h (100%) rename crates/{oakengine => oakengine.bk}/include/common/commandlineparser.h (100%) rename crates/{oakengine => oakengine.bk}/include/common/config.h (100%) rename crates/{oakengine => oakengine.bk}/include/common/current.h (100%) rename crates/{oakengine => oakengine.bk}/include/common/debug.h (100%) rename crates/{oakengine => oakengine.bk}/include/common/dropworkflowbehavior.h (100%) rename crates/{oakengine => oakengine.bk}/include/common/error.h (100%) rename crates/{oakengine => oakengine.bk}/include/common/ffmpegutils.h (100%) rename crates/{oakengine => oakengine.bk}/include/common/filefunctions.h (100%) rename crates/{oakengine => oakengine.bk}/include/common/handle.h (100%) rename crates/{oakengine => oakengine.bk}/include/common/loopmode.h (100%) rename crates/{oakengine => oakengine.bk}/include/common/miscutils.h (100%) rename crates/{oakengine => oakengine.bk}/include/common/ocioutils.h (100%) rename crates/{oakengine => oakengine.bk}/include/common/oiioutils.h (100%) rename crates/{oakengine => oakengine.bk}/include/common/power.h (100%) rename crates/{oakengine => oakengine.bk}/include/common/qtutils.h (100%) rename crates/{oakengine => oakengine.bk}/include/common/subtitleparams.h (100%) rename crates/{oakengine => oakengine.bk}/include/common/videoparams.h (100%) rename crates/{oakengine => oakengine.bk}/include/common/xmlutils.h (100%) rename crates/{oakengine => oakengine.bk}/include/node/block.h (100%) rename crates/{oakengine => oakengine.bk}/include/node/colormanager.h (100%) rename crates/{oakengine => oakengine.bk}/include/node/dragger.h (100%) rename crates/{oakengine => oakengine.bk}/include/node/error.h (100%) rename crates/{oakengine => oakengine.bk}/include/node/factory.h (100%) rename crates/{oakengine => oakengine.bk}/include/node/folder.h (100%) rename crates/{oakengine => oakengine.bk}/include/node/footage.h (100%) rename crates/{oakengine => oakengine.bk}/include/node/group.h (100%) rename crates/{oakengine => oakengine.bk}/include/node/keyframe.h (100%) rename crates/{oakengine => oakengine.bk}/include/node/multicam.h (100%) rename crates/{oakengine => oakengine.bk}/include/node/node.h (100%) rename crates/{oakengine => oakengine.bk}/include/node/project.h (100%) rename crates/{oakengine => oakengine.bk}/include/node/sequence.h (100%) rename crates/{oakengine => oakengine.bk}/include/node/serializer.h (100%) rename crates/{oakengine => oakengine.bk}/include/node/track.h (100%) rename crates/{oakengine => oakengine.bk}/include/node/traverser.h (100%) rename crates/{oakengine => oakengine.bk}/include/plugin/error.h (100%) rename crates/{oakengine => oakengine.bk}/include/plugin/host.h (100%) rename crates/{oakengine => oakengine.bk}/include/plugin/instance.h (100%) rename crates/{oakengine => oakengine.bk}/include/render/cache.h (100%) rename crates/{oakengine => oakengine.bk}/include/render/cancelatom.h (100%) rename crates/{oakengine => oakengine.bk}/include/render/color.h (100%) rename crates/{oakengine => oakengine.bk}/include/render/copier.h (100%) rename crates/{oakengine => oakengine.bk}/include/render/error.h (100%) rename crates/{oakengine => oakengine.bk}/include/render/manager.h (100%) rename crates/{oakengine => oakengine.bk}/include/render/renderer.h (100%) rename crates/{oakengine => oakengine.bk}/include/render/ticket.h (100%) rename crates/{oakengine => oakengine.bk}/include/task/error.h (100%) rename crates/{oakengine => oakengine.bk}/include/task/manager.h (100%) rename crates/{oakengine => oakengine.bk}/include/task/project.h (100%) rename crates/{oakengine => oakengine.bk}/include/task/task.h (100%) rename crates/{oakengine => oakengine.bk}/include/timeline/displaymode.h (100%) rename crates/{oakengine => oakengine.bk}/include/timeline/edit.h (100%) rename crates/{oakengine => oakengine.bk}/include/timeline/error.h (100%) rename crates/{oakengine => oakengine.bk}/include/timeline/marker.h (100%) rename crates/{oakengine => oakengine.bk}/include/timeline/workarea.h (100%) rename crates/{oakengine => oakengine.bk}/include/undo/error.h (100%) rename crates/{oakengine => oakengine.bk}/include/undo/undocommand.h (100%) rename crates/{oakengine => oakengine.bk}/include/undo/undostack.h (100%) rename crates/{oakengine => oakengine.bk}/src/audio.rs (100%) rename crates/{oakengine => oakengine.bk}/src/codec.rs (100%) rename crates/{oakengine => oakengine.bk}/src/common.rs (100%) rename crates/{oakengine => oakengine.bk}/src/deferred.rs (100%) rename crates/{oakengine => oakengine.bk}/src/error.rs (100%) rename crates/{oakengine => oakengine.bk}/src/handle.rs (100%) rename crates/{oakengine => oakengine.bk}/src/ipc.rs (100%) rename crates/{oakengine => oakengine.bk}/src/lib.rs (100%) rename crates/{oakengine => oakengine.bk}/src/library.rs (100%) rename crates/{oakengine => oakengine.bk}/src/linkage.rs (100%) rename crates/{oakengine => oakengine.bk}/src/node.rs (100%) rename crates/{oakengine => oakengine.bk}/src/plugin.rs (100%) rename crates/{oakengine => oakengine.bk}/src/pods.rs (100%) rename crates/{oakengine => oakengine.bk}/src/render.rs (100%) rename crates/{oakengine => oakengine.bk}/src/storage.rs (100%) rename crates/{oakengine => oakengine.bk}/src/stubs.rs (99%) rename crates/{oakengine => oakengine.bk}/src/task.rs (100%) rename crates/{oakengine => oakengine.bk}/src/test_support/audio.rs (100%) rename crates/{oakengine => oakengine.bk}/src/test_support/codec.rs (100%) rename crates/{oakengine => oakengine.bk}/src/test_support/common/mod.rs (100%) rename crates/{oakengine => oakengine.bk}/src/test_support/common_smoke.rs (100%) rename crates/{oakengine => oakengine.bk}/src/test_support/it_audio.rs (100%) rename crates/{oakengine => oakengine.bk}/src/test_support/it_codec.rs (100%) rename crates/{oakengine => oakengine.bk}/src/test_support/it_common.rs (100%) rename crates/{oakengine => oakengine.bk}/src/test_support/it_export.rs (100%) rename crates/{oakengine => oakengine.bk}/src/test_support/it_library.rs (100%) rename crates/{oakengine => oakengine.bk}/src/test_support/it_plugin.rs (100%) rename crates/{oakengine => oakengine.bk}/src/test_support/it_storage.rs (100%) rename crates/{oakengine => oakengine.bk}/src/test_support/it_task.rs (100%) rename crates/{oakengine => oakengine.bk}/src/test_support/it_timeline.rs (100%) rename crates/{oakengine => oakengine.bk}/src/test_support/it_undo.rs (100%) rename crates/{oakengine => oakengine.bk}/src/test_support/linkage.rs (100%) rename crates/{oakengine => oakengine.bk}/src/test_support/mod.rs (100%) rename crates/{oakengine => oakengine.bk}/src/test_support/node.rs (100%) rename crates/{oakengine => oakengine.bk}/src/test_support/plugin.rs (100%) rename crates/{oakengine => oakengine.bk}/src/test_support/render.rs (100%) rename crates/{oakengine => oakengine.bk}/src/test_support/task.rs (100%) rename crates/{oakengine => oakengine.bk}/src/test_support/undo.rs (100%) rename crates/{oakengine => oakengine.bk}/src/testmedia.rs (100%) rename crates/{oakengine => oakengine.bk}/src/timeline.rs (100%) rename crates/{oakengine => oakengine.bk}/src/undo.rs (100%) rename crates/{oakengine => oakengine.bk}/src/worker.rs (100%) create mode 100644 src/oakui/multicam.rs create mode 100644 src/panels/multicam.rs diff --git a/crates/oakcommon/src/handle.rs b/crates/oakcommon/src/handle.rs deleted file mode 100644 index 405d79cda..000000000 --- a/crates/oakcommon/src/handle.rs +++ /dev/null @@ -1,402 +0,0 @@ -// 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 . - -//! Refcounted-handle scaffolding. Same pattern as the oakplugin crate -//! (`src/plugin/rust/src/handle.rs`); intentionally duplicated rather -//! than shared — each module DLL must run its own addref/release code -//! (the function pointers in a handle always point into the DLL that -//! created the object). -//! -//! Mirrors the C side (`include/common/handle.h`): -//! -//! ```c -//! typedef struct OakXxx { -//! void *ctx; -//! void (*addref)(void *ctx); -//! void (*release)(void *ctx); -//! uint32_t abi_version; -//! } OakXxx; -//! ``` -//! -//! Handles are passed by value; `ctx` points to a heap [`RefBox`]. -//! The `addref`/`release` function pointers always point into this crate. - -use std::panic::{catch_unwind, AssertUnwindSafe}; -use std::sync::atomic::{AtomicU32, Ordering}; - -/// ABI version stamped into every handle. -pub const OAKCOMMON_ABI_VERSION: u32 = 1; - -/// Heap box behind a handle's `ctx`. -/// -/// `value` is deliberately the FIRST field so that `ctx` (which points at -/// the box head) aliases the boxed value: the C-facing `ffi.rs` code -/// frequently casts `ctx` directly to `*mut T` / `*const T`, and that cast -/// only lands on the real value when it sits at offset 0. `refs` follows, -/// located via field access (never a raw offset) by the addref/release -/// thunks. `#[repr(C)]` locks the layout so those direct casts are sound. -// CPP-PARITY: matches `include/common/handle.h` where `ctx` is an opaque -// pointer; the C side never dereferences it, so this layout is private to -// this crate. -#[repr(C)] -pub struct RefBox { - /// Boxed value (at offset 0 — see module doc). - pub value: T, - /// Atomic reference count. - pub refs: AtomicU32, -} - -/// The shared ABI value-handle type (single-lib unification, see -/// `docs/zh/plans/riir/single-lib.md`): one canonical -/// `{ctx, addref, release, abi_version}` type in `oakcore-rs`, re-exported -/// here so the crate's `ffi.rs` signatures and handle scaffolding stay -/// source-compatible. -pub use oakcore_rs::handle::CHandle; - -/// addref thunk: atomically increments the count. Shared by owned and -/// borrowed boxes — for a borrowed handle addref only extends the life of -/// the box, not of the borrowed object. -unsafe extern "C" fn refbox_addref(ctx: *mut std::ffi::c_void) { - unsafe { - let rb = ctx as *const RefBox; - // Caller guarantees the handle is alive (ctx non-null, not yet - // released) for the duration of the borrow. - (*rb).refs.fetch_add(1, Ordering::Relaxed); - } -} - -/// release thunk (owned): atomically decrements; at zero the box and its -/// value are destroyed. -unsafe extern "C" fn refbox_release_owned(ctx: *mut std::ffi::c_void) { - unsafe { - let rb = ctx as *mut RefBox; - // AcqRel: the side that reaches zero must observe all writes made - // before the final release (including internal state the value's - // destructor needs). - if (*rb).refs.fetch_sub(1, Ordering::AcqRel) == 1 { - drop(Box::from_raw(rb)); - } - } -} - -/// release thunk (borrowed, produced by [`make_borrowed`]): at zero only -/// the box allocation is reclaimed; the value inside is forgotten — its -/// ownership remains with the borrower. -unsafe extern "C" fn refbox_release_borrowed( - ctx: *mut std::ffi::c_void, -) { - unsafe { - let rb = ctx as *mut RefBox; - if (*rb).refs.fetch_sub(1, Ordering::AcqRel) == 1 { - // Partial move: move the value out of a temporary Box, then - // forget it so the Box drop only frees the allocation and the - // value's destructor never runs (double-free guard). - std::mem::forget((Box::from_raw(rb)).value); - } - } -} - -/// Owned handle with count 1; empty on allocation failure. -pub fn make_owned(value: T) -> CHandle { - let rb = Box::into_raw(Box::new(RefBox { - refs: AtomicU32::new(1), - value, - })); - CHandle { - ctx: rb as *mut std::ffi::c_void, - addref: Some(refbox_addref::), - release: Some(refbox_release_owned::), - abi_version: OAKCOMMON_ABI_VERSION, - } -} - -/// Borrowed handle for an object owned elsewhere (release frees only -/// the box). -/// -/// The value is bit-copied into the box ("borrowing copy"); the box never -/// runs the value's destructor — the borrower owns the original object -/// and is responsible for destroying it. -/// -/// # Safety -/// Caller guarantees `ptr` outlives every derived handle and that its -/// value is neither moved nor destroyed for the duration of the borrow. -pub unsafe fn make_borrowed(ptr: *mut T) -> CHandle { - if ptr.is_null() { - return CHandle::null(); - } - let rb = Box::into_raw(Box::new(RefBox { - refs: AtomicU32::new(1), - value: unsafe { std::ptr::read(ptr) }, - })); - CHandle { - ctx: rb as *mut std::ffi::c_void, - addref: Some(refbox_addref::), - release: Some(refbox_release_borrowed::), - abi_version: OAKCOMMON_ABI_VERSION, - } -} - -/// Typed view into a handle; `None` for empty handles. -/// -/// # Safety -/// `T` must be the boxed type. -pub unsafe fn get(h: &CHandle) -> Option<&T> { - if h.is_null() { - return None; - } - unsafe { Some(&(*(h.ctx as *const RefBox)).value) } -} - -/// Mutable typed view into a handle; `None` for empty handles. -/// -/// # Safety -/// `T` must be the boxed type, and the caller must not alias the returned -/// reference with any other live reference into the same handle. -pub unsafe fn get_mut(h: &CHandle) -> Option<&mut T> { - if h.is_null() { - return None; - } - unsafe { Some(&mut (*(h.ctx as *mut RefBox)).value) } -} - -/// Panic-catching FFI wrapper for i32-returning exports. -pub fn guard crate::error::Result<()>>(f: F) -> i32 { - match catch_unwind(AssertUnwindSafe(f)) { - Ok(Ok(())) => crate::error::OAKCOMMON_OK, - Ok(Err(e)) => e.code(), - Err(_) => crate::error::OAKCOMMON_E_FAILED, - } -} - -/// Panic-catching FFI wrapper for handle-returning exports. -pub fn guard_handle crate::error::Result>(f: F) -> CHandle { - match catch_unwind(AssertUnwindSafe(f)) { - Ok(Ok(h)) => h, - Ok(Err(_)) | Err(_) => CHandle::null(), - } -} - -/// Panic-catching FFI wrapper for void exports. -pub fn guard_void(f: F) { - let _ = catch_unwind(AssertUnwindSafe(f)); -} - -#[cfg(test)] -mod tests { - use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; - use std::sync::Arc; - - use super::*; - - /// Test payload that counts how many times its destructor ran. - struct DropCounter { - /// Shared counter bumped by `Drop::drop`. - drops: Arc, - } - - impl DropCounter { - /// A new counter plus its shared tally. - fn new() -> (Self, Arc) { - let drops = Arc::new(AtomicUsize::new(0)); - ( - DropCounter { - drops: Arc::clone(&drops), - }, - drops, - ) - } - } - - impl Drop for DropCounter { - fn drop(&mut self) { - self.drops.fetch_add(1, AtomicOrdering::SeqCst); - } - } - - /// Read the refcount behind a handle (test-only peek). - unsafe fn refs_of(h: &CHandle) -> u32 { - unsafe { (*(h.ctx as *const RefBox)).refs.load(Ordering::Relaxed) } - } - - #[test] - fn null_handle_is_null_and_stamped() { - let h = CHandle::null(); - assert!(h.is_null()); - assert!(h.ctx.is_null()); - assert!(h.addref.is_none()); - assert!(h.release.is_none()); - // The shared `null()` stamps no ABI version (single-lib - // unification; `make_owned` stamps the crate version). - assert_eq!(h.abi_version, 0); - } - - #[test] - fn make_owned_starts_at_one_ref_and_exposes_value() { - let (value, drops) = DropCounter::new(); - let h = make_owned(value); - assert!(!h.is_null()); - assert_eq!(h.abi_version, OAKCOMMON_ABI_VERSION); - assert!(h.addref.is_some()); - assert!(h.release.is_some()); - unsafe { - assert_eq!(refs_of::(&h), 1); - // get() sees the boxed value. - let v: &DropCounter = get::(&h).unwrap(); - assert_eq!(v.drops.load(AtomicOrdering::SeqCst), 0); - (h.release.unwrap())(h.ctx); - } - // Releasing the last ref destroyed the box and ran the destructor. - assert_eq!(drops.load(AtomicOrdering::SeqCst), 1); - } - - #[test] - fn owned_addref_release_balance_then_drop_at_zero() { - let (value, drops) = DropCounter::new(); - let h = make_owned(value); - unsafe { - (h.addref.unwrap())(h.ctx); - (h.addref.unwrap())(h.ctx); - assert_eq!(refs_of::(&h), 3); - - (h.release.unwrap())(h.ctx); - assert_eq!(refs_of::(&h), 2); - assert_eq!(drops.load(AtomicOrdering::SeqCst), 0); - (h.release.unwrap())(h.ctx); - assert_eq!(refs_of::(&h), 1); - assert_eq!(drops.load(AtomicOrdering::SeqCst), 0); - (h.release.unwrap())(h.ctx); - } - // Final release to zero ran the destructor exactly once. - assert_eq!(drops.load(AtomicOrdering::SeqCst), 1); - } - - #[test] - fn borrowed_release_to_zero_does_not_run_value_destructor() { - let (value, drops) = DropCounter::new(); - let mut value = value; - let h = unsafe { make_borrowed(&mut value) }; - assert!(!h.is_null()); - unsafe { - assert_eq!(refs_of::(&h), 1); - (h.addref.unwrap())(h.ctx); - assert_eq!(refs_of::(&h), 2); - (h.release.unwrap())(h.ctx); - assert_eq!(refs_of::(&h), 1); - // Releasing the borrowed box to zero frees only the box. - (h.release.unwrap())(h.ctx); - } - // The value's destructor must NOT have run; ownership stayed here. - assert_eq!(drops.load(AtomicOrdering::SeqCst), 0); - drop(value); - assert_eq!(drops.load(AtomicOrdering::SeqCst), 1); - } - - #[test] - fn borrowed_handle_sees_borrowed_value_contents() { - let mut data: u64 = 0xdead_beef; - let h = unsafe { make_borrowed(&mut data) }; - unsafe { - let v: &u64 = get::(&h).unwrap(); - assert_eq!(*v, 0xdead_beef); - (h.release.unwrap())(h.ctx); - } - } - - #[test] - fn make_borrowed_null_ptr_yields_null_handle() { - let h = unsafe { make_borrowed::(std::ptr::null_mut()) }; - assert!(h.is_null()); - assert!(h.addref.is_none()); - assert!(h.release.is_none()); - } - - #[test] - fn get_on_null_handle_is_none() { - let h = CHandle::null(); - assert!(unsafe { get::(&h) }.is_none()); - } - - #[test] - fn get_returns_typed_view_of_owned_box() { - let h = make_owned(String::from("hello")); - unsafe { - let s: &String = get::(&h).unwrap(); - assert_eq!(s, "hello"); - (h.release.unwrap())(h.ctx); - } - } - - #[test] - fn guard_maps_result_to_status_code() { - assert_eq!(guard(|| Ok(())), crate::error::OAKCOMMON_OK); - assert_eq!( - guard(|| Err(crate::error::Error::Invalid)), - crate::error::OAKCOMMON_E_INVALID - ); - assert_eq!( - guard(|| Err(crate::error::Error::State)), - crate::error::OAKCOMMON_E_STATE - ); - assert_eq!( - guard(|| Err(crate::error::Error::Failed("x".into()))), - crate::error::OAKCOMMON_E_FAILED - ); - assert_eq!( - guard(|| Err(crate::error::Error::NotFound)), - crate::error::OAKCOMMON_E_NOT_FOUND - ); - assert_eq!( - guard(|| Err(crate::error::Error::NoMem)), - crate::error::OAKCOMMON_E_NOMEM - ); - } - - #[test] - fn guard_catches_panic_as_e_failed() { - let code = guard(|| -> crate::error::Result<()> { panic!("kaboom") }); - assert_eq!(code, crate::error::OAKCOMMON_E_FAILED); - } - - #[test] - fn guard_handle_passes_through_success() { - let h = guard_handle(|| Ok(make_owned(42u32))); - assert!(!h.is_null()); - unsafe { - assert_eq!(*get::(&h).unwrap(), 42); - (h.release.unwrap())(h.ctx); - } - } - - #[test] - fn guard_handle_maps_err_and_panic_to_null() { - let h = guard_handle(|| Err(crate::error::Error::NoMem)); - assert!(h.is_null()); - let h = guard_handle(|| -> crate::error::Result { panic!("kaboom") }); - assert!(h.is_null()); - } - - #[test] - fn guard_void_runs_closure_and_swallows_panic() { - let ran = Arc::new(AtomicUsize::new(0)); - let r = Arc::clone(&ran); - guard_void(move || { - r.fetch_add(1, AtomicOrdering::SeqCst); - }); - assert_eq!(ran.load(AtomicOrdering::SeqCst), 1); - // A panicking closure must not unwind across the FFI boundary. - guard_void(|| panic!("kaboom")); - } -} diff --git a/crates/oakengine/Cargo.lock b/crates/oakengine.bk/Cargo.lock similarity index 100% rename from crates/oakengine/Cargo.lock rename to crates/oakengine.bk/Cargo.lock diff --git a/crates/oakengine/Cargo.toml b/crates/oakengine.bk/Cargo.toml similarity index 100% rename from crates/oakengine/Cargo.toml rename to crates/oakengine.bk/Cargo.toml diff --git a/crates/oakengine/README.md b/crates/oakengine.bk/README.md similarity index 100% rename from crates/oakengine/README.md rename to crates/oakengine.bk/README.md diff --git a/crates/oakengine/build.rs b/crates/oakengine.bk/build.rs similarity index 100% rename from crates/oakengine/build.rs rename to crates/oakengine.bk/build.rs diff --git a/crates/oakengine/include/audio/error.h b/crates/oakengine.bk/include/audio/error.h similarity index 100% rename from crates/oakengine/include/audio/error.h rename to crates/oakengine.bk/include/audio/error.h diff --git a/crates/oakengine/include/audio/levelmeter.h b/crates/oakengine.bk/include/audio/levelmeter.h similarity index 100% rename from crates/oakengine/include/audio/levelmeter.h rename to crates/oakengine.bk/include/audio/levelmeter.h diff --git a/crates/oakengine/include/audio/manager.h b/crates/oakengine.bk/include/audio/manager.h similarity index 100% rename from crates/oakengine/include/audio/manager.h rename to crates/oakengine.bk/include/audio/manager.h diff --git a/crates/oakengine/include/audio/processor.h b/crates/oakengine.bk/include/audio/processor.h similarity index 100% rename from crates/oakengine/include/audio/processor.h rename to crates/oakengine.bk/include/audio/processor.h diff --git a/crates/oakengine/include/audio/sync.h b/crates/oakengine.bk/include/audio/sync.h similarity index 100% rename from crates/oakengine/include/audio/sync.h rename to crates/oakengine.bk/include/audio/sync.h diff --git a/crates/oakengine/include/audio/waveform.h b/crates/oakengine.bk/include/audio/waveform.h similarity index 100% rename from crates/oakengine/include/audio/waveform.h rename to crates/oakengine.bk/include/audio/waveform.h diff --git a/crates/oakengine/include/codec/conform.h b/crates/oakengine.bk/include/codec/conform.h similarity index 100% rename from crates/oakengine/include/codec/conform.h rename to crates/oakengine.bk/include/codec/conform.h diff --git a/crates/oakengine/include/codec/decoder.h b/crates/oakengine.bk/include/codec/decoder.h similarity index 100% rename from crates/oakengine/include/codec/decoder.h rename to crates/oakengine.bk/include/codec/decoder.h diff --git a/crates/oakengine/include/codec/encoder.h b/crates/oakengine.bk/include/codec/encoder.h similarity index 100% rename from crates/oakengine/include/codec/encoder.h rename to crates/oakengine.bk/include/codec/encoder.h diff --git a/crates/oakengine/include/codec/error.h b/crates/oakengine.bk/include/codec/error.h similarity index 100% rename from crates/oakengine/include/codec/error.h rename to crates/oakengine.bk/include/codec/error.h diff --git a/crates/oakengine/include/codec/format.h b/crates/oakengine.bk/include/codec/format.h similarity index 100% rename from crates/oakengine/include/codec/format.h rename to crates/oakengine.bk/include/codec/format.h diff --git a/crates/oakengine/include/codec/frame.h b/crates/oakengine.bk/include/codec/frame.h similarity index 100% rename from crates/oakengine/include/codec/frame.h rename to crates/oakengine.bk/include/codec/frame.h diff --git a/crates/oakengine/include/codec/proxy.h b/crates/oakengine.bk/include/codec/proxy.h similarity index 100% rename from crates/oakengine/include/codec/proxy.h rename to crates/oakengine.bk/include/codec/proxy.h diff --git a/crates/oakengine/include/codec/task.h b/crates/oakengine.bk/include/codec/task.h similarity index 100% rename from crates/oakengine/include/codec/task.h rename to crates/oakengine.bk/include/codec/task.h diff --git a/crates/oakengine/include/common/colortransform.h b/crates/oakengine.bk/include/common/colortransform.h similarity index 100% rename from crates/oakengine/include/common/colortransform.h rename to crates/oakengine.bk/include/common/colortransform.h diff --git a/crates/oakengine/include/common/commandlineparser.h b/crates/oakengine.bk/include/common/commandlineparser.h similarity index 100% rename from crates/oakengine/include/common/commandlineparser.h rename to crates/oakengine.bk/include/common/commandlineparser.h diff --git a/crates/oakengine/include/common/config.h b/crates/oakengine.bk/include/common/config.h similarity index 100% rename from crates/oakengine/include/common/config.h rename to crates/oakengine.bk/include/common/config.h diff --git a/crates/oakengine/include/common/current.h b/crates/oakengine.bk/include/common/current.h similarity index 100% rename from crates/oakengine/include/common/current.h rename to crates/oakengine.bk/include/common/current.h diff --git a/crates/oakengine/include/common/debug.h b/crates/oakengine.bk/include/common/debug.h similarity index 100% rename from crates/oakengine/include/common/debug.h rename to crates/oakengine.bk/include/common/debug.h diff --git a/crates/oakengine/include/common/dropworkflowbehavior.h b/crates/oakengine.bk/include/common/dropworkflowbehavior.h similarity index 100% rename from crates/oakengine/include/common/dropworkflowbehavior.h rename to crates/oakengine.bk/include/common/dropworkflowbehavior.h diff --git a/crates/oakengine/include/common/error.h b/crates/oakengine.bk/include/common/error.h similarity index 100% rename from crates/oakengine/include/common/error.h rename to crates/oakengine.bk/include/common/error.h diff --git a/crates/oakengine/include/common/ffmpegutils.h b/crates/oakengine.bk/include/common/ffmpegutils.h similarity index 100% rename from crates/oakengine/include/common/ffmpegutils.h rename to crates/oakengine.bk/include/common/ffmpegutils.h diff --git a/crates/oakengine/include/common/filefunctions.h b/crates/oakengine.bk/include/common/filefunctions.h similarity index 100% rename from crates/oakengine/include/common/filefunctions.h rename to crates/oakengine.bk/include/common/filefunctions.h diff --git a/crates/oakengine/include/common/handle.h b/crates/oakengine.bk/include/common/handle.h similarity index 100% rename from crates/oakengine/include/common/handle.h rename to crates/oakengine.bk/include/common/handle.h diff --git a/crates/oakengine/include/common/loopmode.h b/crates/oakengine.bk/include/common/loopmode.h similarity index 100% rename from crates/oakengine/include/common/loopmode.h rename to crates/oakengine.bk/include/common/loopmode.h diff --git a/crates/oakengine/include/common/miscutils.h b/crates/oakengine.bk/include/common/miscutils.h similarity index 100% rename from crates/oakengine/include/common/miscutils.h rename to crates/oakengine.bk/include/common/miscutils.h diff --git a/crates/oakengine/include/common/ocioutils.h b/crates/oakengine.bk/include/common/ocioutils.h similarity index 100% rename from crates/oakengine/include/common/ocioutils.h rename to crates/oakengine.bk/include/common/ocioutils.h diff --git a/crates/oakengine/include/common/oiioutils.h b/crates/oakengine.bk/include/common/oiioutils.h similarity index 100% rename from crates/oakengine/include/common/oiioutils.h rename to crates/oakengine.bk/include/common/oiioutils.h diff --git a/crates/oakengine/include/common/power.h b/crates/oakengine.bk/include/common/power.h similarity index 100% rename from crates/oakengine/include/common/power.h rename to crates/oakengine.bk/include/common/power.h diff --git a/crates/oakengine/include/common/qtutils.h b/crates/oakengine.bk/include/common/qtutils.h similarity index 100% rename from crates/oakengine/include/common/qtutils.h rename to crates/oakengine.bk/include/common/qtutils.h diff --git a/crates/oakengine/include/common/subtitleparams.h b/crates/oakengine.bk/include/common/subtitleparams.h similarity index 100% rename from crates/oakengine/include/common/subtitleparams.h rename to crates/oakengine.bk/include/common/subtitleparams.h diff --git a/crates/oakengine/include/common/videoparams.h b/crates/oakengine.bk/include/common/videoparams.h similarity index 100% rename from crates/oakengine/include/common/videoparams.h rename to crates/oakengine.bk/include/common/videoparams.h diff --git a/crates/oakengine/include/common/xmlutils.h b/crates/oakengine.bk/include/common/xmlutils.h similarity index 100% rename from crates/oakengine/include/common/xmlutils.h rename to crates/oakengine.bk/include/common/xmlutils.h diff --git a/crates/oakengine/include/node/block.h b/crates/oakengine.bk/include/node/block.h similarity index 100% rename from crates/oakengine/include/node/block.h rename to crates/oakengine.bk/include/node/block.h diff --git a/crates/oakengine/include/node/colormanager.h b/crates/oakengine.bk/include/node/colormanager.h similarity index 100% rename from crates/oakengine/include/node/colormanager.h rename to crates/oakengine.bk/include/node/colormanager.h diff --git a/crates/oakengine/include/node/dragger.h b/crates/oakengine.bk/include/node/dragger.h similarity index 100% rename from crates/oakengine/include/node/dragger.h rename to crates/oakengine.bk/include/node/dragger.h diff --git a/crates/oakengine/include/node/error.h b/crates/oakengine.bk/include/node/error.h similarity index 100% rename from crates/oakengine/include/node/error.h rename to crates/oakengine.bk/include/node/error.h diff --git a/crates/oakengine/include/node/factory.h b/crates/oakengine.bk/include/node/factory.h similarity index 100% rename from crates/oakengine/include/node/factory.h rename to crates/oakengine.bk/include/node/factory.h diff --git a/crates/oakengine/include/node/folder.h b/crates/oakengine.bk/include/node/folder.h similarity index 100% rename from crates/oakengine/include/node/folder.h rename to crates/oakengine.bk/include/node/folder.h diff --git a/crates/oakengine/include/node/footage.h b/crates/oakengine.bk/include/node/footage.h similarity index 100% rename from crates/oakengine/include/node/footage.h rename to crates/oakengine.bk/include/node/footage.h diff --git a/crates/oakengine/include/node/group.h b/crates/oakengine.bk/include/node/group.h similarity index 100% rename from crates/oakengine/include/node/group.h rename to crates/oakengine.bk/include/node/group.h diff --git a/crates/oakengine/include/node/keyframe.h b/crates/oakengine.bk/include/node/keyframe.h similarity index 100% rename from crates/oakengine/include/node/keyframe.h rename to crates/oakengine.bk/include/node/keyframe.h diff --git a/crates/oakengine/include/node/multicam.h b/crates/oakengine.bk/include/node/multicam.h similarity index 100% rename from crates/oakengine/include/node/multicam.h rename to crates/oakengine.bk/include/node/multicam.h diff --git a/crates/oakengine/include/node/node.h b/crates/oakengine.bk/include/node/node.h similarity index 100% rename from crates/oakengine/include/node/node.h rename to crates/oakengine.bk/include/node/node.h diff --git a/crates/oakengine/include/node/project.h b/crates/oakengine.bk/include/node/project.h similarity index 100% rename from crates/oakengine/include/node/project.h rename to crates/oakengine.bk/include/node/project.h diff --git a/crates/oakengine/include/node/sequence.h b/crates/oakengine.bk/include/node/sequence.h similarity index 100% rename from crates/oakengine/include/node/sequence.h rename to crates/oakengine.bk/include/node/sequence.h diff --git a/crates/oakengine/include/node/serializer.h b/crates/oakengine.bk/include/node/serializer.h similarity index 100% rename from crates/oakengine/include/node/serializer.h rename to crates/oakengine.bk/include/node/serializer.h diff --git a/crates/oakengine/include/node/track.h b/crates/oakengine.bk/include/node/track.h similarity index 100% rename from crates/oakengine/include/node/track.h rename to crates/oakengine.bk/include/node/track.h diff --git a/crates/oakengine/include/node/traverser.h b/crates/oakengine.bk/include/node/traverser.h similarity index 100% rename from crates/oakengine/include/node/traverser.h rename to crates/oakengine.bk/include/node/traverser.h diff --git a/crates/oakengine/include/plugin/error.h b/crates/oakengine.bk/include/plugin/error.h similarity index 100% rename from crates/oakengine/include/plugin/error.h rename to crates/oakengine.bk/include/plugin/error.h diff --git a/crates/oakengine/include/plugin/host.h b/crates/oakengine.bk/include/plugin/host.h similarity index 100% rename from crates/oakengine/include/plugin/host.h rename to crates/oakengine.bk/include/plugin/host.h diff --git a/crates/oakengine/include/plugin/instance.h b/crates/oakengine.bk/include/plugin/instance.h similarity index 100% rename from crates/oakengine/include/plugin/instance.h rename to crates/oakengine.bk/include/plugin/instance.h diff --git a/crates/oakengine/include/render/cache.h b/crates/oakengine.bk/include/render/cache.h similarity index 100% rename from crates/oakengine/include/render/cache.h rename to crates/oakengine.bk/include/render/cache.h diff --git a/crates/oakengine/include/render/cancelatom.h b/crates/oakengine.bk/include/render/cancelatom.h similarity index 100% rename from crates/oakengine/include/render/cancelatom.h rename to crates/oakengine.bk/include/render/cancelatom.h diff --git a/crates/oakengine/include/render/color.h b/crates/oakengine.bk/include/render/color.h similarity index 100% rename from crates/oakengine/include/render/color.h rename to crates/oakengine.bk/include/render/color.h diff --git a/crates/oakengine/include/render/copier.h b/crates/oakengine.bk/include/render/copier.h similarity index 100% rename from crates/oakengine/include/render/copier.h rename to crates/oakengine.bk/include/render/copier.h diff --git a/crates/oakengine/include/render/error.h b/crates/oakengine.bk/include/render/error.h similarity index 100% rename from crates/oakengine/include/render/error.h rename to crates/oakengine.bk/include/render/error.h diff --git a/crates/oakengine/include/render/manager.h b/crates/oakengine.bk/include/render/manager.h similarity index 100% rename from crates/oakengine/include/render/manager.h rename to crates/oakengine.bk/include/render/manager.h diff --git a/crates/oakengine/include/render/renderer.h b/crates/oakengine.bk/include/render/renderer.h similarity index 100% rename from crates/oakengine/include/render/renderer.h rename to crates/oakengine.bk/include/render/renderer.h diff --git a/crates/oakengine/include/render/ticket.h b/crates/oakengine.bk/include/render/ticket.h similarity index 100% rename from crates/oakengine/include/render/ticket.h rename to crates/oakengine.bk/include/render/ticket.h diff --git a/crates/oakengine/include/task/error.h b/crates/oakengine.bk/include/task/error.h similarity index 100% rename from crates/oakengine/include/task/error.h rename to crates/oakengine.bk/include/task/error.h diff --git a/crates/oakengine/include/task/manager.h b/crates/oakengine.bk/include/task/manager.h similarity index 100% rename from crates/oakengine/include/task/manager.h rename to crates/oakengine.bk/include/task/manager.h diff --git a/crates/oakengine/include/task/project.h b/crates/oakengine.bk/include/task/project.h similarity index 100% rename from crates/oakengine/include/task/project.h rename to crates/oakengine.bk/include/task/project.h diff --git a/crates/oakengine/include/task/task.h b/crates/oakengine.bk/include/task/task.h similarity index 100% rename from crates/oakengine/include/task/task.h rename to crates/oakengine.bk/include/task/task.h diff --git a/crates/oakengine/include/timeline/displaymode.h b/crates/oakengine.bk/include/timeline/displaymode.h similarity index 100% rename from crates/oakengine/include/timeline/displaymode.h rename to crates/oakengine.bk/include/timeline/displaymode.h diff --git a/crates/oakengine/include/timeline/edit.h b/crates/oakengine.bk/include/timeline/edit.h similarity index 100% rename from crates/oakengine/include/timeline/edit.h rename to crates/oakengine.bk/include/timeline/edit.h diff --git a/crates/oakengine/include/timeline/error.h b/crates/oakengine.bk/include/timeline/error.h similarity index 100% rename from crates/oakengine/include/timeline/error.h rename to crates/oakengine.bk/include/timeline/error.h diff --git a/crates/oakengine/include/timeline/marker.h b/crates/oakengine.bk/include/timeline/marker.h similarity index 100% rename from crates/oakengine/include/timeline/marker.h rename to crates/oakengine.bk/include/timeline/marker.h diff --git a/crates/oakengine/include/timeline/workarea.h b/crates/oakengine.bk/include/timeline/workarea.h similarity index 100% rename from crates/oakengine/include/timeline/workarea.h rename to crates/oakengine.bk/include/timeline/workarea.h diff --git a/crates/oakengine/include/undo/error.h b/crates/oakengine.bk/include/undo/error.h similarity index 100% rename from crates/oakengine/include/undo/error.h rename to crates/oakengine.bk/include/undo/error.h diff --git a/crates/oakengine/include/undo/undocommand.h b/crates/oakengine.bk/include/undo/undocommand.h similarity index 100% rename from crates/oakengine/include/undo/undocommand.h rename to crates/oakengine.bk/include/undo/undocommand.h diff --git a/crates/oakengine/include/undo/undostack.h b/crates/oakengine.bk/include/undo/undostack.h similarity index 100% rename from crates/oakengine/include/undo/undostack.h rename to crates/oakengine.bk/include/undo/undostack.h diff --git a/crates/oakengine/src/audio.rs b/crates/oakengine.bk/src/audio.rs similarity index 100% rename from crates/oakengine/src/audio.rs rename to crates/oakengine.bk/src/audio.rs diff --git a/crates/oakengine/src/codec.rs b/crates/oakengine.bk/src/codec.rs similarity index 100% rename from crates/oakengine/src/codec.rs rename to crates/oakengine.bk/src/codec.rs diff --git a/crates/oakengine/src/common.rs b/crates/oakengine.bk/src/common.rs similarity index 100% rename from crates/oakengine/src/common.rs rename to crates/oakengine.bk/src/common.rs diff --git a/crates/oakengine/src/deferred.rs b/crates/oakengine.bk/src/deferred.rs similarity index 100% rename from crates/oakengine/src/deferred.rs rename to crates/oakengine.bk/src/deferred.rs diff --git a/crates/oakengine/src/error.rs b/crates/oakengine.bk/src/error.rs similarity index 100% rename from crates/oakengine/src/error.rs rename to crates/oakengine.bk/src/error.rs diff --git a/crates/oakengine/src/handle.rs b/crates/oakengine.bk/src/handle.rs similarity index 100% rename from crates/oakengine/src/handle.rs rename to crates/oakengine.bk/src/handle.rs diff --git a/crates/oakengine/src/ipc.rs b/crates/oakengine.bk/src/ipc.rs similarity index 100% rename from crates/oakengine/src/ipc.rs rename to crates/oakengine.bk/src/ipc.rs diff --git a/crates/oakengine/src/lib.rs b/crates/oakengine.bk/src/lib.rs similarity index 100% rename from crates/oakengine/src/lib.rs rename to crates/oakengine.bk/src/lib.rs diff --git a/crates/oakengine/src/library.rs b/crates/oakengine.bk/src/library.rs similarity index 100% rename from crates/oakengine/src/library.rs rename to crates/oakengine.bk/src/library.rs diff --git a/crates/oakengine/src/linkage.rs b/crates/oakengine.bk/src/linkage.rs similarity index 100% rename from crates/oakengine/src/linkage.rs rename to crates/oakengine.bk/src/linkage.rs diff --git a/crates/oakengine/src/node.rs b/crates/oakengine.bk/src/node.rs similarity index 100% rename from crates/oakengine/src/node.rs rename to crates/oakengine.bk/src/node.rs diff --git a/crates/oakengine/src/plugin.rs b/crates/oakengine.bk/src/plugin.rs similarity index 100% rename from crates/oakengine/src/plugin.rs rename to crates/oakengine.bk/src/plugin.rs diff --git a/crates/oakengine/src/pods.rs b/crates/oakengine.bk/src/pods.rs similarity index 100% rename from crates/oakengine/src/pods.rs rename to crates/oakengine.bk/src/pods.rs diff --git a/crates/oakengine/src/render.rs b/crates/oakengine.bk/src/render.rs similarity index 100% rename from crates/oakengine/src/render.rs rename to crates/oakengine.bk/src/render.rs diff --git a/crates/oakengine/src/storage.rs b/crates/oakengine.bk/src/storage.rs similarity index 100% rename from crates/oakengine/src/storage.rs rename to crates/oakengine.bk/src/storage.rs diff --git a/crates/oakengine/src/stubs.rs b/crates/oakengine.bk/src/stubs.rs similarity index 99% rename from crates/oakengine/src/stubs.rs rename to crates/oakengine.bk/src/stubs.rs index 382ec6d01..97614899e 100644 --- a/crates/oakengine/src/stubs.rs +++ b/crates/oakengine.bk/src/stubs.rs @@ -49,7 +49,6 @@ pub mod common { use oakcommon::colortransform::ColorTransform; use oakcommon::configstore::ConfigStore; - use oakcommon::handle::{get, get_mut, make_owned}; use oakcommon::ocioutils::PixelFormat; use oakcommon::videoparams::{ColorRange, Interlacing, VideoParams, VideoType}; use oakcommon::error::{OAKCOMMON_E_INVALID, OAKCOMMON_OK}; diff --git a/crates/oakengine/src/task.rs b/crates/oakengine.bk/src/task.rs similarity index 100% rename from crates/oakengine/src/task.rs rename to crates/oakengine.bk/src/task.rs diff --git a/crates/oakengine/src/test_support/audio.rs b/crates/oakengine.bk/src/test_support/audio.rs similarity index 100% rename from crates/oakengine/src/test_support/audio.rs rename to crates/oakengine.bk/src/test_support/audio.rs diff --git a/crates/oakengine/src/test_support/codec.rs b/crates/oakengine.bk/src/test_support/codec.rs similarity index 100% rename from crates/oakengine/src/test_support/codec.rs rename to crates/oakengine.bk/src/test_support/codec.rs diff --git a/crates/oakengine/src/test_support/common/mod.rs b/crates/oakengine.bk/src/test_support/common/mod.rs similarity index 100% rename from crates/oakengine/src/test_support/common/mod.rs rename to crates/oakengine.bk/src/test_support/common/mod.rs diff --git a/crates/oakengine/src/test_support/common_smoke.rs b/crates/oakengine.bk/src/test_support/common_smoke.rs similarity index 100% rename from crates/oakengine/src/test_support/common_smoke.rs rename to crates/oakengine.bk/src/test_support/common_smoke.rs diff --git a/crates/oakengine/src/test_support/it_audio.rs b/crates/oakengine.bk/src/test_support/it_audio.rs similarity index 100% rename from crates/oakengine/src/test_support/it_audio.rs rename to crates/oakengine.bk/src/test_support/it_audio.rs diff --git a/crates/oakengine/src/test_support/it_codec.rs b/crates/oakengine.bk/src/test_support/it_codec.rs similarity index 100% rename from crates/oakengine/src/test_support/it_codec.rs rename to crates/oakengine.bk/src/test_support/it_codec.rs diff --git a/crates/oakengine/src/test_support/it_common.rs b/crates/oakengine.bk/src/test_support/it_common.rs similarity index 100% rename from crates/oakengine/src/test_support/it_common.rs rename to crates/oakengine.bk/src/test_support/it_common.rs diff --git a/crates/oakengine/src/test_support/it_export.rs b/crates/oakengine.bk/src/test_support/it_export.rs similarity index 100% rename from crates/oakengine/src/test_support/it_export.rs rename to crates/oakengine.bk/src/test_support/it_export.rs diff --git a/crates/oakengine/src/test_support/it_library.rs b/crates/oakengine.bk/src/test_support/it_library.rs similarity index 100% rename from crates/oakengine/src/test_support/it_library.rs rename to crates/oakengine.bk/src/test_support/it_library.rs diff --git a/crates/oakengine/src/test_support/it_plugin.rs b/crates/oakengine.bk/src/test_support/it_plugin.rs similarity index 100% rename from crates/oakengine/src/test_support/it_plugin.rs rename to crates/oakengine.bk/src/test_support/it_plugin.rs diff --git a/crates/oakengine/src/test_support/it_storage.rs b/crates/oakengine.bk/src/test_support/it_storage.rs similarity index 100% rename from crates/oakengine/src/test_support/it_storage.rs rename to crates/oakengine.bk/src/test_support/it_storage.rs diff --git a/crates/oakengine/src/test_support/it_task.rs b/crates/oakengine.bk/src/test_support/it_task.rs similarity index 100% rename from crates/oakengine/src/test_support/it_task.rs rename to crates/oakengine.bk/src/test_support/it_task.rs diff --git a/crates/oakengine/src/test_support/it_timeline.rs b/crates/oakengine.bk/src/test_support/it_timeline.rs similarity index 100% rename from crates/oakengine/src/test_support/it_timeline.rs rename to crates/oakengine.bk/src/test_support/it_timeline.rs diff --git a/crates/oakengine/src/test_support/it_undo.rs b/crates/oakengine.bk/src/test_support/it_undo.rs similarity index 100% rename from crates/oakengine/src/test_support/it_undo.rs rename to crates/oakengine.bk/src/test_support/it_undo.rs diff --git a/crates/oakengine/src/test_support/linkage.rs b/crates/oakengine.bk/src/test_support/linkage.rs similarity index 100% rename from crates/oakengine/src/test_support/linkage.rs rename to crates/oakengine.bk/src/test_support/linkage.rs diff --git a/crates/oakengine/src/test_support/mod.rs b/crates/oakengine.bk/src/test_support/mod.rs similarity index 100% rename from crates/oakengine/src/test_support/mod.rs rename to crates/oakengine.bk/src/test_support/mod.rs diff --git a/crates/oakengine/src/test_support/node.rs b/crates/oakengine.bk/src/test_support/node.rs similarity index 100% rename from crates/oakengine/src/test_support/node.rs rename to crates/oakengine.bk/src/test_support/node.rs diff --git a/crates/oakengine/src/test_support/plugin.rs b/crates/oakengine.bk/src/test_support/plugin.rs similarity index 100% rename from crates/oakengine/src/test_support/plugin.rs rename to crates/oakengine.bk/src/test_support/plugin.rs diff --git a/crates/oakengine/src/test_support/render.rs b/crates/oakengine.bk/src/test_support/render.rs similarity index 100% rename from crates/oakengine/src/test_support/render.rs rename to crates/oakengine.bk/src/test_support/render.rs diff --git a/crates/oakengine/src/test_support/task.rs b/crates/oakengine.bk/src/test_support/task.rs similarity index 100% rename from crates/oakengine/src/test_support/task.rs rename to crates/oakengine.bk/src/test_support/task.rs diff --git a/crates/oakengine/src/test_support/undo.rs b/crates/oakengine.bk/src/test_support/undo.rs similarity index 100% rename from crates/oakengine/src/test_support/undo.rs rename to crates/oakengine.bk/src/test_support/undo.rs diff --git a/crates/oakengine/src/testmedia.rs b/crates/oakengine.bk/src/testmedia.rs similarity index 100% rename from crates/oakengine/src/testmedia.rs rename to crates/oakengine.bk/src/testmedia.rs diff --git a/crates/oakengine/src/timeline.rs b/crates/oakengine.bk/src/timeline.rs similarity index 100% rename from crates/oakengine/src/timeline.rs rename to crates/oakengine.bk/src/timeline.rs diff --git a/crates/oakengine/src/undo.rs b/crates/oakengine.bk/src/undo.rs similarity index 100% rename from crates/oakengine/src/undo.rs rename to crates/oakengine.bk/src/undo.rs diff --git a/crates/oakengine/src/worker.rs b/crates/oakengine.bk/src/worker.rs similarity index 100% rename from crates/oakengine/src/worker.rs rename to crates/oakengine.bk/src/worker.rs diff --git a/src/actions.rs b/src/actions.rs index 753deefac..02c6993cf 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -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", diff --git a/src/app.rs b/src/app.rs index a72629a86..63cabd539 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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 PanelRegistry for AppPanelRegistry { HISTORY => "history", TIMELINE => "timeline", EFFECT_LIBRARY => "effect-library", + MULTICAM => "multicam", _ => return None, } .to_string(), @@ -263,6 +265,12 @@ impl PanelRegistry for AppPanelRegistry { }), 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 { history: Entity>, timeline: Entity>, effect_library: Entity>, + multicam: Entity>, } impl OakApp { @@ -403,6 +412,8 @@ impl OakApp { 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 OakApp { 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 OakApp { }), 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 OakApp { 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 OakApp { 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 OakApp { 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 { 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, ); diff --git a/src/i18n.rs b/src/i18n.rs index 7b7f97a96..1aa440cc1 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -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 --- diff --git a/src/oakui/engine.rs b/src/oakui/engine.rs index cec2f4c92..170cd8db3 100644 --- a/src/oakui/engine.rs +++ b/src/oakui/engine.rs @@ -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 { + 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) -> Option> { + 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, + enabled: bool, + cx: &mut Context, + ) { + 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) { + 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)] diff --git a/src/oakui/mock.rs b/src/oakui/mock.rs index 49e281240..d7ab3ad9a 100644 --- a/src/oakui/mock.rs +++ b/src/oakui/mock.rs @@ -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, /// 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>, + /// The demo multicam angle-frame cache: source → (playhead, image), so + /// a paused cell never regenerates its picture. + multicam_frames: Mutex)>>, } 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 { + self.mock_multicam_state() + } + + fn multicam_angle_frame(&mut self, source: i32, cx: &mut Context) -> Option> { + 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, enabled: bool, cx: &mut Context) { + // 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) { + 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, +} + +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::() + .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::() + .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::() + .unwrap(); + l.tracks.push(track_id); + } + let s = g + .graph + .get_mut(sequence) + .unwrap() + .behavior + .as_any_mut() + .unwrap() + .downcast_mut::() + .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::() + .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> { + 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 { + 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> { + 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> { + 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; diff --git a/src/oakui/mod.rs b/src/oakui/mod.rs index 1f1eecb0e..2d59da03d 100644 --- a/src/oakui/mod.rs +++ b/src/oakui/mod.rs @@ -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}; diff --git a/src/oakui/multicam.rs b/src/oakui/multicam.rs new file mode 100644 index 000000000..42be3938b --- /dev/null +++ b/src/oakui/multicam.rs @@ -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 . + +//! 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` / +/// 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::()) + .is_some() +} + +/// The node feeding `node.input[input][element]`, if any. +fn connected_output(g: &Graph, node: NodeId, input: &str, element: i32) -> Option { + 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, +) { + 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(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 { + 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 { + 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 { + 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 { + 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 { + // 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 = { + 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>, + 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::() + .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::() + .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::() + .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::() + .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::() + .unwrap(); + seq.track_lists[0] + }; + let l = p + .graph + .get_mut(list) + .unwrap() + .behavior + .as_any_mut() + .unwrap() + .downcast_mut::() + .unwrap(); + l.tracks.push(id); + let t = p + .graph + .get_mut(id) + .unwrap() + .behavior + .as_any_mut() + .unwrap() + .downcast_mut::() + .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::() + .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)); + } +} diff --git a/src/oakui/real.rs b/src/oakui/real.rs index 12f7f42f8..752f048c6 100644 --- a/src/oakui/real.rs +++ b/src/oakui/real.rs @@ -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>, +} + +/// 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))>, + /// `(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> { + 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> { + 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) { + 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>, proxy_runs: Vec, + /// 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>, + /// The channel background multicam angle workers report finished frames + /// through; drained on the app tick. + multicam_rx: Mutex>, + /// The sending half of `multicam_rx` (cloned into every worker). + multicam_tx: Mutex>, } impl RealEngine { @@ -836,6 +946,7 @@ impl RealEngine { let rate = VideoFormat::hd_1080p25().rate; let (full_res_tx, full_res_rx) = mpsc::channel::(); let (thumb_tx, thumb_rx) = mpsc::channel::(); + let (multicam_tx, multicam_rx) = mpsc::channel::(); 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 { + 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) { + 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> { + 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) { + 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 { + self.multicam_state_internal() + } + + fn multicam_angle_frame(&mut self, source: i32, _cx: &mut Context) -> Option> { + 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, enabled: bool, cx: &mut Context) { + 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 = 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> = 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 = 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) { + 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::() + .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 diff --git a/src/oakui/renderops.rs b/src/oakui/renderops.rs index 2eb21cd01..8e6b17aa6 100644 --- a/src/oakui/renderops.rs +++ b/src/oakui/renderops.rs @@ -186,6 +186,103 @@ pub fn video_montage(p: &ProjectRef, seq: NodeId, time: Rational) -> Vec Vec { + 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 { + 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 { + 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(); diff --git a/src/panels/commands.rs b/src/panels/commands.rs index c7941a06a..648b651dd 100644 --- a/src/panels/commands.rs +++ b/src/panels/commands.rs @@ -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) -> bool { + let _ = (source, split_clip, cx); + false + } + // --- view ---------------------------------------------------------------- fn zoom_in(&mut self, _cx: &mut Context) -> bool { false @@ -267,6 +277,24 @@ pub fn dispatch_to( 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), diff --git a/src/panels/mod.rs b/src/panels/mod.rs index 59479076c..66326086a 100644 --- a/src/panels/mod.rs +++ b/src/panels/mod.rs @@ -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 diff --git a/src/panels/multicam.rs b/src/panels/multicam.rs new file mode 100644 index 000000000..5053bad4c --- /dev/null +++ b/src/panels/multicam.rs @@ -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 . + +//! 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 { + engine: Entity, + /// The program monitor's clock (playhead + playing state). + clock: Entity, + /// The grid container's window-space bounds, recorded on every layout + /// by the invisible `canvas` child (click-to-cell conversion). + grid_bounds: Bounds, + /// The last resolved multicam state (compared by identity so a node or + /// source-count change clears the stale frames). + state: Option, + /// The last image per source (stale-OK during playback; the engine's + /// cache holds the fresh frames). + frames: HashMap>, + /// Pending switches during playback ([`QueuedSwitch`]). + play_queue: VecDeque, + /// 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 MulticamPanel { + /// Builds a panel over the program monitor's clock `clock`. + pub fn new( + engine: Entity, + clock: Entity, + _window: &mut Window, + _cx: &mut Context, + ) -> 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) { + 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) { + 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) { + 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) { + 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) { + 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.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, cx: &mut Context) { + 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 = + (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 Render for MulticamPanel { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> 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 PanelCommandHandler for MulticamPanel { + fn multicam_switch(&mut self, source: i32, split_clip: bool, cx: &mut Context) -> bool { + self.request_switch(source, split_clip, cx); + true + } +} + +impl EventEmitter for MulticamPanel {} + +impl DockPanel for MulticamPanel { + 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 { + 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"); + } +} diff --git a/src/panels/timeline.rs b/src/panels/timeline.rs index 2870d7880..457e3ff40 100644 --- a/src/panels/timeline.rs +++ b/src/panels/timeline.rs @@ -222,11 +222,18 @@ impl TimelinePanel { TimelineHit::Clip(_) => { let ids: Vec = 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 TimelinePanel { | 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 = + 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, ) -> 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]