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:
+156
-71
@@ -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) = ¤t_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
@@ -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", "渲染后端"),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user