Files
oak-editor/src/panels/source_viewer.rs
T
Mike-Solar 55bd1132cd feat(app): OFX Interact viewer integration - overlay drawing and event forwarding
- Main-process interact instances for the selected OFX effect card
  (create on selection change, describe, destroy on deselect/close),
  coexisting with the render-worker plugin instances per the OFX
  multi-instance model.
- Program viewer composites the interact's overlay: draw into a GL
  FBO via gl_bridge, read back, straight-alpha 'over' composite onto
  the displayed frame; cached and only re-rendered on frame/time/
  viewport/instance change or plugin redraw requests.
- Event forwarding: picture-area pointer maps through the contain-fit
  letterbox inverse to OFX pen coordinates (pen_motion/down/up);
  Keystroke to OFX key symbols (ASCII, navigation, F1-F35) for
  key_down/up; a 50ms idle pump; global shortcut consumption keeps
  precedence.
- e2e with the real test plugin: lifecycle marker assertions, pen/key
  event records, and macOS GL overlay compositing verified (265 tests
  green incl. gpui_widgets viewer suite).
2026-08-20 00:28:14 +08:00

256 lines
8.4 KiB
Rust

// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! The source viewer panel (素材查看器): the `ViewerWidget` over the source
//! monitor's clock, with its own transport and format chips.
use gpui::colors::DefaultColors;
use gpui::dock::{DockPanel, PanelEvent};
use gpui::{
div, prelude::*, AnyElement, App, Context, Entity, EventEmitter, MouseButton, Render,
SharedString, Window,
};
use gpui_widgets::viewer::{ViewerEvent, ViewerWidget};
use crate::actions::ActionId;
use crate::menus::context::{ContextMenuHandle, ContextMenuTriggered};
use crate::oakui::timecode::{format_fps, format_resolution};
use crate::oakui::{AppEngine, Monitor};
use crate::panels::commands::{self as panel_commands, PanelCommandHandler};
use crate::panels::{chip, viewer_title};
use crate::panels::ids::SOURCE_VIEWER;
/// The source viewer panel.
pub struct SourceViewerPanel<E: AppEngine> {
viewer: Entity<ViewerWidget<E::Clock>>,
engine: Entity<E>,
/// The source monitor's clock (also owned by the viewer widget; kept
/// here so transport commands can read the playing state).
clock: Entity<E::Clock>,
/// The last CPU frame handed to the viewer (compared by `Arc` identity so
/// a paused playhead does not re-upload the picture every frame).
last_cpu_frame: Option<std::sync::Arc<gpui::RenderImage>>,
/// The right-click context menu.
context_menu: ContextMenuHandle,
}
impl<E: AppEngine> SourceViewerPanel<E> {
/// Builds a viewer over `clock` (the source monitor's clock).
pub fn new(
engine: Entity<E>,
clock: Entity<E::Clock>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let viewer = cx.new(|cx| ViewerWidget::new(2, clock.clone(), window, cx));
// Route every transport request to the engine's source monitor.
cx.subscribe(&viewer, |this, _viewer, event: &ViewerEvent, cx| {
let monitor = Monitor::Source;
this.engine.update(cx, |engine, cx| match event {
ViewerEvent::PlayRequested { .. } => engine.play(monitor, cx),
ViewerEvent::PauseRequested { .. } => engine.pause(monitor, cx),
ViewerEvent::StepRequested { delta, .. } => engine.step(monitor, *delta, cx),
// The source monitor hosts no OFX interact: the picture's
// pointer/key events are not forwarded here (the program
// viewer's panel forwards them when an interact is live).
ViewerEvent::InteractPointer { .. } | ViewerEvent::InteractKey { .. } => {}
other => println!("[source viewer] request: {other:?}"),
});
})
.detach();
let context_menu =
ContextMenuHandle::new(Self::on_local_menu_item, window, cx);
Self {
viewer,
engine,
clock,
last_cpu_frame: None,
context_menu,
}
}
/// Handles the viewer's local (non-registry) context-menu items.
fn on_local_menu_item(&mut self, item: usize, cx: &mut Context<Self>) {
use crate::menus::shared as shared_menu;
let divider = match item {
shared_menu::LOCAL_VIEWER_RES_FULL => Some(1),
shared_menu::LOCAL_VIEWER_RES_HALF => Some(2),
shared_menu::LOCAL_VIEWER_RES_QUARTER => Some(4),
shared_menu::LOCAL_VIEWER_RES_EIGHTH => Some(8),
_ => None,
};
if let Some(divider) = divider {
let engine = self.engine.clone();
engine.update(cx, |engine, cx| engine.set_playback_divider(divider, cx));
return;
}
println!("[source viewer] context-menu item {item} (not implemented yet)");
}
/// Routes a transport command to the engine's source monitor through
/// the shared viewer transport helper.
fn transport(&mut self, action: ActionId, cx: &mut Context<Self>) -> bool {
let engine = self.engine.clone();
let clock = self.clock.clone();
panel_commands::viewer_transport(&engine, &clock, Monitor::Source, action, cx)
}
/// Pushes the engine's synthetic test frame into the viewer, but only when
/// it actually changed (the engine caches one image per playhead frame).
fn sync_frame(&mut self, cx: &mut Context<Self>) {
let frame = self.engine.read(cx).cpu_frame(Monitor::Source, cx);
if self
.last_cpu_frame
.as_ref()
.is_none_or(|last| !std::sync::Arc::ptr_eq(last, &frame))
{
self.last_cpu_frame = Some(frame.clone());
let frame = frame.clone();
self.viewer
.update(cx, |viewer, cx| viewer.set_cpu_frame(Some(frame), cx));
}
}
}
impl<E: AppEngine> Render for SourceViewerPanel<E> {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
self.sync_frame(cx);
let colors = cx.default_colors().clone();
let format = self
.engine
.read(cx)
.current_sequence()
.map(|sequence| sequence.format)
.unwrap_or(crate::oakui::VideoFormat::hd_1080p25());
let media = self.engine.read(cx).source_media_name();
let title = viewer_title("panel.source_viewer", &media);
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 focused-panel commands).
.on_mouse_down(MouseButton::Left, {
cx.listener(|_this, _event: &gpui::MouseDownEvent, _window, cx| {
cx.emit(PanelEvent::Focused);
})
})
// The viewer widget has no right-click handling of its own, so
// the panel opens the shared viewer menu here.
.on_mouse_down(MouseButton::Right, {
cx.listener(|this, event: &gpui::MouseDownEvent, _window, cx| {
let divider = this.engine.read(cx).playback_divider();
this.context_menu.show(
event.position,
crate::menus::shared::viewer_menu(divider),
cx,
);
})
})
.child(
div()
.flex()
.items_center()
.gap_2()
.px_2()
.py_1()
.border_b_1()
.border_color(colors.border)
.child(chip(&colors, title))
.child(chip(
&colors,
format_resolution(format.width, format.height),
))
.child(chip(&colors, format_fps(format.rate))),
)
.child(
div()
.flex_1()
.min_w_0()
.min_h_0()
.overflow_hidden()
.child(self.viewer.clone()),
)
// The right-click popup renders anchored above the panel.
.child(self.context_menu.widget())
}
}
impl<E: AppEngine> PanelCommandHandler for SourceViewerPanel<E> {
fn play_pause(&mut self, cx: &mut Context<Self>) -> bool {
self.transport(ActionId::PlayPause, cx)
}
fn prev_frame(&mut self, cx: &mut Context<Self>) -> bool {
self.transport(ActionId::PrevFrame, cx)
}
fn next_frame(&mut self, cx: &mut Context<Self>) -> bool {
self.transport(ActionId::NextFrame, cx)
}
fn go_to_start(&mut self, cx: &mut Context<Self>) -> bool {
self.transport(ActionId::GoToStart, cx)
}
fn go_to_end(&mut self, cx: &mut Context<Self>) -> bool {
self.transport(ActionId::GoToEnd, cx)
}
fn play_in_to_out(&mut self, cx: &mut Context<Self>) -> bool {
self.transport(ActionId::PlayInToOut, cx)
}
fn go_to_in(&mut self, cx: &mut Context<Self>) -> bool {
self.transport(ActionId::GoToIn, cx)
}
fn go_to_out(&mut self, cx: &mut Context<Self>) -> bool {
self.transport(ActionId::GoToOut, cx)
}
fn shuttle_left(&mut self, cx: &mut Context<Self>) -> bool {
self.transport(ActionId::ShuttleLeft, cx)
}
fn shuttle_stop(&mut self, cx: &mut Context<Self>) -> bool {
self.transport(ActionId::ShuttleStop, cx)
}
fn shuttle_right(&mut self, cx: &mut Context<Self>) -> bool {
self.transport(ActionId::ShuttleRight, cx)
}
}
impl<E: AppEngine> EventEmitter<PanelEvent> for SourceViewerPanel<E> {}
impl<E: AppEngine> EventEmitter<ContextMenuTriggered> for SourceViewerPanel<E> {}
impl<E: AppEngine> DockPanel for SourceViewerPanel<E> {
fn panel_id(&self) -> gpui::dock::PanelId {
SOURCE_VIEWER
}
fn title(&self, cx: &App) -> SharedString {
viewer_title("panel.source_viewer", &self.engine.read(cx).source_media_name()).into()
}
fn tab_content(&self, cx: &App) -> AnyElement {
div()
.child(viewer_title(
"panel.source_viewer",
&self.engine.read(cx).source_media_name(),
))
.into_any_element()
}
}