feat(engine): clip move, clip effect_input, mandatory static FFmpeg

- oakengine_sequence_move_clip implemented for real (oaktimeline
  TrackMoveBlockCommand; fixes the graph-ownership/gap-anchor/ripple
  trim bugs the stub was hiding); same-track via the frozen C ABI,
  cross-track supported by the module command
- oaknode clip blocks now declare a tex_in texture input and set
  effect_input to it, so timeline clips can host effect chains; facade
  test covers effect insert/remove on a real clip
- oakffmpeg-link: FFMPEG_DIR is now mandatory with a clear panic (a
  Homebrew upgrade left the system ffmpeg .pc pointing at a deleted
  dav1d Cellar path, breaking links); reads a git-ignored workspace
  .env for IDEs that cannot inject env vars (RustRover); links the C++
  stdlib for C++ codec libs (svt-av1)
- oakengine re-exports oaknode so tests share one crate instance;
  it_node uses the direct instance's value type where it calls the
  module FFI (the --workspace dev-dependency feature split builds
  oaknode twice)
This commit is contained in:
2026-08-11 23:04:48 +08:00
parent a209f63d52
commit 18ff60f147
351 changed files with 32880 additions and 15939 deletions
+47 -49
View File
@@ -47,12 +47,12 @@ use gpui::{
WindowBounds, WindowOptions,
};
use gpui_widgets::audio_meter::AudioLevelMeter;
use gpui_widgets::viewer::PlaybackClock;
use gpui_widgets::dialog::file_dialog::FileDialogContent;
use gpui_widgets::dialog::progress::{ProgressContent, progress_dialog};
use gpui_widgets::dialog::progress::{progress_dialog, ProgressContent};
use gpui_widgets::dialog::{DialogButton, Modal, ModalEvent, ModalOptions};
use gpui_widgets::menu::{Menu, MenuBar, MenuBarEntry, MenuBarEvent, MenuItem};
use gpui_widgets::theme::{apply_theme, OakTheme};
use gpui_widgets::viewer::PlaybackClock;
use crate::dialogs::{ExportDialogContent, PreferencesContent};
use crate::oakui::{AppEngine, ExportSession, MockEngine, Monitor, RealEngine};
@@ -268,11 +268,7 @@ pub struct OakApp<E: AppEngine> {
impl<E: AppEngine> OakApp<E> {
/// Builds the whole shell. `initial_path` (a CLI argument) is opened
/// after the layout is up.
pub fn new(
window: &mut Window,
initial_path: Option<PathBuf>,
cx: &mut Context<Self>,
) -> Self {
pub fn new(window: &mut Window, initial_path: Option<PathBuf>, cx: &mut Context<Self>) -> Self {
apply_theme(cx, &OakTheme::olive_dark());
crate::oakui::icons::init(cx);
@@ -400,20 +396,15 @@ impl<E: AppEngine> OakApp<E> {
// `SelectionChanged` carries no payload — the selection is read from
// the view and forwarded to the engine so the inspector's effect
// stack can target the selected clip.
cx.subscribe(
&timeline,
|this, timeline, event: &TimelineEvent, cx| {
if matches!(event, TimelineEvent::SelectionChanged) {
let clips: Vec<ClipId> =
timeline.read(cx).selection().iter().copied().collect();
this.engine.update(cx, |engine, cx| {
engine.set_selected_clips(clips, cx)
});
}
cx.subscribe(&timeline, |this, timeline, event: &TimelineEvent, cx| {
if matches!(event, TimelineEvent::SelectionChanged) {
let clips: Vec<ClipId> = timeline.read(cx).selection().iter().copied().collect();
this.engine
.update(cx, |engine, cx| engine.apply_timeline_event(event, cx));
},
)
.update(cx, |engine, cx| engine.set_selected_clips(clips, cx));
}
this.engine
.update(cx, |engine, cx| engine.apply_timeline_event(event, cx));
})
.detach();
// --- tick loop -----------------------------------------------------
@@ -480,7 +471,9 @@ impl<E: AppEngine> OakApp<E> {
OPEN_PROJECT => self.open_file_dialog(FileAction::Open, cx),
SAVE => self.save_project(None, cx),
SAVE_AS => self.open_file_dialog(FileAction::SaveAs, cx),
CLOSE => self.engine.update(cx, |engine, cx| engine.close_project(cx)),
CLOSE => self
.engine
.update(cx, |engine, cx| engine.close_project(cx)),
EXPORT => self.open_export_dialog(cx),
QUIT => cx.quit(),
// --- Edit ------------------------------------------------------
@@ -547,9 +540,9 @@ impl<E: AppEngine> OakApp<E> {
.update(cx, |engine, cx| engine.add_track(kind, cx));
}
REMOVE_TRACK => self.remove_selected_track(cx),
SPLIT_AT_PLAYHEAD => {
self.engine.update(cx, |engine, cx| engine.split_at_playhead(cx))
}
SPLIT_AT_PLAYHEAD => self
.engine
.update(cx, |engine, cx| engine.split_at_playhead(cx)),
// --- Window ----------------------------------------------------
FOCUS_PROJECT => self.focus_panel(PROJECT, cx),
FOCUS_SOURCE_VIEWER => self.focus_panel(SOURCE_VIEWER, cx),
@@ -575,13 +568,8 @@ impl<E: AppEngine> OakApp<E> {
/// Deletes the timeline's selected clips (ripple or gap) through the
/// engine's edit commands.
fn delete_timeline_selection(&mut self, ripple: bool, cx: &mut Context<Self>) {
let ids: Vec<gpui::timeline::ClipId> = self
.timeline
.read(cx)
.selection()
.iter()
.copied()
.collect();
let ids: Vec<gpui::timeline::ClipId> =
self.timeline.read(cx).selection().iter().copied().collect();
if ids.is_empty() {
println!("[timeline] delete: nothing selected");
return;
@@ -692,10 +680,7 @@ impl<E: AppEngine> OakApp<E> {
/// Opens the file open / save-as dialog.
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::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,
@@ -712,7 +697,9 @@ impl<E: AppEngine> OakApp<E> {
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));
content.update(app, |content, cx| {
content.set_path(path.to_string_lossy().into_owned(), cx)
});
}
}
ModalState::FileDialog {
@@ -983,21 +970,31 @@ fn make_menus(dark: bool) -> Vec<MenuBarEntry> {
MenuItem::new(NEW_PROJECT, tr("menu.file.new_project")).with_shortcut("⌘N"),
MenuItem::new(OPEN_PROJECT, tr("menu.file.open_project")).with_shortcut("⌘O"),
MenuItem::new(SAVE, tr("menu.file.save")).with_shortcut("⌘S"),
MenuItem::new(SAVE_AS, tr("menu.file.save_as")).with_shortcut("⇧⌘S").separated(),
MenuItem::new(SAVE_AS, tr("menu.file.save_as"))
.with_shortcut("⇧⌘S")
.separated(),
MenuItem::new(CLOSE, tr("menu.file.close")),
MenuItem::new(EXPORT, tr("menu.file.export")).with_shortcut("⌘E").separated(),
MenuItem::new(QUIT, tr("menu.file.quit")).with_shortcut("⌘Q").separated(),
MenuItem::new(EXPORT, tr("menu.file.export"))
.with_shortcut("⌘E")
.separated(),
MenuItem::new(QUIT, tr("menu.file.quit"))
.with_shortcut("⌘Q")
.separated(),
]),
),
MenuBarEntry::new(
tr("menu.edit"),
Menu::new(vec![
MenuItem::new(UNDO, tr("menu.edit.undo")).with_shortcut("⌘Z"),
MenuItem::new(REDO, tr("menu.edit.redo")).with_shortcut("⇧⌘Z").separated(),
MenuItem::new(REDO, tr("menu.edit.redo"))
.with_shortcut("⇧⌘Z")
.separated(),
MenuItem::new(CUT, tr("menu.edit.cut")).with_shortcut("⌘X"),
MenuItem::new(COPY, tr("menu.edit.copy")).with_shortcut("⌘C"),
MenuItem::new(PASTE, tr("menu.edit.paste")).with_shortcut("⌘V"),
MenuItem::new(DELETE, tr("menu.edit.delete")).with_shortcut("⌫").separated(),
MenuItem::new(DELETE, tr("menu.edit.delete"))
.with_shortcut("⌫")
.separated(),
MenuItem::new(RIPPLE_DELETE, tr("menu.edit.ripple_delete")),
]),
),
@@ -1149,7 +1146,7 @@ fn run_with<E: AppEngine>(args: AppArgs) {
#[cfg(test)]
mod tests {
use super::*;
use gpui::{TestAppContext, px, size};
use gpui::{px, size, TestAppContext};
/// The 视图/View menu carries a 语言/Language submenu whose items are
/// labeled in their own language and whose checkmark follows the active
@@ -1288,7 +1285,11 @@ mod tests {
// The same ids exist in the zh-CN menu bar.
crate::i18n::set_language(crate::i18n::Language::ZhCN);
let file = entry("文件(F)");
assert!(file.menu.items.iter().any(|item| item.id == menu_ids::SAVE_AS));
assert!(file
.menu
.items
.iter()
.any(|item| item.id == menu_ids::SAVE_AS));
}
/// Opening 视图 → Preferences… must not crash: the dialog content and the
@@ -1306,9 +1307,7 @@ mod tests {
cx.run_until_parked();
let root = window.root(cx).expect("app root");
cx.update(|app| {
root.update(app, |app, cx| app.on_menu(menu_ids::PREFERENCES, cx))
});
cx.update(|app| root.update(app, |app, cx| app.on_menu(menu_ids::PREFERENCES, cx)));
cx.run_until_parked();
// Force a draw so render-time panics in the dialog content surface.
cx.update_window(window.into(), |_root, window, cx| {
@@ -1316,9 +1315,8 @@ mod tests {
})
.expect("window is still open");
let has_modal = cx.read(|app| {
matches!(root.read(app).modal, ModalState::Preferences { .. })
});
let has_modal =
cx.read(|app| matches!(root.read(app).modal, ModalState::Preferences { .. }));
assert!(
has_modal,
"preferences modal should be shown after the menu action"
+21 -10
View File
@@ -24,8 +24,8 @@
use gpui::colors::DefaultColors;
use gpui::prelude::*;
use gpui::{App, Context, Entity, Render, SharedString, Window, div};
use gpui_elements::editable_text::{EditableTextState, StringStorage, text_input};
use gpui::{div, App, Context, Entity, Render, SharedString, Window};
use gpui_elements::editable_text::{text_input, EditableTextState, StringStorage};
use gpui_widgets::combo_box::{ComboBox, ComboBoxEvent, ComboBoxOption};
use crate::i18n;
@@ -77,7 +77,9 @@ impl PreferencesContent {
let _ = cx;
})
.detach();
backend.update(cx, |combo, cx| combo.set_selected(Some(backend_selected), cx));
backend.update(cx, |combo, cx| {
combo.set_selected(Some(backend_selected), cx)
});
let language_options = vec![
ComboBoxOption::new(0, "English (en-US)"),
@@ -102,7 +104,9 @@ impl PreferencesContent {
let _ = cx;
})
.detach();
language.update(cx, |combo, cx| combo.set_selected(Some(language_selected), cx));
language.update(cx, |combo, cx| {
combo.set_selected(Some(language_selected), cx)
});
Self {
backend,
@@ -201,7 +205,11 @@ impl Render for PathField {
.bg(colors.background)
.px_2()
.py_1()
.child(text_input("gpui-widgets-export-path").state(weak).accepts_input(true))
.child(
text_input("gpui-widgets-export-path")
.state(weak)
.accepts_input(true),
)
}
}
@@ -225,9 +233,7 @@ impl ExportDialogContent {
let options = formats
.iter()
.enumerate()
.map(|(i, (_, name, ext))| {
ComboBoxOption::new(i, format!("{name} (.{ext})"))
})
.map(|(i, (_, name, ext))| ComboBoxOption::new(i, format!("{name} (.{ext})")))
.collect();
let format = cx.new(|cx| {
ComboBox::new(3, options, window, cx)
@@ -281,7 +287,8 @@ impl ExportDialogContent {
/// Pre-fills the output path.
pub fn set_path(&mut self, path: impl Into<SharedString>, cx: &mut Context<Self>) {
let path = path.into();
self.path.update(cx, |content, cx| content.set_path(path, cx));
self.path
.update(cx, |content, cx| content.set_path(path, cx));
cx.notify();
}
}
@@ -299,7 +306,11 @@ impl Render for ExportDialogContent {
i18n::tr("export.format").into(),
self.format.clone(),
))
.child(form_row(&colors, i18n::tr("export.path").into(), self.path.clone()))
.child(form_row(
&colors,
i18n::tr("export.path").into(),
self.path.clone(),
))
.child(
div()
.text_color(colors.disabled)
+22 -17
View File
@@ -83,10 +83,13 @@ pub fn language() -> Language {
/// Switches the active language live and persists the choice through the
/// oakengine config C ABI.
pub fn set_language(language: Language) {
CURRENT.store(match language {
Language::EnUs => 0,
Language::ZhCN => 1,
}, Ordering::Relaxed);
CURRENT.store(
match language {
Language::EnUs => 0,
Language::ZhCN => 1,
},
Ordering::Relaxed,
);
persist_language(language);
sync_widgets();
}
@@ -150,8 +153,7 @@ pub fn sync_widgets() {
/// Looks `key` up in the en-US table.
fn en(key: &'static str) -> &'static str {
EN
.iter()
EN.iter()
.find(|(k, _)| *k == key)
.map(|(_, v)| *v)
.unwrap_or(key)
@@ -159,10 +161,7 @@ fn en(key: &'static str) -> &'static str {
/// Looks `key` up in the zh-CN table.
fn zh(key: &'static str) -> Option<&'static str> {
ZH
.iter()
.find(|(k, _)| *k == key)
.map(|(_, v)| *v)
ZH.iter().find(|(k, _)| *k == key).map(|(_, v)| *v)
}
/// The en-US table. Every key must also exist in [`ZH`]; [`tr`] tolerates
@@ -447,13 +446,19 @@ const ZH: &[(&str, &str)] = &[
("preferences.backend.placeholder", "选择一个后端…"),
("preferences.language", "语言"),
("preferences.language.placeholder", "选择语言…"),
("preferences.hint", "渲染后端在下次启动渲染工作进程时生效;语言立即切换。"),
(
"preferences.hint",
"渲染后端在下次启动渲染工作进程时生效;语言立即切换。",
),
("export.title", "导出序列"),
("export.format", "格式"),
("export.format.placeholder", "选择格式…"),
("export.path", "输出路径"),
("export.run", "导出"),
("export.hint", "序列通过 oaktask 导出路径导出;进度显示在对话框中。"),
(
"export.hint",
"序列通过 oaktask 导出路径导出;进度显示在对话框中。",
),
("export.progress.title", "正在导出"),
("export.progress.label", "正在渲染帧…"),
];
@@ -495,10 +500,7 @@ mod tests {
.iter()
.find(|(k, _)| *k == *key)
.unwrap_or_else(|| panic!("zh-CN table is missing key {key}"));
assert!(
!zh_value.1.is_empty(),
"empty zh-CN value for {key}"
);
assert!(!zh_value.1.is_empty(), "empty zh-CN value for {key}");
}
}
@@ -561,7 +563,10 @@ mod tests {
#[test]
fn tr_never_panics_on_missing_key() {
let _guard = lang_lock().lock().unwrap();
for (language, sample) in [(Language::EnUs, "missing.key"), (Language::ZhCN, "missing.key")] {
for (language, sample) in [
(Language::EnUs, "missing.key"),
(Language::ZhCN, "missing.key"),
] {
set_language(language);
assert_eq!(tr(sample), sample);
}
+8 -2
View File
@@ -226,7 +226,12 @@ pub trait AppEngine:
/// [`EffectStackDataSource::effects`] (0 = closest to the source);
/// the panel passes the position carried by the `AddRequested` event.
/// Returns a user-facing error message on failure. Default: unsupported.
fn add_effect(&mut self, index: usize, type_id: &str, cx: &mut Context<Self>) -> Result<(), String> {
fn add_effect(
&mut self,
index: usize,
type_id: &str,
cx: &mut Context<Self>,
) -> Result<(), String> {
let _ = (index, type_id, cx);
Err("add effect not supported".into())
}
@@ -276,7 +281,8 @@ pub trait AppEngine:
/// Saves the project to `path` (or its own filename when `None`). The
/// format is dispatched by extension like [`open_project_path`]
/// (AppEngine::open_project_path).
fn save_project(&mut self, path: Option<PathBuf>, cx: &mut Context<Self>) -> Result<(), String>;
fn save_project(&mut self, path: Option<PathBuf>, cx: &mut Context<Self>)
-> Result<(), String>;
/// Closes the current project, leaving the app with no sequence.
fn close_project(&mut self, cx: &mut Context<Self>);
+34 -14
View File
@@ -222,7 +222,11 @@ unsafe extern "C" {
/// `oakengine_encoding_format_count` — number of export formats.
pub fn oakengine_encoding_format_count() -> c_int;
/// `oakengine_encoding_format_name` (buf/size; -1 invalid).
pub fn oakengine_encoding_format_name(format: c_int, buf: *mut c_char, buf_size: c_int) -> c_int;
pub fn oakengine_encoding_format_name(
format: c_int,
buf: *mut c_char,
buf_size: c_int,
) -> c_int;
/// `oakengine_encoding_format_extension` (buf/size).
pub fn oakengine_encoding_format_extension(
format: c_int,
@@ -273,7 +277,11 @@ unsafe extern "C" {
/// `oakengine_config_get_string` — read a config string; a missing key
/// reads as an empty string.
pub fn oakengine_config_get_string(key: *const c_char, buf: *mut c_char, buf_size: c_int) -> c_int;
pub fn oakengine_config_get_string(
key: *const c_char,
buf: *mut c_char,
buf_size: c_int,
) -> c_int;
/// `oakengine_config_set_string` — write a string value.
pub fn oakengine_config_set_string(key: *const c_char, value: *const c_char) -> c_int;
@@ -336,14 +344,21 @@ unsafe extern "C" {
index: c_int,
) -> *mut OakEngineSequence;
/// `oakengine_project_set_filename`.
pub fn oakengine_project_set_filename(self_: *mut OakEngineProject, path: *const c_char) -> c_int;
pub fn oakengine_project_set_filename(
self_: *mut OakEngineProject,
path: *const c_char,
) -> c_int;
/// `oakengine_project_node_count` — parseable content check.
pub fn oakengine_project_node_count(self_: *const OakEngineProject) -> c_int;
// -- oakengine::task --
/// `oakengine_task_error` (buf/size).
pub fn oakengine_task_error(task: *mut OakEngineTask, buf: *mut c_char, buf_size: c_int) -> c_int;
pub fn oakengine_task_error(
task: *mut OakEngineTask,
buf: *mut c_char,
buf_size: c_int,
) -> c_int;
/// `oakengine_task_cancel` — set the task's cancel atom.
pub fn oakengine_task_cancel(task: *mut OakEngineTask) -> c_int;
/// `oakengine_task_start_sync` — run the task to completion.
@@ -379,7 +394,10 @@ unsafe extern "C" {
buf_size: c_int,
) -> c_int;
/// `oakengine_sequence_get_length` — length in seconds.
pub fn oakengine_sequence_get_length(self_: *const OakEngineSequence, seconds: *mut f64) -> c_int;
pub fn oakengine_sequence_get_length(
self_: *const OakEngineSequence,
seconds: *mut f64,
) -> c_int;
/// `oakengine_sequence_get_frame_rate` — num/den rational.
pub fn oakengine_sequence_get_frame_rate(
self_: *const OakEngineSequence,
@@ -435,6 +453,15 @@ unsafe extern "C" {
clip_index: c_int,
time: 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(
seq: *mut OakEngineSequence,
track_type: c_int,
track_index: c_int,
clip_index: c_int,
new_in: i64,
) -> c_int;
/// `oakengine_sequence_ripple_delete_clip`.
pub fn oakengine_sequence_ripple_delete_clip(
seq: *mut OakEngineSequence,
@@ -508,10 +535,7 @@ unsafe extern "C" {
/// `oakengine_node_is_enabled` — 1/0 (the stack's enable switch).
pub fn oakengine_node_is_enabled(self_: *const OakEngineNode) -> c_int;
/// `oakengine_node_effect_set_enabled` — undoable enable toggle.
pub fn oakengine_node_effect_set_enabled(
self_: *mut OakEngineNode,
enabled: c_int,
) -> c_int;
pub fn oakengine_node_effect_set_enabled(self_: *mut OakEngineNode, enabled: c_int) -> c_int;
/// `oakengine_node_effect_insert` — undoable insert at `index`
/// (0 = closest to the source; clamped to the ends).
pub fn oakengine_node_effect_insert(
@@ -557,11 +581,7 @@ unsafe extern "C" {
pub fn oakengine_node_factory_id_count() -> c_int;
/// `oakengine_node_factory_id_at` — type id at `index` (buf/size;
/// negative error out of range).
pub fn oakengine_node_factory_id_at(
index: c_int,
buf: *mut c_char,
buf_size: c_int,
) -> c_int;
pub fn oakengine_node_factory_id_at(index: c_int, buf: *mut c_char, buf_size: c_int) -> c_int;
/// `oakengine_node_factory_name_from_id` — display name (buf/size).
pub fn oakengine_node_factory_name_from_id(
type_id: *const c_char,
+5 -5
View File
@@ -220,7 +220,10 @@ pub extern "C" fn oakcore_rational_free(rational: *mut c_void) {
if rational.is_null() {
return;
}
rational_store().lock().unwrap().remove(&(rational as usize));
rational_store()
.lock()
.unwrap()
.remove(&(rational as usize));
// SAFETY: produced by `oakcore_audioparams_time_base`; we hold the only
// reference after removal.
unsafe { drop(Box::from_raw(rational as *mut (i32, i32))) };
@@ -231,10 +234,7 @@ pub extern "C" fn oakcore_rational_free(rational: *mut c_void) {
/// implementation lives in ffmpeg_bridge). Stub: exact matches win,
/// otherwise the first (most desirable) candidate.
#[no_mangle]
pub extern "C" fn fb_find_best_pix_fmt_of_list(
list: *const c_int,
pix_fmt: c_int,
) -> c_int {
pub extern "C" fn fb_find_best_pix_fmt_of_list(list: *const c_int, pix_fmt: c_int) -> c_int {
if list.is_null() {
return 0;
}
+69 -65
View File
@@ -52,82 +52,86 @@ pub const ICON_FF: &str = "ff";
/// The theme-dependent filesystem path of an icon (`assets/icons/{dark,light}`
/// under the crate root). Absolute, so it works from any working directory.
pub fn icon_path(name: &str, cx: &App) -> PathBuf {
let theme = gpui_widgets::theme::current_theme(cx);
let family = if theme.name == "Olive Dark" { "dark" } else { "light" };
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("assets/icons")
.join(family)
.join(format!("{name}.png"))
let theme = gpui_widgets::theme::current_theme(cx);
let family = if theme.name == "Olive Dark" {
"dark"
} else {
"light"
};
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("assets/icons")
.join(family)
.join(format!("{name}.png"))
}
/// Registers the app's icon resolver, so widget-crate consumers (the viewer
/// transport bar) resolve the same theme-aware paths. Call at startup before
/// any window renders; re-registering after a theme switch is harmless.
pub fn init(cx: &mut App) {
gpui_widgets::icons::set_resolver(
std::sync::Arc::new(|name, cx| Some(icon_path(name, cx))),
cx,
);
gpui_widgets::icons::set_resolver(
std::sync::Arc::new(|name, cx| Some(icon_path(name, cx))),
cx,
);
}
#[cfg(test)]
mod tests {
use super::*;
use super::*;
/// Every icon name resolves to an existing file for both themes — a
/// missing PNG would render as a blank toolbar button.
#[test]
fn every_icon_file_exists_for_both_themes() {
for name in [
ICON_ARROW,
ICON_RAZOR,
ICON_RIPPLE,
ICON_SLIP,
ICON_ROLLING,
ICON_ZOOM,
ICON_ZOOM_IN,
ICON_ZOOM_OUT,
ICON_SLIDE,
ICON_TRACK_SELECT,
ICON_SNAP,
ICON_PLAY,
ICON_PAUSE,
ICON_PREV,
ICON_NEXT,
ICON_REW,
ICON_FF,
] {
for family in ["dark", "light"] {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("assets/icons")
.join(family)
.join(format!("{name}.png"));
assert!(
path.exists(),
"missing icon asset {path:?} (referenced as {name})"
);
}
}
}
/// Every icon name resolves to an existing file for both themes — a
/// missing PNG would render as a blank toolbar button.
#[test]
fn every_icon_file_exists_for_both_themes() {
for name in [
ICON_ARROW,
ICON_RAZOR,
ICON_RIPPLE,
ICON_SLIP,
ICON_ROLLING,
ICON_ZOOM,
ICON_ZOOM_IN,
ICON_ZOOM_OUT,
ICON_SLIDE,
ICON_TRACK_SELECT,
ICON_SNAP,
ICON_PLAY,
ICON_PAUSE,
ICON_PREV,
ICON_NEXT,
ICON_REW,
ICON_FF,
] {
for family in ["dark", "light"] {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("assets/icons")
.join(family)
.join(format!("{name}.png"));
assert!(
path.exists(),
"missing icon asset {path:?} (referenced as {name})"
);
}
}
}
/// The theme-aware path picks the dark family for the default dark theme
/// and the light family once a light theme is applied.
#[gpui::test]
fn icon_path_follows_the_active_theme(cx: &mut gpui::TestAppContext) {
cx.update(|app| {
gpui_widgets::theme::apply_theme(app, &gpui_widgets::theme::OakTheme::olive_dark());
let dark = icon_path(ICON_PLAY, app);
assert!(
dark.ends_with("dark/play.png"),
"dark theme → dark family, got {dark:?}"
);
/// The theme-aware path picks the dark family for the default dark theme
/// and the light family once a light theme is applied.
#[gpui::test]
fn icon_path_follows_the_active_theme(cx: &mut gpui::TestAppContext) {
cx.update(|app| {
gpui_widgets::theme::apply_theme(app, &gpui_widgets::theme::OakTheme::olive_dark());
let dark = icon_path(ICON_PLAY, app);
assert!(
dark.ends_with("dark/play.png"),
"dark theme → dark family, got {dark:?}"
);
gpui_widgets::theme::apply_theme(app, &gpui_widgets::theme::OakTheme::olive_light());
let light = icon_path(ICON_PLAY, app);
assert!(
light.ends_with("light/play.png"),
"light theme → light family, got {light:?}"
);
});
}
gpui_widgets::theme::apply_theme(app, &gpui_widgets::theme::OakTheme::olive_light());
let light = icon_path(ICON_PLAY, app);
assert!(
light.ends_with("light/play.png"),
"light theme → light family, got {light:?}"
);
});
}
}
+64 -32
View File
@@ -465,10 +465,7 @@ impl MockEngine {
// Port ids are globally unique.
let video_type = PortDataType::new("video", hsla(0.55, 0.75, 0.6, 1.0));
let audio_type = PortDataType::new("audio", hsla(0.1, 0.7, 0.55, 1.0));
let port = |id: u64,
kind: PortKind,
label: &str,
data_type: &PortDataType| MockPort {
let port = |id: u64, kind: PortKind, label: &str, data_type: &PortDataType| MockPort {
id: PortId(id),
kind,
label: label.into(),
@@ -549,11 +546,7 @@ impl MockEngine {
vec![],
),
];
let edge = |id: u64,
from_node: u64,
from_port: u64,
to_node: u64,
to_port: u64| MockEdge {
let edge = |id: u64, from_node: u64, from_port: u64, to_node: u64, to_port: u64| MockEdge {
id: EdgeId(id),
from_node: NodeId(from_node),
from_port: PortId(from_port),
@@ -824,8 +817,7 @@ impl MockEngine {
/// Returns the node that owns `port`.
fn node_with_port(&self, port: PortId) -> Option<&MockNode> {
self.nodes.iter().find(|n| {
n.inputs.iter().any(|p| p.id() == port)
|| n.outputs.iter().any(|p| p.id() == port)
n.inputs.iter().any(|p| p.id() == port) || n.outputs.iter().any(|p| p.id() == port)
})
}
@@ -1111,7 +1103,11 @@ impl AppEngine for MockEngine {
self.request_frame(Monitor::Program, *frame, cx);
}
}
TimelineEvent::ClipTrimRequested { clip, edge, new_frame } => {
TimelineEvent::ClipTrimRequested {
clip,
edge,
new_frame,
} => {
if let Some((track, index)) = self.mock_clip_position(*clip) {
let clip = &mut self.tracks[track].clips[index];
match edge {
@@ -1127,7 +1123,11 @@ impl AppEngine for MockEngine {
}
cx.notify();
}
TimelineEvent::ClipMoveRequested { clip, new_track, new_start } => {
TimelineEvent::ClipMoveRequested {
clip,
new_track,
new_start,
} => {
let Some((track, index)) = self.mock_clip_position(*clip) else {
return;
};
@@ -1171,9 +1171,7 @@ impl AppEngine for MockEngine {
.clips
.iter()
.enumerate()
.filter(|(_, clip)| {
clip.range.start.0 < frame.0 && frame.0 < clip.range.end.0
})
.filter(|(_, clip)| clip.range.start.0 < frame.0 && frame.0 < clip.range.end.0)
.map(|(clip_index, _)| (track_index, clip_index))
.collect::<Vec<_>>()
})
@@ -1238,7 +1236,11 @@ impl AppEngine for MockEngine {
Ok(())
}
fn save_project(&mut self, _path: Option<PathBuf>, cx: &mut Context<Self>) -> Result<(), String> {
fn save_project(
&mut self,
_path: Option<PathBuf>,
cx: &mut Context<Self>,
) -> Result<(), String> {
println!("[mock engine] save: no persistence in mock mode");
cx.notify();
Ok(())
@@ -1422,7 +1424,9 @@ impl MockEngine {
let (width, height, samples) = crate::oakui::frames::synthetic_frame_samples(frame);
// Analyze the scopes from the same F32 samples the viewer displays.
let scope = crate::oakui::scopes::analyze_f32_rgba(width, height, &samples);
let image = Arc::new(crate::oakui::frames::f32_rgba_to_bgra_image(width, height, &samples));
let image = Arc::new(crate::oakui::frames::f32_rgba_to_bgra_image(
width, height, &samples,
));
cache.insert(monitor, (frame.0, image.clone(), scope));
image
}
@@ -1565,11 +1569,19 @@ mod tests {
});
let v2 = engine.read(app).track(0).expect("V2");
assert!(
v2.clips().iter().any(|c| c.id() == ClipId(11) && c.range().start == Frame(300)),
v2.clips()
.iter()
.any(|c| c.id() == ClipId(11) && c.range().start == Frame(300)),
"开场 moved to V2 at frame 300"
);
assert!(
!engine.read(app).track(1).expect("V1").clips().iter().any(|c| c.id() == ClipId(11)),
!engine
.read(app)
.track(1)
.expect("V1")
.clips()
.iter()
.any(|c| c.id() == ClipId(11)),
"开场 left V1"
);
});
@@ -1613,7 +1625,11 @@ mod tests {
.iter()
.find(|c| c.id() == ClipId(12))
.expect("B-roll clip");
assert_eq!(b_roll.range().start, Frame(120), "ripple shifted B-roll left");
assert_eq!(
b_roll.range().start,
Frame(120),
"ripple shifted B-roll left"
);
});
}
@@ -1663,9 +1679,7 @@ mod tests {
}
#[gpui::test]
async fn can_connect_enforces_direction_type_duplicates_and_cycles(
cx: &mut TestAppContext,
) {
async fn can_connect_enforces_direction_type_duplicates_and_cycles(cx: &mut TestAppContext) {
cx.update(|app| {
let engine = demo_engine(app);
let engine = engine.read(app);
@@ -1675,11 +1689,20 @@ mod tests {
// second input).
assert!(engine.can_connect(PortId(3), PortId(20)));
// The existing connection is not offered again.
assert!(!engine.can_connect(PortId(21), PortId(40)), "duplicate edge");
assert!(
!engine.can_connect(PortId(21), PortId(40)),
"duplicate edge"
);
// Input → output is rejected (wrong direction).
assert!(!engine.can_connect(PortId(20), PortId(1)), "wrong direction");
assert!(
!engine.can_connect(PortId(20), PortId(1)),
"wrong direction"
);
// A port cannot connect to itself.
assert!(!engine.can_connect(PortId(42), PortId(40)), "self connection");
assert!(
!engine.can_connect(PortId(42), PortId(40)),
"self connection"
);
// Type mismatch: the clip's audio output is not a video signal.
assert!(
!engine.can_connect(PortId(4), PortId(20)),
@@ -1728,7 +1751,9 @@ mod tests {
});
let engine_read = engine.read(app);
assert_eq!(engine_read.edges().len(), 6);
assert!(engine_read.port(PortId(22)).is_some_and(|p| p.is_connected()));
assert!(engine_read
.port(PortId(22))
.is_some_and(|p| p.is_connected()));
let edges = engine_read.edges();
let extra = edges
.iter()
@@ -1743,7 +1768,10 @@ mod tests {
});
assert_eq!(engine.read(app).edges().len(), 5);
assert!(
!engine.read(app).port(PortId(22)).is_some_and(|p| p.is_connected()),
!engine
.read(app)
.port(PortId(22))
.is_some_and(|p| p.is_connected()),
"mask input freed again"
);
@@ -1760,8 +1788,10 @@ mod tests {
let engine_read = engine.read(app);
assert_eq!(engine_read.nodes().len(), 5);
assert!(
!engine_read.edges().iter().any(|e| e.to_node() == NodeId(3)
|| e.from_node() == NodeId(3)),
!engine_read
.edges()
.iter()
.any(|e| e.to_node() == NodeId(3) || e.from_node() == NodeId(3)),
"edges incident to the deleted node are removed"
);
});
@@ -1793,7 +1823,9 @@ mod tests {
let bytes = c.as_bytes(0).expect("single frame");
assert_eq!(
bytes.len(),
(crate::oakui::frames::SYNTH_FRAME_WIDTH * crate::oakui::frames::SYNTH_FRAME_HEIGHT * 4) as usize
(crate::oakui::frames::SYNTH_FRAME_WIDTH
* crate::oakui::frames::SYNTH_FRAME_HEIGHT
* 4) as usize
);
assert!(bytes.chunks_exact(4).all(|px| px[3] == 255), "opaque alpha");
});
+223 -62
View File
@@ -54,8 +54,9 @@
//! and edits go through the facade's undoable effect commands. Node
//! graph and audio meter still feed empty/silent data (their facade
//! surfaces are not bound in this increment).
//! * `oakengine_sequence_move_clip` is a documented facade stub (module gap),
//! so clip moves report the facade error instead of applying.
//! * Clip moves go through `oakengine_sequence_move_clip` (same-track only;
//! the facade's capi signature has no target-track parameter, so a
//! cross-track drag reports "not supported" instead of applying).
//!
//! # Threading note
//!
@@ -76,23 +77,25 @@ use gpui::effect_stack::{
EffectCardKind, EffectData, EffectId, EffectStackDataSource, EffectStackEvent,
};
use gpui::node_graph::{
EdgeData, EdgeId, NodeData, NodeGraphDataSource, NodeGraphEvent, NodeId, PortData, PortId,
PortDataType, PortKind,
EdgeData, EdgeId, NodeData, NodeGraphDataSource, NodeGraphEvent, NodeId, PortData,
PortDataType, PortId, PortKind,
};
use gpui::timeline::{
ClipData, ClipId, Frame, FrameRange, FrameRate, TimelineDataSource, TimelineEvent, TrackData,
TrackKind, TrimEdge,
};
use gpui::{hsla, point, prelude::*, px, App, Context, Entity, Hsla, Pixels, RenderImage, SharedString};
use gpui::{
hsla, point, prelude::*, px, App, Context, Entity, Hsla, Pixels, RenderImage, SharedString,
};
use gpui_widgets::audio_meter::AudioMeterDataSource;
use gpui_widgets::project_explorer::{ProjectDataSource, ProjectEntry};
use gpui_widgets::viewer::PlaybackClock;
use super::ffi::*;
use super::engine::{
AppEngine, EngineGateway, ExportEvent, ExportSession, Monitor, Project, ScopeData, Sequence,
VideoFormat,
};
use super::ffi::*;
use super::frames::{f32_rgba_to_bgra_image, synthetic_frame_samples};
use super::scopes::analyze_f32_rgba;
use super::transport::TransportState;
@@ -283,9 +286,10 @@ fn read_string(f: impl Fn(*mut c_char, c_int) -> c_int) -> String {
/// Reads the error buffer the OVE load/save serializer fills.
fn load_error(err: &mut [c_char]) -> String {
let len = err.iter().position(|&c| c == 0).unwrap_or(err.len());
let text =
String::from_utf8_lossy(unsafe { std::slice::from_raw_parts(err.as_ptr() as *const u8, len) })
.into_owned();
let text = String::from_utf8_lossy(unsafe {
std::slice::from_raw_parts(err.as_ptr() as *const u8, len)
})
.into_owned();
if text.is_empty() {
"the operation failed".to_string()
} else {
@@ -340,7 +344,8 @@ impl RealClock {
let elapsed = started.elapsed();
let mut frame = anchored
+ Frame(
(elapsed.as_secs_f64() * self.rate.num as f64 / self.rate.den as f64).round() as i64,
(elapsed.as_secs_f64() * self.rate.num as f64 / self.rate.den as f64).round()
as i64,
);
if length.0 > 0 && frame.0 >= length.0 {
frame = Frame(frame.0 % length.0);
@@ -711,7 +716,10 @@ impl RealEngine {
/// Current sequence length (0 without a sequence).
fn sequence_length(&self) -> Frame {
self.sequence_info.as_ref().map(|s| s.length).unwrap_or(Frame(0))
self.sequence_info
.as_ref()
.map(|s| s.length)
.unwrap_or(Frame(0))
}
/// Mirrors the program playhead into the facade sequence (best effort).
@@ -817,7 +825,10 @@ impl RealEngine {
}
// The scopes read the same F32 samples the viewer displays.
let scope = analyze_f32_rgba(width as u32, height as u32, &samples);
image = Some((f32_rgba_to_bgra_image(width as u32, height as u32, &samples), scope));
image = Some((
f32_rgba_to_bgra_image(width as u32, height as u32, &samples),
scope,
));
}
unsafe {
oakengine_frame_free(frame_ptr);
@@ -860,9 +871,15 @@ impl RealEngine {
// Cached display info.
let name = read_string(|buf, size| unsafe { oakengine_project_name(project, buf, size) });
let path = PathBuf::from(read_string(|buf, size| unsafe { oakengine_project_filename(project, buf, size) }));
let path = PathBuf::from(read_string(|buf, size| unsafe {
oakengine_project_filename(project, buf, size)
}));
self.project_info = Project {
name: if name.is_empty() { UNTITLED.into() } else { name },
name: if name.is_empty() {
UNTITLED.into()
} else {
name
},
path,
};
self.modified = unsafe { oakengine_project_is_modified(project) != 0 };
@@ -919,7 +936,13 @@ impl RealEngine {
let mut seconds: f64 = 0.0;
unsafe {
oakengine_sequence_get_frame_rate(seq, &mut num, &mut den);
oakengine_sequence_get_video_params(seq, &mut width, &mut height, std::ptr::null_mut(), std::ptr::null_mut());
oakengine_sequence_get_video_params(
seq,
&mut width,
&mut height,
std::ptr::null_mut(),
std::ptr::null_mut(),
);
oakengine_sequence_get_length(seq, &mut seconds);
}
let rate = if num > 0 && den > 0 {
@@ -929,7 +952,11 @@ impl RealEngine {
};
let length = Frame((seconds * rate.num as f64 / rate.den as f64).round() as i64);
self.sequence_info = Some(Sequence {
name: if name.is_empty() { "Sequence 1".into() } else { name },
name: if name.is_empty() {
"Sequence 1".into()
} else {
name
},
format: VideoFormat {
width: width.max(1) as u32,
height: height.max(1) as u32,
@@ -989,17 +1016,21 @@ impl RealEngine {
// Height in internal units → pixels.
let mut internal: f64 = 0.0;
let height = unsafe {
if oakengine_track_get_height(seq, track_type, track_index as c_int, &mut internal) == 0 {
if oakengine_track_get_height(seq, track_type, track_index as c_int, &mut internal) == 0
{
px(oakengine_track_height_internal_to_pixels(internal).max(24) as f32)
} else {
px(64.0)
}
};
let clip_count = unsafe { oakengine_sequence_clip_count(seq, track_type, track_index as c_int) };
let clip_count =
unsafe { oakengine_sequence_clip_count(seq, track_type, track_index as c_int) };
let mut clips = Vec::with_capacity(clip_count.max(0) as usize);
for clip_index in 0..clip_count.max(0) {
let clip = unsafe { oakengine_sequence_clip_at(seq, track_type, track_index as c_int, clip_index) };
let clip = unsafe {
oakengine_sequence_clip_at(seq, track_type, track_index as c_int, clip_index)
};
if clip.is_null() {
continue;
}
@@ -1011,7 +1042,11 @@ impl RealEngine {
free_box(clip);
}
clips.push(RealClip {
id: ClipId((track_type as u64) * 1_000_000 + (track_index as u64 + 1) * 1000 + clip_index as u64),
id: ClipId(
(track_type as u64) * 1_000_000
+ (track_index as u64 + 1) * 1000
+ clip_index as u64,
),
range: FrameRange::new(Frame(in_ts), Frame(out_ts)),
media_in: Frame(media_in),
label: format!("Clip {}", clip_index + 1).into(),
@@ -1050,8 +1085,9 @@ impl RealEngine {
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 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())
@@ -1110,8 +1146,12 @@ impl RealEngine {
let mut buf = [0 as c_char; 64];
let mut element: c_int = 0;
unsafe {
oakengine_node_get_effect_input(node, buf.as_mut_ptr(), buf.len() as c_int, &mut element)
>= 0
oakengine_node_get_effect_input(
node,
buf.as_mut_ptr(),
buf.len() as c_int,
&mut element,
) >= 0
}
}
@@ -1236,7 +1276,6 @@ impl RealEngine {
text
}
}
}
// ---------------------------------------------------------------------------
@@ -1501,7 +1540,8 @@ impl AppEngine for RealEngine {
let Some(seq) = self.seq_ptr() else {
return;
};
let internal = unsafe { oakengine_track_height_pixels_to_internal(f32::from(height) as c_int) };
let internal =
unsafe { oakengine_track_height_pixels_to_internal(f32::from(height) as c_int) };
let mut video: c_int = 0;
let mut audio: c_int = 0;
let mut subtitle: c_int = 0;
@@ -1561,14 +1601,23 @@ impl AppEngine for RealEngine {
// facade two-stage convention.
unsafe { oakengine_node_factory_name_from_id(c_id.as_ptr(), buf, size) }
});
let name = if name.is_empty() { type_id.clone() } else { name };
let name = if name.is_empty() {
type_id.clone()
} else {
name
};
out.push((type_id, name));
}
}
out
}
fn add_effect(&mut self, index: usize, type_id: &str, cx: &mut Context<Self>) -> Result<(), String> {
fn add_effect(
&mut self,
index: usize,
type_id: &str,
cx: &mut Context<Self>,
) -> Result<(), String> {
let Some(host) = self.selected_clip_node() else {
return Err("no selected clip".into());
};
@@ -1688,7 +1737,11 @@ impl AppEngine for RealEngine {
self.request_frame(Monitor::Program, *frame, cx);
}
}
TimelineEvent::ClipTrimRequested { clip, edge, new_frame } => {
TimelineEvent::ClipTrimRequested {
clip,
edge,
new_frame,
} => {
let Some((track_type, track_index, clip_index)) = self.clip_coords(*clip) else {
return;
};
@@ -1722,13 +1775,39 @@ impl AppEngine for RealEngine {
unsafe { free_box(clip_ptr) };
self.apply_edit(rc, "trim clip", cx);
}
TimelineEvent::ClipMoveRequested { clip, .. } => {
// `oakengine_sequence_move_clip` is a documented facade stub
// (module gap); report the facade error.
let coords = self.clip_coords(*clip);
let what = format!("move clip ({coords:?})");
println!("[real engine] {what}: not supported by the facade (stub)");
cx.notify();
TimelineEvent::ClipMoveRequested {
clip,
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.
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,
)
};
self.apply_edit(rc, "move clip", cx);
}
TimelineEvent::TrackHeightChanged { track, height } => {
if let Some(t) = self.tracks.get(*track) {
@@ -1853,7 +1932,15 @@ impl AppEngine for RealEngine {
unsafe { free_box(clip_ptr) };
rc
};
self.apply_edit(rc, if ripple { "ripple delete clip" } else { "delete clip" }, cx);
self.apply_edit(
rc,
if ripple {
"ripple delete clip"
} else {
"delete clip"
},
cx,
);
}
fn can_undo(&self) -> bool {
@@ -1923,7 +2010,11 @@ impl AppEngine for RealEngine {
}
}
fn save_project(&mut self, path: Option<PathBuf>, cx: &mut Context<Self>) -> Result<(), String> {
fn save_project(
&mut self,
path: Option<PathBuf>,
cx: &mut Context<Self>,
) -> Result<(), String> {
if self.project_ptr().is_none() {
return Err("no project open".into());
}
@@ -1977,7 +2068,13 @@ impl AppEngine for RealEngine {
let mut rate_num: c_int = 25;
let mut rate_den: c_int = 1;
unsafe {
oakengine_sequence_get_video_params(seq, &mut width, &mut height, &mut par_num, &mut par_den);
oakengine_sequence_get_video_params(
seq,
&mut width,
&mut height,
&mut par_num,
&mut par_den,
);
oakengine_sequence_get_frame_rate(seq, &mut rate_num, &mut rate_den);
}
let video_codec = unsafe { oakengine_encoding_format_video_codec_at(format, 0) };
@@ -2037,7 +2134,8 @@ impl AppEngine for RealEngine {
}
// Progress events through the module task callback.
let module_handle = unsafe { unbox(task) }.ok_or_else(|| "invalid export task handle".to_string())?;
let module_handle =
unsafe { unbox(task) }.ok_or_else(|| "invalid export task handle".to_string())?;
let (tx, rx) = mpsc::channel::<ExportEvent>();
let cb_userdata = SendPtr(Box::into_raw(Box::new(tx.clone())));
unsafe {
@@ -2131,7 +2229,14 @@ impl RealEngine {
return Err("invalid project path".into());
};
let mut err = [0 as c_char; 4096];
let rc = unsafe { oakengine_project_load(project, cpath.as_ptr(), err.as_mut_ptr(), err.len() as c_int) };
let rc = unsafe {
oakengine_project_load(
project,
cpath.as_ptr(),
err.as_mut_ptr(),
err.len() as c_int,
)
};
if rc != 0 {
let message = load_error(&mut err);
unsafe { oakengine_project_free(project) };
@@ -2184,7 +2289,10 @@ impl RealEngine {
self.adopt_project(project, cx);
Ok(())
}
None => Err(format!("loaded \"{}\" but the loader returned no project", path.display())),
None => Err(format!(
"loaded \"{}\" but the loader returned no project",
path.display()
)),
}
}
@@ -2195,7 +2303,10 @@ impl RealEngine {
return Err("no project open".into());
};
let cpath = path.and_then(cstr_path);
let ptr = cpath.as_ref().map(|c| c.as_ptr()).unwrap_or(std::ptr::null());
let ptr = cpath
.as_ref()
.map(|c| c.as_ptr())
.unwrap_or(std::ptr::null());
let rc = unsafe { oakengine_project_save(project, ptr) };
if rc != 0 {
return Err(format!("failed to save the project (error {rc})"));
@@ -2205,7 +2316,8 @@ impl RealEngine {
if !name.is_empty() {
self.project_info.name = name;
}
let filename = read_string(|buf, size| unsafe { oakengine_project_filename(project, buf, size) }) ;
let filename =
read_string(|buf, size| unsafe { oakengine_project_filename(project, buf, size) });
if !filename.is_empty() {
self.project_info.path = PathBuf::from(filename);
}
@@ -2250,7 +2362,8 @@ pub fn encoding_formats() -> Vec<(c_int, String, String)> {
let mut out = Vec::new();
for i in 0..count.max(0) {
let name = read_string(|buf, size| unsafe { oakengine_encoding_format_name(i, buf, size) });
let ext = read_string(|buf, size| unsafe { oakengine_encoding_format_extension(i, buf, size) });
let ext =
read_string(|buf, size| unsafe { oakengine_encoding_format_extension(i, buf, size) });
out.push((i, name, ext));
}
out
@@ -2298,20 +2411,41 @@ mod tests {
#[test]
fn project_format_dispatches_by_extension() {
assert_eq!(ProjectFormat::of(&PathBuf::from("/tmp/x.ove")), ProjectFormat::Ove);
assert_eq!(ProjectFormat::of(&PathBuf::from("/tmp/x.ovexml")), ProjectFormat::Ove);
assert_eq!(ProjectFormat::of(&PathBuf::from("/tmp/x.OTIO")), ProjectFormat::Otio);
assert_eq!(ProjectFormat::of(&PathBuf::from("/tmp/x.fcpxml")), ProjectFormat::Fcpxml);
assert_eq!(
ProjectFormat::of(&PathBuf::from("/tmp/x.ove")),
ProjectFormat::Ove
);
assert_eq!(
ProjectFormat::of(&PathBuf::from("/tmp/x.ovexml")),
ProjectFormat::Ove
);
assert_eq!(
ProjectFormat::of(&PathBuf::from("/tmp/x.OTIO")),
ProjectFormat::Otio
);
assert_eq!(
ProjectFormat::of(&PathBuf::from("/tmp/x.fcpxml")),
ProjectFormat::Fcpxml
);
// Unknown extensions fall back to the OVE serializer.
assert_eq!(ProjectFormat::of(&PathBuf::from("/tmp/x.xml")), ProjectFormat::Ove);
assert_eq!(ProjectFormat::of(&PathBuf::from("/tmp/x")), ProjectFormat::Ove);
assert_eq!(
ProjectFormat::of(&PathBuf::from("/tmp/x.xml")),
ProjectFormat::Ove
);
assert_eq!(
ProjectFormat::of(&PathBuf::from("/tmp/x")),
ProjectFormat::Ove
);
}
#[test]
fn renderer_backends_list_is_stable() {
let backends = renderer_backends();
assert!(backends.len() >= 2);
assert!(backends.contains(&"opengl"), "the default backend is offered");
assert!(
backends.contains(&"opengl"),
"the default backend is offered"
);
}
/// End-to-end through the facade: a project the engine itself writes
@@ -2336,8 +2470,14 @@ mod tests {
let name = CString::new("Round Trip").unwrap();
let sequence = unsafe { oakengine_sequence_new(project, name.as_ptr()) };
assert!(!sequence.is_null());
assert_eq!(unsafe { oakengine_sequence_add_track(sequence, TRACK_TYPE_VIDEO) }, 0);
assert_eq!(unsafe { oakengine_sequence_add_track(sequence, TRACK_TYPE_AUDIO) }, 0);
assert_eq!(
unsafe { oakengine_sequence_add_track(sequence, TRACK_TYPE_VIDEO) },
0
);
assert_eq!(
unsafe { oakengine_sequence_add_track(sequence, TRACK_TYPE_AUDIO) },
0
);
let mut video: c_int = -1;
let mut audio: c_int = -1;
let mut subtitle: c_int = -1;
@@ -2351,8 +2491,14 @@ mod tests {
let save_path =
std::env::temp_dir().join(format!("oakapp_roundtrip_{}.ovexml", std::process::id()));
let cpath = CString::new(save_path.to_string_lossy().into_owned()).unwrap();
assert_eq!(unsafe { oakengine_project_set_filename(project, cpath.as_ptr()) }, 0);
assert_eq!(unsafe { oakengine_project_save(project, cpath.as_ptr()) }, 0);
assert_eq!(
unsafe { oakengine_project_set_filename(project, cpath.as_ptr()) },
0
);
assert_eq!(
unsafe { oakengine_project_save(project, cpath.as_ptr()) },
0
);
assert!(save_path.exists());
unsafe { free_box(sequence) };
unsafe { oakengine_project_free(project) };
@@ -2363,12 +2509,16 @@ mod tests {
assert!(!project2.is_null());
let mut err = [0 as c_char; 4096];
let rc = unsafe {
oakengine_project_load(project2, cpath.as_ptr(), err.as_mut_ptr(), err.len() as c_int)
oakengine_project_load(
project2,
cpath.as_ptr(),
err.as_mut_ptr(),
err.len() as c_int,
)
};
assert_eq!(rc, 0, "project loads: {}", load_error(&mut err));
let loaded_name = read_string(|buf, size| unsafe {
oakengine_project_name(project2, buf, size)
});
let loaded_name =
read_string(|buf, size| unsafe { oakengine_project_name(project2, buf, size) });
assert!(!loaded_name.is_empty(), "the loaded project has a name");
unsafe { oakengine_project_free(project2) };
@@ -2400,7 +2550,15 @@ mod tests {
// The app's proxy size: sequence aspect (default 1920x1080) scaled
// to a 480px long edge, F32 at 25 fps.
let renderer = unsafe {
oakengine_renderer_create(sequence, 480, 270, PIXEL_FORMAT_F32, 25, 1, std::ptr::null())
oakengine_renderer_create(
sequence,
480,
270,
PIXEL_FORMAT_F32,
25,
1,
std::ptr::null(),
)
};
assert!(!renderer.is_null(), "renderer_create must succeed");
@@ -2421,7 +2579,10 @@ mod tests {
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:?}");
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();
}
// Document the current empty-sequence behavior: a transparent-black
+3 -1
View File
@@ -151,6 +151,8 @@ mod tests {
// Neutral gray chroma projects to the vectorscope's center.
let points = gpui_widgets::scopes::vectorscope_points(&data.chroma);
assert!(points.iter().all(|&(u, v)| u.abs() < 1e-6 && v.abs() < 1e-6));
assert!(points
.iter()
.all(|&(u, v)| u.abs() < 1e-6 && v.abs() < 1e-6));
}
}
+21 -28
View File
@@ -27,11 +27,11 @@
use gpui::colors::DefaultColors;
use gpui::dock::{DockPanel, PanelEvent};
use gpui::node_graph::{
MAX_ZOOM, MIN_ZOOM, NodeData, NodeElement, NodeGraphEvent, NodeGraphView, NodeVisualState,
NodeData, NodeElement, NodeGraphEvent, NodeGraphView, NodeVisualState, MAX_ZOOM, MIN_ZOOM,
};
use gpui::{
div, point, prelude::*, px, AnyElement, App, Bounds, ClickEvent, Context, Entity,
EventEmitter, Pixels, Render, SharedString, Window,
div, point, prelude::*, px, AnyElement, App, Bounds, ClickEvent, Context, Entity, EventEmitter,
Pixels, Render, SharedString, Window,
};
use crate::oakui::AppEngine;
@@ -50,11 +50,7 @@ pub struct NodeEditorPanel<E: AppEngine> {
impl<E: AppEngine> NodeEditorPanel<E> {
/// Builds the graph canvas over `engine` and routes its edit requests back
/// to the engine.
pub fn new(
engine: Entity<E>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
pub fn new(engine: Entity<E>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let graph = cx.new(|cx| NodeGraphView::new(engine.clone(), window, cx));
// The "edits are requests" loop: every graph gesture goes to the
// engine, which applies it to its model and notifies.
@@ -189,16 +185,12 @@ impl<E: AppEngine> Render for NodeEditorPanel<E> {
|this, window, cx| this.fit_graph(window, cx),
))
.child(div().flex_1())
.child(
div()
.text_color(colors.disabled)
.child(format!(
"{}% · {}–{}",
(self.graph.read(cx).state().zoom() * 100.0).round(),
MIN_ZOOM,
MAX_ZOOM,
)),
),
.child(div().text_color(colors.disabled).child(format!(
"{}% · {}–{}",
(self.graph.read(cx).state().zoom() * 100.0).round(),
MIN_ZOOM,
MAX_ZOOM,
))),
)
.child(
div()
@@ -243,16 +235,13 @@ fn zoom_button<E: AppEngine>(
action(this, window, cx);
}));
if let Some(name) = icon_name {
el = el.child(
gpui::img(crate::oakui::icons::icon_path(name, cx)).size(px(16.0)),
);
el = el.child(gpui::img(crate::oakui::icons::icon_path(name, cx)).size(px(16.0)));
} else {
el = el.child(label);
}
el
}
impl<E: AppEngine> EventEmitter<PanelEvent> for NodeEditorPanel<E> {}
impl<E: AppEngine> DockPanel for NodeEditorPanel<E> {
@@ -275,13 +264,16 @@ impl<E: AppEngine> DockPanel for NodeEditorPanel<E> {
mod tests {
use super::*;
use crate::oakui::MockEngine;
use gpui::{TestAppContext, VisualTestContext, size};
use gpui::{size, TestAppContext, VisualTestContext};
/// Builds the panel in a window and returns a `VisualTestContext` for
/// bounds assertions.
fn panel_window(
cx: &mut TestAppContext,
) -> (&'static mut VisualTestContext, Entity<NodeEditorPanel<MockEngine>>) {
) -> (
&'static mut VisualTestContext,
Entity<NodeEditorPanel<MockEngine>>,
) {
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(640.0), px(480.0)), |window, cx| {
let engine = cx.new(|cx| crate::oakui::MockEngine::demo(cx));
@@ -316,10 +308,11 @@ mod tests {
// The initial fit moved the viewport off the default origin, so the
// graph is framed rather than clipped at the corner.
let state = cx.read(|app| {
panel.read(app).graph.read(app).state().clone()
});
assert_ne!(state.offset(), gpui::node_graph::GraphViewState::new().offset());
let state = cx.read(|app| panel.read(app).graph.read(app).state().clone());
assert_ne!(
state.offset(),
gpui::node_graph::GraphViewState::new().offset()
);
assert!(state.zoom() > 0.0);
}
}
+9 -2
View File
@@ -138,7 +138,10 @@ impl<E: AppEngine> ProgramViewerPanel<E> {
/// playhead frame, with the scope samples analyzed in the same pass).
fn sync_frame(&mut self, cx: &mut Context<Self>) {
let frame = self.engine.read(cx).cpu_frame(Monitor::Program, cx);
if self.last_cpu_frame.as_ref().is_none_or(|last| !std::sync::Arc::ptr_eq(last, &frame))
if self
.last_cpu_frame
.as_ref()
.is_none_or(|last| !std::sync::Arc::ptr_eq(last, &frame))
{
self.last_cpu_frame = Some(frame.clone());
let scope = self.engine.read(cx).scope_data(Monitor::Program, cx);
@@ -170,7 +173,11 @@ impl<E: AppEngine> ProgramViewerPanel<E> {
.rounded_sm()
.border_1()
.border_color(colors.border)
.bg(if active { colors.selected } else { colors.container })
.bg(if active {
colors.selected
} else {
colors.container
})
.text_color(colors.text)
.cursor_pointer()
.child(label)
+5 -3
View File
@@ -20,8 +20,7 @@
use gpui::colors::DefaultColors;
use gpui::dock::{DockPanel, PanelEvent};
use gpui::{
div, prelude::*, AnyElement, App, Context, Entity, EventEmitter, Render, SharedString,
Window,
div, prelude::*, AnyElement, App, Context, Entity, EventEmitter, Render, SharedString, Window,
};
use gpui_widgets::viewer::{ViewerEvent, ViewerWidget};
@@ -71,7 +70,10 @@ impl<E: AppEngine> SourceViewerPanel<E> {
/// it actually changed (the engine caches one image per playhead frame).
fn sync_frame(&mut self, cx: &mut Context<Self>) {
let frame = self.engine.read(cx).cpu_frame(Monitor::Source, cx);
if self.last_cpu_frame.as_ref().is_none_or(|last| !std::sync::Arc::ptr_eq(last, &frame))
if self
.last_cpu_frame
.as_ref()
.is_none_or(|last| !std::sync::Arc::ptr_eq(last, &frame))
{
self.last_cpu_frame = Some(frame.clone());
let frame = frame.clone();
+8 -22
View File
@@ -75,18 +75,9 @@ impl<E: AppEngine> Render for StatusBar<E> {
.bg(colors.container)
.text_xs()
.child(segment(&colors, crate::i18n::tr("status.ready").into()))
.child(segment(
&colors,
crate::i18n::tr("status.cache").into(),
))
.child(segment(
&colors,
crate::i18n::tr("status.proxy").into(),
))
.child(segment(
&colors,
crate::i18n::tr("status.autosave").into(),
))
.child(segment(&colors, crate::i18n::tr("status.cache").into()))
.child(segment(&colors, crate::i18n::tr("status.proxy").into()))
.child(segment(&colors, crate::i18n::tr("status.autosave").into()))
.child(div().flex_1())
.child(segment(
&colors,
@@ -102,15 +93,10 @@ impl<E: AppEngine> Render for StatusBar<E> {
format_resolution(format.width, format.height),
))
.child(div().px_2().text_color(colors.disabled).child(project))
.child(
div()
.px_2()
.text_color(colors.disabled)
.child(format!(
"{} · {}",
crate::i18n::tr("status.backend"),
self.engine.read(cx).backend_name(),
)),
)
.child(div().px_2().text_color(colors.disabled).child(format!(
"{} · {}",
crate::i18n::tr("status.backend"),
self.engine.read(cx).backend_name(),
)))
}
}
+38 -40
View File
@@ -72,7 +72,10 @@ const TOOLS: [(&str, &str); 8] = [
("timeline.tool.roll", crate::oakui::icons::ICON_ROLLING),
("timeline.tool.zoom", crate::oakui::icons::ICON_ZOOM),
("timeline.tool.slide", crate::oakui::icons::ICON_SLIDE),
("timeline.tool.track_select", crate::oakui::icons::ICON_TRACK_SELECT),
(
"timeline.tool.track_select",
crate::oakui::icons::ICON_TRACK_SELECT,
),
];
/// The timeline panel.
@@ -111,8 +114,7 @@ impl<E: AppEngine> TimelinePanel<E> {
cx,
)
});
let snap =
cx.new(|cx| CheckBox::new(12, CheckState::Checked, window, cx));
let snap = cx.new(|cx| CheckBox::new(12, CheckState::Checked, window, cx));
// Zoom slider → timeline zoom (pixels per frame).
cx.subscribe(&zoom, |this, _zoom, event: &SliderEvent, cx| {
@@ -191,38 +193,34 @@ impl<E: AppEngine> Render for TimelinePanel<E> {
// A tool button: a 16px icon on a 24px hit target with a localized
// tooltip; the selected tool is highlighted.
let tool_button = |index: usize,
icon_name: &'static str,
key: &'static str,
cx: &mut Context<Self>| {
let tool = i18n::tr(key);
let selected = self.selected_tool == index;
let background = if selected {
colors.selected
} else {
colors.background
};
let hover_bg = colors.selected;
let path = icons::icon_path(icon_name, cx);
div()
.id(SharedString::from(format!("tool-{index}")))
.size(px(24.0))
.flex()
.items_center()
.justify_center()
.rounded_sm()
.cursor_pointer()
.bg(background)
.hover(move |style| style.bg(hover_bg))
.tooltip(move |window, cx| tooltip_view(tool.into(), window, cx))
.on_click(
cx.listener(move |this, _event: &ClickEvent, _window, _cx| {
let tool_button =
|index: usize, icon_name: &'static str, key: &'static str, cx: &mut Context<Self>| {
let tool = i18n::tr(key);
let selected = self.selected_tool == index;
let background = if selected {
colors.selected
} else {
colors.background
};
let hover_bg = colors.selected;
let path = icons::icon_path(icon_name, cx);
div()
.id(SharedString::from(format!("tool-{index}")))
.size(px(24.0))
.flex()
.items_center()
.justify_center()
.rounded_sm()
.cursor_pointer()
.bg(background)
.hover(move |style| style.bg(hover_bg))
.tooltip(move |window, cx| tooltip_view(tool.into(), window, cx))
.on_click(cx.listener(move |this, _event: &ClickEvent, _window, _cx| {
println!("[timeline] tool: {tool} (placeholder)");
this.selected_tool = index;
}),
)
.child(img(path).size(px(16.0)))
};
}))
.child(img(path).size(px(16.0)))
};
for (index, (tool_key, icon_name)) in TOOLS.iter().enumerate() {
toolbar = toolbar.child(tool_button(index, icon_name, tool_key, cx));
@@ -269,9 +267,7 @@ impl<E: AppEngine> Render for TimelinePanel<E> {
.tooltip(move |window, cx| {
tooltip_view(i18n::tr("timeline.snap").into(), window, cx)
})
.child(
img(icons::icon_path(icons::ICON_SNAP, cx)).size(px(16.0)),
),
.child(img(icons::icon_path(icons::ICON_SNAP, cx)).size(px(16.0))),
)
.child(self.snap.clone());
@@ -377,7 +373,7 @@ impl<E: AppEngine> DockPanel for TimelinePanel<E> {
mod tests {
use super::*;
use crate::oakui::MockEngine;
use gpui::{TestAppContext, VisualTestContext, px, size};
use gpui::{px, size, TestAppContext, VisualTestContext};
/// Builds a `TimelinePanel` in a window of the given logical size and
/// returns a `VisualTestContext` for bounds assertions.
@@ -385,12 +381,14 @@ mod tests {
cx: &mut TestAppContext,
width: f32,
height: f32,
) -> (&'static mut VisualTestContext, Entity<TimelinePanel<MockEngine>>) {
) -> (
&'static mut VisualTestContext,
Entity<TimelinePanel<MockEngine>>,
) {
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(width), px(height)), |window, cx| {
let engine = cx.new(|cx| crate::oakui::MockEngine::demo(cx));
let timeline =
cx.new(|cx| TimelineView::new(engine.clone(), window, cx).zoom(2.0));
let timeline = cx.new(|cx| TimelineView::new(engine.clone(), window, cx).zoom(2.0));
TimelinePanel::new(engine, timeline, window, cx)
});
cx.run_until_parked();