Files
oak-editor/src/oakui/ofx.rs
T
Mike-Solar ec7b7e6d13 feat(app): OpenFX UI wiring - effect library, inspector params, startup glue
- src/oakui/ofx.rs: startup sequence (host scan, register_plugin_nodes,
  progress reporter factory -> app progress dialog channel, active
  viewer time provider, project extent sync); all failures degrade to
  logs. oak-worker runtime also scans and registers plugins.
- Effect library groups OpenFX entries by sub-category (Filter/
  Generator/Transition/General); effect insertion goes through
  Factory::create_any so dynamic plugin nodes resolve.
- Inspector renders OFX parameters from node inputs (sliders, combo
  boxes from repeated combo_option/combo_value properties, vec/color
  spinboxes, text with explicit commit, push buttons), edits are
  undoable; persistent plugin messages surface as a card badge.
- oakplugin: push_button_clicked and per-instance persistent message
  counting (thin public layers).
2026-08-18 22:16:08 +08:00

249 lines
9.2 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/>.
//! OFX plugin startup wiring (stage 6b).
//!
//! The app is the only place that holds both the oakplugin host and the UI
//! services the OFX suites consult at runtime, so the wiring lives here:
//!
//! - [`init`] scans the standard plugin paths ([`oakplugin::host::Host`]
//! default path set, `host.rs:440-449`), registers every discovered
//! plugin into the node factory (the effect library and the add-effect
//! menu consume those entries), installs the render executor and the
//! plugin-node duplicator (both idempotent), and registers the
//! progress-reporter factory plus the active-viewer provider.
//! - [`update_project_extent`] / [`update_viewer_time`] keep the
//! oakplugin side's fallback project size and timeline time in sync with
//! the current sequence (the engine calls them on open / seek / tick).
//! - [`set_progress_tx`] wires a progress-event channel the app drains in
//! its tick loop to drive the progress dialog.
//!
//! Every failure degrades to a log: plugin support is an optional
//! capability, never a startup dependency.
//!
//! ## Rendering topology and progress
//!
//! Preview/export rendering runs through the process-isolated oak-worker
//! pool (M15 S2), so plugin rendering happens in the worker process where
//! this main-process reporter factory is not in effect. The wiring still
//! serves the in-process render paths (e.g. the test-only inline backend)
//! and future work; worker-side progress forwarding over IPC is a TODO.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use oakplugin::progress::{ReporterFactory, UiProgressReporter};
use oakplugin::suites::timeline::{ActiveViewerProvider, ViewerTimeInfo};
/// One progress event a plugin reporter pushed to the app channel (drained
/// by the app tick, which drives a progress dialog).
#[derive(Debug, Clone, Default)]
pub struct PluginProgressEvent {
/// The label the plugin passed to progressStart.
pub label: String,
/// The message the plugin passed to progressStart.
pub message: String,
/// Progress fraction in 0.0..=1.0.
pub fraction: f64,
}
/// The app's progress-event channel (registered by [`set_progress_tx`]).
static PROGRESS_TX: OnceLock<Mutex<Option<std::sync::mpsc::Sender<PluginProgressEvent>>>> =
OnceLock::new();
/// The sticky cancel flag read by every live reporter (`update` returns
/// false once set; the progress dialog's cancel button sets it).
static CANCEL: AtomicBool = AtomicBool::new(false);
/// The last active-viewer time snapshot (the timeline-suite provider reads
/// it; the engine refreshes it on seek / tick).
static VIEWER_TIME: OnceLock<Mutex<ViewerTimeInfo>> = OnceLock::new();
/// The last known project extent (normalised-coordinate default conversion;
/// the engine refreshes it whenever the sequence changes).
static PROJECT_EXTENT: OnceLock<Mutex<(f64, f64)>> = OnceLock::new();
fn viewer_slot() -> &'static Mutex<ViewerTimeInfo> {
VIEWER_TIME.get_or_init(|| {
Mutex::new(ViewerTimeInfo {
time: 0.0,
range_min: 0.0,
range_max: 0.0,
})
})
}
fn extent_slot() -> &'static Mutex<(f64, f64)> {
PROJECT_EXTENT.get_or_init(|| Mutex::new((1920.0, 1080.0)))
}
// ---------------------------------------------------------------------------
// App-driven state sync
// ---------------------------------------------------------------------------
/// Wires the app's progress-event channel into the OFX progress suite. The
/// app keeps the receiving half and drains it in its tick loop.
pub fn set_progress_tx(tx: std::sync::mpsc::Sender<PluginProgressEvent>) {
*PROGRESS_TX
.get_or_init(|| Mutex::new(None))
.lock()
.unwrap_or_else(|e| e.into_inner()) = Some(tx);
}
/// Clone of the registered sender, or `None` before
/// [`set_progress_tx`] (a reporter then silently continues).
fn progress_tx() -> Option<std::sync::mpsc::Sender<PluginProgressEvent>> {
PROGRESS_TX
.get_or_init(|| Mutex::new(None))
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
}
/// Updates the active-viewer time snapshot the timeline suite falls back
/// to when no render context is live (engine seek / tick path).
pub fn update_viewer_time(time: f64, range_min: f64, range_max: f64) {
*viewer_slot().lock().unwrap_or_else(|e| e.into_inner()) = ViewerTimeInfo {
time,
range_min,
range_max,
};
}
/// Updates the project extent (width/height) the OFX normalised-coordinate
/// default conversion uses, and pushes it into oakplugin.
pub fn update_project_extent(width: f64, height: f64) {
let (w, h) = (width.max(1.0), height.max(1.0));
*extent_slot().lock().unwrap_or_else(|e| e.into_inner()) = (w, h);
oakplugin::node_factory::set_project_extent(w, h);
}
/// Requests cancellation of the running plugin render (the progress
/// dialog's Cancel button). The next progressStart resets the flag.
pub fn cancel_plugin_render() {
CANCEL.store(true, Ordering::Relaxed);
}
// ---------------------------------------------------------------------------
// Reporters / providers
// ---------------------------------------------------------------------------
/// A reporter that forwards (label, message, fraction) to the app channel
/// and honours the global cancel flag.
struct ChannelProgressReporter {
tx: Option<std::sync::mpsc::Sender<PluginProgressEvent>>,
label: String,
message: String,
}
impl UiProgressReporter for ChannelProgressReporter {
fn update(&mut self, progress: f64) -> bool {
if let Some(tx) = &self.tx {
let _ = tx.send(PluginProgressEvent {
label: self.label.clone(),
message: self.message.clone(),
fraction: progress,
});
}
!CANCEL.load(Ordering::Relaxed)
}
}
fn reporter_factory() -> ReporterFactory {
Arc::new(|label, message| {
// A fresh render begins: reset the sticky cancel flag.
CANCEL.store(false, Ordering::Relaxed);
Box::new(ChannelProgressReporter {
tx: progress_tx(),
label: label.to_string(),
message: message.to_string(),
})
})
}
fn viewer_provider() -> ActiveViewerProvider {
Arc::new(|| {
let info = *viewer_slot().lock().unwrap_or_else(|e| e.into_inner());
Some(info)
})
}
// ---------------------------------------------------------------------------
// Startup
// ---------------------------------------------------------------------------
/// Idempotent OFX startup wiring. Scans the standard plugin directories,
/// registers every discovered plugin into the node factory, installs the
/// render executor / duplicator, and registers the progress factory and
/// the active-viewer provider. Returns the number of plugin node types
/// registered (0 when no plugins were discovered or the scan failed).
pub fn init() -> usize {
// 1. Scan the standard OFX plugin directories (host.rs:440-449 default
// path set: ~/.OFX/Plugins, ~/.local/share/OFX/Plugins, ...
// plus the OLIVE_OFX_PLUGIN_PATH / OLIVE_PLUGIN_PATH /
// OFX_PLUGIN_PATH environment variables). A scan failure only
// logs — plugins are optional.
if let Err(e) = oakplugin::host::Host::global().cache.scan() {
eprintln!("[ofx] plugin scan failed: {e}");
}
// 2. Register discovered plugins into the node factory (idempotent;
// also installs the render executor and the plugin-node duplicator).
let registered = oakplugin::node_factory::register_plugin_nodes();
// 3. Progress reporter factory -> the app progress channel.
oakplugin::progress::set_reporter_factory(Some(reporter_factory()));
// 4. Active-viewer time provider (timeline suite fallback).
oakplugin::suites::timeline::set_active_viewer_provider(Some(viewer_provider()));
// 5. Project extent (the engine refreshes it whenever the sequence
// changes; keep the oakplugin side in sync with the default).
let (w, h) = *extent_slot().lock().unwrap_or_else(|e| e.into_inner());
oakplugin::node_factory::set_project_extent(w, h);
registered.len()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn viewer_time_roundtrip() {
update_viewer_time(42.5, 10.0, 200.0);
let provider = viewer_provider();
let info = provider().expect("provider always reports a snapshot");
assert_eq!(info.time, 42.5);
assert_eq!((info.range_min, info.range_max), (10.0, 200.0));
}
#[test]
fn cancel_is_sticky_until_a_new_reporter() {
CANCEL.store(false, Ordering::Relaxed);
let factory = reporter_factory();
let mut a = factory("a", "b");
assert!(a.update(0.1));
cancel_plugin_render();
assert!(!a.update(0.5), "a cancelled render reports no");
// A fresh progressStart resets the flag.
let mut b = factory("a", "b");
assert!(b.update(0.1));
}
#[test]
fn project_extent_is_forwarded() {
update_project_extent(1280.0, 720.0);
let slot = extent_slot();
assert_eq!(*slot.lock().unwrap(), (1280.0, 720.0));
}
}