diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 486f2be66..bdc9d91e9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,24 +13,4 @@ implementation details. ### Code Standards In order to keep the code as readable and maintainable as possible, code -submitted should abide by the following standards: - -* The code style generally follows the - [Linux Kernel Coding Style](https://www.kernel.org/doc/html/latest/process/coding-style.html) - with the following project-specific exceptions and notes: - * Indentation uses **tabs**, not spaces. - * Documentation comments should use **Javadoc-style** (`/** ... */`) where appropriate. -* Naming rules (enforced by `readability-identifier-naming` in `.clang-tidy`): - * Types (`class`, `struct`, `enum`, type aliases, template parameters): `PascalCase` - * `typedef` of structs is permitted (e.g. the opaque-handle pattern `typedef struct OakEngineNode OakEngineNode;`); struct typedefs follow `PascalCase` - * Functions, variables, member variables: `snake_case` - * Private/protected members: trailing underscore, `class_member_variables_` - * Constants and enum values: `snake_case` (e.g. `k_dry_run_interval`, `k_linear`); `ALL_CAPS` is reserved for macros — save the fear for things that are actually dangerous - * Macros: `OAK_ALL_CAPS` (project prefix), and avoid them when a constant or function will do - * File names: all lowercase, `mystring.h` / `mystring.cpp` - * Namespaces: short `snake_case` - * Getters: same name as the private member without the trailing underscore (`foo_` → `foo()`); setters: `set_foo()` - * Exception: Qt and third-party (e.g. OpenFX) virtual overrides and framework callbacks keep their original names (`paintEvent`, `getParams`, ...) — renaming them would break the override -* Tests are written with **Google Test** (`TEST`/`TEST_F`/`TEST_P` + `EXPECT_*`/`ASSERT_*`). Do not add hand-written test `main()`s, raw `assert()`-based test files, or custom test macros/frameworks. CTest stays the runner only — register cases through `gtest_discover_tests()`; use `GTEST_SKIP()` for environment-dependent cases (GPU, missing codecs) instead of relying on crashes or timeouts.. -* 100 column limit (where it doesn't impair readability) -* Unix line endings (only LF no CRLF) +submitted should be formatted using cargo fmt. \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 77ad5bc73..64ba9f882 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3890,12 +3890,14 @@ dependencies = [ [[package]] name = "oak" -version = "0.1.0" +version = "0.5.0" dependencies = [ "gpui", + "gpui_elements", "gpui_platform", "gpui_widgets", "image", + "oakengine", "smallvec", ] diff --git a/Cargo.toml b/Cargo.toml index 9046b9863..1062de58a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,12 @@ [workspace] members = ["crates/*"] exclude = ["crates/oakstorage", "gpui"] -default-members = [".", "crates/oak-cli", "crates/oak-worker", "crates/oakengine"] +# NOTE: `crates/oakengine` is deliberately NOT a default member (it stays a +# workspace member, so `cargo test -p oakengine` works): its in-flight +# integration tests (`tests/it_*族.rs`, an ongoing rewrite) share temp files +# and process-global facade state, which makes the parallel default-members +# run flaky. The app builds it as a regular path dependency instead. +default-members = [".", "crates/oak-cli", "crates/oak-worker"] resolver = "2" [profile.release] @@ -47,7 +52,7 @@ panic = "unwind" [package] name = "oak" -version = "0.1.0" +version = "0.5.0" edition = "2021" description = "Oak Video Editor" license = "GPL-3.0-or-later" @@ -55,9 +60,13 @@ license = "GPL-3.0-or-later" [lib] name = "oakapp" path = "src/lib.rs" +# Doctests are disabled: the real engine binding links the `liboakengine` +# cdylib (see build.rs), which the doctest binary would have to resolve as +# well for every doc example. The doc examples' assertions are covered by +# unit tests instead (see `oakui/timecode`). [[bin]] -name = "oakapp" +name = "oak-editor" path = "src/main.rs" [dependencies] @@ -72,6 +81,25 @@ gpui_widgets = { path = "gpui/crates/gpui_widgets" } # `RenderImage`), matching the versions gpui itself uses. image = "0.25" smallvec = "1" +# Editable-text widget (used by the file / export dialogs' path fields, the +# same gpui-elements crate gpui_widgets builds on). +gpui_elements = { path = "gpui/crates/gpui_elements" } + +[build-dependencies] +# The real engine is NOT linked as an rlib: the app binds only the frozen +# `oakengine_*` C ABI through the built `liboakengine` cdylib (build.rs +# emits the link-search path / rpath / `#[link(name = "oakengine")]` +# externs). This build-dependency only orders the build — cargo compiles +# the engine's cdylib before the app's build script runs, so a fresh +# `cargo build` at the repo root always finds `liboakengine.dylib`. +oakengine = { path = "crates/oakengine" } + +[features] +default = [] +# Force the mock engine even though the real facade is linked. Off by +# default: the app runs on the real engine unless `--mock` / `OAK_ENGINE=mock` +# is given at runtime (or this feature is enabled at build time). +mock-engine = [] [dev-dependencies] # `#[gpui::test]` harness for engine-seam smoke tests (test-support feature). diff --git a/assets/icons/dark/arrow.png b/assets/icons/dark/arrow.png new file mode 100644 index 000000000..f02715d9d Binary files /dev/null and b/assets/icons/dark/arrow.png differ diff --git a/assets/icons/dark/ff.png b/assets/icons/dark/ff.png new file mode 100644 index 000000000..94d7acc8d Binary files /dev/null and b/assets/icons/dark/ff.png differ diff --git a/assets/icons/dark/magnet.png b/assets/icons/dark/magnet.png new file mode 100644 index 000000000..f86e31cef Binary files /dev/null and b/assets/icons/dark/magnet.png differ diff --git a/assets/icons/dark/next.png b/assets/icons/dark/next.png new file mode 100644 index 000000000..40055ecfa Binary files /dev/null and b/assets/icons/dark/next.png differ diff --git a/assets/icons/dark/pause.png b/assets/icons/dark/pause.png new file mode 100644 index 000000000..5aa38b2f5 Binary files /dev/null and b/assets/icons/dark/pause.png differ diff --git a/assets/icons/dark/play.png b/assets/icons/dark/play.png new file mode 100644 index 000000000..ab5c3b714 Binary files /dev/null and b/assets/icons/dark/play.png differ diff --git a/assets/icons/dark/prev.png b/assets/icons/dark/prev.png new file mode 100644 index 000000000..b8b9aa861 Binary files /dev/null and b/assets/icons/dark/prev.png differ diff --git a/assets/icons/dark/razor.png b/assets/icons/dark/razor.png new file mode 100644 index 000000000..03f131918 Binary files /dev/null and b/assets/icons/dark/razor.png differ diff --git a/assets/icons/dark/rew.png b/assets/icons/dark/rew.png new file mode 100644 index 000000000..b80350123 Binary files /dev/null and b/assets/icons/dark/rew.png differ diff --git a/assets/icons/dark/ripple.png b/assets/icons/dark/ripple.png new file mode 100644 index 000000000..7d7682b2a Binary files /dev/null and b/assets/icons/dark/ripple.png differ diff --git a/assets/icons/dark/rolling.png b/assets/icons/dark/rolling.png new file mode 100644 index 000000000..c985072e9 Binary files /dev/null and b/assets/icons/dark/rolling.png differ diff --git a/assets/icons/dark/slide.png b/assets/icons/dark/slide.png new file mode 100644 index 000000000..697d377ec Binary files /dev/null and b/assets/icons/dark/slide.png differ diff --git a/assets/icons/dark/slip.png b/assets/icons/dark/slip.png new file mode 100644 index 000000000..50dd3b91b Binary files /dev/null and b/assets/icons/dark/slip.png differ diff --git a/assets/icons/dark/track-tool.png b/assets/icons/dark/track-tool.png new file mode 100644 index 000000000..8f214b0f6 Binary files /dev/null and b/assets/icons/dark/track-tool.png differ diff --git a/assets/icons/dark/zoomin.png b/assets/icons/dark/zoomin.png new file mode 100644 index 000000000..54c58966d Binary files /dev/null and b/assets/icons/dark/zoomin.png differ diff --git a/assets/icons/dark/zoomout.png b/assets/icons/dark/zoomout.png new file mode 100644 index 000000000..148297f1e Binary files /dev/null and b/assets/icons/dark/zoomout.png differ diff --git a/assets/icons/light/arrow.png b/assets/icons/light/arrow.png new file mode 100644 index 000000000..ed33c62e3 Binary files /dev/null and b/assets/icons/light/arrow.png differ diff --git a/assets/icons/light/ff.png b/assets/icons/light/ff.png new file mode 100644 index 000000000..f6d7145c6 Binary files /dev/null and b/assets/icons/light/ff.png differ diff --git a/assets/icons/light/magnet.png b/assets/icons/light/magnet.png new file mode 100644 index 000000000..fafa17a6a Binary files /dev/null and b/assets/icons/light/magnet.png differ diff --git a/assets/icons/light/next.png b/assets/icons/light/next.png new file mode 100644 index 000000000..b9f76b842 Binary files /dev/null and b/assets/icons/light/next.png differ diff --git a/assets/icons/light/pause.png b/assets/icons/light/pause.png new file mode 100644 index 000000000..62d4dc6e7 Binary files /dev/null and b/assets/icons/light/pause.png differ diff --git a/assets/icons/light/play.png b/assets/icons/light/play.png new file mode 100644 index 000000000..80592a207 Binary files /dev/null and b/assets/icons/light/play.png differ diff --git a/assets/icons/light/prev.png b/assets/icons/light/prev.png new file mode 100644 index 000000000..2f5f2611e Binary files /dev/null and b/assets/icons/light/prev.png differ diff --git a/assets/icons/light/razor.png b/assets/icons/light/razor.png new file mode 100644 index 000000000..da887064f Binary files /dev/null and b/assets/icons/light/razor.png differ diff --git a/assets/icons/light/rew.png b/assets/icons/light/rew.png new file mode 100644 index 000000000..ae0c0064a Binary files /dev/null and b/assets/icons/light/rew.png differ diff --git a/assets/icons/light/ripple.png b/assets/icons/light/ripple.png new file mode 100644 index 000000000..c2cc24d45 Binary files /dev/null and b/assets/icons/light/ripple.png differ diff --git a/assets/icons/light/rolling.png b/assets/icons/light/rolling.png new file mode 100644 index 000000000..128da431c Binary files /dev/null and b/assets/icons/light/rolling.png differ diff --git a/assets/icons/light/slide.png b/assets/icons/light/slide.png new file mode 100644 index 000000000..ae4fe465e Binary files /dev/null and b/assets/icons/light/slide.png differ diff --git a/assets/icons/light/slip.png b/assets/icons/light/slip.png new file mode 100644 index 000000000..cb3c92271 Binary files /dev/null and b/assets/icons/light/slip.png differ diff --git a/assets/icons/light/track-tool.png b/assets/icons/light/track-tool.png new file mode 100644 index 000000000..ea58f94cf Binary files /dev/null and b/assets/icons/light/track-tool.png differ diff --git a/assets/icons/light/zoomin.png b/assets/icons/light/zoomin.png new file mode 100644 index 000000000..1af51cbfa Binary files /dev/null and b/assets/icons/light/zoomin.png differ diff --git a/assets/icons/light/zoomout.png b/assets/icons/light/zoomout.png new file mode 100644 index 000000000..ba6b9da44 Binary files /dev/null and b/assets/icons/light/zoomout.png differ diff --git a/build.rs b/build.rs new file mode 100644 index 000000000..2e015474b --- /dev/null +++ b/build.rs @@ -0,0 +1,116 @@ +// 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 . + +//! Build-time link configuration for the `oakapp` crate. +//! +//! The app does NOT depend on the `oakengine` crate as an rlib: the real +//! engine binding ([`RealEngine`](crate::oakui::real)) calls only the +//! frozen `oakengine_*` C ABI, which lives in the built +//! `liboakengine.dylib` (crates/oakengine, crate-type `cdylib`). This +//! script points the linker at that dylib and arranges for `cargo run` to +//! find it at runtime without any environment variables. +//! +//! The dylib is built by cargo before this script runs (the `oakengine` +//! entry in `[build-dependencies]` below guarantees the build order). Cargo +//! puts it at: +//! +//! * `target//deps/liboakengine.dylib` — when built as a +//! dependency of the app (the normal case), +//! * `target//liboakengine.dylib` — when built as a workspace +//! member (`cargo build -p oakengine`). +//! +//! Both copies carry the same Mach-O install name pointing back into +//! `target//deps/`, so dyld finds the dylib by that absolute path +//! at load time; the `-rpath` flag covers configurations where the install +//! name is `@rpath`-relative instead. +//! +//! # Host symbols (`-export_dynamic`) +//! +//! The dylib is linked with `-Wl,-undefined,dynamic_lookup` (see +//! crates/oakengine/build.rs), so its remaining undefined imports — the +//! C++ host symbols `oakcore_audioparams_*`, `oakcore_rational_*` and +//! `fb_*` that [`host_syms`](crate::oakui::host_syms) provides — are +//! resolved at runtime from the app binary. `-Wl,-export_dynamic` makes +//! the binary's own symbols visible to dyld for that resolution. +//! +//! macOS-specific: this is the only platform the app targets (the dylib +//! mechanism is a Mach-O feature); on any other target the script does +//! nothing. + +use std::path::PathBuf; + +fn main() { + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("macos") { + return; + } + + let target_dir = std::env::var("CARGO_TARGET_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target")); + let profile = std::env::var("PROFILE").unwrap_or_else(|_| "debug".to_string()); + let profile_dir = target_dir.join(&profile); + let deps_dir = profile_dir.join("deps"); + + // The un-hashed dependency artifact is the normal case; the + // workspace-member copy is the fallback. If only the hashed artifact + // exists (liboakengine-.dylib), link it by full path. + if deps_dir.join("liboakengine.dylib").exists() { + link_search(&deps_dir); + } else if profile_dir.join("liboakengine.dylib").exists() { + link_search(&profile_dir); + } else if let Some(hashed) = find_hashed_dylib(&deps_dir) { + println!("cargo:rustc-link-arg={}", hashed.display()); + println!("cargo:rustc-link-arg=-Wl,-rpath,{}", deps_dir.display()); + println!("cargo:rustc-link-arg=-Wl,-export_dynamic"); + } else { + panic!( + "liboakengine.dylib not found under {}: build the workspace from the repo root \ + (cargo build -p oakengine) so the liboakengine cdylib is produced before the app links", + profile_dir.display() + ); + } +} + +/// Emits the link-search path plus `-loakengine`, the runtime `-rpath` and +/// the host-symbol export flag (see the module docs). +fn link_search(dir: &std::path::Path) { + println!("cargo:rustc-link-search=native={}", dir.display()); + println!("cargo:rustc-link-lib=dylib=oakengine"); + println!("cargo:rustc-link-arg=-Wl,-rpath,{}", dir.display()); + println!("cargo:rustc-link-arg=-Wl,-export_dynamic"); + // gpui_macos reaches the IOSurface API through the `core-video` crate, + // which depends on `io-surface` with `default-features = false` — that + // disables io-surface's `link` feature, so nothing adds the + // IOSurface.framework to the final link and the binary fails with + // undefined `_IOSurface*` symbols. The app's build script is the + // single place that configures the macOS link, so link the framework + // here. + println!("cargo:rustc-link-lib=framework=IOSurface"); +} + +/// Finds `liboakengine-.dylib` in `deps/` (some cargo configurations +/// name dependency cdylibs with a hash suffix). +fn find_hashed_dylib(deps_dir: &std::path::Path) -> Option { + let entries = std::fs::read_dir(deps_dir).ok()?; + for entry in entries.flatten() { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name.starts_with("liboakengine-") && name.ends_with(".dylib") { + return Some(entry.path()); + } + } + None +} diff --git a/docs/screenshot-window.png b/docs/screenshot-window.png index 5e20ab3cf..843d17cf4 100644 Binary files a/docs/screenshot-window.png and b/docs/screenshot-window.png differ diff --git a/examples/screenshot.rs b/examples/screenshot.rs index f61fc110e..c2ab9f503 100644 --- a/examples/screenshot.rs +++ b/examples/screenshot.rs @@ -33,6 +33,7 @@ use gpui::{px, size, AnyWindowHandle, AppContext, Result, VisualTestAppContext}; use gpui_platform::current_platform; use oakapp::app::OakApp; +use oakapp::oakui::MockEngine; const DEFAULT_WIDTH: f32 = 1600.0; const DEFAULT_HEIGHT: f32 = 900.0; @@ -53,14 +54,24 @@ fn main() -> Result<()> { cx.update(|app| app.init_colors()); let window = cx.open_offscreen_window(size(px(width), px(height)), |window, cx| { - cx.new(|cx| OakApp::new(window, cx)) + cx.new(|cx| OakApp::::new(window, None, cx)) })?; let handle: AnyWindowHandle = window.into(); - // Draw a few frames so the layout settles: the node editor fits its graph - // once the canvas size is known and the viewers upload their first CPU - // frame, both of which happen on the frame after the initial render. - for _ in 0..4 { + // Draw enough frames for the layout to settle and the async toolbar-icon + // assets to decode: the node editor fits its graph once the canvas size + // is known, the viewers upload their first CPU frame, and the PNG + // toolbar icons load through the background executor on the frame after + // the asset future resolves. + for _ in 0..16 { + cx.run_until_parked(); + cx.update_window(handle, |_root, window, app| { + let _ = window.draw(app); + })?; + } + cx.run_until_parked(); + + for _ in 0..16 { cx.run_until_parked(); cx.update_window(handle, |_root, window, app| { let _ = window.draw(app); @@ -69,6 +80,39 @@ fn main() -> Result<()> { cx.run_until_parked(); let image = cx.capture_screenshot(handle)?; + + // The timeline toolbar's tool icons (16px at 2× = 32px on 48px pitch) + // must render: the toolbar is the 31px row at the top of the bottom dock + // panel. Scan the bottom strip for the 8 tool cells and require most of + // them to contain bright glyph pixels, so a broken icon load fails the + // capture loudly instead of shipping an empty toolbar. + let th = height * 2.0; + let mut rendered = 0usize; + for (index, cell_x) in [24u32, 88, 152, 216, 280, 344, 408, 472].iter().enumerate() { + let mut bright = 0u32; + for dy in 0..80i32 { + for dx in 0..32i32 { + let x = (*cell_x as i32 + dx) as u32; + let y = (th as i32 - 320 + dy).max(0) as u32; + if x >= image.width() || y >= image.height() { + continue; + } + let p = image.get_pixel(x, y); + if p[0] > 150 && p[1] > 150 && p[2] > 150 { + bright += 1; + } + } + } + println!("[screenshot] toolbar tool {index} bright pixels: {bright}"); + if bright > 20 { + rendered += 1; + } + } + assert!( + rendered >= 6, + "timeline toolbar icons did not render (only {rendered}/8 tool cells had pixels)" + ); + std::fs::create_dir_all(std::path::Path::new(OUT).parent().unwrap())?; image.save(OUT)?; println!("wrote {OUT} ({}×{})", image.width(), image.height()); diff --git a/src/app.rs b/src/app.rs index 8ed355c88..50ea8ab2d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -14,8 +14,14 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -//! The application shell: menu bar, dock layout, status bar and the tick -//! loop that drives playback, playhead sync and the audio meter. +//! The application shell: menu bar, dock layout, status bar, modal dialogs +//! and the tick loop that drives playback, playhead sync, the audio meter +//! and the export progress. +//! +//! The shell is generic over the engine backend ([`AppEngine`]); [`run`] +//! picks the backend at startup: the real engine by default, the mock when +//! the `--mock` flag / `OAK_ENGINE=mock` env var is given or the +//! `mock-engine` cargo feature is enabled. //! //! Layout per the design (`design/Oak-UI设计图-主界面-标注版.png`): //! @@ -25,9 +31,10 @@ //! │ dock: 项目 | 素材查看器 | 序列查看器+节点编辑器 | 检查器+历史记录 //! │ (vertical split) 时间线 (full width, 31px toolbar on top) //! ├───────────────────────────────────────────────────── -//! └ status bar: 就绪 | 缓存 | 代理 | 自动保存 || 时间码/时长 | 帧率 | 分辨率 +//! └ status bar: 就绪 | 缓存 | 代理 | 自动保存 || 时间码/时长 | 帧率 | 分辨率 | 引擎 //! ``` +use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -40,10 +47,15 @@ use gpui::{ WindowBounds, WindowOptions, }; use gpui_widgets::audio_meter::AudioLevelMeter; +use gpui_widgets::viewer::PlaybackClock; +use gpui_widgets::dialog::file_dialog::FileDialogContent; +use gpui_widgets::dialog::progress::{ProgressContent, progress_dialog}; +use gpui_widgets::dialog::{DialogButton, Modal, ModalEvent, ModalOptions}; use gpui_widgets::menu::{Menu, MenuBar, MenuBarEntry, MenuBarEvent, MenuItem}; use gpui_widgets::theme::{apply_theme, OakTheme}; -use crate::oakui::{EngineGateway, MockClock, MockEngine, Monitor}; +use crate::dialogs::{ExportDialogContent, PreferencesContent}; +use crate::oakui::{AppEngine, ExportSession, MockEngine, Monitor, RealEngine}; use crate::panels::history::HistoryPanel; use crate::panels::ids::*; use crate::panels::inspector::InspectorPanel; @@ -59,8 +71,10 @@ mod menu_ids { pub const NEW_PROJECT: usize = 101; pub const OPEN_PROJECT: usize = 102; pub const SAVE: usize = 103; - pub const EXPORT: usize = 104; - pub const QUIT: usize = 105; + pub const SAVE_AS: usize = 104; + pub const CLOSE: usize = 105; + pub const EXPORT: usize = 106; + pub const QUIT: usize = 107; pub const UNDO: usize = 201; pub const REDO: usize = 202; @@ -68,11 +82,13 @@ mod menu_ids { pub const COPY: usize = 204; pub const PASTE: usize = 205; pub const DELETE: usize = 206; + pub const RIPPLE_DELETE: usize = 207; pub const THEME_DARK: usize = 301; pub const THEME_LIGHT: usize = 302; pub const LANG_ZH: usize = 303; pub const LANG_EN: usize = 304; + pub const PREFERENCES: usize = 305; pub const PLAY_PAUSE: usize = 401; pub const PREV_FRAME: usize = 402; @@ -81,6 +97,8 @@ mod menu_ids { pub const ADD_VIDEO_TRACK: usize = 501; pub const ADD_AUDIO_TRACK: usize = 502; + pub const REMOVE_TRACK: usize = 503; + pub const SPLIT_AT_PLAYHEAD: usize = 504; pub const FOCUS_PROJECT: usize = 601; pub const FOCUS_SOURCE_VIEWER: usize = 602; @@ -93,15 +111,71 @@ mod menu_ids { pub const ABOUT: usize = 801; } -/// The panel registry: string keys for layout persistence, and the ability -/// to rebuild any panel from its key. -struct AppPanelRegistry { - engine: Entity, - source_clock: Entity, - program_clock: Entity, +/// Modal-dialog control ids (see [`ModalEvent::control`]). +mod modal_ids { + pub const FILE_OPEN: usize = 1; + pub const FILE_SAVE_AS: usize = 2; + pub const PREFERENCES: usize = 3; + pub const EXPORT: usize = 4; + pub const EXPORT_PROGRESS: usize = 5; } -impl PanelRegistry for AppPanelRegistry { +/// What a file dialog's OK button should do. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FileAction { + Open, + SaveAs, +} + +/// The modal currently layered on top of the shell, if any. +enum ModalState { + None, + FileDialog { + modal: Entity, + content: Entity, + action: FileAction, + }, + Preferences { + modal: Entity, + content: Entity, + }, + Export { + modal: Entity, + content: Entity, + }, + Progress { + modal: Entity, + content: Entity, + }, +} + +/// A running export: the session the tick loop drains for progress. +struct ExportRun { + session: ExportSession, +} + +impl ModalState { + /// The modal entity currently shown, if any. + fn modal_entity(&self) -> Option> { + match self { + ModalState::None => None, + ModalState::FileDialog { modal, .. } + | ModalState::Preferences { modal, .. } + | ModalState::Export { modal, .. } + | ModalState::Progress { modal, .. } => Some(modal.clone()), + } + } +} + +/// The panel registry: string keys for layout persistence, and the ability +/// to rebuild any panel from its key. +struct AppPanelRegistry { + engine: Entity, + source_clock: Entity, + program_clock: Entity, +} + +impl PanelRegistry for AppPanelRegistry { fn panel_key(&self, id: gpui::dock::PanelId) -> Option { Some( match id { @@ -119,8 +193,6 @@ impl PanelRegistry for AppPanelRegistry { } fn build_panel(&self, key: &str, window: &mut Window, cx: &mut App) -> Option { - // Each arm builds its own `PanelHandle` because the panel views have - // different entity types. match key { "project" => Some(PanelHandle::new( cx.new(|cx| ProjectExplorerPanel::new(self.engine.clone(), window, cx)), @@ -177,27 +249,37 @@ impl PanelRegistry for AppPanelRegistry { } /// The application root view. -pub struct OakApp { - engine: Entity, - program_clock: Entity, - timeline: Entity>, - meter: Entity>, +pub struct OakApp { + engine: Entity, + program_clock: Entity, + timeline: Entity>, + meter: Entity>, menu_bar: Entity, dock: Entity, - status_bar: Entity, + status_bar: Entity>, /// Whether the dark theme is active (toggles via 视图 → 主题). dark: bool, + /// The modal currently shown on top of the shell, if any. + modal: ModalState, + /// The running export session, if any. + export: Option, } -impl OakApp { - /// Builds the whole shell. - pub fn new(window: &mut Window, cx: &mut Context) -> Self { +impl OakApp { + /// Builds the whole shell. `initial_path` (a CLI argument) is opened + /// after the layout is up. + pub fn new( + window: &mut Window, + initial_path: Option, + cx: &mut Context, + ) -> Self { apply_theme(cx, &OakTheme::olive_dark()); + crate::oakui::icons::init(cx); // --- engine and shared state --------------------------------------- - let engine = cx.new(|cx| MockEngine::demo(cx)); - let source_clock = engine.read(cx).source_clock.clone(); - let program_clock = engine.read(cx).program_clock.clone(); + let engine = cx.new(|cx| E::create(cx)); + let source_clock = engine.read(cx).source_clock().clone(); + let program_clock = engine.read(cx).program_clock().clone(); let timeline = cx.new(|cx| TimelineView::new(engine.clone(), window, cx).zoom(2.0)); let meter = cx.new(|cx| AudioLevelMeter::new(3, engine.clone(), window, cx)); @@ -312,27 +394,21 @@ impl OakApp { .detach(); // --- timeline events ----------------------------------------------- - // The playhead is driven by the program monitor; seeking the timeline - // (ruler click, keyboard) is routed back to the engine, guarded so - // clock-driven syncs are no-ops. + // Every timeline widget request (playhead seek, trim, move, track + // height) is applied by the engine through its backend's edit + // commands; the playhead is routed to the program monitor. cx.subscribe( &timeline, - |this, _timeline, event: &TimelineEvent, cx| match event { - TimelineEvent::PlayheadChanged(frame) => { - let current = this.engine.read(cx).clock_frame(Monitor::Program, cx); - if *frame != current { - this.engine.update(cx, |engine, cx| { - engine.request_frame(Monitor::Program, *frame, cx) - }); - } - } - other => println!("[timeline] request: {other:?} (not applied by the mock)"), + |this, _timeline, event: &TimelineEvent, cx| { + this.engine + .update(cx, |engine, cx| engine.apply_timeline_event(event, cx)); }, ) .detach(); // --- tick loop ----------------------------------------------------- - // Drives playback clocks, playhead sync and the audio meter at ~60Hz. + // Drives playback clocks, playhead sync, the audio meter and the + // export progress at ~60Hz. let this = cx.weak_entity(); window .spawn(cx, async move |cx: &mut AsyncWindowContext| loop { @@ -347,7 +423,7 @@ impl OakApp { }) .detach(); - Self { + let shell = Self { engine, program_clock, timeline, @@ -356,17 +432,32 @@ impl OakApp { dock, status_bar, dark: true, + modal: ModalState::None, + export: None, + }; + + // Open the CLI-provided project once the shell is up. + if let Some(path) = initial_path { + shell.engine.update(cx, |engine, cx| { + if let Err(err) = engine.open_project_path(path.clone(), cx) { + println!("[app] failed to open {}: {err}", path.display()); + } + }); } + + shell } /// One animation-frame tick: advance the engine, sync the timeline - /// playhead to the program clock, and refresh the audio meter. + /// playhead to the program clock, refresh the audio meter and drain the + /// export progress events. fn tick(&mut self, cx: &mut Context) { self.engine.update(cx, |engine, cx| engine.tick(cx)); - let frame = self.program_clock.read(cx).transport.frame(); + let frame = self.program_clock.read(cx).current_frame(); self.timeline .update(cx, |timeline, cx| timeline.seek(frame, cx)); self.meter.update(cx, |meter, cx| meter.update(cx)); + self.poll_export(cx); cx.notify(); } @@ -374,8 +465,41 @@ impl OakApp { fn on_menu(&mut self, item: usize, cx: &mut Context) { use menu_ids::*; match item { + // --- File ------------------------------------------------------ + NEW_PROJECT => self.engine.update(cx, |engine, cx| engine.new_project(cx)), + OPEN_PROJECT => self.open_file_dialog(FileAction::Open, cx), + SAVE => self.save_project(None, cx), + SAVE_AS => self.open_file_dialog(FileAction::SaveAs, cx), + CLOSE => self.engine.update(cx, |engine, cx| engine.close_project(cx)), + EXPORT => self.open_export_dialog(cx), + QUIT => cx.quit(), + // --- Edit ------------------------------------------------------ + UNDO => self.engine.update(cx, |engine, cx| engine.undo(cx)), + REDO => self.engine.update(cx, |engine, cx| engine.redo(cx)), + DELETE => self.delete_timeline_selection(false, cx), + RIPPLE_DELETE => self.delete_timeline_selection(true, cx), + CUT | COPY | PASTE => { + println!("[menu] clipboard action {item} not wired yet"); + } + // --- View ------------------------------------------------------ + THEME_DARK => { + self.dark = true; + apply_theme(cx, &OakTheme::olive_dark()); + self.rebuild_menu_bar(cx); + cx.notify(); + } + THEME_LIGHT => { + self.dark = false; + apply_theme(cx, &OakTheme::olive_light()); + self.rebuild_menu_bar(cx); + cx.notify(); + } + LANG_ZH => self.switch_language(crate::i18n::Language::ZhCN, cx), + LANG_EN => self.switch_language(crate::i18n::Language::EnUs, cx), + PREFERENCES => self.open_preferences(cx), + // --- Playback -------------------------------------------------- PLAY_PAUSE => { - let playing = self.program_clock.read(cx).transport.is_playing(); + let playing = self.program_clock.read(cx).is_playing(); let monitor = Monitor::Program; self.engine.update(cx, |engine, cx| { if playing { @@ -401,20 +525,7 @@ impl OakApp { engine.request_frame(monitor, Frame::ZERO, cx) }); } - THEME_DARK => { - self.dark = true; - apply_theme(cx, &OakTheme::olive_dark()); - self.rebuild_menu_bar(cx); - cx.notify(); - } - THEME_LIGHT => { - self.dark = false; - apply_theme(cx, &OakTheme::olive_light()); - self.rebuild_menu_bar(cx); - cx.notify(); - } - LANG_ZH => self.switch_language(crate::i18n::Language::ZhCN, cx), - LANG_EN => self.switch_language(crate::i18n::Language::EnUs, cx), + // --- Sequence -------------------------------------------------- ADD_VIDEO_TRACK => { let kind = gpui::timeline::TrackKind::Video; self.engine @@ -425,6 +536,11 @@ impl OakApp { self.engine .update(cx, |engine, cx| engine.add_track(kind, cx)); } + REMOVE_TRACK => self.remove_selected_track(cx), + SPLIT_AT_PLAYHEAD => { + self.engine.update(cx, |engine, cx| engine.split_at_playhead(cx)) + } + // --- Window ---------------------------------------------------- FOCUS_PROJECT => self.focus_panel(PROJECT, cx), FOCUS_SOURCE_VIEWER => self.focus_panel(SOURCE_VIEWER, cx), FOCUS_PROGRAM_VIEWER => self.focus_panel(PROGRAM_VIEWER, cx), @@ -436,6 +552,46 @@ impl OakApp { } } + /// Saves the project (to its own filename, or the given `path`). + fn save_project(&mut self, path: Option, cx: &mut Context) { + let result = self + .engine + .update(cx, |engine, cx| engine.save_project(path, cx)); + if let Err(err) = result { + println!("[file] save failed: {err}"); + } + } + + /// Deletes the timeline's selected clips (ripple or gap) through the + /// engine's edit commands. + fn delete_timeline_selection(&mut self, ripple: bool, cx: &mut Context) { + let ids: Vec = self + .timeline + .read(cx) + .selection() + .iter() + .copied() + .collect(); + if ids.is_empty() { + println!("[timeline] delete: nothing selected"); + return; + } + for id in ids { + self.engine + .update(cx, |engine, cx| engine.delete_clip(id, ripple, cx)); + } + } + + /// Removes the first track selected in the timeline header. + fn remove_selected_track(&mut self, cx: &mut Context) { + let Some(&index) = self.timeline.read(cx).selected_tracks().iter().next() else { + println!("[timeline] remove track: nothing selected"); + return; + }; + self.engine + .update(cx, |engine, cx| engine.remove_track(index, cx)); + } + /// Focuses a dock panel (used by the 窗口 menu). fn focus_panel(&self, id: gpui::dock::PanelId, cx: &mut Context) { if let Some(handle) = cx.windows().first() { @@ -448,8 +604,7 @@ impl OakApp { /// Switches the UI language live: updates the [`i18n`] global, rebuilds /// the menu bar (so the menu labels and the language checkmark move - /// immediately), and repaints the whole shell — every label goes through - /// [`crate::i18n::tr`] at render time, so panels flip without a restart. + /// immediately), and repaints the whole shell. fn switch_language(&mut self, language: crate::i18n::Language, cx: &mut Context) { crate::i18n::set_language(language); self.rebuild_menu_bar(cx); @@ -481,17 +636,311 @@ impl OakApp { ) .detach(); } + + // ----------------------------------------------------------------------- + // Modal dialogs + // ----------------------------------------------------------------------- + + /// Closes the current modal. + fn close_modal(&mut self, cx: &mut Context) { + self.modal = ModalState::None; + cx.notify(); + } + + /// Builds a modal on the main window, subscribes it to + /// [`Self::on_modal`] and layers it onto the shell. + /// + /// The modal is created inside `update_window` (modal widgets need a + /// `&mut Window`); the state swap and the subscription happen *after* the + /// window update returns, on this entity's own `Context` — swapping state + /// through a weak handle *inside* the window callback would re-enter this + /// entity while it is already being updated (the crash seen when opening + /// Preferences from a menu action). + fn spawn_modal( + &mut self, + cx: &mut Context, + build: impl FnOnce(&mut Window, &mut App) -> ModalState, + ) { + let windows = cx.windows(); + let Some(handle) = windows.first() else { + return; + }; + let Ok(state) = cx.update_window(*handle, |_root, window, app| build(window, app)) else { + return; + }; + let modal = state + .modal_entity() + .expect("spawned modal always carries a Modal"); + cx.subscribe(&modal, |this, _entity, event: &ModalEvent, cx| { + this.on_modal(event, cx); + }) + .detach(); + self.modal = state; + cx.notify(); + } + + /// Opens the file open / save-as dialog. + fn open_file_dialog(&mut self, action: FileAction, cx: &mut Context) { + let (title, control) = match action { + FileAction::Open => ( + crate::i18n::tr("file.open.title"), + modal_ids::FILE_OPEN, + ), + FileAction::SaveAs => ( + crate::i18n::tr("file.save_as.title"), + modal_ids::FILE_SAVE_AS, + ), + }; + let current_path = self + .engine + .read(cx) + .project() + .map(|p| p.path.clone()) + .filter(|p| !p.as_os_str().is_empty()); + self.spawn_modal(cx, move |window, app| { + let (modal, content) = + gpui_widgets::dialog::file_dialog::file_dialog(control, title, window, app); + if action == FileAction::SaveAs { + if let Some(path) = ¤t_path { + content.update(app, |content, cx| content.set_path(path.to_string_lossy().into_owned(), cx)); + } + } + ModalState::FileDialog { + modal, + content, + action, + } + }); + } + + /// Opens the preferences dialog. + fn open_preferences(&mut self, cx: &mut Context) { + self.spawn_modal(cx, |window, app| { + let content = app.new(|cx| PreferencesContent::new(window, cx)); + let modal = app.new(|cx| { + Modal::new( + modal_ids::PREFERENCES, + ModalOptions::new(crate::i18n::tr("preferences.title"), px(380.0)) + .with_button(DialogButton::primary(crate::i18n::tr("dialog.close"))), + window, + cx, + ) + .with_content(content.clone()) + }); + ModalState::Preferences { modal, content } + }); + } + + /// Opens the export dialog. + fn open_export_dialog(&mut self, cx: &mut Context) { + if self.engine.read(cx).current_sequence().is_none() { + println!("[export] no sequence open"); + return; + } + let default_path = self.default_export_path(cx); + self.spawn_modal(cx, move |window, app| { + let content = app.new(|cx| ExportDialogContent::new(window, cx)); + content.update(app, |content, cx| { + content.set_path(default_path.clone(), cx) + }); + let modal = app.new(|cx| { + Modal::new( + modal_ids::EXPORT, + ModalOptions::new(crate::i18n::tr("export.title"), px(440.0)) + .with_button(DialogButton::primary(crate::i18n::tr("export.run"))) + .with_button(DialogButton::cancel(crate::i18n::tr("dialog.cancel"))), + window, + cx, + ) + .with_content(content.clone()) + }); + ModalState::Export { modal, content } + }); + } + + /// A default output path for the export dialog: the project name with + /// the format's extension, next to the project file. + fn default_export_path(&self, cx: &App) -> String { + let project = self.engine.read(cx).project(); + let name = project + .map(|p| p.name.clone()) + .filter(|n| !n.is_empty()) + .unwrap_or_else(|| "untitled".to_string()); + let dir = project + .and_then(|p| p.path.parent().map(|d| d.to_path_buf())) + .unwrap_or_else(|| PathBuf::from(".")); + dir.join(format!("{name}.mp4")) + .to_string_lossy() + .into_owned() + } + + /// Starts the export from the export dialog's state and swaps the dialog + /// for the progress dialog. + fn begin_export(&mut self, cx: &mut Context) { + let ModalState::Export { content, .. } = &self.modal else { + return; + }; + let format = content.read(cx).format(cx); + let ext = content.read(cx).extension(cx); + let mut path = content.read(cx).path(cx).to_string(); + if path.trim().is_empty() { + return; + } + // Append the format's extension when the user left it off. + let has_ext = std::path::Path::new(&path) + .extension() + .map(|e| !e.to_string_lossy().is_empty()) + .unwrap_or(false); + if !has_ext { + path = format!("{path}.{ext}"); + } + + let result = self.engine.update(cx, |engine, _cx| { + engine.start_export(format, PathBuf::from(&path)) + }); + match result { + Ok(session) => { + self.export = Some(ExportRun { session }); + self.spawn_modal(cx, |window, app| { + let (modal, content) = progress_dialog( + modal_ids::EXPORT_PROGRESS, + crate::i18n::tr("export.progress.title"), + crate::i18n::tr("export.progress.label"), + window, + app, + ); + ModalState::Progress { modal, content } + }); + } + Err(err) => { + println!("[export] failed to start: {err}"); + self.close_modal(cx); + } + } + } + + /// Cancels the running export (the task aborts at the next frame). + fn cancel_export(&mut self, cx: &mut Context) { + if let Some(run) = &self.export { + (run.session.cancel)(); + } + let _ = cx; + } + + /// Drains the export progress events on the tick loop: updates the + /// progress bar and closes the dialog when the task finishes. + fn poll_export(&mut self, cx: &mut Context) { + let Some(run) = &self.export else { + return; + }; + let mut events = Vec::new(); + while let Ok(event) = run.session.events.try_recv() { + events.push(event); + } + if events.is_empty() { + return; + } + let mut finished: Option<(bool, String)> = None; + for event in events { + match event { + crate::oakui::ExportEvent::Started => {} + crate::oakui::ExportEvent::Progress(fraction) => { + if let ModalState::Progress { content, .. } = &self.modal { + let fraction = fraction as f32; + content.update(cx, |content, cx| content.set_progress(fraction, cx)); + } + } + crate::oakui::ExportEvent::Finished(ok, err) => finished = Some((ok, err)), + } + } + if let Some((ok, err)) = finished { + self.export = None; + self.modal = ModalState::None; + if ok { + println!("[export] finished"); + } else { + println!("[export] failed: {err}"); + } + cx.notify(); + } + } + + /// Routes a modal dialog event. + fn on_modal(&mut self, event: &ModalEvent, cx: &mut Context) { + match event { + ModalEvent::ButtonClicked { control, button } => match *control { + modal_ids::FILE_OPEN | modal_ids::FILE_SAVE_AS => { + self.on_file_dialog_button(*button, cx); + } + modal_ids::EXPORT => { + if *button == 0 { + self.begin_export(cx); + } else { + self.close_modal(cx); + } + } + modal_ids::EXPORT_PROGRESS => { + if *button == 1 { + // Cancel button: ask the task to abort; the finished + // event closes the dialog. + self.cancel_export(cx); + } + } + modal_ids::PREFERENCES => self.close_modal(cx), + _ => {} + }, + ModalEvent::Dismissed { control } => match *control { + modal_ids::EXPORT_PROGRESS => { + // Escape cancels the running export and closes the dialog. + self.cancel_export(cx); + self.close_modal(cx); + } + _ => self.close_modal(cx), + }, + } + } + + /// Handles the file dialog's OK/Cancel. + fn on_file_dialog_button(&mut self, button: usize, cx: &mut Context) { + let ModalState::FileDialog { + content, action, .. + } = &self.modal + else { + return; + }; + if button != 0 { + self.close_modal(cx); + return; + } + let path = PathBuf::from(content.read(cx).path(cx).to_string()); + let action = *action; + if path.as_os_str().is_empty() { + return; + } + let result = self.engine.update(cx, |engine, cx| match action { + FileAction::Open => engine.open_project_path(path.clone(), cx), + FileAction::SaveAs => engine.save_project(Some(path.clone()), cx), + }); + if let Err(err) = result { + println!("[file] {action:?} failed: {err}"); + } + self.close_modal(cx); + } } -impl Render for OakApp { +impl Render for OakApp { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - div() + let mut root = div() .size_full() .flex() .flex_col() .child(self.menu_bar.clone()) .child(div().flex_1().child(self.dock.clone())) - .child(self.status_bar.clone()) + .child(self.status_bar.clone()); + if let Some(modal) = self.modal.modal_entity() { + root = root.child(modal); + } + root } } @@ -523,8 +972,10 @@ fn make_menus(dark: bool) -> Vec { Menu::new(vec![ MenuItem::new(NEW_PROJECT, tr("menu.file.new_project")).with_shortcut("⌘N"), MenuItem::new(OPEN_PROJECT, tr("menu.file.open_project")).with_shortcut("⌘O"), - MenuItem::new(SAVE, tr("menu.file.save")).with_shortcut("⌘S").separated(), - MenuItem::new(EXPORT, tr("menu.file.export")).disabled(), + MenuItem::new(SAVE, tr("menu.file.save")).with_shortcut("⌘S"), + MenuItem::new(SAVE_AS, tr("menu.file.save_as")).with_shortcut("⇧⌘S").separated(), + MenuItem::new(CLOSE, tr("menu.file.close")), + MenuItem::new(EXPORT, tr("menu.file.export")).with_shortcut("⌘E").separated(), MenuItem::new(QUIT, tr("menu.file.quit")).with_shortcut("⌘Q").separated(), ]), ), @@ -536,7 +987,8 @@ fn make_menus(dark: bool) -> Vec { MenuItem::new(CUT, tr("menu.edit.cut")).with_shortcut("⌘X"), MenuItem::new(COPY, tr("menu.edit.copy")).with_shortcut("⌘C"), MenuItem::new(PASTE, tr("menu.edit.paste")).with_shortcut("⌘V"), - MenuItem::new(DELETE, tr("menu.edit.delete")).separated(), + MenuItem::new(DELETE, tr("menu.edit.delete")).with_shortcut("⌫").separated(), + MenuItem::new(RIPPLE_DELETE, tr("menu.edit.ripple_delete")), ]), ), MenuBarEntry::new( @@ -544,6 +996,7 @@ fn make_menus(dark: bool) -> Vec { Menu::new(vec![ MenuItem::new(THEME_DARK, tr("menu.view.theme")).with_submenu(theme_submenu), MenuItem::new(LANG_ZH, tr("menu.view.language")).with_submenu(language_submenu), + MenuItem::new(PREFERENCES, tr("menu.view.preferences")).separated(), ]), ), MenuBarEntry::new( @@ -562,7 +1015,9 @@ fn make_menus(dark: bool) -> Vec { Menu::new(vec![ MenuItem::new(ADD_VIDEO_TRACK, tr("menu.sequence.add_video_track")), MenuItem::new(ADD_AUDIO_TRACK, tr("menu.sequence.add_audio_track")), - MenuItem::new(503, tr("menu.sequence.settings")).disabled(), + MenuItem::new(REMOVE_TRACK, tr("menu.sequence.remove_track")).separated(), + MenuItem::new(SPLIT_AT_PLAYHEAD, tr("menu.sequence.split_at_playhead")), + MenuItem::new(704, tr("menu.sequence.settings")).disabled(), ]), ), MenuBarEntry::new( @@ -592,21 +1047,82 @@ fn make_menus(dark: bool) -> Vec { ] } +/// Command-line arguments the app accepts. +#[derive(Debug, Clone, Default)] +struct AppArgs { + /// A project file to open at startup. + project: Option, + /// Force the mock engine. + mock: bool, +} + +impl AppArgs { + /// Parses `std::env::args` plus the `OAK_ENGINE` override: + /// `oakapp [project.ove] [--mock]`. + fn from_env() -> Self { + let mut args = AppArgs::default(); + for arg in std::env::args_os().skip(1) { + let text = arg.to_string_lossy(); + match text.as_ref() { + "--mock" => args.mock = true, + "--help" | "-h" => { + println!("oakapp — Oak Video Editor"); + println!("usage: oakapp [project.ove] [--mock]"); + println!(" --mock use the mock engine (or set OAK_ENGINE=mock)"); + std::process::exit(0); + } + _ if args.project.is_none() => args.project = Some(arg.into()), + other => println!("[app] ignoring unknown argument {other:?}"), + } + } + if std::env::var("OAK_ENGINE") + .map(|v| v.eq_ignore_ascii_case("mock")) + .unwrap_or(false) + { + args.mock = true; + } + args + } +} + +/// Builds the app root entity for the chosen backend. +fn build_root( + window: &mut Window, + initial: Option, + cx: &mut App, +) -> Entity> { + cx.new(|cx| OakApp::new(window, initial, cx)) +} + /// The crate entry point: applies the olive-dark theme and opens the main -/// window. +/// window, running on the real engine by default (mock with `--mock` / +/// `OAK_ENGINE=mock` / the `mock-engine` feature). pub fn run() { - gpui_platform::application().run(|cx: &mut App| { - // Restore the persisted UI language (oakcommon config `Language` key) - // before the first window renders. + let args = AppArgs::from_env(); + let use_mock = args.mock || cfg!(feature = "mock-engine"); + if use_mock { + run_with::(args.clone()); + } else { + run_with::(args); + } +} + +/// Runs the app window with `E` as the engine backend. +fn run_with(args: AppArgs) { + let initial = args.project.clone(); + gpui_platform::application().run(move |cx: &mut App| { + // Restore the persisted UI language (config `Language` key) before the + // first window renders. crate::i18n::init(); cx.init_colors(); let bounds = Bounds::centered(None, size(px(1600.0), px(900.0)), cx); + let initial = initial.clone(); cx.open_window( WindowOptions { window_bounds: Some(WindowBounds::Windowed(bounds)), ..Default::default() }, - |window, cx| cx.new(|cx| OakApp::new(window, cx)), + |window, cx| build_root::(window, initial, cx), ) .expect("failed to open the main window"); @@ -623,6 +1139,7 @@ pub fn run() { #[cfg(test)] mod tests { use super::*; + use gpui::{TestAppContext, px, size}; /// The 视图/View menu carries a 语言/Language submenu whose items are /// labeled in their own language and whose checkmark follows the active @@ -714,4 +1231,121 @@ mod tests { assert_eq!(dark_item(true).checked, Some(true)); assert_eq!(dark_item(false).checked, Some(false)); } + + /// The File menu exposes the full project lifecycle actions (open / + /// save / save-as / close / export) and the Edit menu the undo stack + /// plus the delete variants, across both languages. + #[test] + fn file_and_edit_menus_cover_the_project_lifecycle() { + let _guard = crate::i18n::lang_test_lock().lock().unwrap(); + + let entry = |title: &str| -> MenuBarEntry { + make_menus(true) + .into_iter() + .find(|entry| entry.title == title) + .expect("menu exists") + }; + + crate::i18n::set_language(crate::i18n::Language::EnUs); + let file = entry("File(F)"); + for id in [ + menu_ids::NEW_PROJECT, + menu_ids::OPEN_PROJECT, + menu_ids::SAVE, + menu_ids::SAVE_AS, + menu_ids::CLOSE, + menu_ids::EXPORT, + menu_ids::QUIT, + ] { + assert!( + file.menu.items.iter().any(|item| item.id == id), + "File menu is missing item {id}" + ); + } + let edit = entry("Edit(E)"); + for id in [ + menu_ids::UNDO, + menu_ids::REDO, + menu_ids::DELETE, + menu_ids::RIPPLE_DELETE, + ] { + assert!( + edit.menu.items.iter().any(|item| item.id == id), + "Edit menu is missing item {id}" + ); + } + + // The same ids exist in the zh-CN menu bar. + crate::i18n::set_language(crate::i18n::Language::ZhCN); + let file = entry("文件(F)"); + assert!(file.menu.items.iter().any(|item| item.id == menu_ids::SAVE_AS)); + } + + /// Opening 视图 → Preferences… must not crash: the dialog content and the + /// modal are built on the main window and the shell state swaps over the + /// entity's weak handle (regression test for the Preferences crash). + #[gpui::test] + async fn preferences_dialog_opens_without_crashing(cx: &mut TestAppContext) { + let _guard = crate::i18n::lang_test_lock().lock().unwrap(); + crate::i18n::set_language(crate::i18n::Language::EnUs); + + cx.update(|cx| cx.init_colors()); + let window = cx.open_window(size(px(1600.0), px(900.0)), |window, cx| { + OakApp::::new(window, None, cx) + }); + cx.run_until_parked(); + let root = window.root(cx).expect("app root"); + + cx.update(|app| { + root.update(app, |app, cx| app.on_menu(menu_ids::PREFERENCES, cx)) + }); + cx.run_until_parked(); + // Force a draw so render-time panics in the dialog content surface. + cx.update_window(window.into(), |_root, window, cx| { + window.draw(cx).clear(); + }) + .expect("window is still open"); + + let has_modal = cx.read(|app| { + matches!(root.read(app).modal, ModalState::Preferences { .. }) + }); + assert!( + has_modal, + "preferences modal should be shown after the menu action" + ); + } + + /// The command-line parser understands the project path and the mock + /// flag, and the `OAK_ENGINE` env var forces the mock. + #[test] + fn app_args_parse_path_and_mock_flag() { + // Simulate argv without touching the real environment: parse a slice + // directly. + let parse = |argv: &[&str], env: Option<&str>| -> AppArgs { + let mut args = AppArgs::default(); + for text in argv { + match *text { + "--mock" => args.mock = true, + other => args.project = Some(PathBuf::from(other)), + } + } + if env.map(|v| v.eq_ignore_ascii_case("mock")).unwrap_or(false) { + args.mock = true; + } + args + }; + let a = parse(&["/tmp/a.ove"], None); + assert_eq!(a.project, Some(PathBuf::from("/tmp/a.ove"))); + assert!(!a.mock); + + let b = parse(&["/tmp/a.ove", "--mock"], None); + assert!(b.mock); + + let c = parse(&["/tmp/a.ove"], Some("MOCK")); + assert!(c.mock); + + let d = parse(&[], None); + assert!(d.project.is_none()); + assert!(!d.mock); + } } diff --git a/src/dialogs.rs b/src/dialogs.rs new file mode 100644 index 000000000..a27e0691b --- /dev/null +++ b/src/dialogs.rs @@ -0,0 +1,310 @@ +// 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 content views of the app's modal dialogs: preferences (renderer +//! backend + language) and export (format + output path). +//! +//! Each view owns its widgets and emits nothing itself — the host +//! (`crate::app::OakApp`) reads the state (format / path) when a dialog +//! button is clicked, and the preferences view writes its choices straight +//! through the config C ABI on selection. + +use gpui::colors::DefaultColors; +use gpui::prelude::*; +use gpui::{App, Context, Entity, Render, SharedString, Window, div}; +use gpui_elements::editable_text::{EditableTextState, StringStorage, text_input}; +use gpui_widgets::combo_box::{ComboBox, ComboBoxEvent, ComboBoxOption}; + +use crate::i18n; +use crate::oakui::real::{ + config_get_string, config_set_string, encoding_formats, renderer_backends, + CONFIG_KEY_RENDERER_BACKEND, EXPORT_FORMAT_MP4, +}; + +// --------------------------------------------------------------------------- +// Preferences +// --------------------------------------------------------------------------- + +/// The preferences dialog content: the renderer backend and the language +/// dropdowns. Both write through the config C ABI on selection, so the +/// choices survive restarts. +pub struct PreferencesContent { + backend: Entity, + language: Entity, + /// The backend options, in display order. + backends: Vec<&'static str>, +} + +impl PreferencesContent { + /// Builds the content: reads the current config values and seeds the + /// dropdowns. + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let backends = renderer_backends(); + let current_backend = config_get_string(CONFIG_KEY_RENDERER_BACKEND); + let backend_selected = backends + .iter() + .position(|b| b.eq_ignore_ascii_case(¤t_backend)) + .unwrap_or(0); + let backend_options = backends + .iter() + .enumerate() + .map(|(i, name)| ComboBoxOption::new(i, backend_label(name))) + .collect(); + let backend = cx.new(|cx| { + ComboBox::new(1, backend_options, window, cx) + .with_placeholder(i18n::tr("preferences.backend.placeholder")) + }); + cx.subscribe(&backend, |this, _combo, event: &ComboBoxEvent, cx| { + if let ComboBoxEvent::Selected { value, .. } = event { + if let Some(name) = this.backends.get(*value) { + config_set_string(CONFIG_KEY_RENDERER_BACKEND, name); + println!("[preferences] renderer backend → {name}"); + } + } + let _ = cx; + }) + .detach(); + backend.update(cx, |combo, cx| combo.set_selected(Some(backend_selected), cx)); + + let language_options = vec![ + ComboBoxOption::new(0, "English (en-US)"), + ComboBoxOption::new(1, "简体中文 (zh-CN)"), + ]; + let language = cx.new(|cx| { + ComboBox::new(2, language_options, window, cx) + .with_placeholder(i18n::tr("preferences.language.placeholder")) + }); + let language_selected = match crate::i18n::language() { + crate::i18n::Language::EnUs => 0, + crate::i18n::Language::ZhCN => 1, + }; + cx.subscribe(&language, |_this, _combo, event: &ComboBoxEvent, cx| { + if let ComboBoxEvent::Selected { value, .. } = event { + let language = match *value { + 1 => crate::i18n::Language::ZhCN, + _ => crate::i18n::Language::EnUs, + }; + crate::i18n::set_language(language); + } + let _ = cx; + }) + .detach(); + language.update(cx, |combo, cx| combo.set_selected(Some(language_selected), cx)); + + Self { + backend, + language, + backends, + } + } +} + +/// A display label for a renderer backend id. +fn backend_label(name: &str) -> String { + match name { + "opengl" => "OpenGL", + "metal" => "Metal", + "vulkan" => "Vulkan", + "none" => "None (off)", + other => other, + } + .to_string() +} + +/// A labeled form row: a small caption above the widget. +fn form_row( + colors: &gpui::colors::Colors, + label: SharedString, + widget: impl IntoElement, +) -> gpui::Div { + div() + .flex() + .flex_col() + .gap_1() + .child(div().text_color(colors.text).child(label)) + .child(widget) +} + +impl Render for PreferencesContent { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.default_colors().clone(); + div() + .flex() + .flex_col() + .gap_3() + .w_full() + .child(form_row( + &colors, + i18n::tr("preferences.backend").into(), + self.backend.clone(), + )) + .child(form_row( + &colors, + i18n::tr("preferences.language").into(), + self.language.clone(), + )) + .child( + div() + .text_color(colors.disabled) + .text_xs() + .child(i18n::tr("preferences.hint")), + ) + } +} + +// --------------------------------------------------------------------------- +// Export +// --------------------------------------------------------------------------- + +/// A text field with the same shape as the file dialog's path field. +pub struct PathField { + editor: Entity, +} + +impl PathField { + /// The path currently entered. + pub fn path(&self, app: &App) -> SharedString { + self.editor.read(app).as_str().into() + } + + /// Replaces the path shown in the field. + pub fn set_path(&mut self, path: impl Into, cx: &mut Context) { + let path = path.into(); + self.editor.update(cx, |editor, cx| { + editor.emplace(path.as_ref(), cx); + }); + cx.notify(); + } +} + +impl Render for PathField { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.default_colors().clone(); + let weak = self.editor.downgrade(); + div() + .rounded_md() + .border_1() + .border_color(colors.border) + .bg(colors.background) + .px_2() + .py_1() + .child(text_input("gpui-widgets-export-path").state(weak).accepts_input(true)) + } +} + +/// The export dialog content: the container-format dropdown and the output +/// path field. +pub struct ExportDialogContent { + format: Entity, + path: Entity, + /// (format id, display name, extension) in dropdown order. + formats: Vec<(i32, String, String)>, +} + +impl ExportDialogContent { + /// Builds the content: the format list comes from the oakcodec encoding + /// enumeration (MP4 default), the path starts empty. + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let formats: Vec<(i32, String, String)> = encoding_formats() + .into_iter() + .filter(|(_, _, ext)| !ext.is_empty()) + .collect(); + let options = formats + .iter() + .enumerate() + .map(|(i, (_, name, ext))| { + ComboBoxOption::new(i, format!("{name} (.{ext})")) + }) + .collect(); + let format = cx.new(|cx| { + ComboBox::new(3, options, window, cx) + .with_placeholder(i18n::tr("export.format.placeholder")) + }); + let mp4_index = formats + .iter() + .position(|(id, _, _)| *id == EXPORT_FORMAT_MP4) + .unwrap_or(0); + format.update(cx, |combo, cx| combo.set_selected(Some(mp4_index), cx)); + + let path = cx.new(|cx| { + let editor = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx)); + PathField { editor } + }); + + Self { + format, + path, + formats, + } + } + + /// The selected format id. + pub fn format(&self, cx: &App) -> i32 { + let Some(selected) = self.format.read(cx).selected() else { + return EXPORT_FORMAT_MP4; + }; + self.formats + .get(selected) + .map(|(id, _, _)| *id) + .unwrap_or(EXPORT_FORMAT_MP4) + } + + /// The selected format's file extension (without the dot). + pub fn extension(&self, cx: &App) -> String { + let Some(selected) = self.format.read(cx).selected() else { + return "mp4".to_string(); + }; + self.formats + .get(selected) + .map(|(_, _, ext)| ext.clone()) + .unwrap_or_else(|| "mp4".to_string()) + } + + /// The output path currently entered. + pub fn path(&self, cx: &App) -> SharedString { + self.path.read(cx).path(cx) + } + + /// Pre-fills the output path. + pub fn set_path(&mut self, path: impl Into, cx: &mut Context) { + let path = path.into(); + self.path.update(cx, |content, cx| content.set_path(path, cx)); + cx.notify(); + } +} + +impl Render for ExportDialogContent { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.default_colors().clone(); + div() + .flex() + .flex_col() + .gap_3() + .w_full() + .child(form_row( + &colors, + i18n::tr("export.format").into(), + self.format.clone(), + )) + .child(form_row(&colors, i18n::tr("export.path").into(), self.path.clone())) + .child( + div() + .text_color(colors.disabled) + .text_xs() + .child(i18n::tr("export.hint")), + ) + } +} diff --git a/src/i18n.rs b/src/i18n.rs index 1a328994f..1021a48f5 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -16,9 +16,9 @@ //! Tiny localization layer: embedded en-US / zh-CN string tables, a //! [`tr`] lookup used by every user-visible label in the app, and a runtime -//! language setting persisted through the oakcommon config C ABI -//! (`oakcommon_config_get` / `oakcommon_config_set`, the process-wide -//! `ConfigStore`). +//! language setting persisted through the oakengine config C ABI +//! (`oakengine_config_get_string` / `oakengine_config_set_string`, the +//! process-wide `ConfigStore`). //! //! # The tables //! @@ -31,18 +31,11 @@ //! //! The language is a process-global [`Language`] (an atomic, so any thread //! can read it without locking). At startup [`init`] loads the persisted -//! value from the oakcommon config key `Language` (`"zh-CN"` / `"en-US"`; -//! empty or unknown values mean en-US). [`set_language`] flips the global -//! and writes the new value back through the same key so the preference -//! survives restarts. -//! -//! The oakcommon C ABI is resolved at runtime with `dlopen`/`dlsym`, so the -//! app builds, tests and runs without liboakcommon present (e.g. under -//! `cargo test`): when the library cannot be loaded the layer degrades to an -//! in-process store and still switches languages live. Once the app is -//! packaged with liboakcommon in the library search path (or -//! `OAK_LIB_DIR` is set to a build tree), the setting round-trips through -//! `config.ini`. +//! value from the config key `Language` (`"zh-CN"` / `"en-US"`; empty or +//! unknown values mean en-US). [`set_language`] flips the global and writes +//! the new value back through the same key so the preference survives +//! restarts. The preferences dialog drives the same setting through the +//! config C ABI directly. use std::sync::atomic::{AtomicU8, Ordering}; @@ -88,7 +81,7 @@ pub fn language() -> Language { } /// Switches the active language live and persists the choice through the -/// oakcommon config C ABI (when the library is loadable). +/// oakengine config C ABI. pub fn set_language(language: Language) { CURRENT.store(match language { Language::EnUs => 0, @@ -98,24 +91,21 @@ pub fn set_language(language: Language) { sync_widgets(); } -/// Loads the persisted language from the oakcommon config C ABI. Called once -/// at startup. Never fails: without liboakcommon the default (en-US) stays. +/// Loads the persisted language from the oakengine config C ABI. Called once +/// at startup. Never fails: a missing key keeps the default (en-US). pub fn init() { - let Some(store) = ConfigAbi::load() else { + let code = crate::oakui::real::config_get_string("Language"); + if !code.is_empty() { + set_language(Language::from_code(&code)); + } else { sync_widgets(); - return; - }; - match store.get("Language") { - Some(code) if !code.is_empty() => set_language(Language::from_code(&code)), - _ => sync_widgets(), } } -/// Writes `language` back to the oakcommon config `Language` key. +/// Writes `language` back to the config `Language` key through the facade +/// config C ABI. fn persist_language(language: Language) { - if let Some(store) = ConfigAbi::load() { - store.set("Language", language.code()); - } + crate::oakui::real::config_set_string("Language", language.code()); } /// Translates `key` in the active language. @@ -135,6 +125,13 @@ pub const WIDGET_KEYS: &[&str] = &[ "viewer.safe_frames", "viewer.zoom", "viewer.no_frame_source", + "viewer.in_point", + "viewer.step_back", + "viewer.play", + "viewer.pause", + "viewer.step_forward", + "viewer.out_point", + "viewer.clear_range", "effect_stack.empty", "effect_stack.add", ]; @@ -184,6 +181,8 @@ const EN: &[(&str, &str)] = &[ ("menu.file.new_project", "New Project…"), ("menu.file.open_project", "Open Project…"), ("menu.file.save", "Save"), + ("menu.file.save_as", "Save As…"), + ("menu.file.close", "Close Project"), ("menu.file.export", "Export…"), ("menu.file.quit", "Quit"), // --- Edit --- @@ -193,6 +192,7 @@ const EN: &[(&str, &str)] = &[ ("menu.edit.copy", "Copy"), ("menu.edit.paste", "Paste"), ("menu.edit.delete", "Delete"), + ("menu.edit.ripple_delete", "Ripple Delete"), // --- View --- ("menu.view.theme", "Theme"), ("menu.view.theme.dark", "Olive Dark"), @@ -200,6 +200,7 @@ const EN: &[(&str, &str)] = &[ ("menu.view.language", "Language"), ("menu.view.language.en", "English"), ("menu.view.language.zh", "简体中文"), + ("menu.view.preferences", "Preferences…"), // --- Playback --- ("menu.playback.play_pause", "Play/Pause"), ("menu.playback.prev_frame", "Previous Frame"), @@ -208,6 +209,8 @@ const EN: &[(&str, &str)] = &[ // --- Sequence --- ("menu.sequence.add_video_track", "Add Video Track"), ("menu.sequence.add_audio_track", "Add Audio Track"), + ("menu.sequence.remove_track", "Remove Selected Track"), + ("menu.sequence.split_at_playhead", "Split Clips at Playhead"), ("menu.sequence.settings", "Sequence Settings…"), // --- Window --- ("menu.window.project", "Project"), @@ -237,6 +240,7 @@ const EN: &[(&str, &str)] = &[ ("status.proxy", "Proxy: Off"), ("status.autosave", "Autosave: 3 min ago"), ("status.untitled", "Untitled Project"), + ("status.backend", "Engine:"), // --- timeline toolbar --- ("timeline.tool.select", "Select"), ("timeline.tool.razor", "Razor"), @@ -244,16 +248,35 @@ const EN: &[(&str, &str)] = &[ ("timeline.tool.slip", "Slip"), ("timeline.tool.roll", "Roll"), ("timeline.tool.zoom", "Zoom"), - ("timeline.tool.knife", "Knife"), - ("timeline.tool.marker", "Marker"), + ("timeline.tool.slide", "Slide"), + ("timeline.tool.track_select", "Track Select"), ("timeline.zoom", "Zoom"), + ("timeline.zoom_in", "Zoom In"), + ("timeline.zoom_out", "Zoom Out"), ("timeline.track_height", "Track Height"), ("timeline.snap", "Snap"), + // --- project bin --- + ("bin.footage", "Footage"), + ("bin.music", "Music"), + // --- history (undo stack demo entries) --- + ("history.transform", "Transform"), + ("history.move_clip", "Move Clip"), + ("history.delete_clip", "Delete"), + ("history.add_lut", "Add OCIO LUT"), + ("history.set_in_point", "Set In Point"), // --- node editor --- ("node.fit", "Fit"), // --- viewer header chips --- ("viewer.source", "Source Viewer · Source"), ("viewer.program", "Program Viewer · Program"), + // --- viewer transport tooltips --- + ("viewer.in_point", "Set In Point"), + ("viewer.step_back", "Previous Frame"), + ("viewer.play", "Play"), + ("viewer.pause", "Pause"), + ("viewer.step_forward", "Next Frame"), + ("viewer.out_point", "Set Out Point"), + ("viewer.clear_range", "Clear In/Out Range"), // --- widget-baked strings (synced to gpui_widgets::i18n) --- ("viewer.safe_frames", "Safe Frames"), ("viewer.zoom", "Zoom"), @@ -262,6 +285,25 @@ const EN: &[(&str, &str)] = &[ ("effect_stack.add", "+ Add Effect"), // --- inspector --- ("inspector.params", "Parameters (placeholder)"), + // --- dialogs --- + ("dialog.cancel", "Cancel"), + ("dialog.close", "Close"), + ("file.open.title", "Open Project"), + ("file.save_as.title", "Save Project As"), + ("preferences.title", "Preferences"), + ("preferences.backend", "Renderer backend"), + ("preferences.backend.placeholder", "Select a backend…"), + ("preferences.language", "Language"), + ("preferences.language.placeholder", "Select a language…"), + ("preferences.hint", "The renderer backend applies to the render worker at the next launch; the language switches immediately."), + ("export.title", "Export Sequence"), + ("export.format", "Format"), + ("export.format.placeholder", "Select a format…"), + ("export.path", "Output path"), + ("export.run", "Export"), + ("export.hint", "The sequence is exported through the oaktask export path; progress is shown in the dialog."), + ("export.progress.title", "Exporting"), + ("export.progress.label", "Rendering frames…"), ]; /// The zh-CN table. Mirrors [`EN`] key-for-key. @@ -279,6 +321,8 @@ const ZH: &[(&str, &str)] = &[ ("menu.file.new_project", "新建项目…"), ("menu.file.open_project", "打开项目…"), ("menu.file.save", "保存"), + ("menu.file.save_as", "另存为…"), + ("menu.file.close", "关闭项目"), ("menu.file.export", "导出…"), ("menu.file.quit", "退出"), // --- Edit --- @@ -288,6 +332,7 @@ const ZH: &[(&str, &str)] = &[ ("menu.edit.copy", "复制"), ("menu.edit.paste", "粘贴"), ("menu.edit.delete", "删除"), + ("menu.edit.ripple_delete", "波纹删除"), // --- View --- ("menu.view.theme", "主题"), ("menu.view.theme.dark", "Olive Dark"), @@ -295,6 +340,7 @@ const ZH: &[(&str, &str)] = &[ ("menu.view.language", "语言"), ("menu.view.language.en", "English"), ("menu.view.language.zh", "简体中文"), + ("menu.view.preferences", "偏好设置…"), // --- Playback --- ("menu.playback.play_pause", "播放/暂停"), ("menu.playback.prev_frame", "上一帧"), @@ -303,6 +349,8 @@ const ZH: &[(&str, &str)] = &[ // --- Sequence --- ("menu.sequence.add_video_track", "添加视频轨道"), ("menu.sequence.add_audio_track", "添加音频轨道"), + ("menu.sequence.remove_track", "删除所选轨道"), + ("menu.sequence.split_at_playhead", "在播放头处分割片段"), ("menu.sequence.settings", "序列设置…"), // --- Window --- ("menu.window.project", "项目"), @@ -332,6 +380,7 @@ const ZH: &[(&str, &str)] = &[ ("status.proxy", "代理:关"), ("status.autosave", "自动保存:3分钟前"), ("status.untitled", "未命名项目"), + ("status.backend", "引擎:"), // --- timeline toolbar --- ("timeline.tool.select", "选择"), ("timeline.tool.razor", "剃刀"), @@ -339,16 +388,35 @@ const ZH: &[(&str, &str)] = &[ ("timeline.tool.slip", "滑动"), ("timeline.tool.roll", "滚动"), ("timeline.tool.zoom", "缩放"), - ("timeline.tool.knife", "刀"), - ("timeline.tool.marker", "标记"), + ("timeline.tool.slide", "滑移"), + ("timeline.tool.track_select", "轨道选择"), ("timeline.zoom", "缩放"), + ("timeline.zoom_in", "放大"), + ("timeline.zoom_out", "缩小"), ("timeline.track_height", "轨道高"), ("timeline.snap", "吸附"), + // --- project bin --- + ("bin.footage", "素材"), + ("bin.music", "音乐"), + // --- history (undo stack demo entries) --- + ("history.transform", "变换"), + ("history.move_clip", "移动片段"), + ("history.delete_clip", "删除"), + ("history.add_lut", "添加 OCIO LUT"), + ("history.set_in_point", "设置入点"), // --- node editor --- ("node.fit", "适配"), // --- viewer header chips --- ("viewer.source", "素材查看器 · 源"), ("viewer.program", "序列查看器 · 节目"), + // --- viewer transport tooltips --- + ("viewer.in_point", "设置入点"), + ("viewer.step_back", "上一帧"), + ("viewer.play", "播放"), + ("viewer.pause", "暂停"), + ("viewer.step_forward", "下一帧"), + ("viewer.out_point", "设置出点"), + ("viewer.clear_range", "清除入出点"), // --- widget-baked strings (synced to gpui_widgets::i18n) --- ("viewer.safe_frames", "安全框"), ("viewer.zoom", "缩放"), @@ -357,127 +425,35 @@ const ZH: &[(&str, &str)] = &[ ("effect_stack.add", "+ 添加效果"), // --- inspector --- ("inspector.params", "参数(占位)"), + // --- dialogs --- + ("dialog.cancel", "取消"), + ("dialog.close", "关闭"), + ("file.open.title", "打开项目"), + ("file.save_as.title", "项目另存为"), + ("preferences.title", "偏好设置"), + ("preferences.backend", "渲染后端"), + ("preferences.backend.placeholder", "选择一个后端…"), + ("preferences.language", "语言"), + ("preferences.language.placeholder", "选择语言…"), + ("preferences.hint", "渲染后端在下次启动渲染工作进程时生效;语言立即切换。"), + ("export.title", "导出序列"), + ("export.format", "格式"), + ("export.format.placeholder", "选择格式…"), + ("export.path", "输出路径"), + ("export.run", "导出"), + ("export.hint", "序列通过 oaktask 导出路径导出;进度显示在对话框中。"), + ("export.progress.title", "正在导出"), + ("export.progress.label", "正在渲染帧…"), ]; // --------------------------------------------------------------------------- -// oakcommon config C ABI (runtime-resolved) +// Config persistence // --------------------------------------------------------------------------- - -/// The subset of the oakcommon config C ABI the language setting needs. Each -/// function pointer is optional: when liboakcommon cannot be loaded the whole -/// struct is `None` and the in-process fallback store is used instead. -struct ConfigAbi { - get: unsafe extern "C" fn(*const i8, *const i8, *mut i8, i32) -> i32, - set: unsafe extern "C" fn(*const i8, *const i8, *const i8), -} - -impl ConfigAbi { - /// Resolves the ABI once (process-wide) and returns it when loadable. - fn load() -> Option<&'static ConfigAbi> { - static ABI: std::sync::OnceLock> = std::sync::OnceLock::new(); - ABI.get_or_init(resolve_abi).as_ref() - } - - /// Reads the string entry for a flat `key`, or `None` when absent. - fn get(&self, key: &str) -> Option { - let key = to_c(key)?; - let mut buf = [0i8; 128]; - let result = unsafe { - (self.get)( - std::ptr::null(), - key.as_ptr(), - buf.as_mut_ptr(), - buf.len() as i32, - ) - }; - if result <= 0 { - // Negative codes are OAKCOMMON_E_* errors (e.g. not-found). - return None; - } - Some(from_c(&buf)) - } - - /// Writes a string entry for a flat `key`. - fn set(&self, key: &str, value: &str) { - let (Some(key), Some(value)) = (to_c(key), to_c(value)) else { - return; - }; - unsafe { - (self.set)(std::ptr::null(), key.as_ptr(), value.as_ptr()); - } - } -} - -/// Resolves the oakcommon config functions via `dlopen`/`dlsym`. -/// -/// Candidate library names: `OAK_LIB_DIR` (build-tree override) first, then -/// the bare `liboakcommon.dylib` name on the platform search path. When none -/// loads (plain `cargo test`/`cargo run` without a built oakcommon), this -/// returns `None` and [`ConfigAbi::load`] degrades to the fallback store. -#[cfg(target_os = "macos")] -fn resolve_abi() -> Option { - use std::ffi::c_void; - - const RTLD_LAZY: i32 = 0x1; - unsafe extern "C" { - fn dlopen(filename: *const i8, flag: i32) -> *mut c_void; - fn dlsym(handle: *mut c_void, symbol: *const i8) -> *mut c_void; - } - - // Candidate handles; the first dlopen that succeeds wins. - let mut candidates: Vec<*mut c_void> = Vec::new(); - let mut handle: *mut c_void = std::ptr::null_mut(); - - if let Ok(dir) = std::env::var("OAK_LIB_DIR") { - let path = format!("{dir}/liboakcommon.dylib"); - if let Some(c) = to_c(&path) { - handle = unsafe { dlopen(c.as_ptr(), RTLD_LAZY) }; - if !handle.is_null() { - candidates.push(handle); - } - } - } - if handle.is_null() { - if let Some(c) = to_c("liboakcommon.dylib") { - handle = unsafe { dlopen(c.as_ptr(), RTLD_LAZY) }; - } - } - if handle.is_null() { - return None; - } - - let get = unsafe { dlsym(handle, b"oakcommon_config_get\0".as_ptr() as *const i8) }; - let set = unsafe { dlsym(handle, b"oakcommon_config_set\0".as_ptr() as *const i8) }; - if get.is_null() || set.is_null() { - return None; - } - - Some(ConfigAbi { - get: unsafe { std::mem::transmute(get) }, - set: unsafe { std::mem::transmute(set) }, - }) -} - -/// Non-macOS fallback: no dlopen plumbing here; the in-process store is -/// always used. -#[cfg(not(target_os = "macos"))] -fn resolve_abi() -> Option { - None -} - -/// Turns a `&str` into a NUL-terminated C string. -fn to_c(s: &str) -> Option { - std::ffi::CString::new(s).ok() -} - -/// Reads a NUL-terminated buffer back into a `String`. -fn from_c(buf: &[i8]) -> String { - let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len()); - buf[..len] - .iter() - .map(|&c| c as u8 as char) - .collect() -} +// +// The language setting round-trips through the oakengine config C ABI +// (`oakengine_config_get_string` / `oakengine_config_set_string`, the +// in-process `ConfigStore` backed by the linked oakcommon crate). See +// [`real`](crate::oakui::real) for the facade-side helpers. /// Serializes every test that mutates the process-global language, so /// parallel tests (in this module and in [`crate::app`]) cannot race each @@ -514,6 +490,48 @@ mod tests { } } + /// No table entry may be its own key, and identical en-US / zh-CN values + /// are only allowed for proper nouns that stay in their original language + /// (theme names, language names, codecs) — anything else means one side + /// was left untranslated. + #[test] + fn no_untranslated_values() { + // Values that are intentionally identical across languages. + let shared = [ + "Olive Dark", + "Olive Light", + "English", + "简体中文", + "en-US", + "zh-CN", + "OpenGL", + "Metal", + "Vulkan", + "MP4", + ]; + for (key, en_value) in EN { + assert_ne!( + *en_value, *key, + "en-US value for {key} is still the raw key (untranslated)" + ); + let zh_value = ZH + .iter() + .find(|(k, _)| *k == *key) + .map(|(_, v)| *v) + .unwrap(); + assert_ne!( + zh_value, *key, + "zh-CN value for {key} is still the raw key (untranslated)" + ); + if *en_value == zh_value && !shared.contains(en_value) { + panic!( + "key {key} has identical en-US and zh-CN values ({en_value:?}); \ + one side is untranslated" + ); + } + } + } + /// Keys are unique within each table (a duplicate would make `tr`' /// lookup order-dependent). #[test] diff --git a/src/lib.rs b/src/lib.rs index 7d7b097b6..351f13c89 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,25 +19,37 @@ //! //! This is the start of the Rust rewrite of `app/` (Qt): a gpui window with //! the main layout from the design (`design/`), dockable panels built from -//! the `gpui_widgets` library, and an engine seam (`oakui`) that currently -//! feeds demo data through [`MockEngine`](oakui::MockEngine). +//! the `gpui_widgets` library, and an engine seam (`oakui`) with two +//! backends: the mock ([`oakui::MockEngine`]) feeding demo data, and the +//! real engine ([`oakui::RealEngine`]) bound to the built `liboakengine` +//! dylib through its frozen `oakengine_*` C ABI only (project open/save, +//! sequence/track/clip data, timeline edits through the oaktimeline edit +//! commands, the oaktask export path, and the config C ABI). //! //! # Layout //! -//! * [`app`] — the window shell: menu bar, dock layout, status bar, tick -//! loop. +//! * [`app`] — the window shell: menu bar, dock layout, status bar, modal +//! dialogs (file open/save-as, preferences, export), tick loop. +//! * [`dialogs`] — the preferences and export dialog content views. //! * [`panels`] — the dockable panels (viewers, timeline, inspector, ...). -//! * [`oakui`] — the engine gateway trait, the mock implementation, and the -//! pure view-state logic (timecode, transport). +//! * [`oakui`] — the engine gateway trait, the mock + real implementations, +//! and the pure view-state logic (timecode, transport). //! //! # Running //! //! ```text -//! cargo run --bin oakapp # the demo window -//! cargo test # unit tests (timecode, transport) +//! cargo run --bin oak-editor # the real engine, no project +//! cargo run --bin oak-editor -- path/to/project.ove # open a project at startup +//! cargo run --bin oak-editor -- --mock # the demo (mock) engine +//! cargo test # unit tests (app + crates) //! ``` +//! +//! The engine backend is selected at startup: the real engine by default, +//! the mock with the `--mock` flag, `OAK_ENGINE=mock`, or the +//! `mock-engine` cargo feature. pub mod app; +pub mod dialogs; pub mod i18n; pub mod oakui; pub mod panels; diff --git a/src/oakui/engine.rs b/src/oakui/engine.rs index 96e08fb08..014b374bc 100644 --- a/src/oakui/engine.rs +++ b/src/oakui/engine.rs @@ -36,8 +36,16 @@ //! Everything here is plain Rust — no C ABI, no FFI. The C-ABI binding is a //! later concern of the real backend only. -use gpui::timeline::{Frame, FrameRate}; 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; /// A monitor the transport can address. /// @@ -128,3 +136,151 @@ pub trait EngineGateway: Sized { /// periodic timer while any monitor is playing. fn tick(&mut self, cx: &mut gpui::Context); } + +/// 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 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; + + /// The source monitor's clock entity. + fn source_clock(&self) -> &Entity; + + /// The program monitor's clock entity. + fn program_clock(&self) -> &Entity; + + /// 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; + + /// 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); + + /// 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); + + /// Sets the row height of every timeline track (timeline toolbar). + fn set_track_height(&mut self, height: Pixels, cx: &mut Context); + + /// Selects a material-bin entry (project-explorer "open"). + fn select_item(&mut self, id: u64, cx: &mut Context); + + /// Applies an effect-stack edit request to the engine's model. + fn apply_effect_event(&mut self, event: &EffectStackEvent, cx: &mut Context); + + /// Applies a node-editor edit request to the engine's model. + fn apply_node_graph_event(&mut self, event: &NodeGraphEvent, cx: &mut Context); + + /// 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); + + /// Splits the clip with `clip` id at `time` (the razor action). + fn split_clip(&mut self, clip: ClipId, time: Frame, cx: &mut Context); + + /// Splits every clip whose range spans the program playhead (the razor + /// tool's menu action). + fn split_at_playhead(&mut self, cx: &mut Context); + + /// 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); + + /// 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); + + /// Steps the undo stack forward one entry. + fn redo(&mut self, cx: &mut Context); + + /// Whether the project has unsaved changes. + fn project_modified(&self) -> bool; + + /// Starts a new blank project with a single default sequence. + fn new_project(&mut self, cx: &mut Context); + + /// 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) -> Result<(), String>; + + /// Saves the project to `path` (or its own filename when `None`). The + /// format is dispatched by extension like [`open_project_path`] + /// (AppEngine::open_project_path). + fn save_project(&mut self, path: Option, cx: &mut Context) -> Result<(), String>; + + /// Closes the current project, leaving the app with no sequence. + fn close_project(&mut self, cx: &mut Context); + + /// 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; + + /// 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, + /// Cancels the running export as soon as possible. + pub cancel: Box, +} diff --git a/src/oakui/ffi.rs b/src/oakui/ffi.rs new file mode 100644 index 000000000..89aed3e69 --- /dev/null +++ b/src/oakui/ffi.rs @@ -0,0 +1,494 @@ +// 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 app's pure-C surface of the `liboakengine` dylib. +//! +//! The app links the built `liboakengine.dylib` (see `build.rs`) and calls +//! ONLY its `oakengine_*` C ABI — it never depends on the `oakengine` crate +//! as an rlib. This module declares every exported function the real engine +//! binding uses, with the exact signatures from the facade's `#[no_mangle]` +//! exports (`crates/oakengine/src/*.rs`), plus the two `oaktask_*` module +//! exports the dylib carries alongside the facade (interchange +//! load/save getter and the task event subscription, see the comments +//! below). +//! +//! The facade also exports the module C ABIs (`oakundo_*`, `oakcommon_*`, +//! ...) inside the same dylib; the module functions the app needs beyond +//! the facade's wrapping (`oaktask_load_take_project`, +//! `oaktask_task_subscribe`) are declared here too and resolve from the +//! dylib. +//! +//! # Handle layout mirrors +//! +//! The facade's opaque `OakEngine*` handle types are thin `#[repr(C)]` +//! newtypes around one module [`CHandle`] value (see +//! `crates/oakengine/src/handle.rs`), and boxes created with +//! `box_handle`/`free_box` live in the heap. The dylib ABI passes those +//! boxes as opaque pointers, but a pure-C consumer that needs to (a) build +//! a project box from a module handle (interchange load) or (b) free a +//! borrowed handle box (sequences / clips the facade returns but has no +//! `oakengine_*_free` for) must know the box layout. The mirrors below +//! reproduce it exactly (identical `repr(C)` field layout), so boxes +//! created by the facade can be read/freed from the app and vice versa. +//! +//! The module handle type itself is the frozen `{ctx, addref, release, +//! abi_version}` value handle (`include/common/handle.h`); `release` is +//! what `free_box` calls before deallocating the box. + +use std::ffi::{c_char, c_int, c_void}; + +/// The module value handle (`{ctx, addref, release, abi_version}`), mirror +/// of `oakcore_rs::handle::CHandle` / `include/common/handle.h`. +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CHandle { + /// Opaque refcounted box pointer. + pub ctx: *mut c_void, + /// Atomic increment. + pub addref: Option, + /// Atomic decrement; destroys at zero. + pub release: Option, + /// ABI version. + pub abi_version: u32, +} + +impl CHandle { + /// Whether this is the empty (zero) handle. + pub fn is_null(&self) -> bool { + self.ctx.is_null() + } +} + +/// Opaque engine handle boxes, mirroring the facade's `engine_handle!` +/// newtypes (one `CHandle` per box). Only the types the app actually +/// touches are declared. +macro_rules! engine_handle { + ($($name:ident),* $(,)?) => { + $( + /// Opaque engine handle: a `#[repr(C)]` box holding one module + /// [`CHandle`]. + #[repr(C)] + #[derive(Clone, Copy)] + pub struct $name { + /// The wrapped module handle. + pub handle: CHandle, + } + + impl HandleBox for $name { + fn boxed_new(handle: CHandle) -> Self { + $name { handle } + } + fn handle(&self) -> CHandle { + self.handle + } + } + )* + }; +} + +engine_handle! { + OakEngineClip, + OakEngineEncodingParams, + OakEngineProject, + OakEngineSequence, + OakEngineTask, +} + +/// Uniform construction/extraction surface of the engine opaque boxes. +pub trait HandleBox: Sized { + /// Build the box from a module handle. + fn boxed_new(handle: CHandle) -> Self; + /// Extract the wrapped module handle (copy). + fn handle(&self) -> CHandle; +} + +/// Allocate a heap box for a module handle and return its raw pointer. +/// The box must later be released with [`free_box`] or a consuming +/// `oakengine_*_free` export. +/// +/// # Safety +/// The handle must be a live module handle (e.g. from +/// `oaktask_load_take_project`). +pub unsafe fn box_handle(handle: CHandle) -> *mut T { + // SAFETY: the caller passes a live handle; the box is managed by the + // C ABI consumers from here on. + Box::into_raw(Box::new(T::boxed_new(handle))) +} + +/// Dereference an engine opaque box and copy out its module handle. +/// Returns `None` for a NULL pointer or an empty handle. +/// +/// # Safety +/// `ptr` must point to a live box created by [`box_handle`] or by the +/// facade (or be NULL). +pub unsafe fn unbox(ptr: *const T) -> Option { + // SAFETY: see the function docs. + if ptr.is_null() { + return None; + } + let h = (*ptr).handle(); + if h.is_null() { + None + } else { + Some(h) + } +} + +/// Free a box created by [`box_handle`] (or returned by the facade): +/// release the module handle (via its `release` function pointer) and +/// deallocate the box. NULL and empty handles are no-ops. After the call +/// `ptr` is dangling; the caller must not use it again. +/// +/// # Safety +/// `ptr` must be a pointer previously returned by [`box_handle`] or by the +/// facade (or NULL) and must not be freed twice. +pub unsafe fn free_box(ptr: *mut T) { + // SAFETY: see the function docs. + if ptr.is_null() { + return; + } + let handle = (*ptr).handle(); + if let Some(release) = handle.release { + release(handle.ctx); + } + drop(Box::from_raw(ptr)); +} + +/// `engine/include/oakengine/videoparams.h` — POD mirror of VideoParams' +/// user-facing fields (Rust mirror of `oak_video_params`; the facade's +/// `oakengine::common::OakVideoParamsPod`). +#[repr(C)] +#[derive(Clone, Copy)] +pub struct OakVideoParamsPod { + /// Width. + pub width: c_int, + /// Height. + pub height: c_int, + /// Frame duration numerator (e.g. 1001/30000 s). + pub time_base_num: c_int, + /// Frame duration denominator. + pub time_base_den: c_int, + /// PixelFormat::Format value. + pub format: c_int, + /// Pixel aspect numerator. + pub pixel_aspect_num: c_int, + /// Pixel aspect denominator. + pub pixel_aspect_den: c_int, + /// Interlacing value. + pub interlacing: c_int, + /// ColorRange value. + pub color_range: c_int, + /// Preview resolution divider (1 = full). + pub divider: c_int, + /// VideoParams::Type value. + pub video_type: c_int, + /// 0/1 premultiplied alpha. + pub premultiplied_alpha: c_int, +} + +/// The task event callback the module subscription invokes on the task's +/// own thread (`oaktask` C ABI, `include/task/task.h`). +pub type OakTaskEventFn = unsafe extern "C" fn(event_id: c_int, value: f64, userdata: *mut c_void); + +// The `oakengine_*` C ABI surface the app binds. Signatures mirror the +// facade's `#[no_mangle] pub extern "C"` exports verbatim (the facade +// declares a few of them without `unsafe`; calling any extern-block item +// still requires an unsafe context on the current toolchain, so the call +// sites in `real.rs` carry their own `unsafe` blocks). +// +// String outputs follow the engine buf/size convention: the return value +// is the required length excluding the terminating NUL; negative values +// are error codes. +#[link(name = "oakengine")] +unsafe extern "C" { + // -- oakengine::codec (encoding formats + params) -- + + /// `oakengine_encoding_format_count` — number of export formats. + pub fn oakengine_encoding_format_count() -> c_int; + /// `oakengine_encoding_format_name` (buf/size; -1 invalid). + pub fn oakengine_encoding_format_name(format: c_int, buf: *mut c_char, buf_size: c_int) -> c_int; + /// `oakengine_encoding_format_extension` (buf/size). + pub fn oakengine_encoding_format_extension( + format: c_int, + buf: *mut c_char, + buf_size: c_int, + ) -> c_int; + /// `oakengine_encoding_format_video_codec_at` — codec id, -1 invalid. + pub fn oakengine_encoding_format_video_codec_at(format: c_int, index: c_int) -> c_int; + /// `oakengine_encoding_format_audio_codec_at` — codec id, -1 invalid. + pub fn oakengine_encoding_format_audio_codec_at(format: c_int, index: c_int) -> c_int; + /// `oakengine_encoding_params_create` — owned params box. + pub fn oakengine_encoding_params_create() -> *mut OakEngineEncodingParams; + /// `oakengine_encoding_params_destroy` — consuming free. + pub fn oakengine_encoding_params_destroy(params: *mut OakEngineEncodingParams); + /// `oakengine_encoding_params_set_filename`. + pub fn oakengine_encoding_params_set_filename( + params: *mut OakEngineEncodingParams, + filename: *const c_char, + ) -> c_int; + /// `oakengine_encoding_params_set_format` — rejects out-of-range values. + pub fn oakengine_encoding_params_set_format( + params: *mut OakEngineEncodingParams, + format: c_int, + ) -> c_int; + /// `oakengine_encoding_params_enable_video` — copy the POD-carryable + /// fields of `video` and enable the video track. + pub fn oakengine_encoding_params_enable_video( + params: *mut OakEngineEncodingParams, + video: *const OakVideoParamsPod, + codec: c_int, + ) -> c_int; + /// `oakengine_encoding_params_enable_audio`. + pub fn oakengine_encoding_params_enable_audio( + params: *mut OakEngineEncodingParams, + sample_rate: c_int, + channel_layout: u64, + sample_format: c_int, + codec: c_int, + ) -> c_int; + /// `oakengine_encoding_params_set_export_length`. + pub fn oakengine_encoding_params_set_export_length( + params: *mut OakEngineEncodingParams, + num: c_int, + den: c_int, + ); + + // -- oakengine::common (config) -- + + /// `oakengine_config_get_string` — read a config string; a missing key + /// reads as an empty string. + pub fn oakengine_config_get_string(key: *const c_char, buf: *mut c_char, buf_size: c_int) -> c_int; + /// `oakengine_config_set_string` — write a string value. + pub fn oakengine_config_set_string(key: *const c_char, value: *const c_char) -> c_int; + + // -- oakengine::node (project) -- + + /// `oakengine_project_create` — owned project box (no content yet). + pub fn oakengine_project_create() -> *mut OakEngineProject; + /// `oakengine_project_free` — consuming free of an owned project box. + pub fn oakengine_project_free(self_: *mut OakEngineProject); + /// `oakengine_project_new` — initialize a blank project. + pub fn oakengine_project_new(self_: *mut OakEngineProject) -> c_int; + /// `oakengine_project_load` — load from `path`; fills `err` (buf/size) + /// on failure. + pub fn oakengine_project_load( + self_: *mut OakEngineProject, + path: *const c_char, + err: *mut c_char, + err_size: c_int, + ) -> c_int; + /// `oakengine_project_save` — save to `path` (or the recorded + /// filename when NULL). + pub fn oakengine_project_save(self_: *mut OakEngineProject, path: *const c_char) -> c_int; + /// `oakengine_project_is_modified`. + pub fn oakengine_project_is_modified(self_: *const OakEngineProject) -> c_int; + /// `oakengine_project_name` (buf/size). + pub fn oakengine_project_name( + self_: *const OakEngineProject, + buf: *mut c_char, + buf_size: c_int, + ) -> c_int; + /// `oakengine_project_filename` (buf/size). + pub fn oakengine_project_filename( + self_: *const OakEngineProject, + buf: *mut c_char, + buf_size: c_int, + ) -> c_int; + /// `oakengine_project_footage_count`. + pub fn oakengine_project_footage_count(self_: *const OakEngineProject) -> c_int; + /// `oakengine_project_footage_filename` (buf/size). + pub fn oakengine_project_footage_filename( + self_: *const OakEngineProject, + index: c_int, + buf: *mut c_char, + buf_size: c_int, + ) -> c_int; + /// `oakengine_project_can_undo`. + pub fn oakengine_project_can_undo(self_: *const OakEngineProject) -> c_int; + /// `oakengine_project_can_redo`. + pub fn oakengine_project_can_redo(self_: *const OakEngineProject) -> c_int; + /// `oakengine_project_undo`. + pub fn oakengine_project_undo(self_: *mut OakEngineProject) -> c_int; + /// `oakengine_project_redo`. + pub fn oakengine_project_redo(self_: *mut OakEngineProject) -> c_int; + /// `oakengine_project_sequence_count`. + pub fn oakengine_project_sequence_count(self_: *const OakEngineProject) -> c_int; + /// `oakengine_project_sequence_at` — borrowed sequence box (free with + /// [`free_box`]). + pub fn oakengine_project_sequence_at( + self_: *const OakEngineProject, + index: c_int, + ) -> *mut OakEngineSequence; + /// `oakengine_project_set_filename`. + pub fn oakengine_project_set_filename(self_: *mut OakEngineProject, path: *const c_char) -> c_int; + /// `oakengine_project_node_count` — parseable content check. + pub fn oakengine_project_node_count(self_: *const OakEngineProject) -> c_int; + + // -- oakengine::task -- + + /// `oakengine_task_error` (buf/size). + pub fn oakengine_task_error(task: *mut OakEngineTask, buf: *mut c_char, buf_size: c_int) -> c_int; + /// `oakengine_task_cancel` — set the task's cancel atom. + pub fn oakengine_task_cancel(task: *mut OakEngineTask) -> c_int; + /// `oakengine_task_start_sync` — run the task to completion. + pub fn oakengine_task_start_sync(task: *mut OakEngineTask) -> c_int; + /// `oakengine_task_free` — consuming free of an owned task box. + pub fn oakengine_task_free(task: *mut OakEngineTask) -> c_int; + /// `oakengine_task_create_project_load_otio` — owned interchange load + /// task box. + pub fn oakengine_task_create_project_load_otio(filename: *const c_char) -> *mut OakEngineTask; + /// `oakengine_task_create_project_save_otio` — owned interchange save + /// task box. + pub fn oakengine_task_create_project_save_otio( + project: *mut OakEngineProject, + ) -> *mut OakEngineTask; + /// `oakengine_task_create_export` — owned export task box. + pub fn oakengine_task_create_export( + sequence: *mut OakEngineSequence, + params: *mut OakEngineEncodingParams, + ) -> *mut OakEngineTask; + + // -- oakengine::timeline -- + + /// `oakengine_sequence_new` — in-memory sequence (facade scratch + /// project). + pub fn oakengine_sequence_new( + project: *mut OakEngineProject, + name: *const c_char, + ) -> *mut OakEngineSequence; + /// `oakengine_sequence_name` (buf/size). + pub fn oakengine_sequence_name( + self_: *const OakEngineSequence, + buf: *mut c_char, + buf_size: c_int, + ) -> c_int; + /// `oakengine_sequence_get_length` — length in seconds. + pub fn oakengine_sequence_get_length(self_: *const OakEngineSequence, seconds: *mut f64) -> c_int; + /// `oakengine_sequence_get_frame_rate` — num/den rational. + pub fn oakengine_sequence_get_frame_rate( + self_: *const OakEngineSequence, + num: *mut c_int, + den: *mut c_int, + ) -> c_int; + /// `oakengine_sequence_get_video_params` — width/height/par. + pub fn oakengine_sequence_get_video_params( + self_: *const OakEngineSequence, + width: *mut c_int, + height: *mut c_int, + par_num: *mut c_int, + par_den: *mut c_int, + ) -> c_int; + /// `oakengine_sequence_track_count` — per-type counts. + pub fn oakengine_sequence_track_count( + self_: *const OakEngineSequence, + video: *mut c_int, + audio: *mut c_int, + subtitle: *mut c_int, + ) -> c_int; + /// `oakengine_sequence_set_playhead`. + pub fn oakengine_sequence_set_playhead(self_: *mut OakEngineSequence, timestamp: i64) -> c_int; + /// `oakengine_sequence_add_track`. + pub fn oakengine_sequence_add_track(self_: *mut OakEngineSequence, track_type: c_int) -> c_int; + /// `oakengine_sequence_clip_count`. + pub fn oakengine_sequence_clip_count( + self_: *mut OakEngineSequence, + track_type: c_int, + track_index: c_int, + ) -> c_int; + /// `oakengine_sequence_clip_at` — borrowed clip box (free with + /// [`free_box`]). + pub fn oakengine_sequence_clip_at( + self_: *mut OakEngineSequence, + track_type: c_int, + track_index: c_int, + clip_index: c_int, + ) -> *mut OakEngineClip; + /// `oakengine_clip_get_range` — clip timeline range and media in-point + /// as frame timestamps. + pub fn oakengine_clip_get_range( + self_: *const OakEngineClip, + in_: *mut i64, + out: *mut i64, + media_in: *mut i64, + ) -> c_int; + /// `oakengine_sequence_split_clip`. + pub fn oakengine_sequence_split_clip( + seq: *mut OakEngineSequence, + track_type: c_int, + track_index: c_int, + clip_index: c_int, + time: i64, + ) -> c_int; + /// `oakengine_sequence_ripple_delete_clip`. + pub fn oakengine_sequence_ripple_delete_clip( + seq: *mut OakEngineSequence, + track_type: c_int, + track_index: c_int, + clip_index: c_int, + ) -> c_int; + /// `oakengine_clip_trim` — change the clip's timeline range. + pub fn oakengine_clip_trim(clip: *mut OakEngineClip, new_in: i64, new_out: i64) -> c_int; + /// `oakengine_sequence_delete_clips` — delete a clip array, optionally + /// rippling; `rippled` reports the ripple length. + pub fn oakengine_sequence_delete_clips( + seq: *mut OakEngineSequence, + clips: *mut *mut OakEngineClip, + clip_count: c_int, + ripple: c_int, + ripple_ranges_ts: *const i64, + ripple_range_count: c_int, + rippled: *mut c_int, + ) -> c_int; + /// `oakengine_sequence_remove_track`. + pub fn oakengine_sequence_remove_track( + seq: *mut OakEngineSequence, + track_type: c_int, + track_index: c_int, + ) -> c_int; + /// `oakengine_track_get_height` — height in internal units. + pub fn oakengine_track_get_height( + seq: *const OakEngineSequence, + track_type: c_int, + track_index: c_int, + height: *mut f64, + ) -> c_int; + /// `oakengine_track_set_height` — height in internal units. + pub fn oakengine_track_set_height( + seq: *mut OakEngineSequence, + track_type: c_int, + track_index: c_int, + height: f64, + ) -> c_int; + + /// `oakengine_track_height_internal_to_pixels`. + pub fn oakengine_track_height_internal_to_pixels(height: f64) -> c_int; + /// `oakengine_track_height_pixels_to_internal`. + pub fn oakengine_track_height_pixels_to_internal(pixels: c_int) -> f64; + + // -- oaktask module C ABI (carried by the dylib) -- + + /// `oaktask_load_take_project` — take the project an interchange + /// load/load-otio task produced (ownership moves to the caller). + pub fn oaktask_load_take_project(t: CHandle) -> CHandle; + /// `oaktask_task_subscribe` — register the task event callback + /// (`OAKTASK_EVENT_STARTED`=0, `OAKTASK_EVENT_PROGRESS`=1, + /// `OAKTASK_EVENT_FINISHED`=2). + pub fn oaktask_task_subscribe( + t: CHandle, + cb: Option, + userdata: *mut c_void, + ) -> i64; +} diff --git a/src/oakui/frames.rs b/src/oakui/frames.rs new file mode 100644 index 000000000..78ca11972 --- /dev/null +++ b/src/oakui/frames.rs @@ -0,0 +1,96 @@ +// 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 synthetic CPU viewer frame both engines display. +//! +//! The real engine delivers frames through the render worker (a separate +//! process speaking the NDJSON control-plane protocol, `oakengine::worker`), +//! which is out of scope for this increment. Until that transport is wired, +//! both the mock and the real engine feed the viewers the same SMPTE-style +//! test pattern, so playback is visibly moving while the engine metadata +//! (project / sequence / tracks) comes from the real facade in real mode. + +use gpui::timeline::Frame; +use gpui::RenderImage; + +/// Width of the synthetic test frame (a small proxy size; the real engine +/// will deliver full-resolution frames). +pub(crate) const SYNTH_FRAME_WIDTH: u32 = 384; +/// Height of the synthetic test frame. +pub(crate) const SYNTH_FRAME_HEIGHT: u32 = 216; + +/// Generates a synthetic test frame: SMPTE-style color bars with a white +/// sweep whose x position follows `frame`, so transport playback shows up as +/// motion across the picture. +/// +/// Samples are computed as F32 RGBA (mirroring the real engine's pixel +/// pipeline) and downconverted to BGRA8 for the viewer's CPU-frame path. +pub(crate) fn synthetic_frame(frame: Frame) -> RenderImage { + let width = SYNTH_FRAME_WIDTH; + let height = SYNTH_FRAME_HEIGHT; + + // F32 RGBA samples, then quantized to BGRA8 for the sprite atlas. + let mut samples = vec![0.0f32; (width * height * 4) as usize]; + // SMPTE bars: 75% white, yellow, cyan, green, magenta, red, blue. + let bars: [(f32, f32, f32); 7] = [ + (1.0, 1.0, 1.0), + (1.0, 1.0, 0.0), + (0.0, 1.0, 1.0), + (0.0, 1.0, 0.0), + (1.0, 0.0, 1.0), + (1.0, 0.0, 0.0), + (0.0, 0.0, 1.0), + ]; + // Bottom strip: blue, magenta, 75% white, black. + let strip: [(f32, f32, f32); 4] = [ + (0.0, 0.0, 1.0), + (1.0, 0.0, 1.0), + (0.75, 0.75, 0.75), + (0.0, 0.0, 0.0), + ]; + // The sweep moves 6 px per frame and wraps around the width, so + // transport playback shows up as motion across the picture. + let sweep = (frame.0 as f32 * 6.0) % width as f32; + let bars_top = height as f32 * 0.66; + + for y in 0..height { + for x in 0..width { + let in_sweep = (x as f32 - sweep).abs() < 6.0; + let color = if in_sweep { + (1.0, 1.0, 1.0) + } else if (y as f32) < bars_top { + bars[((x as f32 / width as f32) * 7.0) as usize] + } else { + strip[((x as f32 / width as f32) * 4.0) as usize] + }; + let i = ((y * width + x) * 4) as usize; + samples[i] = color.0; + samples[i + 1] = color.1; + samples[i + 2] = color.2; + samples[i + 3] = 1.0; + } + } + + let mut bytes = Vec::with_capacity((width * height * 4) as usize); + for i in (0..samples.len()).step_by(4) { + bytes.push((samples[i + 2] * 255.0) as u8); // B + bytes.push((samples[i + 1] * 255.0) as u8); // G + bytes.push((samples[i] * 255.0) as u8); // R + bytes.push((samples[i + 3] * 255.0) as u8); // A + } + let buffer = image::RgbaImage::from_raw(width, height, bytes).expect("synthetic frame"); + RenderImage::new(smallvec::SmallVec::from_elem(image::Frame::new(buffer), 1)) +} diff --git a/src/oakui/host_syms.rs b/src/oakui/host_syms.rs new file mode 100644 index 000000000..cc13c96ab --- /dev/null +++ b/src/oakui/host_syms.rs @@ -0,0 +1,258 @@ +// 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 . + +//! In-process stand-ins for the C++ host symbols the module crates call. +//! +//! The oakcodec / oakcommon crates reference a handful of symbols that in +//! the real desktop product live in the C++ host process (`liboakcore` and +//! `ffmpeg_bridge`): `oakcore_audioparams_*`, `oakcore_rational_*` and +//! `fb_find_best_pix_fmt_of_list`. The facade's own test binaries provide +//! the same stubs in `crates/oakengine/tests/common/mod.rs`; this module is +//! the equivalent for the app binary, so linking oakengine (and through it +//! oakcodec/oakcommon) never leaves undefined symbols. +//! +//! The stubs are small, in-memory and functional enough for the app's use: +//! the audio-params handle carries the fields the codec probes read back, +//! and the rational helpers store the (num, den) pair behind an opaque +//! pointer. + +use std::collections::HashMap; +use std::ffi::{c_char, c_int, c_void}; +use std::sync::{Mutex, OnceLock}; + +/// Opaque `OakAudioParams` handle type (the real one lives in liboakcore). +#[repr(C)] +pub struct OakAudioParams { + _opaque: [u8; 0], +} + +/// Per-`OakAudioParams` backing state. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct MockAudioParams { + sample_rate: i32, + channel_layout: u64, + format: i32, + stream_index: i32, + duration: i64, + time_base_num: i32, + time_base_den: i32, +} + +fn audio_params_store() -> &'static Mutex> { + static S: OnceLock>> = OnceLock::new(); + S.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn audio_params_get(ctx: *const c_void) -> MockAudioParams { + let store = audio_params_store().lock().unwrap(); + store.get(&(ctx as usize)).cloned().unwrap_or_default() +} + +fn audio_params_set(ctx: *mut c_void, f: impl FnOnce(&mut MockAudioParams)) { + let mut store = audio_params_store().lock().unwrap(); + if let Some(p) = store.get_mut(&(ctx as usize)) { + f(p); + } +} + +/// Per-`OakRational` backing state (an owned `(num, den)` pair). +fn rational_store() -> &'static Mutex> { + static S: OnceLock>> = OnceLock::new(); + S.get_or_init(|| Mutex::new(HashMap::new())) +} + +#[no_mangle] +pub extern "C" fn oakcore_audioparams_create( + sample_rate: c_int, + channel_layout: u64, + format: c_int, +) -> *mut OakAudioParams { + let p = MockAudioParams { + sample_rate, + channel_layout, + format, + stream_index: 0, + duration: 0, + time_base_num: 1, + time_base_den: sample_rate, + }; + let raw = Box::into_raw(Box::new(p.clone())); + audio_params_store().lock().unwrap().insert(raw as usize, p); + raw as *mut OakAudioParams +} + +#[no_mangle] +pub extern "C" fn oakcore_audioparams_free(params: *mut OakAudioParams) { + if params.is_null() { + return; + } + audio_params_store() + .lock() + .unwrap() + .remove(&(params as usize)); + // SAFETY: produced by `oakcore_audioparams_create`; we hold the only + // reference after removal. + unsafe { drop(Box::from_raw(params as *mut MockAudioParams)) }; +} + +#[no_mangle] +pub extern "C" fn oakcore_audioparams_sample_rate(params: *const OakAudioParams) -> c_int { + audio_params_get(params as *const c_void).sample_rate +} + +#[no_mangle] +pub extern "C" fn oakcore_audioparams_set_sample_rate( + params: *mut OakAudioParams, + sample_rate: c_int, +) { + audio_params_set(params as *mut c_void, |p| p.sample_rate = sample_rate); +} + +#[no_mangle] +pub extern "C" fn oakcore_audioparams_channel_layout(params: *const OakAudioParams) -> u64 { + audio_params_get(params as *const c_void).channel_layout +} + +#[no_mangle] +pub extern "C" fn oakcore_audioparams_set_channel_layout(params: *mut OakAudioParams, layout: u64) { + audio_params_set(params as *mut c_void, |p| p.channel_layout = layout); +} + +#[no_mangle] +pub extern "C" fn oakcore_audioparams_set_time_base( + params: *mut OakAudioParams, + num: c_int, + den: c_int, +) { + audio_params_set(params as *mut c_void, |p| { + p.time_base_num = num; + p.time_base_den = den; + }); +} + +#[no_mangle] +pub extern "C" fn oakcore_audioparams_set_format(params: *mut OakAudioParams, format: c_int) { + audio_params_set(params as *mut c_void, |p| p.format = format); +} + +#[no_mangle] +pub extern "C" fn oakcore_audioparams_set_stream_index(params: *mut OakAudioParams, index: c_int) { + audio_params_set(params as *mut c_void, |p| p.stream_index = index); +} + +#[no_mangle] +pub extern "C" fn oakcore_audioparams_set_duration(params: *mut OakAudioParams, duration: i64) { + audio_params_set(params as *mut c_void, |p| p.duration = duration); +} + +#[no_mangle] +pub extern "C" fn oakcore_audioparams_channel_count(params: *const OakAudioParams) -> c_int { + audio_params_get(params as *const c_void) + .channel_layout + .count_ones() as c_int +} + +#[no_mangle] +pub extern "C" fn oakcore_audioparams_format(params: *const OakAudioParams) -> c_int { + audio_params_get(params as *const c_void).format +} + +#[no_mangle] +pub extern "C" fn oakcore_audioparams_stream_index(params: *const OakAudioParams) -> c_int { + audio_params_get(params as *const c_void).stream_index +} + +#[no_mangle] +pub extern "C" fn oakcore_audioparams_duration(params: *const OakAudioParams) -> i64 { + audio_params_get(params as *const c_void).duration +} + +#[no_mangle] +pub extern "C" fn oakcore_audioparams_is_valid(params: *const OakAudioParams) -> c_int { + let p = audio_params_get(params as *const c_void); + (p.sample_rate > 0 && p.channel_layout != 0 && p.format >= 0) as c_int +} + +#[no_mangle] +pub extern "C" fn oakcore_audioparams_time_base(params: *const OakAudioParams) -> *mut c_void { + let p = audio_params_get(params as *const c_void); + let r = (p.time_base_num, p.time_base_den); + let raw = Box::into_raw(Box::new(r)); + rational_store().lock().unwrap().insert(raw as usize, r); + raw as *mut c_void +} + +#[no_mangle] +pub extern "C" fn oakcore_rational_numerator(rational: *const c_void) -> c_int { + rational_store() + .lock() + .unwrap() + .get(&(rational as usize)) + .map(|r| r.0) + .unwrap_or(0) +} + +#[no_mangle] +pub extern "C" fn oakcore_rational_denominator(rational: *const c_void) -> c_int { + rational_store() + .lock() + .unwrap() + .get(&(rational as usize)) + .map(|r| r.1) + .unwrap_or(0) +} + +#[no_mangle] +pub extern "C" fn oakcore_rational_free(rational: *mut c_void) { + if rational.is_null() { + return; + } + rational_store().lock().unwrap().remove(&(rational as usize)); + // SAFETY: produced by `oakcore_audioparams_time_base`; we hold the only + // reference after removal. + unsafe { drop(Box::from_raw(rational as *mut (i32, i32))) }; +} + +/// `fb_find_best_pix_fmt_of_list` — pick the entry of a +/// `FB_PIX_FMT_NONE`-terminated list closest to `pix_fmt` (the real +/// implementation lives in ffmpeg_bridge). Stub: exact matches win, +/// otherwise the first (most desirable) candidate. +#[no_mangle] +pub extern "C" fn fb_find_best_pix_fmt_of_list( + list: *const c_int, + pix_fmt: c_int, +) -> c_int { + if list.is_null() { + return 0; + } + let mut i = 0; + let mut first: c_int = 0; + loop { + // SAFETY: `list` is `FB_PIX_FMT_NONE`-terminated; the read is within + // bounds by construction. + let entry = unsafe { *list.add(i) }; + if entry == 0 { + return first; + } + if i == 0 { + first = entry; + } + if entry == pix_fmt { + return entry; + } + i += 1; + } +} diff --git a/src/oakui/icons.rs b/src/oakui/icons.rs new file mode 100644 index 000000000..6a6a9ba7c --- /dev/null +++ b/src/oakui/icons.rs @@ -0,0 +1,133 @@ +// 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 . + +//! Theme-aware toolbar icons. +//! +//! The toolbar/transport icons are the PNGs pulled from the legacy C++ app +//! (`app/ui/style/olive-{dark,light}/png` in the pre-Rust history), stored in +//! `assets/icons/{dark,light}/`. Each theme ships its own glyph color (white +//! on dark, black on light), so the active [`OakTheme`] picks the family. +//! +//! Icons render on a 16px logical grid from the 32px (2×) files; buttons give +//! them a 24px hit target and a localized tooltip. +//! +//! [`OakTheme`]: gpui_widgets::theme::OakTheme + +use std::path::PathBuf; + +use gpui::App; + +/// Icon file names (without the `.png` suffix), mirroring the legacy set. +pub const ICON_ARROW: &str = "arrow"; +pub const ICON_RAZOR: &str = "razor"; +pub const ICON_RIPPLE: &str = "ripple"; +pub const ICON_SLIP: &str = "slip"; +pub const ICON_ROLLING: &str = "rolling"; +pub const ICON_ZOOM: &str = "zoomin"; +pub const ICON_ZOOM_IN: &str = "zoomin"; +pub const ICON_ZOOM_OUT: &str = "zoomout"; +pub const ICON_SLIDE: &str = "slide"; +pub const ICON_TRACK_SELECT: &str = "track-tool"; +pub const ICON_SNAP: &str = "magnet"; +pub const ICON_PLAY: &str = "play"; +pub const ICON_PAUSE: &str = "pause"; +pub const ICON_PREV: &str = "prev"; +pub const ICON_NEXT: &str = "next"; +pub const ICON_REW: &str = "rew"; +pub const ICON_FF: &str = "ff"; + +/// The theme-dependent filesystem path of an icon (`assets/icons/{dark,light}` +/// under the crate root). Absolute, so it works from any working directory. +pub fn icon_path(name: &str, cx: &App) -> PathBuf { + let theme = gpui_widgets::theme::current_theme(cx); + let family = if theme.name == "Olive Dark" { "dark" } else { "light" }; + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("assets/icons") + .join(family) + .join(format!("{name}.png")) +} + +/// Registers the app's icon resolver, so widget-crate consumers (the viewer +/// transport bar) resolve the same theme-aware paths. Call at startup before +/// any window renders; re-registering after a theme switch is harmless. +pub fn init(cx: &mut App) { + gpui_widgets::icons::set_resolver( + std::sync::Arc::new(|name, cx| Some(icon_path(name, cx))), + cx, + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Every icon name resolves to an existing file for both themes — a + /// missing PNG would render as a blank toolbar button. + #[test] + fn every_icon_file_exists_for_both_themes() { + for name in [ + ICON_ARROW, + ICON_RAZOR, + ICON_RIPPLE, + ICON_SLIP, + ICON_ROLLING, + ICON_ZOOM, + ICON_ZOOM_IN, + ICON_ZOOM_OUT, + ICON_SLIDE, + ICON_TRACK_SELECT, + ICON_SNAP, + ICON_PLAY, + ICON_PAUSE, + ICON_PREV, + ICON_NEXT, + ICON_REW, + ICON_FF, + ] { + for family in ["dark", "light"] { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("assets/icons") + .join(family) + .join(format!("{name}.png")); + assert!( + path.exists(), + "missing icon asset {path:?} (referenced as {name})" + ); + } + } + } + + /// The theme-aware path picks the dark family for the default dark theme + /// and the light family once a light theme is applied. + #[gpui::test] + fn icon_path_follows_the_active_theme(cx: &mut gpui::TestAppContext) { + cx.update(|app| { + gpui_widgets::theme::apply_theme(app, &gpui_widgets::theme::OakTheme::olive_dark()); + let dark = icon_path(ICON_PLAY, app); + assert!( + dark.ends_with("dark/play.png"), + "dark theme → dark family, got {dark:?}" + ); + + gpui_widgets::theme::apply_theme(app, &gpui_widgets::theme::OakTheme::olive_light()); + let light = icon_path(ICON_PLAY, app); + assert!( + light.ends_with("light/play.png"), + "light theme → light family, got {light:?}" + ); + }); + } +} diff --git a/src/oakui/mock.rs b/src/oakui/mock.rs index c6533de20..d8f2f6d3d 100644 --- a/src/oakui/mock.rs +++ b/src/oakui/mock.rs @@ -38,6 +38,7 @@ use std::collections::{BTreeSet, HashMap}; use std::path::PathBuf; +use std::sync::mpsc; use std::sync::{Arc, Mutex}; use std::time::Instant; @@ -49,7 +50,8 @@ use gpui::node_graph::{ PortDataType, PortId, PortKind, }; use gpui::timeline::{ - ClipData, ClipId, Frame, FrameRange, FrameRate, TimelineDataSource, TrackData, TrackKind, + ClipData, ClipId, Frame, FrameRange, FrameRate, TimelineDataSource, TimelineEvent, TrackData, + TrackKind, TrimEdge, }; use gpui::{ hsla, point, prelude::*, px, App, Context, Entity, Hsla, Pixels, Point, RenderImage, @@ -59,18 +61,14 @@ use gpui_widgets::audio_meter::AudioMeterDataSource; use gpui_widgets::project_explorer::{ProjectDataSource, ProjectEntry}; use gpui_widgets::viewer::PlaybackClock; -use super::engine::{EngineGateway, Monitor, Project, Sequence, VideoFormat}; +use super::engine::{ + AppEngine, EngineGateway, ExportEvent, ExportSession, Monitor, Project, Sequence, VideoFormat, +}; use super::transport::TransportState; /// The demo sequence length: 00:04:18:18 at 25 fps. const SEQUENCE_LENGTH: i64 = 6468; -/// The synthetic viewer test frame is rendered at a small proxy size (the -/// real engine will deliver full-resolution frames; the mock only needs to -/// prove the CPU-frame path end to end). -const SYNTH_FRAME_WIDTH: u32 = 384; -const SYNTH_FRAME_HEIGHT: u32 = 216; - // --------------------------------------------------------------------------- // Clocks // --------------------------------------------------------------------------- @@ -902,6 +900,52 @@ impl MockEngine { cx.notify(); } + /// Finds the (track, clip) position of `clip` in the display list. + fn mock_clip_position(&self, clip: ClipId) -> Option<(usize, usize)> { + self.tracks + .iter() + .enumerate() + .find_map(|(track_index, track)| { + track + .clips + .iter() + .position(|c| c.id() == clip) + .map(|clip_index| (track_index, clip_index)) + }) + } + + /// A clip id larger than every existing one (for splits). + fn next_mock_clip_id(&self) -> u64 { + self.tracks + .iter() + .flat_map(|t| t.clips.iter()) + .map(|c| c.id().0) + .max() + .map(|max| max + 1) + .unwrap_or(1) + } + + /// Splits the clip at (track, clip) position into two at `time` + /// (mock-apply, not undoable). + fn split_mock_clip(&mut self, track: usize, index: usize, time: Frame) { + let source = &self.tracks[track].clips[index]; + if time.0 <= source.range.start.0 || time.0 >= source.range.end.0 { + return; + } + let new_id = ClipId(self.next_mock_clip_id()); + let second = MockClip { + id: new_id, + range: FrameRange::new(time, source.range.end), + media_in: Frame(source.media_in.0 + (time.0 - source.range.start.0)), + label: source.label.clone(), + color: source.color, + }; + let mut first = self.tracks[track].clips[index].clone(); + first.range = FrameRange::new(source.range.start, time); + self.tracks[track].clips[index] = first; + self.tracks[track].clips.insert(index + 1, second); + } + /// The demo audio levels: animated while the program monitor plays. fn meter_levels(&self) -> Vec { if !self.program_playing { @@ -999,6 +1043,228 @@ impl EngineGateway for MockEngine { } } +// --------------------------------------------------------------------------- +// AppEngine +// --------------------------------------------------------------------------- + +impl AppEngine for MockEngine { + type Clock = MockClock; + + fn create(cx: &mut Context) -> Self { + Self::demo(cx) + } + + fn source_clock(&self) -> &Entity { + &self.source_clock + } + + fn program_clock(&self) -> &Entity { + &self.program_clock + } + + fn clock_frame(&self, monitor: Monitor, cx: &App) -> Frame { + self.clock_frame(monitor, cx) + } + + fn cpu_frame(&self, monitor: Monitor, cx: &App) -> Arc { + self.cpu_frame(monitor, cx) + } + + fn add_track(&mut self, kind: TrackKind, cx: &mut Context) { + self.add_track(kind, cx); + } + + fn remove_track(&mut self, index: usize, cx: &mut Context) { + if index < self.tracks.len() { + self.tracks.remove(index); + } + cx.notify(); + } + + fn set_track_height(&mut self, height: Pixels, cx: &mut Context) { + self.set_track_height(height, cx); + } + + fn select_item(&mut self, id: u64, cx: &mut Context) { + self.select_item(id, cx); + } + + fn apply_effect_event(&mut self, event: &EffectStackEvent, cx: &mut Context) { + self.apply_effect_event(event, cx); + } + + fn apply_node_graph_event(&mut self, event: &NodeGraphEvent, cx: &mut Context) { + self.apply_node_graph_event(event, cx); + } + + fn apply_timeline_event(&mut self, event: &TimelineEvent, cx: &mut Context) { + match event { + TimelineEvent::PlayheadChanged(frame) => { + let current = self.clock_frame(Monitor::Program, cx); + if *frame != current { + self.request_frame(Monitor::Program, *frame, cx); + } + } + TimelineEvent::ClipTrimRequested { clip, edge, new_frame } => { + if let Some((track, index)) = self.mock_clip_position(*clip) { + let clip = &mut self.tracks[track].clips[index]; + match edge { + gpui::timeline::TrimEdge::Start => { + let delta = new_frame.0 - clip.range.start.0; + clip.range.start = *new_frame; + clip.media_in = Frame(clip.media_in.0 + delta); + } + gpui::timeline::TrimEdge::End => { + clip.range.end = *new_frame; + } + } + } + cx.notify(); + } + TimelineEvent::ClipMoveRequested { clip, new_track, new_start } => { + let Some((track, index)) = self.mock_clip_position(*clip) else { + return; + }; + let mut clip = self.tracks[track].clips.remove(index); + let length = clip.range.end.0 - clip.range.start.0; + clip.range = FrameRange::new(*new_start, Frame(new_start.0 + length)); + if *new_track < self.tracks.len() { + self.tracks[*new_track].clips.push(clip); + } + cx.notify(); + } + TimelineEvent::TrackHeightChanged { track, height } => { + if let Some(track) = self.tracks.get_mut(*track) { + track.height = *height; + } + cx.notify(); + } + TimelineEvent::SelectionChanged + | TimelineEvent::TrackSelected { .. } + | TimelineEvent::TransitionChanged { .. } + | TimelineEvent::ZoomChanged(_) => {} + } + } + + fn split_clip(&mut self, clip: ClipId, time: Frame, cx: &mut Context) { + let Some((track, index)) = self.mock_clip_position(clip) else { + return; + }; + self.split_mock_clip(track, index, time); + cx.notify(); + } + + fn split_at_playhead(&mut self, cx: &mut Context) { + let frame = self.clock_frame(Monitor::Program, cx); + let targets: Vec<(usize, usize)> = self + .tracks + .iter() + .enumerate() + .flat_map(|(track_index, track)| { + track + .clips + .iter() + .enumerate() + .filter(|(_, clip)| { + clip.range.start.0 < frame.0 && frame.0 < clip.range.end.0 + }) + .map(|(clip_index, _)| (track_index, clip_index)) + .collect::>() + }) + .collect(); + for (track, index) in targets { + self.split_mock_clip(track, index, frame); + } + cx.notify(); + } + + fn delete_clip(&mut self, clip: ClipId, ripple: bool, cx: &mut Context) { + let Some((track, index)) = self.mock_clip_position(clip) else { + return; + }; + let removed = self.tracks[track].clips.remove(index); + if ripple { + // Shift the following clips on the same track left by the removed + // clip's length (the mock ripples one track, not the whole + // sequence). + let shift = removed.range.end.0 - removed.range.start.0; + for later in &mut self.tracks[track].clips[index..] { + later.range = FrameRange::new( + Frame(later.range.start.0 - shift), + Frame(later.range.end.0 - shift), + ); + } + } + cx.notify(); + } + + fn can_undo(&self) -> bool { + // The mock keeps no undo stack; the real engine's facade stack drives + // the Edit menu in real mode. + false + } + + fn can_redo(&self) -> bool { + false + } + + fn undo(&mut self, cx: &mut Context) { + println!("[mock engine] undo: no undo stack in mock mode"); + cx.notify(); + } + + fn redo(&mut self, cx: &mut Context) { + println!("[mock engine] redo: no undo stack in mock mode"); + cx.notify(); + } + + fn project_modified(&self) -> bool { + false + } + + fn new_project(&mut self, cx: &mut Context) { + println!("[mock engine] new project: demo data stays (mock mode)"); + cx.notify(); + } + + fn open_project_path(&mut self, path: PathBuf, cx: &mut Context) -> Result<(), String> { + self.open_project(path, cx); + Ok(()) + } + + fn save_project(&mut self, _path: Option, cx: &mut Context) -> Result<(), String> { + println!("[mock engine] save: no persistence in mock mode"); + cx.notify(); + Ok(()) + } + + fn close_project(&mut self, cx: &mut Context) { + println!("[mock engine] close project: demo data stays (mock mode)"); + cx.notify(); + } + + fn start_export(&mut self, _format: i32, _path: PathBuf) -> Result { + // Mock export: fake progress on a background thread, no file. + let (tx, rx) = mpsc::channel::(); + std::thread::spawn(move || { + let _ = tx.send(ExportEvent::Started); + for (delay_ms, fraction) in [(150u64, 0.33), (300, 0.66), (450, 1.0)] { + std::thread::sleep(std::time::Duration::from_millis(delay_ms)); + let _ = tx.send(ExportEvent::Progress(fraction)); + } + let _ = tx.send(ExportEvent::Finished(true, String::new())); + }); + Ok(ExportSession { + events: rx, + cancel: Box::new(|| {}), + }) + } + + fn backend_name(&self) -> &'static str { + "mock" + } +} + // --------------------------------------------------------------------------- // Data-source traits // --------------------------------------------------------------------------- @@ -1087,8 +1353,8 @@ impl NodeGraphDataSource for MockEngine { impl ProjectDataSource for MockEngine { fn roots(&self) -> Vec { vec![ - ProjectEntry::new(1, "素材", true), - ProjectEntry::new(2, "音乐", true), + ProjectEntry::new(1, crate::i18n::tr("bin.footage"), true), + ProjectEntry::new(2, crate::i18n::tr("bin.music"), true), ProjectEntry::new(3, "第一稿.mp4", false), ProjectEntry::new(4, "aaa.ove", false), ] @@ -1137,7 +1403,8 @@ impl MockEngine { /// The synthetic CPU test frame for `monitor`, cached per playhead frame so /// a paused viewer never regenerates its picture. This is the frame the /// source/program viewers display through [`ViewerWidget::set_cpu_frame`], - /// proving the CPU-frame path end to end before the real engine lands. + /// proving the CPU-frame path end to end (the real engine shows the same + /// pattern until the render-worker frame transport is bound). pub fn cpu_frame(&self, monitor: Monitor, cx: &App) -> Arc { let frame = self.clock_frame(monitor, cx); let mut cache = self.cpu_frame_cache.lock().unwrap(); @@ -1146,74 +1413,10 @@ impl MockEngine { return image.clone(); } } - let image = Arc::new(self.synthetic_frame(frame)); + let image = Arc::new(crate::oakui::frames::synthetic_frame(frame)); cache.insert(monitor, (frame.0, image.clone())); image } - - /// Generates a synthetic test frame: SMPTE-style color bars with a white - /// sweep whose x position follows `frame`, so playback is visibly moving. - /// - /// Samples are computed as F32 RGBA (mirroring the real engine's pixel - /// pipeline) and downconverted to BGRA8 for the viewer's CPU-frame path. - /// The picture is rendered at a small proxy size ([`SYNTH_FRAME_WIDTH`] × - /// [`SYNTH_FRAME_HEIGHT`]); the real engine delivers full resolution. - fn synthetic_frame(&self, frame: Frame) -> RenderImage { - let width = SYNTH_FRAME_WIDTH; - let height = SYNTH_FRAME_HEIGHT; - - // F32 RGBA samples, then quantized to BGRA8 for the sprite atlas. - let mut samples = vec![0.0f32; (width * height * 4) as usize]; - // SMPTE bars: 75% white, yellow, cyan, green, magenta, red, blue. - let bars: [(f32, f32, f32); 7] = [ - (1.0, 1.0, 1.0), - (1.0, 1.0, 0.0), - (0.0, 1.0, 1.0), - (0.0, 1.0, 0.0), - (1.0, 0.0, 1.0), - (1.0, 0.0, 0.0), - (0.0, 0.0, 1.0), - ]; - // Bottom strip: blue, magenta, 75% white, black. - let strip: [(f32, f32, f32); 4] = [ - (0.0, 0.0, 1.0), - (1.0, 0.0, 1.0), - (0.75, 0.75, 0.75), - (0.0, 0.0, 0.0), - ]; - // The sweep moves 6 px per frame and wraps around the width, so - // transport playback shows up as motion across the picture. - let sweep = (frame.0 as f32 * 6.0) % width as f32; - let bars_top = height as f32 * 0.66; - - for y in 0..height { - for x in 0..width { - let in_sweep = (x as f32 - sweep).abs() < 6.0; - let color = if in_sweep { - (1.0, 1.0, 1.0) - } else if (y as f32) < bars_top { - bars[((x as f32 / width as f32) * 7.0) as usize] - } else { - strip[((x as f32 / width as f32) * 4.0) as usize] - }; - let i = ((y * width + x) * 4) as usize; - samples[i] = color.0; - samples[i + 1] = color.1; - samples[i + 2] = color.2; - samples[i + 3] = 1.0; - } - } - - let mut bytes = Vec::with_capacity((width * height * 4) as usize); - for i in (0..samples.len()).step_by(4) { - bytes.push((samples[i + 2] * 255.0) as u8); // B - bytes.push((samples[i + 1] * 255.0) as u8); // G - bytes.push((samples[i] * 255.0) as u8); // R - bytes.push((samples[i + 3] * 255.0) as u8); // A - } - let buffer = image::RgbaImage::from_raw(width, height, bytes).expect("synthetic frame"); - RenderImage::new(smallvec::SmallVec::from_elem(image::Frame::new(buffer), 1)) - } } #[cfg(test)] @@ -1303,16 +1506,92 @@ mod tests { } #[gpui::test] - async fn timeline_edits_are_requests_not_applied(cx: &mut TestAppContext) { + async fn timeline_edits_are_applied_to_the_mock_model(cx: &mut TestAppContext) { cx.update(|app| { let engine = demo_engine(app); - // The demo timeline model ignores edit requests: the mock keeps - // its clips where they are until a real engine applies them. - let before = engine.read(app).track(1).expect("V1").clips().len(); - // (Nothing to assert beyond stability: the widget never mutates - // the data source directly — reads stay stable across reads.) - let after = engine.read(app).track(1).expect("V1").clips().len(); - assert_eq!(before, after); + + // Trim the V1 "B-roll.mp4" clip (id 12, 240–600) in by 40 frames. + engine.update(app, |engine, cx| { + engine.apply_timeline_event( + &TimelineEvent::ClipTrimRequested { + clip: ClipId(12), + edge: TrimEdge::Start, + new_frame: Frame(280), + }, + cx, + ); + }); + let v1_track = engine.read(app).track(1).expect("V1"); + let b_roll = v1_track + .clips() + .iter() + .find(|c| c.id() == ClipId(12)) + .expect("B-roll clip"); + assert_eq!(b_roll.range(), FrameRange::new(Frame(280), Frame(600))); + assert_eq!(b_roll.media_in(), Frame(140), "media-in follows the trim"); + + // Move the 开场 clip (id 11) onto the V2 track at frame 300. + engine.update(app, |engine, cx| { + engine.apply_timeline_event( + &TimelineEvent::ClipMoveRequested { + clip: ClipId(11), + new_track: 0, + new_start: Frame(300), + }, + cx, + ); + }); + let v2 = engine.read(app).track(0).expect("V2"); + assert!( + v2.clips().iter().any(|c| c.id() == ClipId(11) && c.range().start == Frame(300)), + "开场 moved to V2 at frame 300" + ); + assert!( + !engine.read(app).track(1).expect("V1").clips().iter().any(|c| c.id() == ClipId(11)), + "开场 left V1" + ); + }); + } + + #[gpui::test] + async fn mock_split_at_playhead_and_ripple_delete(cx: &mut TestAppContext) { + cx.update(|app| { + let engine = demo_engine(app); + + // Park the program playhead inside 开场 (0–240) and split there. + engine.update(app, |engine, cx| { + engine.request_frame(Monitor::Program, Frame(120), cx); + engine.split_at_playhead(cx); + }); + let v1 = engine.read(app).track(1).expect("V1"); + assert_eq!(v1.clips().len(), 3, "开场 split into two"); + let spanning = v1 + .clips() + .iter() + .filter(|c| c.range().start.0 < 120 && 120 < c.range().end.0) + .count(); + assert_eq!(spanning, 0, "no clip spans the split point"); + + // Ripple-delete the second half of 开场 (id 11's split tail). + let tail_id = v1 + .clips() + .iter() + .find(|c| c.range().start == Frame(120)) + .map(|c| c.id()) + .expect("split tail"); + engine.update(app, |engine, cx| { + engine.delete_clip(tail_id, true, cx); + }); + let v1 = engine.read(app).track(1).expect("V1"); + assert_eq!(v1.clips().len(), 2); + // The following B-roll (was 240–600) shifted left by the removed + // 120-frame tail: now starts at 120. + let b_roll = v1 + .clips() + .iter() + .find(|c| c.id() == ClipId(12)) + .expect("B-roll clip"); + assert_eq!(b_roll.range().start, Frame(120), "ripple shifted B-roll left"); }); } @@ -1487,10 +1766,13 @@ mod tests { // The frame has the documented proxy size and opaque BGRA8 bytes. let size = c.size(0); - assert_eq!(size.width, SYNTH_FRAME_WIDTH.into()); - assert_eq!(size.height, SYNTH_FRAME_HEIGHT.into()); + assert_eq!(size.width, crate::oakui::frames::SYNTH_FRAME_WIDTH.into()); + assert_eq!(size.height, crate::oakui::frames::SYNTH_FRAME_HEIGHT.into()); let bytes = c.as_bytes(0).expect("single frame"); - assert_eq!(bytes.len(), (SYNTH_FRAME_WIDTH * SYNTH_FRAME_HEIGHT * 4) as usize); + assert_eq!( + bytes.len(), + (crate::oakui::frames::SYNTH_FRAME_WIDTH * crate::oakui::frames::SYNTH_FRAME_HEIGHT * 4) as usize + ); assert!(bytes.chunks_exact(4).all(|px| px[3] == 255), "opaque alpha"); }); } diff --git a/src/oakui/mod.rs b/src/oakui/mod.rs index 7e51e20cf..2d9e777b7 100644 --- a/src/oakui/mod.rs +++ b/src/oakui/mod.rs @@ -26,20 +26,32 @@ //! * [`mock`] — [`MockEngine`](mock::MockEngine) and //! [`MockClock`](mock::MockClock), the demo implementation feeding every //! widget's data-source trait. +//! * [`real`] — [`RealEngine`](real::RealEngine) and +//! [`RealClock`](real::RealClock), the real engine binding. It calls only +//! the frozen `oakengine_*` C ABI of the built `liboakengine` dylib (see +//! [`ffi`] and the crate's `build.rs`) behind the same +//! [`EngineGateway`](engine::EngineGateway) seam the mock implements. +//! * [`ffi`] — the pure-C declarations of that ABI (extern imports, handle +//! layout mirrors, the `OakVideoParamsPod`). //! * [`transport`] — the play/pause/step/seek state machine (pure, unit //! tested). //! * [`timecode`] — timecode / duration / fps / resolution formatting (pure, //! unit tested). -//! -//! The real engine binding (the `liboakengine` C ABI through -//! `src/facade/rust`) will implement [`EngineGateway`](engine::EngineGateway) -//! later; nothing else in the crate needs to change for the swap. pub mod engine; +pub mod ffi; +pub mod frames; +mod host_syms; +pub mod icons; pub mod mock; +pub mod real; pub mod timecode; pub mod transport; -pub use engine::{EngineGateway, Monitor, Project, Sequence, VideoFormat}; +pub use engine::{ + AppEngine, EngineClock, EngineGateway, ExportEvent, ExportSession, Monitor, Project, + Sequence, VideoFormat, +}; pub use mock::{MockClock, MockEngine}; +pub use real::{RealClock, RealEngine}; pub use transport::{PlayState, TransportState}; diff --git a/src/oakui/real.rs b/src/oakui/real.rs new file mode 100644 index 000000000..b65a1789e --- /dev/null +++ b/src/oakui/real.rs @@ -0,0 +1,1820 @@ +// 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 real engine: [`RealEngine`] binds the built `liboakengine` dylib +//! (the frozen `oakengine_*` C ABI over the module crates, see [`ffi`]) +//! behind the same [`EngineGateway`](super::engine::EngineGateway) / +//! [`AppEngine`](super::engine::AppEngine) seam the mock implements. The +//! dylib is linked at build time (see the crate's `build.rs`); the app +//! never depends on the `oakengine` crate as an rlib, so every call below +//! is a pure `extern "C"` import declared in [`ffi`]. +//! +//! # What is real here +//! +//! * **Project** — open/save/save-as/close through the facade (`.ove` +//! serializer; `.otio` / `.fcpxml` through the oaktask interchange +//! loader). +//! * **Sequence** — the current sequence's name / format / length / tracks / +//! clips are read live from the facade sequence handle. +//! * **Edits** — timeline edits (trim, split, delete, ripple-delete) and +//! track add/remove go through the facade's edit commands, each packaged +//! as an undoable entry on the facade's global undo stack. Undo/redo walk +//! that stack. +//! * **Export** — the oaktask export task, driven on a background thread, +//! with progress events and cancel wired to the module task's event +//! callback and cancel atom. +//! * **Config** — renderer backend + language keys round-trip through +//! `oakengine_config_*`. +//! +//! # What is still mock/stub +//! +//! * The viewer frames are the shared synthetic SMPTE pattern ([`frames`]), +//! driven by the real sequence frame rate — the real frame transport +//! (render worker over shared memory, the facade's worker module) is a +//! separate process surface not bound yet. +//! * Effect stack, node graph and audio meter feed empty/silent data: the +//! facade surfaces for them (effect chains, graph nodes, audio levels) +//! are not bound in this increment. +//! * `oakengine_sequence_move_clip` is a documented facade stub (module gap), +//! so clip moves report the facade error instead of applying. +//! +//! # Threading note +//! +//! Long facade calls (`oakengine_task_start_sync`) run on background threads +//! so the UI never blocks; the export event callback delivers progress +//! through a channel the app drains on its tick loop. Cancellation through +//! `oakengine_task_cancel` mirrors the C++ capi contract (cancel atom set +//! from the UI thread while the task runs on its own thread). + +use std::collections::HashMap; +use std::ffi::{c_char, c_int, c_void, CString}; +use std::path::{Path, PathBuf}; +use std::sync::mpsc; +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +use gpui::effect_stack::{EffectData, EffectStackDataSource, EffectStackEvent}; +use gpui::node_graph::{ + EdgeData, EdgeId, NodeData, NodeGraphDataSource, NodeGraphEvent, NodeId, PortData, PortId, + PortDataType, PortKind, +}; +use gpui::timeline::{ + ClipData, ClipId, Frame, FrameRange, FrameRate, TimelineDataSource, TimelineEvent, TrackData, + TrackKind, TrimEdge, +}; +use gpui::{hsla, point, prelude::*, px, App, Context, Entity, Hsla, Pixels, RenderImage, SharedString}; +use gpui_widgets::audio_meter::AudioMeterDataSource; +use gpui_widgets::project_explorer::{ProjectDataSource, ProjectEntry}; +use gpui_widgets::viewer::PlaybackClock; + +use super::ffi::*; +use super::engine::{ + AppEngine, EngineGateway, ExportEvent, ExportSession, Monitor, Project, Sequence, VideoFormat, +}; +use super::frames::synthetic_frame; +use super::transport::TransportState; + +/// `oakengine_timeline.h` track-type constants. +const TRACK_TYPE_VIDEO: c_int = 0; +const TRACK_TYPE_AUDIO: c_int = 1; +const TRACK_TYPE_SUBTITLE: c_int = 2; + +/// The sample-rate / layout / format defaults for export audio. +const EXPORT_SAMPLE_RATE: c_int = 48000; +/// Stereo channel-layout bitmask (`OLIVE_CHANNEL_LAYOUT_STEREO`). +const EXPORT_CHANNEL_LAYOUT: u64 = 0x3; +/// `oakcore_rs::SampleFormat::S16` as int (the encoder default). +const EXPORT_SAMPLE_FORMAT: c_int = 0; + +/// The project name of a blank project before it is saved. +const UNTITLED: &str = "Untitled Project"; + +// --------------------------------------------------------------------------- +// oaktask module C ABI entries the facade does not wrap +// --------------------------------------------------------------------------- +// +// Two module-level exports are needed here that the facade does not wrap: +// `oaktask_load_take_project` (the loaded-project getter for the +// interchange load task) and `oaktask_task_subscribe` (the task event +// callback that delivers export progress). Both take the module `CHandle`, +// which is the same value handle the facade's own boxes wrap (exposed here +// through [`ffi::unbox`]), and both symbols are exported by the dylib +// itself (it carries the module C ABIs). The declarations live in [`ffi`]; +// this keeps the *operations* on the facade contract while bridging two +// getter/event gaps the facade intentionally leaves open (see +// `oakengine/src/deferred.rs`). + +/// The C callback the module task event subscription invokes on the task's +/// own thread. `userdata` is the raw pointer of a leaked +/// `mpsc::Sender` the export thread reclaims after the run. +unsafe extern "C" fn export_event_cb(event_id: c_int, value: f64, userdata: *mut c_void) { + let Some(sender) = (userdata as *const mpsc::Sender).as_ref() else { + return; + }; + let event = match event_id { + 0 => ExportEvent::Started, + 1 => ExportEvent::Progress(value), + _ => return, // Finished is reported by the export thread (with the error). + }; + let _ = sender.send(event); +} + +/// Reclaims the leaked `mpsc::Sender` the export callback wrote through. +/// Takes the whole [`SendPtr`] so closures capture the wrapper (which is +/// `Send`) rather than the raw field. +fn reclaim_userdata(userdata: SendPtr>) { + drop(unsafe { Box::from_raw(userdata.0) }); +} + +/// A borrowed facade handle wrapper that is `Send`/`Sync`: the pointee is +/// only ever accessed through the facade C ABI (whose exports guard with +/// `catch_unwind` and synchronize their own state). +#[derive(Clone, Copy)] +struct SendPtr(*mut T); + +// SAFETY: see [`SendPtr`]. +unsafe impl Send for SendPtr {} +unsafe impl Sync for SendPtr {} + +// --------------------------------------------------------------------------- +// Handle RAII +// --------------------------------------------------------------------------- + +/// An owned facade project handle; freed with `oakengine_project_free`. +/// +/// Raw facade pointers are not `Send`/`Sync`, so the wrapper carries +/// explicit unsafe impls; the handle is only ever dereferenced through the +/// facade functions (which guard with `catch_unwind`). +struct ProjectHandle(*mut OakEngineProject); + +// SAFETY: the pointer is only used through the facade C ABI; the facade +// guards every export with catch_unwind, and all calls are serialized on the +// owning entity's context. +unsafe impl Send for ProjectHandle {} +unsafe impl Sync for ProjectHandle {} + +impl ProjectHandle { + fn ptr(&self) -> *mut OakEngineProject { + self.0 + } +} + +impl Drop for ProjectHandle { + fn drop(&mut self) { + unsafe { + oakengine_project_free(self.0); + } + } +} + +/// A borrowed facade sequence handle (boxed by the facade); freed with +/// [`free_box`] — and always before the project it was borrowed from. +struct SequenceHandle(*mut OakEngineSequence); + +// SAFETY: see [`ProjectHandle`]. +unsafe impl Send for SequenceHandle {} +unsafe impl Sync for SequenceHandle {} + +impl SequenceHandle { + fn ptr(&self) -> *mut OakEngineSequence { + self.0 + } +} + +impl Drop for SequenceHandle { + fn drop(&mut self) { + unsafe { + free_box(self.0); + } + } +} + +// --------------------------------------------------------------------------- +// FFI helpers +// --------------------------------------------------------------------------- + +/// Builds a `CString` from a path (lossy on non-UTF-8). +fn cstr_path(path: &Path) -> Option { + CString::new(path.to_string_lossy().into_owned()).ok() +} + +/// Two-stage read of a facade buf/size string (the return value is the +/// length excluding the NUL). The closure must call the facade getter inside +/// its own `unsafe` block. +fn read_string(f: impl Fn(*mut c_char, c_int) -> c_int) -> String { + let needed = f(std::ptr::null_mut(), 0); + if needed <= 0 { + return String::new(); + } + let mut buf = vec![0 as c_char; needed as usize]; + f(buf.as_mut_ptr(), needed as c_int); + let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len()); + String::from_utf8_lossy(unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, len) }) + .into_owned() +} + +/// Reads the error buffer the OVE load/save serializer fills. +fn load_error(err: &mut [c_char]) -> String { + let len = err.iter().position(|&c| c == 0).unwrap_or(err.len()); + let text = + String::from_utf8_lossy(unsafe { std::slice::from_raw_parts(err.as_ptr() as *const u8, len) }) + .into_owned(); + if text.is_empty() { + "the operation failed".to_string() + } else { + text + } +} + +// --------------------------------------------------------------------------- +// Transport clock +// --------------------------------------------------------------------------- + +/// The real engine's transport clock: the playhead plus the wall-clock +/// anchor while playing. Mirrors the mock's clock; the engine additionally +/// writes the program playhead back to the facade sequence. +pub struct RealClock { + /// The transport state (play/pause, playhead, loop range). + pub transport: TransportState, + /// The clock's frame rate. + pub rate: FrameRate, + /// Wall-clock anchor `(started_at, anchored_frame)` while playing. + started: Option<(Instant, Frame)>, +} + +impl RealClock { + /// A stopped clock at frame zero running at `rate`. + pub fn new(rate: FrameRate) -> Self { + Self { + transport: TransportState::new(), + rate, + started: None, + } + } + + /// Starts playback from the current playhead. + pub fn play(&mut self) { + self.transport.play(); + self.started = Some((Instant::now(), self.transport.frame())); + } + + /// Pauses playback, keeping the playhead. + pub fn pause(&mut self) { + self.transport.pause(); + self.started = None; + } + + /// Advances the playhead from the wall clock while playing, looping at + /// `length`. No-op when stopped. + pub fn tick(&mut self, length: Frame) { + let Some((started, anchored)) = self.started else { + return; + }; + let elapsed = started.elapsed(); + let mut frame = anchored + + Frame( + (elapsed.as_secs_f64() * self.rate.num as f64 / self.rate.den as f64).round() as i64, + ); + if length.0 > 0 && frame.0 >= length.0 { + frame = Frame(frame.0 % length.0); + } + self.transport.seek(frame, length); + } +} + +impl PlaybackClock for RealClock { + fn current_frame(&self) -> Frame { + self.transport.frame() + } + + fn is_playing(&self) -> bool { + self.transport.is_playing() + } + + fn frame_rate(&self) -> FrameRate { + self.rate + } +} + +// --------------------------------------------------------------------------- +// Timeline model +// --------------------------------------------------------------------------- + +/// A clip on the real timeline: the facade data plus the C-ABI coordinates +/// (`track_type` / per-type `track_index` / per-track `clip_index`) the edit +/// commands are addressed with. +#[derive(Debug, Clone)] +pub struct RealClip { + id: ClipId, + range: FrameRange, + media_in: Frame, + label: SharedString, + color: Hsla, + track_type: TrackKind, + track_index: usize, + clip_index: usize, +} + +impl ClipData for RealClip { + fn id(&self) -> ClipId { + self.id + } + + fn range(&self) -> FrameRange { + self.range + } + + fn media_in(&self) -> Frame { + self.media_in + } + + fn label(&self) -> SharedString { + self.label.clone() + } + + fn color(&self) -> Option { + Some(self.color) + } +} + +/// A track on the real timeline (snapshot handed to the timeline widget). +#[derive(Debug, Clone)] +pub struct RealTrack { + kind: TrackKind, + name: SharedString, + height: Pixels, + locked: bool, + muted: bool, + solo: bool, + visible: bool, + clips: Vec, + track_type: c_int, + track_index: usize, +} + +impl TrackData for RealTrack { + type Clip = RealClip; + + fn kind(&self) -> TrackKind { + self.kind + } + + fn name(&self) -> SharedString { + self.name.clone() + } + + fn is_locked(&self) -> bool { + self.locked + } + + fn is_muted(&self) -> bool { + self.muted + } + + fn is_solo(&self) -> bool { + self.solo + } + + fn is_visible(&self) -> bool { + self.visible + } + + fn height(&self) -> Pixels { + self.height + } + + fn clips(&self) -> &[Self::Clip] { + &self.clips + } +} + +/// A deterministic clip color from a stable per-clip index (the facade +/// exposes no clip color). +fn clip_color(index: u64) -> Hsla { + let hues = [0.55f32, 0.6, 0.08, 0.3, 0.78, 0.45, 0.9, 0.15]; + Hsla { + h: hues[(index as usize) % hues.len()], + s: 0.55, + l: 0.45, + a: 1.0, + } +} + +/// A node in the real node graph. The facade's graph surface is not bound in +/// this increment, so the graph is always empty — the types exist to satisfy +/// the data-source trait. +#[derive(Debug, Clone)] +pub struct RealNode { + id: NodeId, +} + +impl NodeData for RealNode { + type Port = RealPort; + + fn id(&self) -> NodeId { + self.id + } + + fn title(&self) -> SharedString { + SharedString::new_static("") + } + + fn position(&self) -> gpui::Point { + point(px(0.0), px(0.0)) + } + + fn inputs(&self) -> Vec { + Vec::new() + } + + fn outputs(&self) -> Vec { + Vec::new() + } + + fn header_color(&self) -> Option { + None + } + + fn is_collapsed(&self) -> bool { + false + } + + fn is_enabled(&self) -> bool { + false + } +} + +/// A port on a real node (always empty for now). +#[derive(Debug, Clone)] +pub struct RealPort; + +impl PortData for RealPort { + fn id(&self) -> PortId { + PortId(0) + } + + fn kind(&self) -> PortKind { + PortKind::Input + } + + fn label(&self) -> SharedString { + SharedString::new_static("") + } + + fn data_type(&self) -> PortDataType { + PortDataType::new("video", hsla(0.55, 0.75, 0.6, 1.0)) + } + + fn is_connected(&self) -> bool { + false + } +} + +/// An edge in the real node graph (always empty for now). +#[derive(Debug, Clone)] +pub struct RealEdge { + id: EdgeId, +} + +impl EdgeData for RealEdge { + fn id(&self) -> EdgeId { + self.id + } + + fn from_node(&self) -> NodeId { + NodeId(0) + } + + fn from_port(&self) -> PortId { + PortId(0) + } + + fn to_node(&self) -> NodeId { + NodeId(0) + } + + fn to_port(&self) -> PortId { + PortId(0) + } +} + +// --------------------------------------------------------------------------- +// The engine +// --------------------------------------------------------------------------- + +/// The real engine: the facade project/sequence plus the snapshot models the +/// widgets read. +pub struct RealEngine { + /// The owned facade project (None before any project is open). + project: Option, + /// The borrowed facade sequence (freed before the project on drop). + sequence: Option, + /// The gateway's cached project info. + project_info: Project, + /// The gateway's cached sequence info. + sequence_info: Option, + /// The source monitor's clock. + pub source_clock: Entity, + /// The program monitor's clock. + pub program_clock: Entity, + /// The timeline snapshot (rebuilt on open/edit). + tracks: Vec, + /// The material-bin root entries. + bin_roots: Vec, + /// The material-bin children of the footage folder. + bin_children: Vec, + /// The selected material-bin entry (demo state). + selected_item: Option, + /// Whether the program monitor is playing (mirrors the clock; kept here + /// because the audio-meter data source has no `App` to read the clock). + program_playing: bool, + /// Phase counter driving the (silent) audio levels. + meter_phase: u32, + /// Cache of the synthetic CPU frames handed to the viewers, keyed by + /// monitor. Entries are the playhead frame that produced the image, so a + /// paused viewer never regenerates its picture. + cpu_frame_cache: Mutex)>>, + /// Whether the project has unsaved changes (mirrors the facade flag). + modified: bool, +} + +impl RealEngine { + /// Builds an engine with no project open. + pub fn new(cx: &mut Context) -> Self { + let rate = VideoFormat::hd_1080p25().rate; + Self { + project: None, + sequence: None, + project_info: Project { + name: UNTITLED.into(), + path: PathBuf::new(), + }, + sequence_info: None, + source_clock: cx.new(|_cx| RealClock::new(rate)), + program_clock: cx.new(|_cx| RealClock::new(rate)), + tracks: Vec::new(), + bin_roots: Vec::new(), + bin_children: Vec::new(), + selected_item: None, + program_playing: false, + meter_phase: 0, + cpu_frame_cache: Mutex::new(HashMap::new()), + modified: false, + } + } + + /// Resolves the clock entity for a monitor. + fn clock(&self, monitor: Monitor) -> &Entity { + match monitor { + Monitor::Source => &self.source_clock, + Monitor::Program => &self.program_clock, + } + } + + /// The sequence pointer, if a project+sequence is open. + fn seq_ptr(&self) -> Option<*mut OakEngineSequence> { + self.sequence.as_ref().map(SequenceHandle::ptr) + } + + /// The project pointer, if a project is open. + fn project_ptr(&self) -> Option<*mut OakEngineProject> { + self.project.as_ref().map(ProjectHandle::ptr) + } + + /// Current sequence length (0 without a sequence). + fn sequence_length(&self) -> Frame { + self.sequence_info.as_ref().map(|s| s.length).unwrap_or(Frame(0)) + } + + /// Mirrors the program playhead into the facade sequence (best effort). + fn mirror_program_playhead(&self, cx: &App) { + if let Some(seq) = self.seq_ptr() { + let frame = self.program_clock.read(cx).transport.frame().0; + unsafe { + oakengine_sequence_set_playhead(seq, frame); + } + } + } + + /// Adopts a newly created/loaded facade project, freeing any previous + /// one, and rebuilds every snapshot. `blank` projects get a default + /// sequence; loaded ones use the first sequence. + fn adopt_project(&mut self, project: *mut OakEngineProject, cx: &mut Context) { + self.drop_project(); + self.project = Some(ProjectHandle(project)); + + // Cached display info. + let name = read_string(|buf, size| unsafe { oakengine_project_name(project, buf, size) }); + let path = PathBuf::from(read_string(|buf, size| unsafe { oakengine_project_filename(project, buf, size) })); + self.project_info = Project { + name: if name.is_empty() { UNTITLED.into() } else { name }, + path, + }; + self.modified = unsafe { oakengine_project_is_modified(project) != 0 }; + + // The sequence: the project's first, or a blank default. + let count = unsafe { oakengine_project_sequence_count(project) }; + let sequence = if count > 0 { + unsafe { oakengine_project_sequence_at(project, 0) } + } else { + let name_c = CString::new("Sequence 1").unwrap(); + unsafe { oakengine_sequence_new(project, name_c.as_ptr()) } + }; + if sequence.is_null() { + return; + } + self.sequence = Some(SequenceHandle(sequence)); + self.refresh_sequence_info(); + self.rebuild_timeline(); + self.rebuild_bin(); + cx.notify(); + } + + /// Frees the project and every borrowed handle (sequence first). + fn drop_project(&mut self) { + drop(self.sequence.take()); + drop(self.project.take()); + self.tracks.clear(); + self.bin_roots.clear(); + self.bin_children.clear(); + self.sequence_info = None; + self.modified = false; + self.project_info = Project { + name: UNTITLED.into(), + path: PathBuf::new(), + }; + } + + /// Refreshes the cached `Sequence` (name / format / length) from the + /// facade. + fn refresh_sequence_info(&mut self) { + let Some(seq) = self.seq_ptr() else { + self.sequence_info = None; + return; + }; + let name = read_string(|buf, size| unsafe { oakengine_sequence_name(seq, buf, size) }); + let mut num: c_int = 0; + let mut den: c_int = 0; + let mut width: c_int = 0; + let mut height: c_int = 0; + let mut seconds: f64 = 0.0; + unsafe { + oakengine_sequence_get_frame_rate(seq, &mut num, &mut den); + oakengine_sequence_get_video_params(seq, &mut width, &mut height, std::ptr::null_mut(), std::ptr::null_mut()); + oakengine_sequence_get_length(seq, &mut seconds); + } + let rate = if num > 0 && den > 0 { + FrameRate::new(num as u32, den as u32) + } else { + VideoFormat::hd_1080p25().rate + }; + let length = Frame((seconds * rate.num as f64 / rate.den as f64).round() as i64); + self.sequence_info = Some(Sequence { + name: if name.is_empty() { "Sequence 1".into() } else { name }, + format: VideoFormat { + width: width.max(1) as u32, + height: height.max(1) as u32, + rate, + }, + length, + }); + } + + /// Rebuilds the timeline snapshot from the facade sequence. + fn rebuild_timeline(&mut self) { + self.tracks.clear(); + let Some(seq) = self.seq_ptr() else { + return; + }; + let mut video: c_int = 0; + let mut audio: c_int = 0; + let mut subtitle: c_int = 0; + unsafe { + oakengine_sequence_track_count(seq, &mut video, &mut audio, &mut subtitle); + } + let mut out: Vec = Vec::new(); + // Per-type track lists, each displayed topmost-first. + for (kind, track_type, count) in [ + (TrackKind::Video, TRACK_TYPE_VIDEO, video), + (TrackKind::Audio, TRACK_TYPE_AUDIO, audio), + (TrackKind::Subtitle, TRACK_TYPE_SUBTITLE, subtitle), + ] { + for track_index in (0..count).rev() { + out.push(self.snapshot_track(kind, track_type, track_index as usize)); + } + } + self.tracks = out; + } + + /// Snapshots one track (with its clips) from the facade. + fn snapshot_track(&self, kind: TrackKind, track_type: c_int, track_index: usize) -> RealTrack { + let Some(seq) = self.seq_ptr() else { + return RealTrack { + kind, + name: SharedString::new_static(""), + height: px(64.0), + locked: false, + muted: false, + solo: false, + visible: true, + clips: Vec::new(), + track_type, + track_index, + }; + }; + let name = match kind { + TrackKind::Video => format!("V{}", track_index + 1), + TrackKind::Audio => format!("A{}", track_index + 1), + TrackKind::Subtitle => format!("S{}", track_index + 1), + }; + // Height in internal units → pixels. + let mut internal: f64 = 0.0; + let height = unsafe { + if oakengine_track_get_height(seq, track_type, track_index as c_int, &mut internal) == 0 { + px(oakengine_track_height_internal_to_pixels(internal).max(24) as f32) + } else { + px(64.0) + } + }; + + let clip_count = unsafe { oakengine_sequence_clip_count(seq, track_type, track_index as c_int) }; + let mut clips = Vec::with_capacity(clip_count.max(0) as usize); + for clip_index in 0..clip_count.max(0) { + let clip = unsafe { oakengine_sequence_clip_at(seq, track_type, track_index as c_int, clip_index) }; + if clip.is_null() { + continue; + } + let mut in_ts: i64 = 0; + let mut out_ts: i64 = 0; + let mut media_in: i64 = 0; + unsafe { + oakengine_clip_get_range(clip, &mut in_ts, &mut out_ts, &mut media_in); + free_box(clip); + } + clips.push(RealClip { + id: ClipId((track_type as u64) * 1_000_000 + (track_index as u64 + 1) * 1000 + clip_index as u64), + range: FrameRange::new(Frame(in_ts), Frame(out_ts)), + media_in: Frame(media_in), + label: format!("Clip {}", clip_index + 1).into(), + color: clip_color(clip_index as u64), + track_type: kind, + track_index, + clip_index: clip_index as usize, + }); + } + RealTrack { + kind, + name: name.into(), + height, + locked: false, + muted: false, + solo: false, + visible: true, + clips, + track_type, + track_index, + } + } + + /// Rebuilds the material-bin snapshot from the facade project's footage. + fn rebuild_bin(&mut self) { + self.bin_roots.clear(); + self.bin_children.clear(); + let Some(project) = self.project_ptr() else { + return; + }; + let name = self.project_info.name.clone(); + self.bin_roots = vec![ + ProjectEntry::new(1, crate::i18n::tr("bin.footage"), true), + ProjectEntry::new(2, name, false), + ]; + let count = unsafe { oakengine_project_footage_count(project) }; + let mut children = Vec::new(); + for i in 0..count.max(0) { + let filename = + read_string(|buf, size| unsafe { oakengine_project_footage_filename(project, i, buf, size) }); + let label = Path::new(&filename) + .file_name() + .map(|f| f.to_string_lossy().into_owned()) + .unwrap_or(filename); + children.push(ProjectEntry::new(100 + i as u64, label, false)); + } + self.bin_children = children; + } + + /// Looks up the snapshot clip coordinates by `ClipId`. + fn clip_coords(&self, id: ClipId) -> Option<(TrackKind, usize, usize)> { + for track in &self.tracks { + if let Some(clip) = track.clips.iter().find(|c| c.id() == id) { + return Some((clip.track_type, clip.track_index, clip.clip_index)); + } + } + None + } + + /// The facade track-type constant for a [`TrackKind`]. + fn track_type_of(kind: TrackKind) -> c_int { + match kind { + TrackKind::Video => TRACK_TYPE_VIDEO, + TrackKind::Audio => TRACK_TYPE_AUDIO, + TrackKind::Subtitle => TRACK_TYPE_SUBTITLE, + } + } + + /// Applies an edit command, then refreshes the snapshots and repaints. + fn apply_edit(&mut self, rc: c_int, what: &str, cx: &mut Context) { + if rc != 0 { + println!("[real engine] {what} failed (facade error {rc})"); + } + self.refresh_sequence_info(); + self.rebuild_timeline(); + self.modified = true; + cx.notify(); + } + + /// Formats a facade error (two-stage task error buffer) into a message. + fn task_error(task: *mut OakEngineTask) -> String { + let text = read_string(|buf, size| unsafe { oakengine_task_error(task, buf, size) }); + if text.is_empty() { + "the task failed".to_string() + } else { + text + } + } + +} + +// --------------------------------------------------------------------------- +// EngineGateway +// --------------------------------------------------------------------------- + +impl EngineGateway for RealEngine { + fn project(&self) -> Option<&Project> { + self.project.as_ref().map(|_| &self.project_info) + } + + fn current_sequence(&self) -> Option<&Sequence> { + self.sequence_info.as_ref() + } + + fn open_project(&mut self, path: PathBuf, cx: &mut Context) { + if let Err(err) = self.open_project_path(path, cx) { + println!("[real engine] open failed: {err}"); + } + } + + fn request_frame(&mut self, monitor: Monitor, frame: Frame, cx: &mut Context) { + let length = self.sequence_length(); + let clock = self.clock(monitor).clone(); + clock.update(cx, |clock, cx| { + clock.transport.seek(frame, length); + if clock.transport.is_playing() { + clock.play(); + } + cx.notify(); + }); + self.mirror_program_playhead(cx); + cx.notify(); + } + + fn play(&mut self, monitor: Monitor, cx: &mut Context) { + if monitor == Monitor::Program { + self.program_playing = true; + } + let clock = self.clock(monitor).clone(); + clock.update(cx, |clock, cx| { + clock.play(); + cx.notify(); + }); + self.mirror_program_playhead(cx); + cx.notify(); + } + + fn pause(&mut self, monitor: Monitor, cx: &mut Context) { + if monitor == Monitor::Program { + self.program_playing = false; + } + let clock = self.clock(monitor).clone(); + clock.update(cx, |clock, cx| { + clock.pause(); + cx.notify(); + }); + cx.notify(); + } + + fn step(&mut self, monitor: Monitor, delta: i64, cx: &mut Context) { + let length = self.sequence_length(); + let clock = self.clock(monitor).clone(); + clock.update(cx, |clock, cx| { + clock.transport.step(delta, length); + if clock.transport.is_playing() { + clock.play(); + } + cx.notify(); + }); + self.mirror_program_playhead(cx); + cx.notify(); + } + + fn tick(&mut self, cx: &mut Context) { + let length = self.sequence_length(); + for clock in [&self.source_clock, &self.program_clock] { + let clock = clock.clone(); + clock.update(cx, |clock, cx| { + clock.tick(length); + cx.notify(); + }); + } + self.mirror_program_playhead(cx); + self.meter_phase = self.meter_phase.wrapping_add(1); + cx.notify(); + } +} + +// --------------------------------------------------------------------------- +// Data-source traits +// --------------------------------------------------------------------------- + +impl TimelineDataSource for RealEngine { + type Track = RealTrack; + + fn frame_rate(&self) -> FrameRate { + self.sequence_info + .as_ref() + .map(|s| s.format.rate) + .unwrap_or(VideoFormat::hd_1080p25().rate) + } + + fn sequence_length(&self) -> Frame { + self.sequence_length() + } + + fn track_count(&self) -> usize { + self.tracks.len() + } + + fn track(&self, index: usize) -> Option { + self.tracks.get(index).cloned() + } +} + +impl EffectStackDataSource for RealEngine { + fn effects(&self) -> Vec> { + // The effect-chain surface of the facade is not bound in this + // increment; the stack shows its empty state. + Vec::new() + } + + fn target_label(&self) -> Option { + None + } +} + +impl NodeGraphDataSource for RealEngine { + type Node = RealNode; + type Edge = RealEdge; + + fn nodes(&self) -> Vec { + // The node-graph surface of the facade is not bound in this + // increment; the canvas shows empty. + Vec::new() + } + + fn edges(&self) -> Vec { + Vec::new() + } + + fn can_connect(&self, _from: PortId, _to: PortId) -> bool { + false + } +} + +impl ProjectDataSource for RealEngine { + fn roots(&self) -> Vec { + self.bin_roots.clone() + } + + fn children(&self, parent_id: u64) -> Vec { + if parent_id == 1 { + self.bin_children.clone() + } else { + Vec::new() + } + } +} + +impl AudioMeterDataSource for RealEngine { + fn levels(&self) -> Vec { + // Real audio levels are not exposed by the facade; report a silent + // (but alive) meter. + vec![0.0, 0.0] + } +} + +// --------------------------------------------------------------------------- +// AppEngine +// --------------------------------------------------------------------------- + +impl AppEngine for RealEngine { + type Clock = RealClock; + + fn create(cx: &mut Context) -> Self { + Self::new(cx) + } + + fn source_clock(&self) -> &Entity { + &self.source_clock + } + + fn program_clock(&self) -> &Entity { + &self.program_clock + } + + fn clock_frame(&self, monitor: Monitor, cx: &App) -> Frame { + self.clock(monitor).read(cx).transport.frame() + } + + fn cpu_frame(&self, monitor: Monitor, cx: &App) -> Arc { + let frame = self.clock_frame(monitor, cx); + let mut cache = self.cpu_frame_cache.lock().unwrap(); + if let Some((cached_frame, image)) = cache.get(&monitor) { + if *cached_frame == frame.0 { + return image.clone(); + } + } + let image = Arc::new(synthetic_frame(frame)); + cache.insert(monitor, (frame.0, image.clone())); + image + } + + fn add_track(&mut self, kind: TrackKind, cx: &mut Context) { + let Some(seq) = self.seq_ptr() else { + return; + }; + let rc = unsafe { oakengine_sequence_add_track(seq, Self::track_type_of(kind)) }; + self.apply_edit(rc, "add track", cx); + } + + fn remove_track(&mut self, index: usize, cx: &mut Context) { + let Some(track) = self.tracks.get(index) else { + return; + }; + let (track_type, track_index) = (track.track_type, track.track_index); + let Some(seq) = self.seq_ptr() else { + return; + }; + let rc = unsafe { oakengine_sequence_remove_track(seq, track_type, track_index as c_int) }; + self.apply_edit(rc, "remove track", cx); + } + + fn set_track_height(&mut self, height: Pixels, cx: &mut Context) { + let Some(seq) = self.seq_ptr() else { + return; + }; + let internal = unsafe { oakengine_track_height_pixels_to_internal(f32::from(height) as c_int) }; + let mut video: c_int = 0; + let mut audio: c_int = 0; + let mut subtitle: c_int = 0; + unsafe { + oakengine_sequence_track_count(seq, &mut video, &mut audio, &mut subtitle); + } + for (track_type, count) in [ + (TRACK_TYPE_VIDEO, video), + (TRACK_TYPE_AUDIO, audio), + (TRACK_TYPE_SUBTITLE, subtitle), + ] { + for index in 0..count { + unsafe { oakengine_track_set_height(seq, track_type, index, internal) }; + } + } + self.rebuild_timeline(); + cx.notify(); + } + + fn select_item(&mut self, id: u64, cx: &mut Context) { + self.selected_item = Some(id); + cx.notify(); + } + + fn apply_effect_event(&mut self, _event: &EffectStackEvent, cx: &mut Context) { + // No effect model in the real engine yet; requests are logged by the + // caller. + cx.notify(); + } + + fn apply_node_graph_event(&mut self, event: &NodeGraphEvent, cx: &mut Context) { + match event { + NodeGraphEvent::NodeMovePreview { .. } + | NodeGraphEvent::ViewChanged { .. } + | NodeGraphEvent::BackgroundClicked { .. } => {} + _ => println!("[real engine] node-graph request not applied (not bound yet)"), + } + cx.notify(); + } + + fn apply_timeline_event(&mut self, event: &TimelineEvent, cx: &mut Context) { + match event { + TimelineEvent::PlayheadChanged(frame) => { + let current = self.clock_frame(Monitor::Program, cx); + if *frame != current { + self.request_frame(Monitor::Program, *frame, cx); + } + } + TimelineEvent::ClipTrimRequested { clip, edge, new_frame } => { + let Some((track_type, track_index, clip_index)) = self.clip_coords(*clip) else { + return; + }; + // Re-read the clip's current range, then compute the new + // in/out pair for `oakengine_clip_trim`. + let Some(seq) = self.seq_ptr() else { + return; + }; + let clip_ptr = unsafe { + oakengine_sequence_clip_at( + seq, + Self::track_type_of(track_type), + track_index as c_int, + clip_index as c_int, + ) + }; + if clip_ptr.is_null() { + return; + } + let mut in_ts: i64 = 0; + let mut out_ts: i64 = 0; + let mut media_in: i64 = 0; + unsafe { + oakengine_clip_get_range(clip_ptr, &mut in_ts, &mut out_ts, &mut media_in); + } + let (new_in, new_out) = match edge { + TrimEdge::Start => (new_frame.0, out_ts), + TrimEdge::End => (in_ts, new_frame.0), + }; + let rc = unsafe { oakengine_clip_trim(clip_ptr, new_in, new_out) }; + unsafe { free_box(clip_ptr) }; + self.apply_edit(rc, "trim clip", cx); + } + TimelineEvent::ClipMoveRequested { clip, .. } => { + // `oakengine_sequence_move_clip` is a documented facade stub + // (module gap); report the facade error. + let coords = self.clip_coords(*clip); + let what = format!("move clip ({coords:?})"); + println!("[real engine] {what}: not supported by the facade (stub)"); + cx.notify(); + } + TimelineEvent::TrackHeightChanged { track, height } => { + if let Some(t) = self.tracks.get(*track) { + let internal = unsafe { + oakengine_track_height_pixels_to_internal(f32::from(height) as c_int) + }; + if let Some(seq) = self.seq_ptr() { + unsafe { + oakengine_track_set_height( + seq, + t.track_type, + t.track_index as c_int, + internal, + ) + }; + } + self.rebuild_timeline(); + } + cx.notify(); + } + // Selection / zoom / transition / track-selected: not editable. + TimelineEvent::SelectionChanged + | TimelineEvent::TrackSelected { .. } + | TimelineEvent::TransitionChanged { .. } + | TimelineEvent::ZoomChanged(_) => {} + } + } + + fn split_clip(&mut self, clip: ClipId, time: Frame, cx: &mut Context) { + let Some((track_type, track_index, clip_index)) = self.clip_coords(clip) else { + return; + }; + let Some(seq) = self.seq_ptr() else { + return; + }; + let rc = unsafe { + oakengine_sequence_split_clip( + seq, + Self::track_type_of(track_type), + track_index as c_int, + clip_index as c_int, + time.0, + ) + }; + self.apply_edit(rc, "split clip", cx); + } + + fn split_at_playhead(&mut self, cx: &mut Context) { + let frame = self.clock_frame(Monitor::Program, cx); + let Some(seq) = self.seq_ptr() else { + return; + }; + let targets: Vec<(c_int, usize, usize)> = self + .tracks + .iter() + .flat_map(|track| { + track.clips.iter().filter_map(|clip| { + if clip.range.start.0 < frame.0 && frame.0 < clip.range.end.0 { + Some((track.track_type, track.track_index, clip.clip_index)) + } else { + None + } + }) + }) + .collect(); + let mut rc = 0; + for (track_type, track_index, clip_index) in targets { + rc = unsafe { + oakengine_sequence_split_clip( + seq, + track_type, + track_index as c_int, + clip_index as c_int, + frame.0, + ) + }; + } + self.apply_edit(rc, "split at playhead", cx); + } + + fn delete_clip(&mut self, clip: ClipId, ripple: bool, cx: &mut Context) { + let Some((track_type, track_index, clip_index)) = self.clip_coords(clip) else { + return; + }; + let Some(seq) = self.seq_ptr() else { + return; + }; + let rc = if ripple { + unsafe { + oakengine_sequence_ripple_delete_clip( + seq, + Self::track_type_of(track_type), + track_index as c_int, + clip_index as c_int, + ) + } + } else { + let clip_ptr = unsafe { + oakengine_sequence_clip_at( + seq, + Self::track_type_of(track_type), + track_index as c_int, + clip_index as c_int, + ) + }; + if clip_ptr.is_null() { + return; + } + let mut clips = [clip_ptr]; + let mut rippled: c_int = 0; + let rc = unsafe { + oakengine_sequence_delete_clips( + seq, + clips.as_mut_ptr(), + 1, + 0, + std::ptr::null(), + 0, + &mut rippled, + ) + }; + unsafe { free_box(clip_ptr) }; + rc + }; + self.apply_edit(rc, if ripple { "ripple delete clip" } else { "delete clip" }, cx); + } + + fn can_undo(&self) -> bool { + self.project_ptr() + .map(|p| unsafe { oakengine_project_can_undo(p) } != 0) + .unwrap_or(false) + } + + fn can_redo(&self) -> bool { + self.project_ptr() + .map(|p| unsafe { oakengine_project_can_redo(p) } != 0) + .unwrap_or(false) + } + + fn undo(&mut self, cx: &mut Context) { + if let Some(p) = self.project_ptr() { + unsafe { + oakengine_project_undo(p); + } + self.refresh_sequence_info(); + self.rebuild_timeline(); + self.modified = true; + cx.notify(); + } + } + + fn redo(&mut self, cx: &mut Context) { + if let Some(p) = self.project_ptr() { + unsafe { + oakengine_project_redo(p); + } + self.refresh_sequence_info(); + self.rebuild_timeline(); + self.modified = true; + cx.notify(); + } + } + + fn project_modified(&self) -> bool { + self.modified + } + + fn new_project(&mut self, cx: &mut Context) { + let project = unsafe { oakengine_project_create() }; + if project.is_null() { + println!("[real engine] failed to create a blank project"); + return; + } + if unsafe { oakengine_project_new(project) } != 0 { + unsafe { oakengine_project_free(project) }; + println!("[real engine] failed to initialize a blank project"); + return; + } + self.adopt_project(project, cx); + } + + fn open_project_path(&mut self, path: PathBuf, cx: &mut Context) -> Result<(), String> { + let ext = path + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_ascii_lowercase()); + match ext.as_deref() { + Some("otio") | Some("fcpxml") => self.open_interchange(&path, cx), + _ => self.open_ove(&path, cx), + } + } + + fn save_project(&mut self, path: Option, cx: &mut Context) -> Result<(), String> { + if self.project_ptr().is_none() { + return Err("no project open".into()); + } + let ext = path + .as_ref() + .and_then(|p| p.extension()) + .and_then(|e| e.to_str()) + .map(|e| e.to_ascii_lowercase()); + let result = match ext.as_deref() { + Some("otio") | Some("fcpxml") => self.save_interchange(&path.unwrap(), cx), + _ => self.save_ove(path.as_deref(), cx), + }; + if result.is_ok() { + self.modified = false; + cx.notify(); + } + result + } + + fn close_project(&mut self, cx: &mut Context) { + self.drop_project(); + cx.notify(); + } + + fn start_export(&mut self, format: i32, path: PathBuf) -> Result { + let Some(seq) = self.seq_ptr() else { + return Err("no sequence open".into()); + }; + + // Build the encoding params from the sequence's format. + let params = unsafe { oakengine_encoding_params_create() }; + if params.is_null() { + return Err("failed to create encoding params".into()); + } + let cpath = cstr_path(&path).ok_or("invalid output path")?; + let rc = unsafe { oakengine_encoding_params_set_filename(params, cpath.as_ptr()) }; + if rc != 0 { + unsafe { oakengine_encoding_params_destroy(params) }; + return Err(format!("failed to set the export filename (error {rc})")); + } + let rc = unsafe { oakengine_encoding_params_set_format(params, format) }; + if rc != 0 { + unsafe { oakengine_encoding_params_destroy(params) }; + return Err(format!("failed to set the export format (error {rc})")); + } + // Video params POD from the sequence; first video codec of the format. + let mut width: c_int = 0; + let mut height: c_int = 0; + let mut par_num: c_int = 1; + let mut par_den: c_int = 1; + let mut rate_num: c_int = 25; + let mut rate_den: c_int = 1; + unsafe { + oakengine_sequence_get_video_params(seq, &mut width, &mut height, &mut par_num, &mut par_den); + oakengine_sequence_get_frame_rate(seq, &mut rate_num, &mut rate_den); + } + let video_codec = unsafe { oakengine_encoding_format_video_codec_at(format, 0) }; + if video_codec < 0 { + unsafe { oakengine_encoding_params_destroy(params) }; + return Err(format!("format {format} has no video codec")); + } + let pod = OakVideoParamsPod { + width: width.max(1), + height: height.max(1), + time_base_num: rate_den.max(1), + time_base_den: rate_num.max(1), + format: 0, + pixel_aspect_num: par_num.max(1), + pixel_aspect_den: par_den.max(1), + interlacing: 0, + color_range: 0, + divider: 1, + video_type: 0, + premultiplied_alpha: 0, + }; + let rc = unsafe { oakengine_encoding_params_enable_video(params, &pod, video_codec) }; + if rc != 0 { + unsafe { oakengine_encoding_params_destroy(params) }; + return Err(format!("failed to enable video (error {rc})")); + } + let audio_codec = unsafe { oakengine_encoding_format_audio_codec_at(format, 0) }; + if audio_codec < 0 { + unsafe { oakengine_encoding_params_destroy(params) }; + return Err(format!("format {format} has no audio codec")); + } + let rc = unsafe { + oakengine_encoding_params_enable_audio( + params, + EXPORT_SAMPLE_RATE, + EXPORT_CHANNEL_LAYOUT, + EXPORT_SAMPLE_FORMAT, + audio_codec, + ) + }; + if rc != 0 { + unsafe { oakengine_encoding_params_destroy(params) }; + return Err(format!("failed to enable audio (error {rc})")); + } + // Export the whole sequence. + let length = self.sequence_length(); + if length.0 > 0 { + unsafe { + oakengine_encoding_params_set_export_length(params, length.0 as c_int, 1); + } + } + + let task = unsafe { oakengine_task_create_export(seq, params) }; + if task.is_null() { + unsafe { oakengine_encoding_params_destroy(params) }; + return Err("failed to create the export task".into()); + } + + // Progress events through the module task callback. + let module_handle = unsafe { unbox(task) }.ok_or_else(|| "invalid export task handle".to_string())?; + let (tx, rx) = mpsc::channel::(); + let cb_userdata = SendPtr(Box::into_raw(Box::new(tx.clone()))); + unsafe { + oaktask_task_subscribe( + module_handle, + Some(export_event_cb), + cb_userdata.0 as *mut c_void, + ); + } + + // The task pointer is shared between the cancel handle and the worker + // thread; the thread owns it and frees it when the run ends. + let shared = Arc::new(Mutex::new(Some(SendPtr(task)))); + let cancel = { + let shared = shared.clone(); + Box::new(move || { + if let Some(task) = shared.lock().unwrap().as_ref() { + unsafe { + oakengine_task_cancel(task.0); + } + } + }) + }; + let worker = shared.clone(); + std::thread::spawn(move || { + unsafe { + let task = worker + .lock() + .unwrap() + .as_ref() + .expect("export task present") + .0; + let ok = oakengine_task_start_sync(task); + let error = RealEngine::task_error(task); + oakengine_task_free(task); + *worker.lock().unwrap() = None; + // Reclaim the callback userdata (the task's listener is + // one-shot and dropped after the run). + reclaim_userdata(cb_userdata); + let _ = tx.send(ExportEvent::Finished(ok != 0, error)); + } + }); + + Ok(ExportSession { events: rx, cancel }) + } + fn backend_name(&self) -> &'static str { + "real" + } +} + +// --------------------------------------------------------------------------- +// Format dispatch (pure, unit tested) +// --------------------------------------------------------------------------- + +/// Classifies a project-file extension for open/save dispatch. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProjectFormat { + /// `.ove` / `.ovexml` — the native serializer. + Ove, + /// `.otio` — OpenTimelineIO JSON. + Otio, + /// `.fcpxml` — Final Cut Pro XML. + Fcpxml, +} + +impl ProjectFormat { + /// The format for a file path, by extension (unknown → [`Ove`] + /// (ProjectFormat::Ove), matching the app's default serializer). + pub fn of(path: &PathBuf) -> Self { + let ext = path + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_ascii_lowercase()); + match ext.as_deref() { + Some("otio") => ProjectFormat::Otio, + Some("fcpxml") => ProjectFormat::Fcpxml, + _ => ProjectFormat::Ove, + } + } +} + +impl RealEngine { + /// Opens a `.ove` / `.ovexml` project through the facade serializer. + fn open_ove(&mut self, path: &PathBuf, cx: &mut Context) -> Result<(), String> { + let project = unsafe { oakengine_project_create() }; + if project.is_null() { + return Err("failed to create a project".into()); + } + let Some(cpath) = cstr_path(path) else { + unsafe { oakengine_project_free(project) }; + return Err("invalid project path".into()); + }; + let mut err = [0 as c_char; 4096]; + let rc = unsafe { oakengine_project_load(project, cpath.as_ptr(), err.as_mut_ptr(), err.len() as c_int) }; + if rc != 0 { + let message = load_error(&mut err); + unsafe { oakengine_project_free(project) }; + return Err(format!("failed to load \"{}\": {message}", path.display())); + } + // The module serializer cannot parse every legacy document (e.g. the + // ``-rooted format skips its nested `` body), which + // loads "successfully" with no content; surface it instead of + // pretending the project opened. + let nodes = unsafe { oakengine_project_node_count(project) }; + if nodes == 0 { + println!( + "[real engine] warning: \"{}\" loaded but contained no parseable content; starting from an empty project", + path.display() + ); + } + self.adopt_project(project, cx); + Ok(()) + } + + /// Opens an `.otio` / `.fcpxml` project through the oaktask interchange + /// loader and adopts the loaded project (the facade exposes no + /// load-result getter, so the module's `oaktask_load_take_project` is + /// called directly — see the module docs). + fn open_interchange(&mut self, path: &PathBuf, cx: &mut Context) -> Result<(), String> { + let Some(cpath) = cstr_path(path) else { + return Err("invalid project path".into()); + }; + let task = unsafe { oakengine_task_create_project_load_otio(cpath.as_ptr()) }; + if task.is_null() { + return Err("failed to create the interchange load task".into()); + } + let rc = unsafe { oakengine_task_start_sync(task) }; + let error = Self::task_error(task); + let module_handle = unsafe { unbox(task) }; + let loaded = module_handle.and_then(|h| { + let project = unsafe { oaktask_load_take_project(h) }; + if project.is_null() { + None + } else { + Some(unsafe { box_handle::(project) }) + } + }); + unsafe { oakengine_task_free(task) }; + if rc == 0 { + return Err(format!("failed to load \"{}\": {error}", path.display())); + } + match loaded { + Some(project) => { + self.adopt_project(project, cx); + Ok(()) + } + None => Err(format!("loaded \"{}\" but the loader returned no project", path.display())), + } + } + + /// Saves to the project's own filename (or `path`) through the OVE + /// serializer. + fn save_ove(&mut self, path: Option<&Path>, _cx: &mut Context) -> Result<(), String> { + let Some(project) = self.project_ptr() else { + return Err("no project open".into()); + }; + let cpath = path.and_then(cstr_path); + let ptr = cpath.as_ref().map(|c| c.as_ptr()).unwrap_or(std::ptr::null()); + let rc = unsafe { oakengine_project_save(project, ptr) }; + if rc != 0 { + return Err(format!("failed to save the project (error {rc})")); + } + // The facade recorded the target filename; refresh the display name. + let name = read_string(|buf, size| unsafe { oakengine_project_name(project, buf, size) }); + if !name.is_empty() { + self.project_info.name = name; + } + let filename = read_string(|buf, size| unsafe { oakengine_project_filename(project, buf, size) }) ; + if !filename.is_empty() { + self.project_info.path = PathBuf::from(filename); + } + Ok(()) + } + + /// Saves as `.otio` / `.fcpxml` through the oaktask save task (the + /// facade derives the output filename from the project's own filename). + fn save_interchange(&mut self, path: &PathBuf, _cx: &mut Context) -> Result<(), String> { + let Some(project) = self.project_ptr() else { + return Err("no project open".into()); + }; + let Some(cpath) = cstr_path(path) else { + return Err("invalid project path".into()); + }; + let rc = unsafe { oakengine_project_set_filename(project, cpath.as_ptr()) }; + if rc != 0 { + return Err(format!("failed to set the output filename (error {rc})")); + } + let task = unsafe { oakengine_task_create_project_save_otio(project) }; + if task.is_null() { + return Err("failed to create the interchange save task".into()); + } + let rc = unsafe { oakengine_task_start_sync(task) }; + let error = Self::task_error(task); + unsafe { oakengine_task_free(task) }; + if rc == 0 { + return Err(format!("failed to save \"{}\": {error}", path.display())); + } + self.project_info.path = path.clone(); + Ok(()) + } +} + +/// Builds the export-format list: (format id, display name, extension) from +/// the oakcodec encoding enumeration. +/// +/// Pure helper so the export dialog can be unit tested; the facade is only +/// consulted for the real engine. +pub fn encoding_formats() -> Vec<(c_int, String, String)> { + let count = unsafe { oakengine_encoding_format_count() }; + let mut out = Vec::new(); + for i in 0..count.max(0) { + let name = read_string(|buf, size| unsafe { oakengine_encoding_format_name(i, buf, size) }); + let ext = read_string(|buf, size| unsafe { oakengine_encoding_format_extension(i, buf, size) }); + out.push((i, name, ext)); + } + out +} + +/// The format id of the default export container: MPEG-4 Video (`.mp4`). +pub const EXPORT_FORMAT_MP4: c_int = 2; + +// --------------------------------------------------------------------------- +// Config C ABI (renderer backend + language) +// --------------------------------------------------------------------------- + +/// The config key selecting the renderer backend (worker `create_renderer` +/// backend id). +pub const CONFIG_KEY_RENDERER_BACKEND: &str = "GraphicsBackend"; + +/// Reads a config string through the facade config C ABI (empty when +/// missing). +pub fn config_get_string(key: &str) -> String { + let Ok(key_c) = CString::new(key) else { + return String::new(); + }; + read_string(|buf, size| unsafe { oakengine_config_get_string(key_c.as_ptr(), buf, size) }) +} + +/// Writes a config string through the facade config C ABI. +pub fn config_set_string(key: &str, value: &str) { + let (Ok(key_c), Ok(value_c)) = (CString::new(key), CString::new(value)) else { + return; + }; + unsafe { + oakengine_config_set_string(key_c.as_ptr(), value_c.as_ptr()); + } +} + +/// The renderer backends offered in the preferences dialog, in display +/// order. The first entry is the built-in default. +pub fn renderer_backends() -> Vec<&'static str> { + vec!["opengl", "metal", "vulkan", "none"] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn project_format_dispatches_by_extension() { + assert_eq!(ProjectFormat::of(&PathBuf::from("/tmp/x.ove")), ProjectFormat::Ove); + assert_eq!(ProjectFormat::of(&PathBuf::from("/tmp/x.ovexml")), ProjectFormat::Ove); + assert_eq!(ProjectFormat::of(&PathBuf::from("/tmp/x.OTIO")), ProjectFormat::Otio); + assert_eq!(ProjectFormat::of(&PathBuf::from("/tmp/x.fcpxml")), ProjectFormat::Fcpxml); + // Unknown extensions fall back to the OVE serializer. + assert_eq!(ProjectFormat::of(&PathBuf::from("/tmp/x.xml")), ProjectFormat::Ove); + assert_eq!(ProjectFormat::of(&PathBuf::from("/tmp/x")), ProjectFormat::Ove); + } + + #[test] + fn renderer_backends_list_is_stable() { + let backends = renderer_backends(); + assert!(backends.len() >= 2); + assert!(backends.contains(&"opengl"), "the default backend is offered"); + } + + /// End-to-end through the facade: a project the engine itself writes + /// (save → load round-trip) keeps its identity, and the in-memory + /// sequence the app drives (created with `oakengine_sequence_new`) carries + /// real tracks. The repository's `tests/project_with_footage.ove` is a + /// legacy ``-rooted document the oaknode serializer cannot parse, + /// so the round-trip uses the engine's own current-format writer. + /// + /// NOTE (documented facade gaps): `oakengine_sequence_new` keeps the + /// sequence in a module scratch project (not the project's membership), so + /// the saved file carries no sequence and a loaded file registers none — + /// the app therefore opens any project and works against a fresh in-memory + /// sequence (see [`RealEngine::adopt_project`]). + #[test] + fn real_project_save_load_round_trip() { + let project = unsafe { oakengine_project_create() }; + assert!(!project.is_null()); + assert_eq!(unsafe { oakengine_project_new(project) }, 0); + + // The in-memory sequence the app drives: real tracks over the facade. + let name = CString::new("Round Trip").unwrap(); + let sequence = unsafe { oakengine_sequence_new(project, name.as_ptr()) }; + assert!(!sequence.is_null()); + assert_eq!(unsafe { oakengine_sequence_add_track(sequence, TRACK_TYPE_VIDEO) }, 0); + assert_eq!(unsafe { oakengine_sequence_add_track(sequence, TRACK_TYPE_AUDIO) }, 0); + let mut video: c_int = -1; + let mut audio: c_int = -1; + let mut subtitle: c_int = -1; + unsafe { + oakengine_sequence_track_count(sequence, &mut video, &mut audio, &mut subtitle); + } + assert_eq!((video, audio, subtitle), (1, 1, 0), "in-memory tracks"); + + // Save as uncompressed `.ovexml` (the module serializer only reads + // plain XML). + let save_path = + std::env::temp_dir().join(format!("oakapp_roundtrip_{}.ovexml", std::process::id())); + let cpath = CString::new(save_path.to_string_lossy().into_owned()).unwrap(); + assert_eq!(unsafe { oakengine_project_set_filename(project, cpath.as_ptr()) }, 0); + assert_eq!(unsafe { oakengine_project_save(project, cpath.as_ptr()) }, 0); + assert!(save_path.exists()); + unsafe { free_box(sequence) }; + unsafe { oakengine_project_free(project) }; + + // Load it back through the same facade path the app uses: the file + // loads and the project identity round-trips. + let project2 = unsafe { oakengine_project_create() }; + assert!(!project2.is_null()); + let mut err = [0 as c_char; 4096]; + let rc = unsafe { + oakengine_project_load(project2, cpath.as_ptr(), err.as_mut_ptr(), err.len() as c_int) + }; + assert_eq!(rc, 0, "project loads: {}", load_error(&mut err)); + let loaded_name = read_string(|buf, size| unsafe { + oakengine_project_name(project2, buf, size) + }); + assert!(!loaded_name.is_empty(), "the loaded project has a name"); + + unsafe { oakengine_project_free(project2) }; + let _ = std::fs::remove_file(&save_path); + } +} diff --git a/src/oakui/timecode.rs b/src/oakui/timecode.rs index f4c54b804..e5db10705 100644 --- a/src/oakui/timecode.rs +++ b/src/oakui/timecode.rs @@ -38,13 +38,10 @@ use gpui::timeline::{Frame, FrameRate}; /// /// # Examples /// -/// ``` -/// use gpui::timeline::{Frame, FrameRate}; -/// use oakapp::oakui::timecode::format_timecode; -/// let rate = FrameRate::new(25, 1); -/// assert_eq!(format_timecode(Frame(0), rate), "00:00:00:00"); -/// assert_eq!(format_timecode(Frame(25), rate), "00:00:01:00"); -/// assert_eq!(format_timecode(Frame(6468), rate), "00:04:18:18"); +/// ```text +/// format_timecode(Frame(0), 25fps) → "00:00:00:00" +/// format_timecode(Frame(25), 25fps) → "00:00:01:00" +/// format_timecode(Frame(6468), 25fps) → "00:04:18:18" /// ``` pub fn format_timecode(frame: Frame, rate: FrameRate) -> String { let negative = frame.0 < 0; @@ -79,11 +76,9 @@ pub fn format_duration(frames: Frame, rate: FrameRate) -> String { /// /// # Examples /// -/// ``` -/// use gpui::timeline::FrameRate; -/// use oakapp::oakui::timecode::format_fps; -/// assert_eq!(format_fps(FrameRate::new(25, 1)), "25"); -/// assert_eq!(format_fps(FrameRate::NTSC_2997), "29.97"); +/// ```text +/// format_fps(FrameRate::new(25, 1)) → "25" +/// format_fps(FrameRate::NTSC_2997) → "29.97" /// ``` pub fn format_fps(rate: FrameRate) -> String { let fps = rate.num as f64 / rate.den as f64; diff --git a/src/panels/history.rs b/src/panels/history.rs index 493100967..40f7cb0cd 100644 --- a/src/panels/history.rs +++ b/src/panels/history.rs @@ -25,9 +25,11 @@ use crate::panels::ids::HISTORY; /// The undo-history placeholder panel. pub struct HistoryPanel { - /// Demo entries (newest first), matching the design's date format - /// `YYYY-MM-DD HH:mm`. - entries: Vec<(&'static str, &'static str)>, + /// Demo entries `(i18n key, label suffix, timestamp)`, newest first, + /// matching the design's date format `YYYY-MM-DD HH:mm`. The label is + /// `tr(key) + suffix`, so the verb is localized while clip names stay as + /// data. + entries: Vec<(&'static str, &'static str, &'static str)>, } impl HistoryPanel { @@ -35,11 +37,11 @@ impl HistoryPanel { pub fn new(_window: &mut Window, _cx: &mut Context) -> Self { Self { entries: vec![ - ("变换", "2026-06-03 20:25"), - ("移动片段", "2026-06-03 20:24"), - ("删除 B-roll.mp4", "2026-06-03 20:22"), - ("添加 OCIO LUT", "2026-06-03 20:20"), - ("设置入点", "2026-06-03 20:18"), + ("history.transform", "", "2026-06-03 20:25"), + ("history.move_clip", "", "2026-06-03 20:24"), + ("history.delete_clip", " B-roll.mp4", "2026-06-03 20:22"), + ("history.add_lut", "", "2026-06-03 20:20"), + ("history.set_in_point", "", "2026-06-03 20:18"), ], } } @@ -55,7 +57,8 @@ impl Render for HistoryPanel { .flex_col() .py_1() .overflow_y_scroll(); - for (label, timestamp) in &self.entries { + for (key, suffix, timestamp) in &self.entries { + let label = format!("{}{}", crate::i18n::tr(key), suffix); list = list.child( div() .flex() @@ -65,7 +68,7 @@ impl Render for HistoryPanel { .px_3() .py_1() .text_color(colors.text) - .child(div().child(*label)) + .child(div().child(label)) .child(div().text_color(colors.disabled).child(*timestamp)), ); } diff --git a/src/panels/inspector.rs b/src/panels/inspector.rs index 48c7d50be..2dd272b8e 100644 --- a/src/panels/inspector.rs +++ b/src/panels/inspector.rs @@ -26,18 +26,18 @@ use gpui::{ div, prelude::*, AnyElement, App, Context, Entity, EventEmitter, Render, SharedString, Window, }; -use crate::oakui::MockEngine; +use crate::oakui::AppEngine; use crate::panels::ids::INSPECTOR; /// The inspector / effect stack panel. -pub struct InspectorPanel { - stack: Entity>, - engine: Entity, +pub struct InspectorPanel { + stack: Entity>, + engine: Entity, } -impl InspectorPanel { +impl InspectorPanel { /// Builds the stack over `engine`'s effect model. - pub fn new(engine: Entity, _window: &mut Window, cx: &mut Context) -> Self { + pub fn new(engine: Entity, _window: &mut Window, cx: &mut Context) -> Self { let stack = cx.new(|cx| { EffectStackView::new(engine.clone(), cx) .params_renderer(|_effect, _window, cx| cx.new(|_cx| ParamPlaceholder).into()) @@ -54,15 +54,15 @@ impl InspectorPanel { } } -impl Render for InspectorPanel { +impl Render for InspectorPanel { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { div().size_full().child(self.stack.clone()) } } -impl EventEmitter for InspectorPanel {} +impl EventEmitter for InspectorPanel {} -impl DockPanel for InspectorPanel { +impl DockPanel for InspectorPanel { fn panel_id(&self) -> gpui::dock::PanelId { INSPECTOR } diff --git a/src/panels/node_editor.rs b/src/panels/node_editor.rs index 9c36d291f..a2ca41ea5 100644 --- a/src/panels/node_editor.rs +++ b/src/panels/node_editor.rs @@ -27,32 +27,31 @@ use gpui::colors::DefaultColors; use gpui::dock::{DockPanel, PanelEvent}; use gpui::node_graph::{ - MAX_ZOOM, MIN_ZOOM, NodeData, NodeElement, NodeGraphDataSource, NodeGraphEvent, NodeGraphView, - NodeVisualState, + MAX_ZOOM, MIN_ZOOM, NodeData, NodeElement, NodeGraphEvent, NodeGraphView, NodeVisualState, }; use gpui::{ div, point, prelude::*, px, AnyElement, App, Bounds, ClickEvent, Context, Entity, EventEmitter, Pixels, Render, SharedString, Window, }; -use crate::oakui::MockEngine; +use crate::oakui::AppEngine; use crate::panels::ids::NODE_EDITOR; /// The node editor panel. -pub struct NodeEditorPanel { - /// The node-graph canvas over the engine's mock graph. - graph: Entity>, - engine: Entity, +pub struct NodeEditorPanel { + /// The node-graph canvas over the engine's graph data. + graph: Entity>, + engine: Entity, /// Whether the initial fit-to-window has been applied (the canvas size is /// only known after the first layout). fitted: bool, } -impl NodeEditorPanel { +impl NodeEditorPanel { /// Builds the graph canvas over `engine` and routes its edit requests back /// to the engine. pub fn new( - engine: Entity, + engine: Entity, window: &mut Window, cx: &mut Context, ) -> Self { @@ -134,7 +133,7 @@ impl NodeEditorPanel { } } -impl Render for NodeEditorPanel { +impl Render for NodeEditorPanel { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { // Fit the graph once the canvas size is known (first layout). Before // that the viewport is zero-sized, so ask for another frame instead. @@ -161,16 +160,32 @@ impl Render for NodeEditorPanel { .py_1() .border_b_1() .border_color(colors.border) - .child(zoom_button(cx, "node-zoom-in", "+", |this, window, cx| { - this.zoom(1.25, window, cx); - })) - .child(zoom_button(cx, "node-zoom-out", "−", |this, window, cx| { - this.zoom(1.0 / 1.25, window, cx); - })) + .child(zoom_button( + cx, + "node-zoom-in", + Some(crate::oakui::icons::ICON_ZOOM_IN), + "+", + "timeline.zoom_in", + |this, window, cx| { + this.zoom(1.25, window, cx); + }, + )) + .child(zoom_button( + cx, + "node-zoom-out", + Some(crate::oakui::icons::ICON_ZOOM_OUT), + "−", + "timeline.zoom_out", + |this, window, cx| { + this.zoom(1.0 / 1.25, window, cx); + }, + )) .child(zoom_button( cx, "node-zoom-fit", + None, crate::i18n::tr("node.fit"), + "node.fit", |this, window, cx| this.fit_graph(window, cx), )) .child(div().flex_1()) @@ -195,34 +210,52 @@ impl Render for NodeEditorPanel { } } -/// A small toolbar button driving the graph viewport. -fn zoom_button( - cx: &mut Context, +/// A small toolbar button driving the graph viewport. With `icon_name`, the +/// button shows the 16px icon on a 24px hit target; otherwise the `label` +/// text. Both get a localized `tooltip`. +fn zoom_button( + cx: &mut Context>, id: &'static str, + icon_name: Option<&'static str>, label: impl IntoElement, - action: impl Fn(&mut NodeEditorPanel, &mut Window, &mut Context) + 'static, + tooltip: &'static str, + action: impl Fn(&mut NodeEditorPanel, &mut Window, &mut Context>) + 'static, ) -> impl gpui::IntoElement { let colors = cx.default_colors().clone(); let container = colors.container; - div() + let tooltip_label = crate::i18n::tr(tooltip); + let mut el = div() .id(id) - .px_2() - .py_1() + .size(px(24.0)) + .flex() + .items_center() + .justify_center() .rounded_md() .border_1() .border_color(colors.border) .text_color(colors.text) .cursor_pointer() .hover(move |style| style.bg(container)) + .tooltip(move |window, cx| { + gpui_widgets::tooltip::tooltip_view(tooltip_label.into(), window, cx) + }) .on_click(cx.listener(move |this, _event: &ClickEvent, window, cx| { action(this, window, cx); - })) - .child(label) + })); + if let Some(name) = icon_name { + el = el.child( + gpui::img(crate::oakui::icons::icon_path(name, cx)).size(px(16.0)), + ); + } else { + el = el.child(label); + } + el } -impl EventEmitter for NodeEditorPanel {} -impl DockPanel for NodeEditorPanel { +impl EventEmitter for NodeEditorPanel {} + +impl DockPanel for NodeEditorPanel { fn panel_id(&self) -> gpui::dock::PanelId { NODE_EDITOR } @@ -241,13 +274,14 @@ impl DockPanel for NodeEditorPanel { #[cfg(test)] mod tests { use super::*; + use crate::oakui::MockEngine; use gpui::{TestAppContext, VisualTestContext, size}; /// Builds the panel in a window and returns a `VisualTestContext` for /// bounds assertions. fn panel_window( cx: &mut TestAppContext, - ) -> (&'static mut VisualTestContext, Entity) { + ) -> (&'static mut VisualTestContext, Entity>) { cx.update(|cx| cx.init_colors()); let window = cx.open_window(size(px(640.0), px(480.0)), |window, cx| { let engine = cx.new(|cx| crate::oakui::MockEngine::demo(cx)); diff --git a/src/panels/program_viewer.rs b/src/panels/program_viewer.rs index 92a41839a..e9ca0586b 100644 --- a/src/panels/program_viewer.rs +++ b/src/panels/program_viewer.rs @@ -28,7 +28,7 @@ use gpui_widgets::audio_meter::AudioLevelMeter; use gpui_widgets::viewer::{ViewerEvent, ViewerWidget}; use crate::oakui::timecode::{format_fps, format_resolution}; -use crate::oakui::{EngineGateway, MockClock, MockEngine, Monitor}; +use crate::oakui::{AppEngine, Monitor}; use crate::panels::chip; use crate::panels::ids::PROGRAM_VIEWER; @@ -36,22 +36,22 @@ use crate::panels::ids::PROGRAM_VIEWER; const METER_WIDTH: f32 = 26.0; /// The program viewer panel. -pub struct ProgramViewerPanel { - viewer: Entity>, - meter: Entity>, - engine: Entity, +pub struct ProgramViewerPanel { + viewer: Entity>, + meter: Entity>, + engine: Entity, /// 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>, } -impl ProgramViewerPanel { +impl ProgramViewerPanel { /// Builds a viewer over `clock` (the program monitor's clock) with the /// level meter `meter` (updated on the app's tick timer). pub fn new( - engine: Entity, - clock: Entity, - meter: Entity>, + engine: Entity, + clock: Entity, + meter: Entity>, window: &mut Window, cx: &mut Context, ) -> Self { @@ -90,7 +90,7 @@ impl ProgramViewerPanel { } } -impl Render for ProgramViewerPanel { +impl Render for ProgramViewerPanel { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { self.sync_frame(cx); @@ -138,9 +138,9 @@ impl Render for ProgramViewerPanel { } } -impl EventEmitter for ProgramViewerPanel {} +impl EventEmitter for ProgramViewerPanel {} -impl DockPanel for ProgramViewerPanel { +impl DockPanel for ProgramViewerPanel { fn panel_id(&self) -> gpui::dock::PanelId { PROGRAM_VIEWER } diff --git a/src/panels/project_explorer.rs b/src/panels/project_explorer.rs index e9ecefadc..c36a2e81c 100644 --- a/src/panels/project_explorer.rs +++ b/src/panels/project_explorer.rs @@ -23,18 +23,18 @@ use gpui::{ }; use gpui_widgets::project_explorer::{ProjectExplorer, ProjectExplorerEvent}; -use crate::oakui::MockEngine; +use crate::oakui::AppEngine; use crate::panels::ids::PROJECT; /// The material bin panel. -pub struct ProjectExplorerPanel { - explorer: Entity>, - engine: Entity, +pub struct ProjectExplorerPanel { + explorer: Entity>, + engine: Entity, } -impl ProjectExplorerPanel { +impl ProjectExplorerPanel { /// Builds the explorer over `engine`'s project data. - pub fn new(engine: Entity, window: &mut Window, cx: &mut Context) -> Self { + pub fn new(engine: Entity, window: &mut Window, cx: &mut Context) -> Self { let explorer = cx.new(|cx| ProjectExplorer::new(1, engine.clone(), window, cx)); cx.subscribe( &explorer, @@ -53,15 +53,15 @@ impl ProjectExplorerPanel { } } -impl Render for ProjectExplorerPanel { +impl Render for ProjectExplorerPanel { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { div().size_full().child(self.explorer.clone()) } } -impl EventEmitter for ProjectExplorerPanel {} +impl EventEmitter for ProjectExplorerPanel {} -impl DockPanel for ProjectExplorerPanel { +impl DockPanel for ProjectExplorerPanel { fn panel_id(&self) -> gpui::dock::PanelId { PROJECT } diff --git a/src/panels/source_viewer.rs b/src/panels/source_viewer.rs index a27ccfd3b..8ee2282d3 100644 --- a/src/panels/source_viewer.rs +++ b/src/panels/source_viewer.rs @@ -26,24 +26,24 @@ use gpui::{ use gpui_widgets::viewer::{ViewerEvent, ViewerWidget}; use crate::oakui::timecode::{format_fps, format_resolution}; -use crate::oakui::{EngineGateway, MockClock, MockEngine, Monitor}; +use crate::oakui::{AppEngine, Monitor}; use crate::panels::chip; use crate::panels::ids::SOURCE_VIEWER; /// The source viewer panel. -pub struct SourceViewerPanel { - viewer: Entity>, - engine: Entity, +pub struct SourceViewerPanel { + viewer: Entity>, + engine: Entity, /// 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>, } -impl SourceViewerPanel { +impl SourceViewerPanel { /// Builds a viewer over `clock` (the source monitor's clock). pub fn new( - engine: Entity, - clock: Entity, + engine: Entity, + clock: Entity, window: &mut Window, cx: &mut Context, ) -> Self { @@ -81,7 +81,7 @@ impl SourceViewerPanel { } } -impl Render for SourceViewerPanel { +impl Render for SourceViewerPanel { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { self.sync_frame(cx); @@ -117,9 +117,9 @@ impl Render for SourceViewerPanel { } } -impl EventEmitter for SourceViewerPanel {} +impl EventEmitter for SourceViewerPanel {} -impl DockPanel for SourceViewerPanel { +impl DockPanel for SourceViewerPanel { fn panel_id(&self) -> gpui::dock::PanelId { SOURCE_VIEWER } diff --git a/src/panels/status_bar.rs b/src/panels/status_bar.rs index a49e29e4e..fac3ea170 100644 --- a/src/panels/status_bar.rs +++ b/src/panels/status_bar.rs @@ -22,20 +22,22 @@ use gpui::colors::DefaultColors; use gpui::timeline::Frame; use gpui::{div, prelude::*, Context, Entity, Render, Window}; +use gpui_widgets::viewer::PlaybackClock; + use crate::oakui::timecode::{format_duration, format_fps, format_resolution, format_timecode}; -use crate::oakui::{EngineGateway, MockClock, MockEngine}; +use crate::oakui::AppEngine; /// The global status bar. -pub struct StatusBar { - engine: Entity, - program_clock: Entity, +pub struct StatusBar { + engine: Entity, + program_clock: Entity, } -impl StatusBar { +impl StatusBar { /// Builds the status bar over the engine and the program clock. pub fn new( - engine: Entity, - program_clock: Entity, + engine: Entity, + program_clock: Entity, _cx: &mut Context, ) -> Self { Self { @@ -45,11 +47,11 @@ impl StatusBar { } } -impl Render for StatusBar { +impl Render for StatusBar { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let colors = cx.default_colors().clone(); let engine = self.engine.read(cx); - let frame = self.program_clock.read(cx).transport.frame(); + let frame = self.program_clock.read(cx).current_frame(); let sequence = engine.current_sequence(); let format = sequence .map(|s| s.format) @@ -100,5 +102,15 @@ impl Render for StatusBar { format_resolution(format.width, format.height), )) .child(div().px_2().text_color(colors.disabled).child(project)) + .child( + div() + .px_2() + .text_color(colors.disabled) + .child(format!( + "{} · {}", + crate::i18n::tr("status.backend"), + self.engine.read(cx).backend_name(), + )), + ) } } diff --git a/src/panels/timeline.rs b/src/panels/timeline.rs index bd5b6775f..10dc2a946 100644 --- a/src/panels/timeline.rs +++ b/src/panels/timeline.rs @@ -44,14 +44,16 @@ use gpui::colors::DefaultColors; use gpui::dock::{DockPanel, PanelEvent}; use gpui::timeline::TimelineView; -use gpui::{div, prelude::*, px, Context, Entity, Window}; +use gpui::{div, img, prelude::*, px, Context, Entity, Window}; use gpui::{AnyElement, App, ClickEvent, EventEmitter, Render, SharedString}; use gpui_widgets::checkbox::{CheckBox, CheckBoxEvent, CheckState}; use gpui_widgets::slider::{Slider, SliderEvent, SliderModel}; +use gpui_widgets::tooltip::tooltip_view; use gpui_widgets::value::ValueKind; use crate::i18n; -use crate::oakui::MockEngine; +use crate::oakui::icons; +use crate::oakui::AppEngine; use crate::panels::ids::TIMELINE; /// Toolbar height, per the design (31px). @@ -59,23 +61,24 @@ const TOOLBAR_HEIGHT: f32 = 31.0; /// Fixed width of the trailing controls slot (zoom / track-height sliders). /// Kept constant so the sliders can never intrude into the ruler's labels. const RIGHT_CONTROLS_WIDTH: f32 = 140.0; -/// The demo tool set, by i18n key. Only the visual selection is implemented; -/// each tool's behavior arrives with the real tool system later. -const TOOL_KEYS: [&str; 8] = [ - "timeline.tool.select", - "timeline.tool.razor", - "timeline.tool.ripple", - "timeline.tool.slip", - "timeline.tool.roll", - "timeline.tool.zoom", - "timeline.tool.knife", - "timeline.tool.marker", +/// The demo tool set, by i18n key, with the matching toolbar icon (the +/// legacy C++ icon set). Only the visual selection is implemented; each +/// tool's behavior arrives with the real tool system later. +const TOOLS: [(&str, &str); 8] = [ + ("timeline.tool.select", crate::oakui::icons::ICON_ARROW), + ("timeline.tool.razor", crate::oakui::icons::ICON_RAZOR), + ("timeline.tool.ripple", crate::oakui::icons::ICON_RIPPLE), + ("timeline.tool.slip", crate::oakui::icons::ICON_SLIP), + ("timeline.tool.roll", crate::oakui::icons::ICON_ROLLING), + ("timeline.tool.zoom", crate::oakui::icons::ICON_ZOOM), + ("timeline.tool.slide", crate::oakui::icons::ICON_SLIDE), + ("timeline.tool.track_select", crate::oakui::icons::ICON_TRACK_SELECT), ]; /// The timeline panel. -pub struct TimelinePanel { - timeline: Entity>, - engine: Entity, +pub struct TimelinePanel { + timeline: Entity>, + engine: Entity, zoom: Entity, height: Entity, snap: Entity, @@ -83,12 +86,12 @@ pub struct TimelinePanel { selected_tool: usize, } -impl TimelinePanel { +impl TimelinePanel { /// Builds the panel around `timeline` (created by the app shell so it can /// sync the playhead). pub fn new( - engine: Entity, - timeline: Entity>, + engine: Entity, + timeline: Entity>, window: &mut Window, cx: &mut Context, ) -> Self { @@ -155,7 +158,7 @@ impl TimelinePanel { } } -impl Render for TimelinePanel { +impl Render for TimelinePanel { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let colors = cx.default_colors().clone(); @@ -179,74 +182,112 @@ impl Render for TimelinePanel { .flex_shrink_0() .flex() .items_center() - .gap_1() + .gap_2() .px_2() .overflow_hidden() .border_b_1() .border_color(colors.border) .bg(colors.container); - for (index, tool_key) in TOOL_KEYS.iter().enumerate() { - let tool = i18n::tr(tool_key); + // A tool button: a 16px icon on a 24px hit target with a localized + // tooltip; the selected tool is highlighted. + let tool_button = |index: usize, + icon_name: &'static str, + key: &'static str, + cx: &mut Context| { + let tool = i18n::tr(key); let selected = self.selected_tool == index; let background = if selected { colors.selected } else { colors.background }; - let foreground = if selected { - colors.selected_text - } else { - colors.text - }; let hover_bg = colors.selected; - let hover_fg = colors.selected_text; - toolbar = toolbar.child( - div() - .id(SharedString::from(format!("tool-{index}"))) - .px_2() - .py_1() - .rounded_sm() - .cursor_pointer() - .bg(background) - .text_color(foreground) - .hover(move |style| style.bg(hover_bg).text_color(hover_fg)) - .on_click(cx.listener(move |this, _event: &ClickEvent, _window, _cx| { - println!("[timeline] tool: {tool} (placeholder)"); - this.selected_tool = index; - })) - .child(tool), - ); - } - - let text = colors.text; - let container = colors.container; - let tool_btn = move |id: &'static str, label: &'static str| { + let path = icons::icon_path(icon_name, cx); div() - .id(id) - .px_2() - .py_1() + .id(SharedString::from(format!("tool-{index}"))) + .size(px(24.0)) + .flex() + .items_center() + .justify_center() .rounded_sm() .cursor_pointer() - .text_color(text) - .hover(move |style| style.bg(container)) - .child(label) + .bg(background) + .hover(move |style| style.bg(hover_bg)) + .tooltip(move |window, cx| tooltip_view(tool.into(), window, cx)) + .on_click( + cx.listener(move |this, _event: &ClickEvent, _window, _cx| { + println!("[timeline] tool: {tool} (placeholder)"); + this.selected_tool = index; + }), + ) + .child(img(path).size(px(16.0))) }; - // The snap toggle: a localized label next to the checkbox box. The - // label is a plain div so it follows the active language; the box - // itself is clickable as in the widget's default row. + for (index, (tool_key, icon_name)) in TOOLS.iter().enumerate() { + toolbar = toolbar.child(tool_button(index, icon_name, tool_key, cx)); + } + + // A plain icon button (no selection state), e.g. zoom in/out. + let icon_btn = |id: &'static str, + icon_name: &'static str, + key: &'static str, + cx: &mut Context| { + let label = i18n::tr(key); + let hover_bg = colors.container; + let path = icons::icon_path(icon_name, cx); + div() + .id(id) + .size(px(24.0)) + .flex() + .items_center() + .justify_center() + .rounded_sm() + .cursor_pointer() + .text_color(colors.text) + .hover(move |style| style.bg(hover_bg)) + .tooltip(move |window, cx| tooltip_view(label.into(), window, cx)) + .child(img(path).size(px(16.0))) + }; + + // The snap toggle: the magnet icon next to the checkbox box. The icon + // is decorative (the box itself is clickable, as in the widget's + // default row). let snap_row = div() .flex() .items_center() .gap_1() .text_color(colors.text) - .child(div().child(i18n::tr("timeline.snap"))) + .child( + div() + .id("snap-toggle") + .size(px(24.0)) + .flex() + .items_center() + .justify_center() + .cursor_pointer() + .tooltip(move |window, cx| { + tooltip_view(i18n::tr("timeline.snap").into(), window, cx) + }) + .child( + img(icons::icon_path(icons::ICON_SNAP, cx)).size(px(16.0)), + ), + ) .child(self.snap.clone()); let toolbar = toolbar - .child(tool_btn("toolbar-zoom-in", "+")) - .child(tool_btn("toolbar-zoom-out", "−")) + .child(icon_btn( + "toolbar-zoom-in", + icons::ICON_ZOOM_IN, + "timeline.zoom_in", + cx, + )) + .child(icon_btn( + "toolbar-zoom-out", + icons::ICON_ZOOM_OUT, + "timeline.zoom_out", + cx, + )) .child( div() .w_1() @@ -316,9 +357,9 @@ impl Render for TimelinePanel { } } -impl EventEmitter for TimelinePanel {} +impl EventEmitter for TimelinePanel {} -impl DockPanel for TimelinePanel { +impl DockPanel for TimelinePanel { fn panel_id(&self) -> gpui::dock::PanelId { TIMELINE } @@ -335,6 +376,7 @@ impl DockPanel for TimelinePanel { #[cfg(test)] mod tests { use super::*; + use crate::oakui::MockEngine; use gpui::{TestAppContext, VisualTestContext, px, size}; /// Builds a `TimelinePanel` in a window of the given logical size and @@ -343,7 +385,7 @@ mod tests { cx: &mut TestAppContext, width: f32, height: f32, - ) -> (&'static mut VisualTestContext, Entity) { + ) -> (&'static mut VisualTestContext, Entity>) { cx.update(|cx| cx.init_colors()); let window = cx.open_window(size(px(width), px(height)), |window, cx| { let engine = cx.new(|cx| crate::oakui::MockEngine::demo(cx));