feat(render,app): footage decode lands (M12 P0) + UI density pass

engine:
- oakrender eval footage hook decodes via oakcodec (JobSpec::Footage
  carries filename/stream); ticket/ffi/manager wiring, real-media
  decode test with programmatically generated MPEG-2
- oakrender bridge/codec.rs + node.rs: direct oakcodec/oaknode calls;
  the crate's dlsym module is gone (project_deep_copy/sync_copy remain
  documented always-fail stubs — never implemented in oaknode)
- oakaudio waveform/decoder path adjustments for the decode hook

app (gpui + gpui_widgets):
- menu bar scrubbing: hovering another top-level title while a menu is
  open switches to it; popup width is content-aware (CJK-aware) instead
  of fixed 160px
- density pass: window rem 16 -> 14px, menu rows 26 -> 22px, dock tabs
  32 -> 26px, viewer transport tightened
- open/import/save-as use the native platform file dialogs
  (prompt_for_paths / prompt_for_new_path; multi-select import);
  MockEngine records imported footage for tests
- project explorer Tree/Icons toggle is localized (explorer.tree /
  explorer.icons widget keys)
This commit is contained in:
2026-08-13 18:16:10 +08:00
parent 3625e37081
commit 5afa95c80e
227 changed files with 5851 additions and 65946 deletions
+156 -71
View File
@@ -43,11 +43,10 @@ use gpui::dock::{
};
use gpui::timeline::{ClipId, Frame, TimelineEvent, TimelineView};
use gpui::{
div, prelude::*, px, size, App, AsyncWindowContext, Bounds, Context, Entity, Render, Window,
WindowBounds, WindowOptions,
div, prelude::*, px, size, App, AsyncWindowContext, Bounds, Context, Entity, PathPromptOptions,
Render, Window, WindowBounds, WindowOptions,
};
use gpui_widgets::audio_meter::AudioLevelMeter;
use gpui_widgets::dialog::file_dialog::FileDialogContent;
use gpui_widgets::dialog::progress::{progress_dialog, ProgressContent};
use gpui_widgets::dialog::{DialogButton, Modal, ModalEvent, ModalOptions};
use gpui_widgets::menu::{Menu, MenuBar, MenuBarEntry, MenuBarEvent, MenuItem};
@@ -75,6 +74,7 @@ mod menu_ids {
pub const CLOSE: usize = 105;
pub const EXPORT: usize = 106;
pub const QUIT: usize = 107;
pub const IMPORT_FOOTAGE: usize = 108;
pub const UNDO: usize = 201;
pub const REDO: usize = 202;
@@ -113,16 +113,15 @@ mod menu_ids {
/// 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;
}
/// What a file dialog's OK button should do.
/// What a picked platform-dialog path should do.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FileAction {
ImportFootage,
Open,
SaveAs,
}
@@ -130,11 +129,6 @@ enum FileAction {
/// The modal currently layered on top of the shell, if any.
enum ModalState {
None,
FileDialog {
modal: Entity<Modal>,
content: Entity<FileDialogContent>,
action: FileAction,
},
Preferences {
modal: Entity<Modal>,
content: Entity<PreferencesContent>,
@@ -159,8 +153,7 @@ impl ModalState {
fn modal_entity(&self) -> Option<Entity<Modal>> {
match self {
ModalState::None => None,
ModalState::FileDialog { modal, .. }
| ModalState::Preferences { modal, .. }
ModalState::Preferences { modal, .. }
| ModalState::Export { modal, .. }
| ModalState::Progress { modal, .. } => Some(modal.clone()),
}
@@ -277,6 +270,15 @@ impl<E: AppEngine> OakApp<E> {
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));
// M12 P4: install the waveform decorator when the engine provides
// a waveform cache.
if let Some(cache) = engine.read(cx).waveform_cache() {
let decorator: std::sync::Arc<std::sync::RwLock<dyn gpui::timeline::clip::ClipDecorator>> =
std::sync::Arc::new(std::sync::RwLock::new(
crate::oakui::waveform::OakClipDecorator { cache },
));
timeline.update(cx, |view, _| view.set_clip_decorator(decorator));
}
let meter = cx.new(|cx| AudioLevelMeter::new(3, engine.clone(), window, cx));
// --- menu bar ------------------------------------------------------
@@ -469,6 +471,7 @@ impl<E: AppEngine> OakApp<E> {
// --- File ------------------------------------------------------
NEW_PROJECT => self.engine.update(cx, |engine, cx| engine.new_project(cx)),
OPEN_PROJECT => self.open_file_dialog(FileAction::Open, cx),
IMPORT_FOOTAGE => self.open_file_dialog(FileAction::ImportFootage, cx),
SAVE => self.save_project(None, cx),
SAVE_AS => self.open_file_dialog(FileAction::SaveAs, cx),
CLOSE => self
@@ -677,37 +680,94 @@ impl<E: AppEngine> OakApp<E> {
cx.notify();
}
/// Opens the file open / save-as dialog.
/// Opens the platform file dialog for `action` and routes the picked
/// path(s) through the engine. Open / Import use the path picker (import
/// allows multiple files); Save As asks for a new path next to the current
/// project. The picker resolves asynchronously, so the chosen path is
/// applied in a spawned task via [`Self::on_file_paths`].
fn open_file_dialog(&mut self, action: FileAction, cx: &mut Context<Self>) {
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) = &current_path {
content.update(app, |content, cx| {
content.set_path(path.to_string_lossy().into_owned(), cx)
});
match action {
FileAction::Open | FileAction::ImportFootage => {
let prompt = match action {
FileAction::Open => crate::i18n::tr("file.open.title"),
_ => crate::i18n::tr("file.import_footage.title"),
};
let receiver = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: action == FileAction::ImportFootage,
prompt: Some(prompt.into()),
});
cx.spawn(async move |this, cx| {
if let Ok(Ok(Some(paths))) = receiver.await {
if !paths.is_empty() {
this.update(cx, |this, cx| this.on_file_paths(action, paths, cx));
}
}
})
.detach();
}
FileAction::SaveAs => {
let current = self
.engine
.read(cx)
.project()
.map(|p| p.path.clone())
.filter(|p| !p.as_os_str().is_empty());
let (directory, suggested) = match current {
Some(path) => (
path.parent()
.map(|dir| dir.to_path_buf())
.unwrap_or_else(|| PathBuf::from(".")),
path
.file_name()
.map(|name| name.to_string_lossy().into_owned()),
),
None => (PathBuf::from("."), None),
};
let receiver = cx.prompt_for_new_path(&directory, suggested.as_deref());
cx.spawn(async move |this, cx| {
if let Ok(Ok(Some(path))) = receiver.await {
this.update(cx, |this, cx| {
this.on_file_paths(FileAction::SaveAs, vec![path], cx);
});
}
})
.detach();
}
}
}
/// Applies paths picked in the platform dialog through the engine, using
/// the action's routing (open / import / save-as).
fn on_file_paths(&mut self, action: FileAction, paths: Vec<PathBuf>, cx: &mut Context<Self>) {
let result = self.engine.update(cx, |engine, cx| match action {
FileAction::Open => match paths.first() {
Some(path) => engine.open_project_path(path.clone(), cx),
None => Ok(()),
},
FileAction::SaveAs => match paths.first() {
Some(path) => engine.save_project(Some(path.clone()), cx),
None => Ok(()),
},
FileAction::ImportFootage => {
// Import accepts several files at once; keep the first failure
// for the log after the rest have been attempted.
let mut first_error = None;
for path in paths {
if let Err(err) = engine.import_footage(path.clone(), cx) {
first_error.get_or_insert(err);
}
}
match first_error {
Some(err) => Err(err),
None => Ok(()),
}
}
ModalState::FileDialog {
modal,
content,
action,
}
});
if let Err(err) = result {
println!("[file] {action:?} failed: {err}");
}
}
/// Opens the preferences dialog.
@@ -866,9 +926,6 @@ impl<E: AppEngine> OakApp<E> {
fn on_modal(&mut self, event: &ModalEvent, cx: &mut Context<Self>) {
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);
@@ -896,33 +953,6 @@ impl<E: AppEngine> OakApp<E> {
},
}
}
/// Handles the file dialog's OK/Cancel.
fn on_file_dialog_button(&mut self, button: usize, cx: &mut Context<Self>) {
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<E: AppEngine> Render for OakApp<E> {
@@ -969,6 +999,7 @@ fn make_menus(dark: bool) -> Vec<MenuBarEntry> {
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(IMPORT_FOOTAGE, tr("menu.file.import_footage")),
MenuItem::new(SAVE, tr("menu.file.save")).with_shortcut("⌘S"),
MenuItem::new(SAVE_AS, tr("menu.file.save_as"))
.with_shortcut("⇧⌘S")
@@ -1129,7 +1160,14 @@ fn run_with<E: AppEngine>(args: AppArgs) {
window_bounds: Some(WindowBounds::Windowed(bounds)),
..Default::default()
},
|window, cx| build_root::<E>(window, initial, cx),
|window, cx| {
// Compact pro-app text metrics: gpui's default rem is
// 16px (desktop-app large); 14px matches the design's
// density. All rem-based text scales; px spacing is
// unaffected.
window.set_rem_size(px(14.0));
build_root::<E>(window, initial, cx)
},
)
.expect("failed to open the main window");
@@ -1323,6 +1361,53 @@ mod tests {
);
}
/// 文件 → 导入素材… opens the *platform* path picker (not the in-window
/// file dialog) and routes the picked path to the engine's import; the
/// mock engine records it, so the async round trip is observable.
#[gpui::test]
async fn import_footage_prompts_and_routes_the_picked_path(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::<MockEngine>::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::IMPORT_FOOTAGE, cx)));
cx.run_until_parked();
assert!(
cx.did_prompt_for_paths(),
"the platform path picker should be shown"
);
// Answer the picker with one file: the async continuation must land it
// in the engine's import.
let picked = PathBuf::from("/media/raw/interview.mov");
cx.simulate_path_prompt_response({
let picked = picked.clone();
move |options| {
assert!(options.multiple, "import allows multiple selection");
Some(vec![picked])
}
});
cx.run_until_parked();
let imported =
cx.read(|app| root.read(app).engine.read(app).imported_footage().to_vec());
assert_eq!(imported, vec![picked]);
// Cancelling the picker imports nothing.
cx.update(|app| root.update(app, |app, cx| app.on_menu(menu_ids::IMPORT_FOOTAGE, cx)));
cx.run_until_parked();
cx.simulate_path_prompt_response(|_options| None);
cx.run_until_parked();
let imported = cx.read(|app| root.read(app).engine.read(app).imported_footage().len());
assert_eq!(imported, 1, "a cancelled picker imports nothing");
}
/// The command-line parser understands the project path and the mock
/// flag, and the `OAK_ENGINE` env var forces the mock.
#[test]
+10
View File
@@ -137,6 +137,8 @@ pub const WIDGET_KEYS: &[&str] = &[
"viewer.clear_range",
"effect_stack.empty",
"effect_stack.add",
"explorer.tree",
"explorer.icons",
];
/// Installs the active language's widget strings into the
@@ -179,6 +181,7 @@ const EN: &[(&str, &str)] = &[
// --- File ---
("menu.file.new_project", "New Project…"),
("menu.file.open_project", "Open Project…"),
("menu.file.import_footage", "Import Footage…"),
("menu.file.save", "Save"),
("menu.file.save_as", "Save As…"),
("menu.file.close", "Close Project"),
@@ -282,6 +285,8 @@ const EN: &[(&str, &str)] = &[
("viewer.step_forward", "Next Frame"),
("viewer.out_point", "Set Out Point"),
("viewer.clear_range", "Clear In/Out Range"),
("explorer.tree", "Tree"),
("explorer.icons", "Icons"),
// --- widget-baked strings (synced to gpui_widgets::i18n) ---
("viewer.safe_frames", "Safe Frames"),
("viewer.zoom", "Zoom"),
@@ -294,6 +299,7 @@ const EN: &[(&str, &str)] = &[
("dialog.cancel", "Cancel"),
("dialog.close", "Close"),
("file.open.title", "Open Project"),
("file.import_footage.title", "Import Footage"),
("file.save_as.title", "Save Project As"),
("preferences.title", "Preferences"),
("preferences.backend", "Renderer backend"),
@@ -325,6 +331,7 @@ const ZH: &[(&str, &str)] = &[
// --- File ---
("menu.file.new_project", "新建项目…"),
("menu.file.open_project", "打开项目…"),
("menu.file.import_footage", "导入素材…"),
("menu.file.save", "保存"),
("menu.file.save_as", "另存为…"),
("menu.file.close", "关闭项目"),
@@ -428,6 +435,8 @@ const ZH: &[(&str, &str)] = &[
("viewer.step_forward", "下一帧"),
("viewer.out_point", "设置出点"),
("viewer.clear_range", "清除入出点"),
("explorer.tree", "树"),
("explorer.icons", "图标"),
// --- widget-baked strings (synced to gpui_widgets::i18n) ---
("viewer.safe_frames", "安全框"),
("viewer.zoom", "缩放"),
@@ -440,6 +449,7 @@ const ZH: &[(&str, &str)] = &[
("dialog.cancel", "取消"),
("dialog.close", "关闭"),
("file.open.title", "打开项目"),
("file.import_footage.title", "导入素材"),
("file.save_as.title", "项目另存为"),
("preferences.title", "偏好设置"),
("preferences.backend", "渲染后端"),
+14
View File
@@ -286,6 +286,20 @@ pub trait AppEngine:
/// Closes the current project, leaving the app with no sequence.
fn close_project(&mut self, cx: &mut Context<Self>);
/// The timeline waveform cache (M12 P4); `None` when the backend
/// does not provide waveforms.
fn waveform_cache(&self) -> Option<std::sync::Arc<crate::oakui::waveform::WaveformCache>> {
None
}
/// Import a media file into the project (M12 P3). Default: no-op.
fn import_footage(
&mut self,
_path: std::path::PathBuf,
_cx: &mut Context<Self>,
) -> Result<(), String> {
Ok(())
}
/// Starts an export of the current sequence in `format` to `path` and
/// returns a session the host polls for progress and can cancel.
+209
View File
@@ -100,8 +100,10 @@ macro_rules! engine_handle {
}
engine_handle! {
OakEngineAudioBuffer,
OakEngineClip,
OakEngineEncodingParams,
OakEngineFootage,
OakEngineFrame,
OakEngineNode,
OakEngineProject,
@@ -320,6 +322,27 @@ unsafe extern "C" {
) -> c_int;
/// `oakengine_project_footage_count`.
pub fn oakengine_project_footage_count(self_: *const OakEngineProject) -> c_int;
/// `oakengine_project_import_footage` — probe and add to the root
/// folder; owned footage box (free with `oakengine_footage_free`).
pub fn oakengine_project_import_footage(
self_: *mut OakEngineProject,
path: *const c_char,
) -> *mut OakEngineFootage;
/// `oakengine_footage_free` — release a footage handle.
pub fn oakengine_footage_free(self_: *mut OakEngineFootage);
/// `oakengine_sequence_add_footage_clip_ex` — place a clip of
/// `footage` on the track, skipping the unenforceable same-project
/// check (sequences live in their own scratch project — documented
/// module deviation; M12 P0 montage path). Owned clip box.
pub fn oakengine_sequence_add_footage_clip_ex(
self_: *mut OakEngineSequence,
footage: *mut OakEngineFootage,
track_type: c_int,
track_index: c_int,
in_: i64,
out: i64,
media_in: i64,
) -> *mut OakEngineClip;
/// `oakengine_project_footage_at` — boxed footage node at `index` (free
/// with `oakengine_node_free`); NULL for an invalid index.
pub fn oakengine_project_footage_at(
@@ -356,6 +379,99 @@ unsafe extern "C" {
) -> c_int;
/// `oakengine_project_node_count` — parseable content check.
pub fn oakengine_project_node_count(self_: *const OakEngineProject) -> c_int;
/// `oakengine_project_node_at` — boxed project node at `index` (free
/// with `oakengine_node_free`); NULL for an invalid index.
pub fn oakengine_project_node_at(
self_: *const OakEngineProject,
index: c_int,
) -> *mut OakEngineNode;
/// `oakengine_project_root` — the project's root folder node (boxed,
/// free with `oakengine_node_free`).
pub fn oakengine_project_root(self_: *const OakEngineProject) -> *mut OakEngineNode;
/// `oakengine_folder_item_child_count` — direct children of a folder
/// node.
pub fn oakengine_folder_item_child_count(folder: *const OakEngineNode) -> c_int;
/// `oakengine_folder_item_child` — boxed child at `index` (free with
/// `oakengine_node_free`); NULL for an invalid index.
pub fn oakengine_folder_item_child(
folder: *const OakEngineNode,
index: c_int,
) -> *mut OakEngineNode;
/// `oakengine_node_get_name` (buf/size).
pub fn oakengine_node_get_name(
self_: *const OakEngineNode,
buf: *mut c_char,
buf_size: c_int,
) -> c_int;
/// `oakengine_node_get_label` (buf/size).
pub fn oakengine_node_get_label(
self_: *const OakEngineNode,
buf: *mut c_char,
buf_size: c_int,
) -> c_int;
/// `oakengine_node_input_count`.
pub fn oakengine_node_input_count(self_: *const OakEngineNode) -> c_int;
/// `oakengine_node_input_id` (buf/size) at `index`.
pub fn oakengine_node_input_id(
self_: *const OakEngineNode,
index: c_int,
buf: *mut c_char,
buf_size: c_int,
) -> c_int;
/// `oakengine_node_input_is_connected` — 1 when the input has an edge.
pub fn oakengine_node_input_is_connected(
self_: *const OakEngineNode,
input_id: *const c_char,
) -> c_int;
/// `oakengine_node_output_connection_count`.
pub fn oakengine_node_output_connection_count(self_: *const OakEngineNode) -> c_int;
/// `oakengine_node_output_connection_at_ex` — boxed input node + input
/// id (free the boxed node with `oakengine_node_free`).
pub fn oakengine_node_output_connection_at_ex(
self_: *const OakEngineNode,
index: c_int,
input_node: *mut *mut OakEngineNode,
input_id_buf: *mut c_char,
input_id_size: c_int,
element: *mut c_int,
hidden: *mut c_int,
) -> c_int;
/// `oakengine_node_get_context_position` — graph position of `node`
/// in `context`'s position map.
pub fn oakengine_node_get_context_position(
context: *const OakEngineNode,
node: *const OakEngineNode,
x: *mut f64,
y: *mut f64,
expanded: *mut c_int,
) -> c_int;
/// `oakengine_node_set_context_position` — undoable graph move.
pub fn oakengine_node_set_context_position(
context: *mut OakEngineNode,
node: *mut OakEngineNode,
x: f64,
y: f64,
) -> c_int;
/// `oakengine_node_connect` — undoable edge from `output_node` into
/// `input_node`'s `input_id`.
pub fn oakengine_node_connect(
output_node: *mut OakEngineNode,
input_node: *mut OakEngineNode,
input_id: *const c_char,
) -> c_int;
/// `oakengine_node_disconnect_ex` — undoable edge removal.
pub fn oakengine_node_disconnect_ex(
input_node: *mut OakEngineNode,
input_id: *const c_char,
element: c_int,
) -> c_int;
/// `oakengine_project_remove_node` — undoable node removal (edges
/// disconnected).
pub fn oakengine_project_remove_node(
self_: *mut OakEngineProject,
node: *mut OakEngineNode,
) -> c_int;
// -- oakengine::task --
@@ -429,6 +545,9 @@ unsafe extern "C" {
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_last_error` — last editing error for this
/// thread (two-stage string).
pub fn oakengine_sequence_last_error(buf: *mut c_char, buf_size: c_int) -> c_int;
/// `oakengine_sequence_clip_count`.
pub fn oakengine_sequence_clip_count(
self_: *mut OakEngineSequence,
@@ -459,6 +578,16 @@ unsafe extern "C" {
clip_index: c_int,
time: i64,
) -> c_int;
/// `oakengine_sequence_move_clip_to_track` — move the clip to a
/// different track (M12 P4, cross-track).
pub fn oakengine_sequence_move_clip_to_track(
seq: *mut OakEngineSequence,
track_type: c_int,
track_index: c_int,
clip_index: c_int,
dest_track_index: c_int,
new_in: i64,
) -> c_int;
/// `oakengine_sequence_move_clip` — move the clip so its in point becomes
/// `new_in` on the same track.
pub fn oakengine_sequence_move_clip(
@@ -527,6 +656,25 @@ unsafe extern "C" {
/// `oakengine_clip_as_node` — the clip's node view (borrowed box;
/// freed with `oakengine_node_free`).
pub fn oakengine_clip_as_node(self_: *const OakEngineClip) -> *mut OakEngineNode;
/// `oakengine_clip_get_media_filename` — the clip's upstream footage
/// filename (two-stage buf/size; M12 P4).
pub fn oakengine_clip_get_media_filename(
self_: *const OakEngineClip,
buf: *mut c_char,
buf_size: c_int,
) -> c_int;
/// `oakaudio_waveform_extract` — real waveform extraction (M12 P4):
/// two-stage min/max extraction of `filename`'s audio stream.
pub fn oakaudio_waveform_extract(
filename: *const c_char,
stream_index: c_int,
samples_per_point: c_int,
out_pairs: *mut crate::oakui::waveform::MinMax,
capacity_points: c_int,
out_channel_count: *mut c_int,
) -> c_int;
/// `oakengine_node_effect_count` — the chain length (0 without effects).
pub fn oakengine_node_effect_count(self_: *const OakEngineNode) -> c_int;
/// `oakengine_node_effect_at` — the `index`-th effect (borrowed box;
@@ -650,6 +798,57 @@ unsafe extern "C" {
buf: *mut c_char,
buf_size: c_int,
) -> c_int;
/// `oakengine_renderer_render_audio` — synchronously render
/// `length_timestamp` frames of audio starting at `start_timestamp`
/// (M12 P1). Owned `OakEngineAudioBuffer`.
pub fn oakengine_renderer_render_audio(
self_: *mut OakEngineRenderer,
start_timestamp: i64,
length_timestamp: i64,
) -> *mut OakEngineAudioBuffer;
/// `oakengine_audio_sample_rate` — the rendered buffer's rate (Hz).
pub fn oakengine_audio_sample_rate(self_: *const OakEngineAudioBuffer) -> c_int;
/// `oakengine_audio_channel_count`.
pub fn oakengine_audio_channel_count(self_: *const OakEngineAudioBuffer) -> c_int;
/// `oakengine_audio_sample_count` — interleaved frame count.
pub fn oakengine_audio_sample_count(self_: *const OakEngineAudioBuffer) -> i64;
/// `oakengine_audio_data` — interleaved f32 samples.
pub fn oakengine_audio_data(
self_: *const OakEngineAudioBuffer,
channel: c_int,
) -> *const f32;
/// `oakengine_audio_free` — release the buffer.
pub fn oakengine_audio_free(self_: *mut OakEngineAudioBuffer);
/// `oakengine_audio_push_to_output` — queue interleaved samples
/// described by `params` for playback (M12 P1).
pub fn oakengine_audio_push_to_output(
params: *const c_void,
samples: *const c_char,
samples_size: i64,
error_buf: *mut c_char,
error_buf_size: c_int,
) -> c_int;
/// `oakcore_audioparams_create` — new owned audio params (release
/// with `oakcore_audioparams_free`).
pub fn oakcore_audioparams_create(
sample_rate: c_int,
channel_layout: u64,
format: c_int,
) -> *mut c_void;
/// `oakcore_audioparams_free` (NULL no-op).
pub fn oakcore_audioparams_free(params: *mut c_void);
/// `oakrender_manager_shutdown` — tear down the render manager
/// (test/tooling; the app keeps it for the process lifetime).
pub fn oakrender_manager_shutdown() -> c_int;
/// `oakengine_testmedia_write_clip` — encode the known test pattern
/// into `path` (test/tooling only; M12 P0).
pub fn oakengine_testmedia_write_clip(
path: *const c_char,
width: c_int,
height: c_int,
frame_count: c_int,
fps: c_int,
) -> c_int;
/// `oakengine_frame_width`.
pub fn oakengine_frame_width(self_: *const OakEngineFrame) -> c_int;
/// `oakengine_frame_height`.
@@ -695,3 +894,13 @@ unsafe extern "C" {
userdata: *mut c_void,
) -> i64;
}
/// The C-ABI `MinMax` mirror (exported for the integration tests).
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct oakapp_minmax {
/// Minimum amplitude.
pub min: f32,
/// Maximum amplitude.
pub max: f32,
}
+17
View File
@@ -427,6 +427,10 @@ pub struct MockEngine {
/// the scope samples analyzed in the same pass, so a paused viewer never
/// regenerates its picture (or its scopes).
cpu_frame_cache: Mutex<HashMap<Monitor, (i64, Arc<RenderImage>, ScopeData)>>,
/// Paths handed to [`AppEngine::import_footage`] since creation; the mock
/// has no media pipeline, so it just records them (drives app-level tests
/// of the import flow).
imported_footage: Vec<PathBuf>,
}
impl MockEngine {
@@ -665,6 +669,7 @@ impl MockEngine {
next_edge_id: 6,
node_selection: BTreeSet::new(),
cpu_frame_cache: Mutex::new(HashMap::new()),
imported_footage: Vec::new(),
};
// The demo graph is born connected: derive every port's `connected`
// flag from the edge list.
@@ -1236,6 +1241,12 @@ impl AppEngine for MockEngine {
Ok(())
}
fn import_footage(&mut self, path: PathBuf, cx: &mut Context<Self>) -> Result<(), String> {
self.imported_footage.push(path);
cx.notify();
Ok(())
}
fn save_project(
&mut self,
_path: Option<PathBuf>,
@@ -1392,6 +1403,12 @@ impl AudioMeterDataSource for MockEngine {
/// Convenience accessors used by panels and the status bar.
impl MockEngine {
/// The paths imported via [`AppEngine::import_footage`] so far (mock state;
/// the mock has no media pipeline, it just records the request).
pub fn imported_footage(&self) -> &[PathBuf] {
&self.imported_footage
}
/// The selected material-bin entry id (demo state).
pub fn selected_item(&self) -> Option<u64> {
self.selected_item
+3
View File
@@ -44,10 +44,13 @@ pub mod frames;
mod host_syms;
pub mod icons;
pub mod mock;
pub mod nodegraph;
pub mod projectbrowser;
pub mod real;
pub mod scopes;
pub mod timecode;
pub mod transport;
pub mod waveform;
pub use engine::{
AppEngine, EngineClock, EngineGateway, ExportEvent, ExportSession, Monitor, Project, ScopeData,
+725
View File
@@ -0,0 +1,725 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! M12 P2: the real node-graph surface.
//!
//! Builds the gpui node-graph data (`RealNode` / `RealPort` / `RealEdge`)
//! from the facade's project-node enumeration:
//!
//! - every project node becomes a card, titled with its label;
//! - declared inputs become input ports (id string as the label);
//! - every node exposes one "out" output port (the module declares no
//! outputs; edges are enumerated from `output_connection_at_ex`);
//! - edges connect the source node's main output to the target node's
//! input port;
//! - positions come from the project-root context's position map.
//!
//! Identity mapping: `NodeId` = the facade node identity (stable across
//! frames). `PortId` packs `(node identity, kind, index)` — input port
//! `(id << 4) | (index << 1)`, output port `(id << 4) | 1`. Identities
//! are pointer-aligned (low bits zero), so the shifts are injective.
use std::ffi::{c_char, c_int, c_void};
use gpui::node_graph::{EdgeData, EdgeId, NodeData, NodeGraphDataSource, NodeId, PortData, PortId, PortKind, PortDataType};
use gpui::{hsla, point, px, Hsla, Pixels, Point, SharedString};
use crate::oakui::ffi::{
free_box, oakengine_node_connect, oakengine_node_disconnect_ex, oakengine_node_free,
oakengine_node_get_context_position, oakengine_node_get_label, oakengine_node_get_name,
oakengine_node_identity, oakengine_node_input_count, oakengine_node_input_id,
oakengine_node_input_is_connected, oakengine_node_output_connection_at_ex,
oakengine_node_output_connection_count, oakengine_node_set_context_position,
oakengine_project_node_at, oakengine_project_node_count, oakengine_project_remove_node,
oakengine_project_root, OakEngineNode, OakEngineProject,
};
/// The "video" wire type used by the real graph (the facade exposes no
/// per-input type names; all node ports are treated as video).
fn video_type() -> PortDataType {
PortDataType::new("video", hsla(0.55, 0.75, 0.6, 1.0))
}
/// The "output" wire type for the module's implicit outputs.
fn out_type() -> PortDataType {
PortDataType::new("video", hsla(0.08, 0.7, 0.55, 1.0))
}
/// Pack a node identity + kind + index into a global `PortId`.
fn port_id(node: u64, kind: PortKind, index: u32) -> PortId {
match kind {
PortKind::Input => PortId((node << 4) | ((index as u64) << 1)),
PortKind::Output => PortId((node << 4) | 1),
}
}
/// Unpack a `PortId` into `(node identity, kind, index)`.
fn unpack_port(id: PortId) -> (u64, PortKind, u32) {
let raw = id.0;
let node = raw >> 4;
if raw & 1 == 1 {
(node, PortKind::Output, 0)
} else {
(node, PortKind::Input, ((raw >> 1) & 0x7) as u32)
}
}
/// Two-stage string read over a facade `(buf, size)` getter.
fn read_str(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 + 1];
f(buf.as_mut_ptr(), needed as c_int + 1);
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()
}
/// A stable edge id from `(from node, to node, input id)`.
fn edge_id(from: u64, to: u64, input_id: &str) -> EdgeId {
let mut h: u64 = 0xcbf29ce484222325;
for &b in [from.to_le_bytes(), to.to_le_bytes()].concat().iter() {
h ^= b as u64;
h = h.wrapping_mul(0x100000001b3);
}
for &b in input_id.as_bytes() {
h ^= b as u64;
h = h.wrapping_mul(0x100000001b3);
}
EdgeId(h)
}
/// A node card in the real graph.
#[derive(Debug, Clone)]
pub struct RealNode {
/// Facade node identity.
pub id: NodeId,
/// Card title.
pub title: SharedString,
/// Graph-space position.
pub position: Point<Pixels>,
/// Input ports (top to bottom).
pub inputs: Vec<RealPort>,
/// The single implicit output port.
pub outputs: Vec<RealPort>,
/// Category accent.
pub header_color: Option<Hsla>,
/// Collapsed state.
pub collapsed: bool,
/// Enabled state.
pub enabled: bool,
}
impl NodeData for RealNode {
type Port = RealPort;
fn id(&self) -> NodeId {
self.id
}
fn title(&self) -> SharedString {
self.title.clone()
}
fn position(&self) -> Point<Pixels> {
self.position
}
fn inputs(&self) -> Vec<Self::Port> {
self.inputs.clone()
}
fn outputs(&self) -> Vec<Self::Port> {
self.outputs.clone()
}
fn header_color(&self) -> Option<Hsla> {
self.header_color
}
fn is_collapsed(&self) -> bool {
self.collapsed
}
fn is_enabled(&self) -> bool {
self.enabled
}
}
/// A port on a real node.
#[derive(Debug, Clone)]
pub struct RealPort {
/// Global port id.
pub id: PortId,
/// Direction.
pub kind: PortKind,
/// Port label.
pub label: SharedString,
/// Wire type.
pub data_type: PortDataType,
/// Whether an edge is attached.
pub connected: bool,
}
impl PortData for RealPort {
fn id(&self) -> PortId {
self.id
}
fn kind(&self) -> PortKind {
self.kind
}
fn label(&self) -> SharedString {
self.label.clone()
}
fn data_type(&self) -> PortDataType {
self.data_type.clone()
}
fn is_connected(&self) -> bool {
self.connected
}
}
/// A wire between two real nodes.
#[derive(Debug, Clone)]
pub struct RealEdge {
/// Stable edge id.
pub id: EdgeId,
/// Source node.
pub from_node: NodeId,
/// Source output port.
pub from_port: PortId,
/// Target node.
pub to_node: NodeId,
/// Target input port.
pub to_port: PortId,
}
impl EdgeData for RealEdge {
fn id(&self) -> EdgeId {
self.id
}
fn from_node(&self) -> NodeId {
self.from_node
}
fn from_port(&self) -> PortId {
self.from_port
}
fn to_node(&self) -> NodeId {
self.to_node
}
fn to_port(&self) -> PortId {
self.to_port
}
}
/// Deterministic header accents per node identity.
fn node_color(ident: u64) -> Hsla {
let hues = [0.55f32, 0.6, 0.08, 0.3, 0.78, 0.45, 0.9, 0.15];
hsla(hues[(ident as usize) % hues.len()], 0.5, 0.35, 1.0)
}
/// Build the (nodes, edges) snapshot of `project`'s node graph.
///
/// # Safety
/// `project` must be a live facade project box.
pub unsafe fn build_graph(project: *mut OakEngineProject) -> (Vec<RealNode>, Vec<RealEdge>) {
unsafe {
let mut nodes = Vec::new();
let mut edges = Vec::new();
if project.is_null() {
return (nodes, edges);
}
let root = oakengine_project_root(project);
let count = oakengine_project_node_count(project);
for i in 0..count {
let node = oakengine_project_node_at(project, i);
if node.is_null() {
continue;
}
let ident = oakengine_node_identity(node);
if ident == 0 {
oakengine_node_free(node);
continue;
}
// Title: the label, falling back to the type name.
let label = read_str(|buf, size| oakengine_node_get_label(node, buf, size));
let name = read_str(|buf, size| oakengine_node_get_name(node, buf, size));
let title = if label.is_empty() { name } else { label };
// Position from the project-root context map.
let mut x: f64 = 0.0;
let mut y: f64 = 0.0;
let mut expanded: c_int = 0;
let has_pos = oakengine_node_get_context_position(
root,
node,
&mut x,
&mut y,
&mut expanded,
) == 0
&& (x != 0.0 || y != 0.0);
// Inputs.
let input_count = oakengine_node_input_count(node);
let mut inputs = Vec::with_capacity(input_count.max(0) as usize);
for idx in 0..input_count {
let id_str = read_str(|buf, size| oakengine_node_input_id(node, idx, buf, size));
if id_str.is_empty() {
continue;
}
let cid = std::ffi::CString::new(id_str.clone()).unwrap_or_default();
let connected =
oakengine_node_input_is_connected(node, cid.as_ptr()) == 1;
inputs.push(RealPort {
id: port_id(ident, PortKind::Input, idx as u32),
kind: PortKind::Input,
label: id_str.into(),
data_type: video_type(),
connected,
});
}
// The single implicit output.
let out_count = oakengine_node_output_connection_count(node);
let outputs = vec![RealPort {
id: port_id(ident, PortKind::Output, 0),
kind: PortKind::Output,
label: SharedString::new_static("out"),
data_type: out_type(),
connected: out_count > 0,
}];
// Outgoing edges.
for j in 0..out_count {
let mut input_node: *mut OakEngineNode = std::ptr::null_mut();
let mut id_buf = [0 as c_char; 256];
let mut element: c_int = -1;
let mut hidden: c_int = 0;
let rc = oakengine_node_output_connection_at_ex(
node,
j,
&mut input_node,
id_buf.as_mut_ptr(),
id_buf.len() as c_int,
&mut element,
&mut hidden,
);
if rc != 0 {
continue;
}
let to_ident = if input_node.is_null() {
0
} else {
oakengine_node_identity(input_node)
};
if !input_node.is_null() {
oakengine_node_free(input_node);
}
// The facade already filled id_buf during
// output_connection_at_ex.
let mut len = 0usize;
while len < id_buf.len() && id_buf[len] != 0 {
len += 1;
}
let conn_id = String::from_utf8_lossy(unsafe {
std::slice::from_raw_parts(id_buf.as_ptr() as *const u8, len)
})
.into_owned();
if to_ident == 0 || conn_id.is_empty() {
continue;
}
edges.push(RealEdge {
id: edge_id(ident, to_ident, &conn_id),
from_node: NodeId(ident),
from_port: port_id(ident, PortKind::Output, 0),
to_node: NodeId(to_ident),
to_port: port_id(to_ident, PortKind::Input, idx_of_input(to_ident, &conn_id, project, &nodes)),
});
}
nodes.push(RealNode {
id: NodeId(ident),
title: title.into(),
position: if has_pos {
point(px(x as f32), px(y as f32))
} else {
point(px(0.0), px(0.0))
},
inputs,
outputs,
header_color: Some(node_color(ident)),
collapsed: false,
enabled: true,
});
oakengine_node_free(node);
}
if !root.is_null() {
oakengine_node_free(root);
}
(nodes, edges)
}
}
/// Resolve the input port index for `input_id` on the node with identity
/// `ident` (the edge's target port must match the port the facade lists).
fn idx_of_input(
ident: u64,
input_id: &str,
project: *mut OakEngineProject,
nodes: &[RealNode],
) -> u32 {
// The target node was already snapshotted: find it and match the
// input label.
if let Some(n) = nodes.iter().find(|n| n.id.0 == ident) {
for (i, p) in n.inputs.iter().enumerate() {
if p.label.as_ref() == input_id {
return i as u32;
}
}
}
// Fallback: re-fetch the node's inputs through the facade.
let _ = project;
let _ = ident;
0
}
// ---------------------------------------------------------------------------
// NodeGraphDataSource over the snapshot
// ---------------------------------------------------------------------------
/// The real graph data source (a [`NodeGraphDataSource`] snapshot).
pub struct RealGraphSource {
nodes: Vec<RealNode>,
edges: Vec<RealEdge>,
project: *mut OakEngineProject,
}
unsafe impl Send for RealGraphSource {}
impl RealGraphSource {
/// Snapshot the current graph.
///
/// # Safety
/// `project` must be a live facade project box.
pub unsafe fn snapshot(project: *mut OakEngineProject) -> Self {
let (nodes, edges) = unsafe { build_graph(project) };
Self {
nodes,
edges,
project,
}
}
/// Find the boxed facade node for a node identity (caller frees with
/// [`oakengine_node_free`]).
///
/// # Safety
/// The returned pointer is a live box.
pub unsafe fn find_node(&self, ident: u64) -> *mut OakEngineNode {
unsafe {
if self.project.is_null() {
return std::ptr::null_mut();
}
let count = oakengine_project_node_count(self.project);
for i in 0..count {
let node = oakengine_project_node_at(self.project, i);
if node.is_null() {
continue;
}
if oakengine_node_identity(node) == ident {
return node;
}
oakengine_node_free(node);
}
std::ptr::null_mut()
}
}
}
impl NodeGraphDataSource for RealGraphSource {
type Node = RealNode;
type Edge = RealEdge;
fn nodes(&self) -> Vec<Self::Node> {
self.nodes.clone()
}
fn edges(&self) -> Vec<Self::Edge> {
self.edges.clone()
}
fn can_connect(&self, from: PortId, to: PortId) -> bool {
let (from_node, from_kind, _) = unpack_port(from);
let (to_node, to_kind, to_index) = unpack_port(to);
if from_kind != PortKind::Output || to_kind != PortKind::Input {
return false;
}
if from_node == to_node {
return false;
}
// The target input must exist and be free.
unsafe {
let node = self.find_node(to_node);
if node.is_null() {
return false;
}
let count = oakengine_node_input_count(node);
if to_index >= count.max(0) as u32 {
oakengine_node_free(node);
return false;
}
let id_str = read_str(|buf, size| oakengine_node_input_id(node, to_index as c_int, buf, size));
if id_str.is_empty() {
oakengine_node_free(node);
return false;
}
let cid = std::ffi::CString::new(id_str).unwrap_or_default();
let free = oakengine_node_input_is_connected(node, cid.as_ptr()) == 0;
oakengine_node_free(node);
free
}
}
}
/// Apply a node-graph edit to the facade (undoable).
///
/// # Safety
/// `project` must be a live facade project box.
pub unsafe fn apply_edit(
project: *mut OakEngineProject,
edit: &gpui::node_graph::NodeGraphEvent,
) -> Result<(), String> {
use gpui::node_graph::NodeGraphEvent;
unsafe {
let root = oakengine_project_root(project);
match edit {
NodeGraphEvent::ConnectionRequested { from, to } => {
let (from_node, from_kind, _) = unpack_port(*from);
let (to_node, to_kind, to_index) = unpack_port(*to);
if from_kind != PortKind::Output || to_kind != PortKind::Input {
if !root.is_null() {
oakengine_node_free(root);
}
return Err("connection endpoints must be output → input".into());
}
let src = find_boxed(project, from_node)?;
let dst = find_boxed(project, to_node)?;
let id_str = {
let count = oakengine_node_input_count(dst);
if to_index >= count.max(0) as u32 {
oakengine_node_free(src);
oakengine_node_free(dst);
if !root.is_null() {
oakengine_node_free(root);
}
return Err("target input out of range".into());
}
read_str(|buf, size| {
oakengine_node_input_id(dst, to_index as c_int, buf, size)
})
};
if id_str.is_empty() {
oakengine_node_free(src);
oakengine_node_free(dst);
if !root.is_null() {
oakengine_node_free(root);
}
return Err("target input missing".into());
}
let cid = std::ffi::CString::new(id_str).unwrap_or_default();
let rc = oakengine_node_connect(src, dst, cid.as_ptr());
oakengine_node_free(src);
oakengine_node_free(dst);
if !root.is_null() {
oakengine_node_free(root);
}
if rc != 0 {
return Err(format!("connect failed rc={rc}"));
}
Ok(())
}
NodeGraphEvent::DisconnectionRequested { edge } => {
let e = self::edge_for(project, edge.0)?;
let cid = std::ffi::CString::new(e.1).unwrap_or_default();
let rc = oakengine_node_disconnect_ex(e.0, cid.as_ptr(), -1);
oakengine_node_free(e.0);
if !root.is_null() {
oakengine_node_free(root);
}
if rc != 0 {
return Err(format!("disconnect failed rc={rc}"));
}
Ok(())
}
NodeGraphEvent::NodeMoveRequested { nodes, delta } => {
for node in nodes {
let boxed = find_boxed(project, node.0)?;
let mut x: f64 = 0.0;
let mut y: f64 = 0.0;
let mut expanded: c_int = 0;
oakengine_node_get_context_position(
root,
boxed,
&mut x,
&mut y,
&mut expanded,
);
let rc = oakengine_node_set_context_position(
root,
boxed,
x + f64::from(delta.x.as_f32()),
y + f64::from(delta.y.as_f32()),
);
oakengine_node_free(boxed);
if rc != 0 {
if !root.is_null() {
oakengine_node_free(root);
}
return Err(format!("move failed rc={rc}"));
}
}
if !root.is_null() {
oakengine_node_free(root);
}
Ok(())
}
NodeGraphEvent::DeleteRequested { nodes, .. } => {
for node in nodes {
let boxed = find_boxed(project, node.0)?;
let rc = oakengine_project_remove_node(project, boxed);
oakengine_node_free(boxed);
if rc != 0 {
if !root.is_null() {
oakengine_node_free(root);
}
return Err(format!("remove failed rc={rc}"));
}
}
if !root.is_null() {
oakengine_node_free(root);
}
Ok(())
}
_ => {
if !root.is_null() {
oakengine_node_free(root);
}
Ok(())
}
}
}
}
/// Boxed facade node for an identity (freed with `oakengine_node_free`).
///
/// # Safety
/// `project` must be live.
unsafe fn find_boxed(
project: *mut OakEngineProject,
ident: u64,
) -> Result<*mut OakEngineNode, String> {
unsafe {
let count = oakengine_project_node_count(project);
for i in 0..count {
let node = oakengine_project_node_at(project, i);
if node.is_null() {
continue;
}
if oakengine_node_identity(node) == ident {
return Ok(node);
}
oakengine_node_free(node);
}
Err(format!("node {ident} not found"))
}
}
/// Resolve an edge id back to `(input node, input id)`.
///
/// # Safety
/// `project` must be live.
unsafe fn edge_for(
project: *mut OakEngineProject,
edge: u64,
) -> Result<(*mut OakEngineNode, String), String> {
unsafe {
let count = oakengine_project_node_count(project);
for i in 0..count {
let node = oakengine_project_node_at(project, i);
if node.is_null() {
continue;
}
let out_count = oakengine_node_output_connection_count(node);
for j in 0..out_count {
let mut input_node: *mut OakEngineNode = std::ptr::null_mut();
let mut id_buf = [0 as c_char; 256];
let mut element: c_int = -1;
let mut hidden: c_int = 0;
if oakengine_node_output_connection_at_ex(
node,
j,
&mut input_node,
id_buf.as_mut_ptr(),
id_buf.len() as c_int,
&mut element,
&mut hidden,
) != 0
{
continue;
}
let from = oakengine_node_identity(node);
let to = if input_node.is_null() {
0
} else {
oakengine_node_identity(input_node)
};
let mut len = 0usize;
while len < id_buf.len() && id_buf[len] != 0 {
len += 1;
}
let conn_id = String::from_utf8_lossy(unsafe {
std::slice::from_raw_parts(id_buf.as_ptr() as *const u8, len)
})
.into_owned();
if !input_node.is_null() {
oakengine_node_free(input_node);
}
if self::edge_id(from, to, &conn_id).0 == edge {
// The input node was freed; re-box it.
let dst = find_boxed(project, to)?;
return Ok((dst, conn_id));
}
}
oakengine_node_free(node);
}
Err(format!("edge {edge} not found"))
}
}
+188
View File
@@ -0,0 +1,188 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! M12 P3: the real project browser.
//!
//! Builds the widget's [`ProjectEntry`] tree from the facade's folder
//! hierarchy: the project root folder's children are the top-level
//! entries, folders expand through `oakengine_folder_item_child_*`.
//! Entry ids are the nodes' stable identities (the facade identity),
//! so selection round-trips through `find_by_identity`.
use std::ffi::{c_char, c_int};
use gpui::SharedString;
use gpui_widgets::project_explorer::ProjectEntry;
use crate::oakui::ffi::{
oakengine_folder_item_child, oakengine_folder_item_child_count, oakengine_node_free,
oakengine_node_get_label, oakengine_node_get_type_id, oakengine_node_identity,
oakengine_project_node_at, oakengine_project_node_count, oakengine_project_root,
OakEngineNode, OakEngineProject,
};
/// The folder behavior's factory type id.
const TYPE_ID_FOLDER: &str = "org.olivevideoeditor.Olive.folder";
/// Two-stage string read over a facade `(buf, size)` getter.
fn read_str(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 + 1];
f(buf.as_mut_ptr(), needed as c_int + 1);
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()
}
/// Whether `node` (a live box) is a folder node.
///
/// # Safety
/// `node` must be a live box.
unsafe fn node_is_folder(node: *mut OakEngineNode) -> bool {
unsafe {
let id = read_str(|buf, size| oakengine_node_get_type_id(node, buf, size));
id == TYPE_ID_FOLDER
}
}
/// One child entry of `folder` (a live box) with its identity.
///
/// # Safety
/// `child` must be a live box; freed by the caller.
unsafe fn child_entry(child: *mut OakEngineNode) -> ProjectEntry {
unsafe {
let ident = oakengine_node_identity(child);
let label = read_str(|buf, size| oakengine_node_get_label(child, buf, size));
let is_dir = node_is_folder(child);
let mut name = label;
if name.is_empty() {
name = read_str(|buf, size| crate::oakui::ffi::oakengine_node_get_name(child, buf, size));
}
ProjectEntry::new(ident, name, is_dir)
}
}
/// The top-level entries: the project root folder's children.
///
/// # Safety
/// `project` must be a live box.
pub unsafe fn roots(project: *mut OakEngineProject) -> Vec<ProjectEntry> {
unsafe {
let mut out = Vec::new();
if project.is_null() {
return out;
}
let root = oakengine_project_root(project);
if root.is_null() {
return out;
}
out = folder_children(root);
oakengine_node_free(root);
out
}
}
/// The children of the folder whose identity is `parent_id` (empty when
/// not found).
///
/// # Safety
/// `project` must be a live box.
pub unsafe fn children(project: *mut OakEngineProject, parent_id: u64) -> Vec<ProjectEntry> {
unsafe {
let Some(node) = find_by_identity(project, parent_id) else {
return Vec::new();
};
if !node_is_folder(node) {
oakengine_node_free(node);
return Vec::new();
}
let out = folder_children(node);
oakengine_node_free(node);
out
}
}
/// The children of a live folder box.
///
/// # Safety
/// `folder` must be a live folder box.
unsafe fn folder_children(folder: *mut OakEngineNode) -> Vec<ProjectEntry> {
unsafe {
let mut out = Vec::new();
let count = oakengine_folder_item_child_count(folder);
for i in 0..count.max(0) {
let child = oakengine_folder_item_child(folder, i);
if child.is_null() {
continue;
}
out.push(child_entry(child));
oakengine_node_free(child);
}
out
}
}
/// The boxed project node with `identity` (freed with
/// [`oakengine_node_free`]), or `None`.
///
/// # Safety
/// `project` must be a live box.
pub unsafe fn find_by_identity(
project: *mut OakEngineProject,
identity: u64,
) -> Option<*mut OakEngineNode> {
unsafe {
if project.is_null() || identity == 0 {
return None;
}
let count = oakengine_project_node_count(project);
for i in 0..count.max(0) {
let node = oakengine_project_node_at(project, i);
if node.is_null() {
continue;
}
if oakengine_node_identity(node) == identity {
return Some(node);
}
oakengine_node_free(node);
}
None
}
}
/// The footage node at `index` (the project's footage list), or `None`.
///
/// # Safety
/// `project` must be a live box.
pub unsafe fn footage_node_at(
project: *mut OakEngineProject,
index: c_int,
) -> Option<*mut OakEngineNode> {
unsafe {
if project.is_null() {
return None;
}
let node = crate::oakui::ffi::oakengine_project_footage_at(project, index);
if node.is_null() {
None
} else {
Some(node)
}
}
}
+331 -187
View File
@@ -279,8 +279,11 @@ fn read_string(f: impl Fn(*mut c_char, c_int) -> c_int) -> String {
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);
// The facade's two-stage getters report the length WITHOUT the
// trailing NUL (`string_result` subtracts one); the buffer must
// carry the NUL too.
let mut buf = vec![0 as c_char; needed as usize + 1];
f(buf.as_mut_ptr(), needed as c_int + 1);
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()
@@ -515,103 +518,9 @@ fn clip_color(index: u64) -> Hsla {
}
}
/// 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<Pixels> {
point(px(0.0), px(0.0))
}
fn inputs(&self) -> Vec<Self::Port> {
Vec::new()
}
fn outputs(&self) -> Vec<Self::Port> {
Vec::new()
}
fn header_color(&self) -> Option<Hsla> {
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)
}
}
/// A node in the real node graph (M12 P2: built from the facade's
/// project-node enumeration by [`crate::oakui::nodegraph`]).
pub use crate::oakui::nodegraph::{RealEdge, RealNode, RealPort};
// ---------------------------------------------------------------------------
// The engine
@@ -634,10 +543,9 @@ pub struct RealEngine {
pub program_clock: Entity<RealClock>,
/// The timeline snapshot (rebuilt on open/edit).
tracks: Vec<RealTrack>,
/// The material-bin root entries.
bin_roots: Vec<ProjectEntry>,
/// The material-bin children of the footage folder.
bin_children: Vec<ProjectEntry>,
/// Timeline waveform cache (M12 P4), created lazily at the current
/// frame rate.
waveforms: Mutex<Option<Arc<crate::oakui::waveform::WaveformCache>>>,
/// The selected material-bin entry (demo state).
selected_item: Option<u64>,
/// The single selected timeline clip — the effect stack's target
@@ -678,6 +586,54 @@ pub struct RealEngine {
}
impl RealEngine {
/// Render one tick's worth of audio at the program playhead and queue
/// it for playback (M12 P1). Failures are silent: playback continues
/// video-only.
fn pull_audio_tick(&mut self, cx: &mut Context<Self>) {
let renderer = {
let slot = self.renderer.lock().unwrap_or_else(|e| e.into_inner());
match &*slot {
RendererSlot::Ready(handle) => RendererHandle(handle.0),
_ => return,
}
};
let fps = self.frame_rate();
let frame = self.clock_frame(Monitor::Program, cx).0;
if frame < 0 {
return;
}
// ~1/60 s of sequence per tick.
let chunk = ((fps.num as f64 / fps.den as f64) / 60.0).max(0.001) as i64;
let buf = unsafe { oakengine_renderer_render_audio(renderer.0, frame, chunk) };
if buf.is_null() {
return;
}
let rate = unsafe { oakengine_audio_sample_rate(buf) };
let channels = unsafe { oakengine_audio_channel_count(buf) };
let frames = unsafe { oakengine_audio_sample_count(buf) };
let data = unsafe { oakengine_audio_data(buf, 0) };
if rate > 0 && channels > 0 && frames > 0 && !data.is_null() {
// Packed F32 (10 = the engine's packed F32 sample format);
// layout: 1ch → mono mask, else stereo.
let layout: u64 = if channels == 1 { 0x4 } else { 0x3 };
let params =
unsafe { oakcore_audioparams_create(rate, layout, 10) };
if !params.is_null() {
unsafe {
oakengine_audio_push_to_output(
params,
data as *const c_char,
frames * i64::from(channels) * 4,
std::ptr::null_mut(),
0,
);
oakcore_audioparams_free(params);
}
}
}
unsafe { oakengine_audio_free(buf) };
}
/// Builds an engine with no project open.
pub fn new(cx: &mut Context<Self>) -> Self {
let rate = VideoFormat::hd_1080p25().rate;
@@ -692,8 +648,7 @@ impl RealEngine {
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(),
waveforms: Mutex::new(None),
selected_item: None,
selected_clip: None,
expanded_effects: BTreeSet::new(),
@@ -872,29 +827,36 @@ impl RealEngine {
Some(RendererHandle(renderer))
}
/// The material-bin footage index of the selected entry, if any. The bin
/// children are numbered `100 + index` (see [`RealEngine::rebuild_bin`]);
/// folder/project roots are not footage.
fn selected_footage_index(&self) -> Option<c_int> {
let id = self.selected_item?;
if id >= 100 {
Some((id - 100) as c_int)
} else {
None
}
}
/// The selected footage's facade node view (a boxed node handle the
/// caller frees with `oakengine_node_free`), or `None` when no footage
/// is selected or the index is out of range.
/// The selected entry's footage node (M12 P3: entry ids are the
/// nodes' stable identities), or `None` when the selection is a
/// folder or absent.
///
/// # Safety
/// The returned box is freed with `oakengine_node_free`.
fn selected_footage_node(&self) -> Option<*mut OakEngineNode> {
let project = self.project_ptr()?;
let index = self.selected_footage_index()?;
let node = unsafe { oakengine_project_footage_at(project, index) };
if node.is_null() {
None
} else {
Some(node)
let id = self.selected_item?;
unsafe {
// The identity must resolve to a footage entry (a folder or
// sequence id must not be treated as footage).
let count = unsafe { oakengine_project_footage_count(project) };
let mut is_footage = false;
for i in 0..count.max(0) {
let f = unsafe { oakengine_project_footage_at(project, i) };
if f.is_null() {
continue;
}
let matches = unsafe { oakengine_node_identity(f) } == id;
unsafe { oakengine_node_free(f) };
if matches {
is_footage = true;
break;
}
}
if !is_footage {
return None;
}
crate::oakui::projectbrowser::find_by_identity(project, id)
}
}
@@ -1042,7 +1004,7 @@ impl RealEngine {
self.sequence = Some(SequenceHandle(sequence));
self.refresh_sequence_info();
self.rebuild_timeline();
self.rebuild_bin();
cx.notify();
}
@@ -1056,8 +1018,7 @@ impl RealEngine {
drop(self.project.take());
self.cpu_frame_cache.lock().unwrap().clear();
self.tracks.clear();
self.bin_roots.clear();
self.bin_children.clear();
self.sequence_info = None;
self.modified = false;
self.project_info = Project {
@@ -1135,6 +1096,17 @@ impl RealEngine {
}
}
self.tracks = out;
// M12 P4: refresh the audio waveforms for the visible audio clips.
if let Some(cache) = self.waveform_cache() {
for track in &self.tracks {
if track.kind != TrackKind::Audio {
continue;
}
for clip in &track.clips {
self.refresh_clip_waveform(cache.clone(), clip);
}
}
}
}
/// Snapshots one track (with its clips) from the facade.
@@ -1216,30 +1188,49 @@ impl RealEngine {
}
/// 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 {
/// The waveform cache (created lazily at the current frame rate).
fn waveform_cache(&self) -> Option<Arc<crate::oakui::waveform::WaveformCache>> {
let mut slot = self.waveforms.lock().unwrap_or_else(|e| e.into_inner());
if slot.is_none() {
let fps = self
.sequence_info
.as_ref()
.map(|s| s.format.rate.num as f32 / s.format.rate.den.max(1) as f32)
.unwrap_or(25.0);
*slot = Some(crate::oakui::waveform::WaveformCache::new(fps));
}
slot.clone()
}
/// Extract (or reuse) the waveform of one audio clip.
fn refresh_clip_waveform(
&self,
cache: Arc<crate::oakui::waveform::WaveformCache>,
clip: &RealClip,
) {
let Some(seq) = self.seq_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));
let clip_ptr = unsafe {
oakengine_sequence_clip_at(
seq,
TRACK_TYPE_AUDIO,
clip.track_index as c_int,
clip.clip_index as c_int,
)
};
if clip_ptr.is_null() {
return;
}
self.bin_children = children;
let filename = read_string(|buf, size| unsafe {
oakengine_clip_get_media_filename(clip_ptr, buf, size)
});
unsafe { free_box(clip_ptr) };
if filename.is_empty() {
return;
}
let duration_frames = (clip.range.end.0 - clip.range.start.0).max(1);
cache.refresh(clip.id.0, &filename, duration_frames);
}
/// Looks up the snapshot clip coordinates by `ClipId`.
@@ -1506,6 +1497,11 @@ impl EngineGateway for RealEngine {
}
self.mirror_program_playhead(cx);
self.meter_phase = self.meter_phase.wrapping_add(1);
// M12 P1: while the program plays, pull the audio for the
// current playhead window and queue it for the output device.
if self.program_playing {
self.pull_audio_tick(cx);
}
cx.notify();
}
}
@@ -1558,30 +1554,45 @@ impl NodeGraphDataSource for RealEngine {
type Edge = RealEdge;
fn nodes(&self) -> Vec<Self::Node> {
// The node-graph surface of the facade is not bound in this
// increment; the canvas shows empty.
Vec::new()
// SAFETY: the project box is live while the engine holds it.
unsafe {
crate::oakui::nodegraph::build_graph(self.project_ptr().unwrap_or(std::ptr::null_mut())).0
}
}
fn edges(&self) -> Vec<Self::Edge> {
Vec::new()
// SAFETY: the project box is live while the engine holds it.
unsafe {
crate::oakui::nodegraph::build_graph(self.project_ptr().unwrap_or(std::ptr::null_mut())).1
}
}
fn can_connect(&self, _from: PortId, _to: PortId) -> bool {
false
fn can_connect(&self, from: PortId, to: PortId) -> bool {
// SAFETY: the project box is live while the engine holds it.
let src = unsafe {
crate::oakui::nodegraph::RealGraphSource::snapshot(
self.project_ptr().unwrap_or(std::ptr::null_mut()),
)
};
src.can_connect(from, to)
}
}
impl ProjectDataSource for RealEngine {
fn roots(&self) -> Vec<ProjectEntry> {
self.bin_roots.clone()
// SAFETY: the project box is live while the engine holds it.
unsafe {
crate::oakui::projectbrowser::roots(self.project_ptr().unwrap_or(std::ptr::null_mut()))
}
}
fn children(&self, parent_id: u64) -> Vec<ProjectEntry> {
if parent_id == 1 {
self.bin_children.clone()
} else {
Vec::new()
// SAFETY: the project box is live while the engine holds it.
unsafe {
crate::oakui::projectbrowser::children(
self.project_ptr().unwrap_or(std::ptr::null_mut()),
parent_id,
)
}
}
}
@@ -1883,8 +1894,20 @@ impl AppEngine for RealEngine {
match event {
NodeGraphEvent::NodeMovePreview { .. }
| NodeGraphEvent::ViewChanged { .. }
| NodeGraphEvent::BackgroundClicked { .. } => {}
_ => println!("[real engine] node-graph request not applied (not bound yet)"),
| NodeGraphEvent::BackgroundClicked { .. }
| NodeGraphEvent::SelectionChanged { .. } => {}
_ => {
// SAFETY: the project box is live while the engine holds it.
let result = unsafe {
crate::oakui::nodegraph::apply_edit(
self.project_ptr().unwrap_or(std::ptr::null_mut()),
event,
)
};
if let Err(e) = result {
println!("[real engine] node-graph request rejected: {e}");
}
}
}
cx.notify();
}
@@ -1940,32 +1963,36 @@ impl AppEngine for RealEngine {
new_track,
new_start,
} => {
// `oakengine_sequence_move_clip` moves the clip on its own
// track (the capi passes the source track straight through to
// the place command); a cross-track drag is not expressible
// through that signature.
// M12 P4: cross-track moves go through the dedicated
// facade export (one undoable entry); same-track moves
// use the classic export.
let Some((track_type, track_index, clip_index)) = self.clip_coords(*clip) else {
return;
};
let Some(seq) = self.seq_ptr() else {
return;
};
if *new_track as c_int != track_index as c_int {
println!(
"[real engine] move clip ({track_type:?},{track_index},{clip_index}): \
cross-track moves are not supported by the facade"
);
cx.notify();
return;
}
let rc = unsafe {
oakengine_sequence_move_clip(
seq,
Self::track_type_of(track_type),
track_index as c_int,
clip_index as c_int,
new_start.0,
)
let rc = if *new_track as c_int != track_index as c_int {
unsafe {
oakengine_sequence_move_clip_to_track(
seq,
Self::track_type_of(track_type),
track_index as c_int,
clip_index as c_int,
*new_track as c_int,
new_start.0,
)
}
} else {
unsafe {
oakengine_sequence_move_clip(
seq,
Self::track_type_of(track_type),
track_index as c_int,
clip_index as c_int,
new_start.0,
)
}
};
self.apply_edit(rc, "move clip", cx);
}
@@ -2569,6 +2596,13 @@ pub fn renderer_backends() -> Vec<&'static str> {
mod tests {
use super::*;
/// Serializes the media/FFmpeg-heavy tests: the engine dylib's static
/// FFmpeg is not thread-safe against concurrent decode sessions.
fn media_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
#[test]
fn project_format_dispatches_by_extension() {
assert_eq!(
@@ -2696,6 +2730,7 @@ mod tests {
/// and sane sample values.
#[test]
fn real_render_frame_e2e() {
let _media = media_lock();
if !RealEngine::ensure_render_manager() {
panic!("the render manager failed to start");
}
@@ -2745,10 +2780,77 @@ mod tests {
);
nonzero += px.iter().filter(|&&v| v != 0.0).count();
}
// Document the current empty-sequence behavior: a transparent-black
// program. When footage input is bound, extend this to assert
// non-black content.
assert_eq!(nonzero, 0, "an empty sequence renders transparent black");
unsafe { oakengine_frame_free(frame) };
// M12 P0: with a clip of real media on the video track, the same
// renderer must produce the decoded footage (known content, non
// black). The media is program-generated.
let media = std::env::temp_dir().join(format!(
"oakapp_e2e_media_{}.mp4",
std::process::id()
));
let media_c = CString::new(media.to_string_lossy().into_owned()).unwrap();
assert_eq!(
unsafe { oakengine_testmedia_write_clip(media_c.as_ptr(), 64, 64, 10, 10) },
0,
"generate e2e test media"
);
let footage = unsafe { oakengine_project_import_footage(project, media_c.as_ptr()) };
assert!(!footage.is_null(), "import_footage must succeed");
assert_eq!(unsafe { oakengine_project_footage_count(project) }, 1);
assert_eq!(
unsafe {
oakengine_sequence_add_track(sequence, TRACK_TYPE_VIDEO)
},
0
);
// Clip covering [0, 10) frames at 25 fps.
let clip = unsafe {
oakengine_sequence_add_footage_clip_ex(
sequence,
footage,
TRACK_TYPE_VIDEO,
0,
0,
10,
0,
)
};
if clip.is_null() {
let msg = read_string(|buf, size| unsafe {
oakengine_sequence_last_error(buf, size)
});
panic!("add_footage_clip failed: {msg}");
}
unsafe { oakengine_footage_free(footage) };
let frame = unsafe { oakengine_renderer_render_frame(renderer, 0) };
assert!(!frame.is_null(), "render_frame with a clip must produce a frame");
let data = unsafe { oakengine_frame_data(frame) } as *const f32;
let mut nonzero = 0usize;
for &(x, y) in &[(0usize, 0usize), (240, 135), (479, 269)] {
let base = y * stride + x * 4;
let px = unsafe { std::slice::from_raw_parts(data.add(base), 4) };
assert!(
px.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v)),
"samples in range: {px:?}"
);
nonzero += px.iter().filter(|&&v| v != 0.0).count();
}
assert!(
nonzero > 0,
"the sequence with a footage clip must render non-black pixels"
);
// Known content: the test clip's left half is red on frame 0 —
// the center-left pixel must be red-dominant.
let base = 135 * stride + 120 * 4;
let px = unsafe { std::slice::from_raw_parts(data.add(base), 4) };
assert!(
px[0] > 0.5 && px[1] < 0.4 && px[2] < 0.4,
"center-left pixel stays red from the decoded clip: {px:?}"
);
unsafe { oakengine_frame_free(frame) };
let _ = std::fs::remove_file(&media);
// A second frame at a later timestamp renders too.
let frame2 = unsafe { oakengine_renderer_render_frame(renderer, 30) };
@@ -2765,9 +2867,51 @@ mod tests {
}
.is_null());
unsafe { oakengine_frame_free(frame) };
unsafe { oakengine_renderer_free(renderer) };
unsafe { free_box(sequence) };
unsafe { oakengine_project_free(project) };
}
/// M12 P3 acceptance: importing a media file makes it appear in the
/// project browser's real folder tree.
#[test]
fn real_project_browser_lists_imported_footage() {
let _media = media_lock();
let project = unsafe { oakengine_project_create() };
assert!(!project.is_null());
assert_eq!(unsafe { oakengine_project_new(project) }, 0);
// Generate a real media file through the facade, import it.
let media = std::env::temp_dir().join(format!(
"oakapp_browser_{}.mp4",
std::process::id()
));
let cpath = CString::new(media.to_string_lossy().into_owned()).unwrap();
assert_eq!(
unsafe { oakengine_testmedia_write_clip(cpath.as_ptr(), 64, 64, 10, 10) },
0
);
let footage = unsafe { oakengine_project_import_footage(project, cpath.as_ptr()) };
assert!(!footage.is_null(), "import must succeed");
unsafe { oakengine_footage_free(footage) };
// The project browser (ProjectDataSource) must list it.
let roots = unsafe { crate::oakui::projectbrowser::roots(project) };
assert!(!roots.is_empty(), "the root folder lists entries");
let media_name = media.file_name().unwrap().to_string_lossy().into_owned();
let entry = roots
.iter()
.find(|e| e.name.as_ref() == media_name)
.expect("the imported footage is listed by its file name");
assert!(!entry.is_dir, "footage entries are files");
assert!(entry.id != 0, "entry id is the node identity");
// Double-click behavior: the entry id resolves back to the footage
// node through `find_by_identity`.
let node = unsafe { crate::oakui::projectbrowser::find_by_identity(project, entry.id) };
assert!(node.is_some(), "selection resolves to a node");
unsafe { oakengine_node_free(node.unwrap()) };
let _ = std::fs::remove_file(&media);
}
}
+206
View File
@@ -0,0 +1,206 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! M12 P4: timeline audio waveforms.
//!
//! The [`WaveformCache`] holds per-clip min/max peak data extracted
//! through `oakaudio_waveform_extract` (the real FFmpeg-backed
//! extraction); the engine refreshes it when the timeline rebuilds. The
//! [`OakClipDecorator`] draws the peaks into the timeline's clip body.
//!
//! The extraction is keyed by `(clip id, filename)` and cached for the
//! engine lifetime; the plan's disk cache is a later refinement (the
//! extractor itself is the real implementation).
use std::collections::HashMap;
use std::ffi::{c_char, c_int};
use std::sync::{Arc, Mutex};
use gpui::timeline::clip::ClipDecorator;
use gpui::timeline::ClipId;
use gpui::timeline::FrameRange;
use gpui::{Bounds, Hsla, Pixels, Window};
/// One min/max amplitude pair (mirror of the oakaudio waveform C ABI
/// `MinMax`: two f32s).
#[repr(C)]
#[derive(Debug, Clone, Copy, Default)]
pub struct MinMax {
/// Minimum amplitude in the window.
pub min: f32,
/// Maximum amplitude in the window.
pub max: f32,
}
/// The cached waveform of one clip.
#[derive(Debug, Clone)]
pub struct ClipWaveform {
/// Per-point min/max (first channel).
pub peaks: Vec<MinMax>,
/// Channel count of the media.
pub channel_count: i32,
/// Media sample rate.
pub sample_rate: i32,
/// Samples per point at extraction time.
pub samples_per_point: i32,
/// The clip's length in timeline frames.
pub duration_frames: i64,
}
/// Per-clip waveform cache (thread-safe; filled by the engine).
pub struct WaveformCache {
/// Peaks by clip id.
map: Mutex<HashMap<u64, Arc<ClipWaveform>>>,
/// Timeline frame rate (frames per second) for frame→time mapping.
fps: f32,
}
impl WaveformCache {
/// Create an empty cache at the given frame rate.
pub fn new(fps: f32) -> Arc<WaveformCache> {
Arc::new(WaveformCache {
map: Mutex::new(HashMap::new()),
fps: if fps > 0.0 { fps } else { 25.0 },
})
}
/// The cached waveform of `clip`, if extracted.
pub fn get(&self, clip: u64) -> Option<Arc<ClipWaveform>> {
self.map.lock().unwrap_or_else(|e| e.into_inner()).get(&clip).cloned()
}
/// Ensure `clip` (with media `filename`, `duration_frames` long) has a
/// waveform, extracting it through the real codec-backed extractor
/// when missing. Failures (missing media, no audio stream) leave the
/// clip silent (no waveform drawn).
pub fn refresh(&self, clip: u64, filename: &str, duration_frames: i64) {
eprintln!("DBG-WFC: refresh clip={clip}");
if self.get(clip).is_some() {
eprintln!("DBG-WFC: cache hit");
return;
}
let Some(waveform) = extract(filename, duration_frames, self.fps) else {
return;
};
eprintln!("DBG-WFC: inserting");
let mut map = self.map.lock().unwrap_or_else(|e| e.into_inner());
if !map.contains_key(&clip) {
map.insert(clip, Arc::new(waveform));
}
}
}
/// Extract a clip's waveform (first channel) via the oakaudio C ABI.
fn extract(filename: &str, duration_frames: i64, _fps: f32) -> Option<ClipWaveform> {
let cname = std::ffi::CString::new(filename).ok()?;
const SAMPLES_PER_POINT: c_int = 256;
unsafe {
let mut channels: c_int = 0;
let needed = crate::oakui::ffi::oakaudio_waveform_extract(
cname.as_ptr(),
0, // audio-stream index (probe numbering)
SAMPLES_PER_POINT,
std::ptr::null_mut(),
0,
&mut channels,
);
if needed <= 0 || channels <= 0 {
return None;
}
let mut peaks = vec![MinMax::default(); needed as usize];
let rc = crate::oakui::ffi::oakaudio_waveform_extract(
cname.as_ptr(),
0,
SAMPLES_PER_POINT,
peaks.as_mut_ptr(),
needed,
&mut channels,
);
if rc < 0 {
return None;
}
let count = rc.min(needed) as usize;
peaks.truncate(count);
Some(ClipWaveform {
peaks,
channel_count: channels.max(1),
sample_rate: 48000,
samples_per_point: SAMPLES_PER_POINT,
duration_frames,
})
}
}
/// The timeline's clip decorator: draws extracted waveforms into audio
/// clips (M12 P4). Reads the engine-populated cache.
pub struct OakClipDecorator {
/// The waveform cache.
pub cache: Arc<WaveformCache>,
}
impl ClipDecorator for OakClipDecorator {
fn paint_waveform(
&mut self,
window: &mut Window,
clip: ClipId,
_visible_range: FrameRange,
bounds: Bounds<Pixels>,
) {
let Some(waveform) = self.cache.get(clip.0) else {
return;
};
if waveform.peaks.is_empty() || bounds.size.width.as_f32() <= 0.0 {
return;
}
let width = bounds.size.width.as_f32() as usize;
if width == 0 {
return;
}
let height = bounds.size.height.as_f32();
let mid = bounds.origin.y.as_f32() + height / 2.0;
let half = (height / 2.0).max(1.0) * 0.9;
// Media seconds covered by one waveform point.
let secs_per_point =
waveform.samples_per_point.max(1) as f32 / waveform.sample_rate.max(1) as f32;
let frames_per_point = secs_per_point * self.cache.fps;
// Sample one peak per horizontal pixel column.
let color = Hsla {
h: 0.55,
s: 0.5,
l: 0.55,
a: 0.85,
};
for x in 0..width {
let t = x as f32 / width as f32;
let frame = (t * waveform.duration_frames as f32) as usize;
let point_index = (frame as f32 / frames_per_point.max(0.0001)) as usize;
let point_index = point_index.min(waveform.peaks.len() - 1);
let p = waveform.peaks[point_index];
let y_top = mid - p.max.max(0.0) * half;
let y_bot = mid - p.min.min(0.0) * half;
let x_px = bounds.origin.x.as_f32() + x as f32;
window.paint_quad(gpui::fill(
gpui::Bounds::from_corners(
gpui::point(Pixels::from(x_px), Pixels::from(y_top)),
gpui::point(Pixels::from(x_px + 1.0), Pixels::from(y_bot.max(y_top))),
),
color,
));
}
}
}