Files
oak-editor/src/oakui/engine.rs
T
Mike-Solar 0f82092e95 refactor(app): retire manual-save semantics (M13 D5)
- AppEngine: save_project -> export_project_path(explicit path);
  project_modified removed (write-through has no dirty flag)
- File menu's save/save-as are the export path (extension-dispatched
  ove/otio/fcpxml); the library row export path is unchanged
- facade project_save/is_modified stay frozen, documented as the
  legacy export surface
- docs: M13 D5 checked off; M12 inventory updated
2026-08-16 14:17:45 +08:00

436 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 engine gateway: the narrow Rust API through which the app layer talks
//! to the engine.
//!
//! # Why a gateway trait
//!
//! The UI must never depend on *how* the engine is implemented. Today the
//! only implementation is the mock ([`super::mock::MockEngine`]) feeding demo
//! data; later a real backend will bind the `liboakengine` C ABI
//! (`src/facade/rust`, the frozen `oakengine_*` exports) behind the *same*
//! trait. Swapping backends then touches only the wiring in
//! [`crate::app`] — the panels, the widgets and the view state stay as they
//! are.
//!
//! The trait is intentionally narrow: open a project, inspect the current
//! sequence, and drive the transport (play / pause / step / seek). Timeline
//! edits arrive as widget request events and are applied by the host through
//! methods on the engine type itself (see the `MockEngine` docs for the
//! current mapping), so they do not need to be part of this seam yet.
//!
//! Everything here is plain Rust — no C ABI, no FFI. The C-ABI binding is a
//! later concern of the real backend only.
use std::path::PathBuf;
use std::sync::Arc;
use gpui::effect_stack::{EffectStackDataSource, EffectStackEvent};
use gpui::node_graph::{NodeGraphDataSource, NodeGraphEvent};
use gpui::timeline::{ClipId, Frame, FrameRate, TimelineDataSource, TimelineEvent, TrackKind};
use gpui::{App, Context, Entity, Pixels, RenderImage};
use gpui_widgets::audio_meter::AudioMeterDataSource;
use gpui_widgets::project_explorer::ProjectDataSource;
use gpui_widgets::viewer::PlaybackClock;
pub use super::scopes::ScopeData;
/// A monitor the transport can address.
///
/// Oak has two independent transports: the source monitor plays the clip
/// shown in the source viewer (素材查看器), the program monitor plays the
/// sequence shown in the program viewer (序列查看器).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Monitor {
/// The source (footage) monitor.
Source,
/// The program (sequence) monitor.
Program,
}
/// A video format: resolution plus frame rate.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VideoFormat {
/// Width in pixels.
pub width: u32,
/// Height in pixels.
pub height: u32,
/// The frame rate (rational, e.g. 30000/1001 for NTSC 29.97).
pub rate: FrameRate,
}
impl VideoFormat {
/// The classic HD television format: 1920×1080 at 25 fps.
pub fn hd_1080p25() -> Self {
Self {
width: 1920,
height: 1080,
rate: FrameRate::new(25, 1),
}
}
}
/// A project open in the engine.
#[derive(Debug, Clone, PartialEq)]
pub struct Project {
/// The project's display name.
pub name: String,
/// The project file on disk (`.ove`).
pub path: PathBuf,
}
/// A project-library row, as the project manager lists it (M13 D4). The
/// stats are derived from the row's head state by the backend (they are
/// never stored in the library).
#[derive(Debug, Clone, PartialEq)]
pub struct LibraryProject {
/// The library row uuid (the open / rename / duplicate / delete /
/// export selector).
pub uuid: String,
/// The row's display name.
pub name: String,
/// Row creation time (unix seconds, UTC).
pub created_at: i64,
/// Last-write time (unix seconds, UTC; the manager sort key).
pub modified_at: i64,
/// Longest sequence duration in milliseconds.
pub duration_ms: i64,
/// Total tracks across all sequences.
pub track_count: i32,
/// Total clip blocks.
pub clip_count: i32,
/// Total footage nodes.
pub footage_count: i32,
}
/// The sequence currently open in the project.
#[derive(Debug, Clone, PartialEq)]
pub struct Sequence {
/// The sequence's display name.
pub name: String,
/// The sequence's video format.
pub format: VideoFormat,
/// The sequence length in frames.
pub length: Frame,
}
/// The engine gateway.
///
/// Implementations own the "engine" side of the app: project state, the
/// current sequence, and the transport. Query methods are pure reads;
/// mutating methods take a gpui [`Context`](gpui::Context) so the backend can
/// update its observable entities (clocks, models) and notify them.
pub trait EngineGateway: Sized {
/// The currently open project, or `None` before any project is opened.
fn project(&self) -> Option<&Project>;
/// The current sequence of the open project, if any.
fn current_sequence(&self) -> Option<&Sequence>;
/// The display name of the source media shown in the source viewer, used
/// in the viewer header and dock tab. Empty when the engine has no source
/// media loaded.
fn source_media_name(&self) -> String {
String::new()
}
/// Open a project file. The backend loads it and becomes the source of
/// truth for [`project`](EngineGateway::project) /
/// [`current_sequence`](EngineGateway::current_sequence).
fn open_project(&mut self, path: PathBuf, cx: &mut gpui::Context<Self>);
/// Seek `monitor` to `frame` (clamped to the sequence).
fn request_frame(&mut self, monitor: Monitor, frame: Frame, cx: &mut gpui::Context<Self>);
/// Start playback on `monitor`.
fn play(&mut self, monitor: Monitor, cx: &mut gpui::Context<Self>);
/// Pause playback on `monitor`, leaving the playhead where it is.
fn pause(&mut self, monitor: Monitor, cx: &mut gpui::Context<Self>);
/// Step `monitor`'s playhead by `delta` frames (negative steps back).
fn step(&mut self, monitor: Monitor, delta: i64, cx: &mut gpui::Context<Self>);
/// Advance the playback clocks by one wall-clock tick. Called on a
/// periodic timer while any monitor is playing.
fn tick(&mut self, cx: &mut gpui::Context<Self>);
}
/// The transport clock type an engine drives its monitors with.
///
/// Each engine owns two clocks (source + program), one per
/// [`Monitor`], and exposes them to the viewer widgets through the
/// [`PlaybackClock`] trait.
pub trait EngineClock: PlaybackClock + 'static {}
impl<T: PlaybackClock + 'static> EngineClock for T {}
/// The full app-facing engine surface: the gateway plus every widget
/// data-source trait and the app-only operations (clocks, viewer frames,
/// edits, undo/redo, file operations).
///
/// The app shell (`crate::app::OakApp`) and every panel are generic over
/// `E: AppEngine`, so swapping the backend (mock vs real) is a one-line
/// choice at startup — see [`crate::app::run`].
pub trait AppEngine:
EngineGateway
+ TimelineDataSource
+ EffectStackDataSource
+ NodeGraphDataSource
+ ProjectDataSource
+ AudioMeterDataSource
{
/// The concrete transport-clock type (see [`EngineClock`]).
type Clock: EngineClock;
/// Builds a fresh engine instance (no project open, or demo data for
/// the mock).
fn create(cx: &mut Context<Self>) -> Self;
/// The source monitor's clock entity.
fn source_clock(&self) -> &Entity<Self::Clock>;
/// The program monitor's clock entity.
fn program_clock(&self) -> &Entity<Self::Clock>;
/// The current playhead frame of `monitor`'s clock.
fn clock_frame(&self, monitor: Monitor, cx: &App) -> Frame;
/// The CPU frame the viewers display for `monitor` (cached per playhead
/// frame, so a paused viewer never regenerates its picture).
fn cpu_frame(&self, monitor: Monitor, cx: &App) -> Arc<RenderImage>;
/// The scope samples ([`ScopeData`]) of `monitor`'s current CPU frame.
/// The analysis runs inside the frame render pass (cached per playhead
/// frame alongside the image), so this read is an `Arc` clone and never
/// re-walks the frame.
fn scope_data(&self, monitor: Monitor, cx: &App) -> ScopeData;
/// Adds a new empty track of the given kind (undoable where the backend
/// supports it).
fn add_track(&mut self, kind: TrackKind, cx: &mut Context<Self>);
/// Removes the track at display `index` (the index into
/// [`TimelineDataSource::track`]; undoable where the backend supports
/// it).
fn remove_track(&mut self, index: usize, cx: &mut Context<Self>);
/// Sets the row height of every timeline track (timeline toolbar).
fn set_track_height(&mut self, height: Pixels, cx: &mut Context<Self>);
/// Selects a material-bin entry (project-explorer "open").
fn select_item(&mut self, id: u64, cx: &mut Context<Self>);
/// Applies an effect-stack edit request to the engine's model.
fn apply_effect_event(&mut self, event: &EffectStackEvent, cx: &mut Context<Self>);
/// Updates the timeline clip selection (drives the effect stack's
/// target). The app shell forwards `TimelineEvent::SelectionChanged`
/// with the view's selection set. Default: no-op (engines without a
/// selection-driven stack keep their existing behavior).
fn set_selected_clips(&mut self, _clips: Vec<ClipId>, _cx: &mut Context<Self>) {}
/// The effect types the user can add to the selected clip's chain, as
/// (type id, display name) pairs — the facade factory entries flagged
/// `video_effect` and not hidden from the create menu. The inspector
/// panel lists them in its "add effect" menu. Default: empty.
fn addable_effects(&self) -> Vec<(String, String)> {
Vec::new()
}
/// Inserts the effect `type_id` at `index` into the selected clip's
/// chain (undoable). `index` is an insertion index into
/// [`EffectStackDataSource::effects`] (0 = closest to the source);
/// the panel passes the position carried by the `AddRequested` event.
/// Returns a user-facing error message on failure. Default: unsupported.
fn add_effect(
&mut self,
index: usize,
type_id: &str,
cx: &mut Context<Self>,
) -> Result<(), String> {
let _ = (index, type_id, cx);
Err("add effect not supported".into())
}
/// Applies a node-editor edit request to the engine's model.
fn apply_node_graph_event(&mut self, event: &NodeGraphEvent, cx: &mut Context<Self>);
/// Applies a timeline widget edit request (trim / move / playhead) to the
/// engine's model. Edits are applied through the backend's edit commands
/// with undo packaging; the playhead change is a plain seek.
fn apply_timeline_event(&mut self, event: &TimelineEvent, cx: &mut Context<Self>);
/// Splits the clip with `clip` id at `time` (the razor action).
fn split_clip(&mut self, clip: ClipId, time: Frame, cx: &mut Context<Self>);
/// Splits every clip whose range spans the program playhead (the razor
/// tool's menu action).
fn split_at_playhead(&mut self, cx: &mut Context<Self>);
/// Deletes the clip with `clip` id, rippling following content left when
/// `ripple` is set.
fn delete_clip(&mut self, clip: ClipId, ripple: bool, cx: &mut Context<Self>);
/// Whether the undo stack has an entry to undo.
fn can_undo(&self) -> bool;
/// Whether the undo stack has an entry to redo.
fn can_redo(&self) -> bool;
/// Steps the undo stack back one entry.
fn undo(&mut self, cx: &mut Context<Self>);
/// Steps the undo stack forward one entry.
fn redo(&mut self, cx: &mut Context<Self>);
/// Starts a new blank project with a single default sequence.
fn new_project(&mut self, cx: &mut Context<Self>);
/// Opens a project file. The format is dispatched by extension: `.ove`
/// through the OVE serializer, `.otio` / `.fcpxml` through the oaktask
/// interchange loader.
fn open_project_path(&mut self, path: PathBuf, cx: &mut Context<Self>) -> Result<(), String>;
/// Exports the current project to the file `path` (the 导出工程文件…
/// action's target). The format is dispatched by extension like
/// [`open_project_path`] (AppEngine::open_project_path). Exporting is a
/// pure file write — the write-through library already persists every
/// edit, so there is no "save" anymore.
fn export_project_path(&mut self, path: PathBuf, cx: &mut Context<Self>)
-> Result<(), String>;
/// Closes the current project, leaving the app with no sequence.
fn close_project(&mut self, cx: &mut Context<Self>);
// -------------------------------------------------------------------
// Project library (M13 D4: the write-through database the manager
// window browses). Default: unsupported (empty list / error strings).
// -------------------------------------------------------------------
/// Whether the open project is bound to the library write-through
/// session (the status bar's write state).
fn storage_bound(&self) -> bool {
false
}
/// The last write-through / snapshot error of the open project, if any.
fn storage_last_error(&self) -> Option<String> {
None
}
/// Lists the project library, most recently modified first (the
/// project manager's data source).
fn library_projects(&self) -> Result<Vec<LibraryProject>, String> {
Err("project library not supported".into())
}
/// Creates a blank project named `name` in the library and opens it.
fn library_create_project(&mut self, name: &str, cx: &mut Context<Self>) -> Result<(), String> {
let _ = (name, cx);
Err("project library not supported".into())
}
/// Opens the library project `uuid` (closing the current project).
fn library_open_project(&mut self, uuid: &str, cx: &mut Context<Self>) -> Result<(), String> {
let _ = (uuid, cx);
Err("project library not supported".into())
}
/// Deletes the library project `uuid` (the manager confirms first).
fn library_delete_project(&mut self, uuid: &str) -> Result<(), String> {
let _ = uuid;
Err("project library not supported".into())
}
/// Renames the library project `uuid` (the manager's list name).
fn library_rename_project(&mut self, uuid: &str, name: &str) -> Result<(), String> {
let _ = (uuid, name);
Err("project library not supported".into())
}
/// Duplicates the library project `uuid` (history included) under a
/// fresh uuid.
fn library_duplicate_project(&mut self, uuid: &str) -> Result<(), String> {
let _ = uuid;
Err("project library not supported".into())
}
/// Imports a `.ove` / `.otio` / `.fcpxml` project file into the library
/// as a new row; returns the new row's uuid.
fn library_import_project(&mut self, path: PathBuf) -> Result<String, String> {
let _ = path;
Err("project library not supported".into())
}
/// Exports the library project `uuid` to `path`; the format is
/// dispatched by extension (`.ove` / `.otio` / `.fcpxml`).
fn library_export_project(&mut self, uuid: &str, path: PathBuf) -> Result<(), String> {
let _ = (uuid, path);
Err("project library not supported".into())
}
/// The timeline waveform cache (M12 P4); `None` when the backend
/// does not provide waveforms.
fn waveform_cache(&self) -> Option<std::sync::Arc<crate::oakui::waveform::WaveformCache>> {
None
}
/// Import a media file into the project (M12 P3). Default: no-op.
fn import_footage(
&mut self,
_path: std::path::PathBuf,
_cx: &mut Context<Self>,
) -> Result<(), String> {
Ok(())
}
/// Starts an export of the current sequence in `format` to `path` and
/// returns a session the host polls for progress and can cancel.
///
/// The export runs on a background thread; the returned
/// [`ExportSession`] carries the event channel and the cancel handle.
fn start_export(&mut self, format: i32, path: PathBuf) -> Result<ExportSession, String>;
/// The display name of the engine backend ("mock" / "real"), shown in
/// the status bar.
fn backend_name(&self) -> &'static str;
}
/// A single progress event from a running export task.
#[derive(Debug, Clone, PartialEq)]
pub enum ExportEvent {
/// The task started.
Started,
/// Fraction done, in `0.0..=1.0`.
Progress(f64),
/// The task finished. `true` = succeeded; the string carries the failure
/// message on error.
Finished(bool, String),
}
/// A running export: the event channel the host drains plus the cancel
/// handle. Dropping the session does not abort the export thread; the
/// thread owns the task and frees it when it finishes.
pub struct ExportSession {
/// The event receiver (the background thread's sender lives as long as
/// the session's `cancel` side, so a dropped receiver just stops
/// delivering).
pub events: std::sync::mpsc::Receiver<ExportEvent>,
/// Cancels the running export as soon as possible.
pub cancel: Box<dyn Fn() + Send>,
}