From f2af92958a64a0892d94b75361b61d5b91830c12 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Tue, 18 Aug 2026 18:58:26 +0800 Subject: [PATCH] feat(app): proxy editing and audio sync, aligned with the C++ version Proxy: preview-path proxy substitution (global UseProxyMedia AND per-footage enabled AND on-disk ready; export always uses originals), proxy generate/delete/reveal/enable actions, ProxyDialog with global and per-footage custom params, Tools menu + context-menu Proxy submenus, progress in the status bar, OVE serialization of proxy metadata and source_start_time. Sync: timeline context-menu Synchronize by Source Time / by Waveform / by Waveform (Adjust Speed) with ctrl-shift-w, cache-envelope extraction with validity masks, reference/anchor selection and single multi-undo application (replace-with-gap, speed adjust, re-place) mirroring timelinewidget.cpp semantics. --- crates/oakcodec/src/proxymanager.rs | 35 ++ crates/oaknode/src/footage.rs | 175 +++++- crates/oaktask/src/proxy.rs | 8 +- src/actions.rs | 14 + src/app.rs | 85 ++- src/dialogs.rs | 417 ++++++++++++- src/i18n.rs | 50 ++ src/oakui/engine.rs | 263 ++++++++ src/oakui/mock.rs | 199 ++++++ src/oakui/mod.rs | 1 + src/oakui/real.rs | 914 ++++++++++++++++++++++++++++ src/oakui/renderops.rs | 79 ++- src/oakui/waveformsync.rs | 172 ++++++ src/panels/commands.rs | 14 + src/panels/project_explorer.rs | 93 ++- src/panels/status_bar.rs | 9 +- src/panels/timeline.rs | 250 ++++++-- 17 files changed, 2696 insertions(+), 82 deletions(-) create mode 100644 src/oakui/waveformsync.rs diff --git a/crates/oakcodec/src/proxymanager.rs b/crates/oakcodec/src/proxymanager.rs index cecc94469..226d351b7 100644 --- a/crates/oakcodec/src/proxymanager.rs +++ b/crates/oakcodec/src/proxymanager.rs @@ -41,6 +41,20 @@ pub enum ProxyState { Failed = 3, } +impl TryFrom for ProxyState { + type Error = (); + + fn try_from(value: i32) -> Result { + match value { + 0 => Ok(ProxyState::Missing), + 1 => Ok(ProxyState::Generating), + 2 => Ok(ProxyState::Ready), + 3 => Ok(ProxyState::Failed), + _ => Err(()), + } + } +} + /// `olive::ProxyManager::ProxyParams` — mirror of `oakcodec_proxy_params`. #[derive(Clone, Debug)] #[repr(C)] @@ -166,6 +180,27 @@ impl ProxyManager { } } + /// The proxy state a string name stands for (`Missing` for anything + /// unrecognized; also accepts the numeric form the Rust serializer used + /// before the string form landed). + pub fn proxy_state_from_string(name: &str) -> ProxyState { + match name.trim() { + "generating" | "1" => ProxyState::Generating, + "ready" | "2" => ProxyState::Ready, + "failed" | "3" => ProxyState::Failed, + _ => ProxyState::Missing, + } + } + + /// Whether a proxy filename was generated with an audio track (the + /// generation tags audio-including proxies `.a1.`). + pub fn proxy_filename_has_audio(proxy_filename: &str) -> bool { + std::path::Path::new(proxy_filename) + .file_name() + .map(|n| n.to_string_lossy().contains(".a1.")) + .unwrap_or(false) + } + /// Proxy directory for a project cache path. pub fn get_proxy_directory(cache_path: &str) -> crate::error::Result { Ok(Path::new(cache_path) diff --git a/crates/oaknode/src/footage.rs b/crates/oaknode/src/footage.rs index dd7a32501..8cede2b84 100644 --- a/crates/oaknode/src/footage.rs +++ b/crates/oaknode/src/footage.rs @@ -57,6 +57,14 @@ pub struct FootageBehavior { pub proxy_video_stream_index: i32, /// Proxy preset version. pub proxy_preset_version: i32, + /// Per-footage proxy generation params (C++ `custom_proxy_params_`; + /// `None` = use the global config params). + pub custom_proxy_params: Option, + /// Source start time (timecode embedded in the media; C++ + /// `source_start_time_`). + pub source_start_time: oakcore_rs::Rational, + /// Whether [`Self::source_start_time`] is set. + pub has_source_start_time: bool, /// File last-modified timestamp (ms since epoch). pub timestamp: i64, /// Decoder id recorded at probe time. @@ -78,6 +86,9 @@ impl FootageBehavior { proxy_state: 0, proxy_video_stream_index: -1, proxy_preset_version: 0, + custom_proxy_params: None, + source_start_time: oakcore_rs::Rational::new(0, 1), + has_source_start_time: false, timestamp: 0, decoder: String::new(), valid: false, @@ -123,6 +134,12 @@ impl FootageBehavior { self.decoder = desc.decoder().to_string(); self.streams = streams; self.timestamp = timestamp; + self.has_source_start_time = desc.has_source_start_time(); + self.source_start_time = if self.has_source_start_time { + desc.source_start_time() + } else { + oakcore_rs::Rational::new(0, 1) + }; self.valid = true; Ok(()) } @@ -227,6 +244,46 @@ impl FootageBehavior { self.proxy_enabled = false; } + /// Set the per-footage proxy generation params (C++ + /// `set_custom_proxy_params`). + pub fn set_custom_proxy_params(&mut self, params: oakcodec::proxymanager::ProxyParams) { + self.custom_proxy_params = Some(params); + } + + /// Drop the per-footage proxy generation params (C++ + /// `clear_custom_proxy_params`). + pub fn clear_custom_proxy_params(&mut self) { + self.custom_proxy_params = None; + } + + /// Whether per-footage proxy generation params are set (C++ + /// `has_custom_proxy_params`). + pub fn has_custom_proxy_params(&self) -> bool { + self.custom_proxy_params.is_some() + } + + /// The proxy generation params to use for this footage: the custom + /// params when set, otherwise the global config params (C++ + /// `get_effective_proxy_params`). + pub fn effective_proxy_params(&self) -> oakcodec::proxymanager::ProxyParams { + self.custom_proxy_params + .clone() + .unwrap_or_else(oakcodec::proxymanager::ProxyManager::proxy_params_from_config) + } + + /// Set the source start time (C++ `set_source_start_time`; the Rust + /// probe records it, the serializer persists it). + pub fn set_source_start_time(&mut self, time: oakcore_rs::Rational) { + self.source_start_time = time; + self.has_source_start_time = true; + } + + /// Clear the source start time (C++ `clear_source_start_time`). + pub fn clear_source_start_time(&mut self) { + self.source_start_time = oakcore_rs::Rational::new(0, 1); + self.has_source_start_time = false; + } + /// Constructor for the serializer: the C++ `Footage` input surface /// (`file_in` + the viewer parameter stream arrays) with an unprobed /// behavior (`// CPP-PARITY: footage.cpp:83`, `viewer.cpp:84`). @@ -277,6 +334,9 @@ impl NodeBehavior for FootageBehavior { proxy_state: self.proxy_state, proxy_video_stream_index: self.proxy_video_stream_index, proxy_preset_version: self.proxy_preset_version, + custom_proxy_params: self.custom_proxy_params.clone(), + source_start_time: self.source_start_time, + has_source_start_time: self.has_source_start_time, timestamp: self.timestamp, decoder: self.decoder.clone(), valid: self.valid, @@ -296,15 +356,42 @@ impl NodeBehavior for FootageBehavior { if self.timestamp != 0 { writer.text_element("timestamp", &self.timestamp.to_string()); } - if !self.proxy.is_empty() || self.proxy_enabled { + if !self.proxy.is_empty() || self.proxy_enabled || self.custom_proxy_params.is_some() { writer.start_element("proxy"); writer.attribute("enabled", if self.proxy_enabled { "1" } else { "0" }); - writer.attribute("state", &self.proxy_state.to_string()); + // The C++ writes the state name (footage.cpp save_custom); the + // reader accepts both the name and the legacy numeric form. + let state = oakcodec::proxymanager::ProxyState::try_from(self.proxy_state) + .unwrap_or(oakcodec::proxymanager::ProxyState::Missing); + writer.attribute( + "state", + &oakcodec::proxymanager::ProxyManager::proxy_state_to_string(state), + ); writer.attribute("stream", &self.proxy_video_stream_index.to_string()); writer.attribute("preset", &self.proxy_preset_version.to_string()); + if let Some(custom) = &self.custom_proxy_params { + writer.attribute("custom", "1"); + writer.attribute("pwidth", &custom.width.to_string()); + writer.attribute("pheight", &custom.height.to_string()); + writer.attribute("pdivider", &custom.divider.to_string()); + writer.attribute("pcrf", &custom.crf.to_string()); + writer.attribute("ppreset", preset_str(custom)); + writer.attribute("pext", extension_str(custom)); + writer.attribute("paudio", if custom.include_audio != 0 { "1" } else { "0" }); + } writer.characters(&self.proxy); writer.end_element(); // proxy } + if self.has_source_start_time { + writer.start_element("sourcestarttime"); + writer.attribute("source", ""); + writer.characters(&format!( + "{}/{}", + self.source_start_time.numerator(), + self.source_start_time.denominator() + )); + writer.end_element(); // sourcestarttime + } if !self.streams.is_empty() { writer.start_element("streams"); for s in &self.streams { @@ -335,10 +422,9 @@ impl NodeBehavior for FootageBehavior { } /// Custom project load. C++ segments without a Rust counterpart - /// (`sourcestarttime`, `viewer` workarea/markers) are skipped. The - /// filename falls back to the `file_in` input when the file carries - /// no `` element (the C++ convention; `` is a - /// Rust addition). + /// (`viewer` workarea/markers) are skipped. The filename falls back + /// to the `file_in` input when the file carries no `` + /// element (the C++ convention; `` is a Rust addition). fn load_custom( &mut self, core: &mut NodeCore, @@ -355,9 +441,14 @@ impl NodeBehavior for FootageBehavior { .attribute("enabled") .map(|v| v == "1") .unwrap_or(false); + // The C++ writes the state name; the numeric form is the + // legacy Rust spelling (both accepted). self.proxy_state = reader .attribute("state") - .and_then(|v| v.parse().ok()) + .map(|v| { + oakcodec::proxymanager::ProxyManager::proxy_state_from_string(&v) + as i32 + }) .unwrap_or(0); self.proxy_video_stream_index = reader .attribute("stream") @@ -367,8 +458,58 @@ impl NodeBehavior for FootageBehavior { .attribute("preset") .and_then(|v| v.parse().ok()) .unwrap_or(0); + if reader.attribute("custom").map(|v| v == "1").unwrap_or(false) { + let mut custom = oakcodec::proxymanager::ProxyParams::default(); + if let Some(v) = reader.attribute("pwidth").and_then(|v| v.parse().ok()) { + custom.width = v; + } + if let Some(v) = reader.attribute("pheight").and_then(|v| v.parse().ok()) { + custom.height = v; + } + if let Some(v) = reader.attribute("pdivider").and_then(|v| v.parse().ok()) { + custom.divider = v; + } + if let Some(v) = reader.attribute("pcrf").and_then(|v| v.parse().ok()) { + custom.crf = v; + } + if let Some(v) = reader.attribute("ppreset") { + if !v.is_empty() { + let mut a = [0u8; 32]; + let n = v.as_bytes().len().min(31); + a[..n].copy_from_slice(&v.as_bytes()[..n]); + custom.preset = a; + } + } + if let Some(v) = reader.attribute("pext") { + if !v.is_empty() { + let mut a = [0u8; 32]; + let n = v.as_bytes().len().min(31); + a[..n].copy_from_slice(&v.as_bytes()[..n]); + custom.extension = a; + } + } + if let Some(v) = reader.attribute("paudio") { + custom.include_audio = if v == "1" { 1 } else { 0 }; + } + self.custom_proxy_params = Some(custom); + } self.proxy = reader.read_element_text(); } + "sourcestarttime" => { + let _source = reader.attribute("source").unwrap_or_default(); + let text = reader.read_element_text(); + let mut parts = text.split('/'); + let (num, den) = ( + parts.next().and_then(|v| v.trim().parse::().ok()), + parts.next().and_then(|v| v.trim().parse::().ok()), + ); + if let (Some(num), Some(den)) = (num, den) { + if den != 0 { + self.source_start_time = oakcore_rs::Rational::new(num, den); + self.has_source_start_time = true; + } + } + } "streams" => { self.streams.clear(); while reader.next_start_element() { @@ -525,6 +666,26 @@ fn streams_from_description( streams } +/// View a proxy param's NUL-terminated preset bytes as a `&str`. +fn preset_str(params: &oakcodec::proxymanager::ProxyParams) -> &str { + let end = params + .preset + .iter() + .position(|&b| b == 0) + .unwrap_or(params.preset.len()); + std::str::from_utf8(¶ms.preset[..end]).unwrap_or("") +} + +/// View a proxy param's NUL-terminated extension bytes as a `&str`. +fn extension_str(params: &oakcodec::proxymanager::ProxyParams) -> &str { + let end = params + .extension + .iter() + .position(|&b| b == 0) + .unwrap_or(params.extension.len()); + std::str::from_utf8(¶ms.extension[..end]).unwrap_or("") +} + /// A stream duration in timebase ticks as rational seconds. `0/1` when /// the duration or the timebase is unusable (FFmpeg reports /// `AV_NOPTS_VALUE` for streams without a duration). diff --git a/crates/oaktask/src/proxy.rs b/crates/oaktask/src/proxy.rs index 1b1b802d7..b8b4fe9ea 100644 --- a/crates/oaktask/src/proxy.rs +++ b/crates/oaktask/src/proxy.rs @@ -211,8 +211,12 @@ impl TaskBehavior for ProxyTask { fn run(&mut self, task: &mut Task) -> Result<()> { // Direct call into oakcodec's proxy manager (single-lib // unification: the old two-stage C ABI getter is gone; an empty - // string means "not found"). - let ffmpeg_path = ProxyManager::find_ffmpeg(""); + // string means "not found"). The `FFmpegPath` config takes + // precedence when set (C++ `OAK_CONFIG("FFmpegPath")`). + let configured = oakcommon::configstore::ConfigStore::instance() + .get(None, "FFmpegPath") + .unwrap_or_default(); + let ffmpeg_path = ProxyManager::find_ffmpeg(&configured); if ffmpeg_path.is_empty() { task.set_error( "Failed to generate proxy: ffmpeg executable was not found. Set the ffmpeg path in Preferences > Disk > Proxy Settings.", diff --git a/src/actions.rs b/src/actions.rs index 3c8908b5a..753deefac 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -226,6 +226,11 @@ define_actions! { Snapping { cpp: "snapping", i18n: "menu.tools.snapping", keys: ["s"], route: Global, menu_id: 1110 }; UseProxyMedia { cpp: "useproxymedia", i18n: "menu.tools.use_proxy", keys: [], route: Global, menu_id: 1111 }; ProxySettings { cpp: "proxysettings", i18n: "menu.tools.proxy_settings", keys: [], route: Global, menu_id: 1112 }; + // The timeline clip context menu's synchronize entries (context-menu + // only — they appear in the clip menu, not the menu bar). + SyncBySourceTime { cpp: "syncsourcetime", i18n: "timeline.context.sync_source_time", keys: [], route: FocusedPanel, menu_id: 1130 }; + SyncByWaveform { cpp: "syncwaveform", i18n: "timeline.context.sync_waveform", keys: ["ctrl-shift-w"], route: FocusedPanel, menu_id: 1131 }; + SyncByWaveformSpeed { cpp: "syncwaveformspeed", i18n: "timeline.context.sync_waveform_speed", keys: [], route: FocusedPanel, menu_id: 1132 }; Preferences { cpp: "prefs", i18n: "menu.view.preferences", keys: ["secondary-,"], route: Global, menu_id: 305 }; // --- Help --------------------------------------------------------------- @@ -497,6 +502,15 @@ mod tests { for entry in crate::app::make_menus_for_test() { collect(&entry.menu, &mut ids); } + // Context-menu-only actions (the timeline clip menu's synchronize + // group) appear in the clip menu instead of the menu bar. + collect( + &crate::panels::timeline::clip_menu( + crate::oakui::engine::SyncEligibility::default(), + &[], + ), + &mut ids, + ); for entry in REGISTRY { assert!( ids.contains(&entry.menu_id()), diff --git a/src/app.rs b/src/app.rs index ae3209956..a72629a86 100644 --- a/src/app.rs +++ b/src/app.rs @@ -106,6 +106,7 @@ mod modal_ids { pub const MANAGER: usize = 6; pub const MANAGER_RENAME: usize = 7; pub const MANAGER_DELETE: usize = 8; + pub const PROXY: usize = 9; } /// What a picked platform-dialog path should do. @@ -151,6 +152,11 @@ enum ModalState { }, /// The manager's delete confirmation. ManagerDelete { modal: Entity, uuid: String }, + /// The proxy settings dialog (Tools > Proxy Settings). + Proxy { + modal: Entity, + content: Entity>, + }, } /// A running export: the session the tick loop drains for progress. @@ -168,7 +174,8 @@ impl ModalState { | ModalState::Progress { modal, .. } | ModalState::Manager { modal, .. } | ModalState::ManagerRename { modal, .. } - | ModalState::ManagerDelete { modal, .. } => Some(modal.clone()), + | ModalState::ManagerDelete { modal, .. } + | ModalState::Proxy { modal, .. } => Some(modal.clone()), } } } @@ -903,6 +910,15 @@ impl OakApp { println!("[tools] selected: {tool:?} (placeholder behavior)"); self.rebuild_menu_bar(cx); } + // --- Proxy (Tools) --------------------------------------------- + A::UseProxyMedia => { + let enabled = !self.engine.read(cx).use_proxy_media(); + self.engine.update(cx, |engine, cx| { + engine.set_use_proxy_media(enabled, cx) + }); + self.rebuild_menu_bar(cx); + } + A::ProxySettings => self.open_proxy_dialog(cx), // --- everything else is a placeholder -------------------------- other => println!( "[action] {} not wired yet (placeholder)", @@ -1140,6 +1156,7 @@ impl OakApp { loop_playback: self.loop_playback, show_all: self.show_all, full_screen: self.full_screen, + use_proxy_media: self.engine.read(cx).use_proxy_media(), }; let Ok(menu_bar) = cx.update_window(*handle, |_root, window, app| { app.new(|cx| MenuBar::new(1, make_menus(state), window, cx)) @@ -1602,6 +1619,41 @@ impl OakApp { } } + /// Opens the proxy settings dialog (Tools > Proxy Settings; the C++ + /// `ProxyDialog`): the global generation settings plus the footage + /// proxy list with Generate / Delete buttons. + fn open_proxy_dialog(&mut self, cx: &mut Context) { + if !matches!(self.modal, ModalState::None) { + return; + } + let engine = self.engine.clone(); + self.spawn_modal(cx, move |window, app| { + let content = + app.new(|cx| crate::dialogs::ProxyDialogContent::new(engine, window, cx)); + let modal = app.new(|cx| { + Modal::new( + modal_ids::PROXY, + ModalOptions::new(crate::i18n::tr("proxydialog.title"), px(560.0)) + .with_button(DialogButton::new( + crate::i18n::tr("proxydialog.generate"), + gpui_widgets::dialog::DialogButtonRole::Secondary, + )) + .with_button(DialogButton::new( + crate::i18n::tr("proxydialog.delete"), + gpui_widgets::dialog::DialogButtonRole::Secondary, + )) + .with_button(DialogButton::primary(crate::i18n::tr( + "proxydialog.close", + ))), + window, + cx, + ) + .with_content(content.clone()) + }); + ModalState::Proxy { modal, content } + }); + } + /// Opens the export dialog. fn open_export_dialog(&mut self, cx: &mut Context) { if self.engine.read(cx).current_sequence().is_none() { @@ -1773,6 +1825,19 @@ impl OakApp { self.back_to_manager(cx); } } + modal_ids::PROXY => { + if let ModalState::Proxy { content, .. } = &self.modal { + let content = content.clone(); + match *button { + 0 => content.update(cx, |dialog, cx| dialog.generate(cx)), + 1 => content.update(cx, |dialog, cx| dialog.delete(cx)), + _ => { + content.update(cx, |dialog, cx| dialog.accept(cx)); + self.close_modal(cx); + } + } + } + } _ => {} }, ModalEvent::Dismissed { control } => match *control { @@ -1789,6 +1854,9 @@ impl OakApp { self.commit_preferences(cx); self.close_modal(cx); } + // Escape closes the proxy dialog without applying (the + // Close button is the apply path, like the C++ accept()). + modal_ids::PROXY => self.close_modal(cx), _ => self.close_modal(cx), }, } @@ -1839,6 +1907,7 @@ struct MenuState { loop_playback: bool, show_all: bool, full_screen: bool, + use_proxy_media: bool, } impl MenuState { @@ -1852,6 +1921,9 @@ impl MenuState { loop_playback: false, show_all: false, full_screen: false, + use_proxy_media: oakcommon::configstore::ConfigStore::instance() + .get_bool(None, "UseProxyMedia", 1) + != 0, } } } @@ -1918,7 +1990,7 @@ fn make_menus(state: MenuState) -> Vec { tools.push(item); } tools.push(menu_item(A::Snapping).with_checked(state.snapping).separated()); - tools.push(menu_item(A::UseProxyMedia)); + tools.push(menu_item(A::UseProxyMedia).with_checked(state.use_proxy_media)); tools.push(menu_item(A::ProxySettings)); vec![ @@ -2410,6 +2482,15 @@ mod tests { for entry in make_menus_for_test() { collect(&entry.menu, &mut ids); } + // Context-menu-only actions (the timeline clip menu's synchronize + // group) appear in the clip menu instead of the menu bar. + collect( + &crate::panels::timeline::clip_menu( + crate::oakui::engine::SyncEligibility::default(), + &[], + ), + &mut ids, + ); for entry in crate::actions::REGISTRY { if entry.default_keys.is_empty() { continue; diff --git a/src/dialogs.rs b/src/dialogs.rs index 94dd61348..22b897d3e 100644 --- a/src/dialogs.rs +++ b/src/dialogs.rs @@ -41,9 +41,10 @@ use crate::oakui::real::{ config_get_bool, config_get_int, config_get_string, config_set_bool, config_set_int, config_set_string, encoding_formats, proxy_dividers, renderer_backends, set_audio_input_device, set_audio_output_device, set_theme_dark, theme_is_dark, - CONFIG_KEY_DEFAULT_TRANSITION_SEC, CONFIG_KEY_DISK_CACHE_PATH, CONFIG_KEY_PROXY_DIVIDER, - CONFIG_KEY_RENDERER_BACKEND, CONFIG_KEY_SNAPSHOT_INTERVAL_SEC, CONFIG_KEY_USE_PROXY, - DEFAULT_SNAPSHOT_INTERVAL_SEC, DEFAULT_TRANSITION_SEC, EXPORT_FORMAT_MP4, + CONFIG_KEY_DEFAULT_TRANSITION_SEC, CONFIG_KEY_DISK_CACHE_PATH, CONFIG_KEY_FFMPEG_PATH, + CONFIG_KEY_PROXY_DIVIDER, CONFIG_KEY_RENDERER_BACKEND, CONFIG_KEY_SNAPSHOT_INTERVAL_SEC, + CONFIG_KEY_USE_PROXY, DEFAULT_SNAPSHOT_INTERVAL_SEC, DEFAULT_TRANSITION_SEC, + EXPORT_FORMAT_MP4, }; // --------------------------------------------------------------------------- @@ -708,3 +709,413 @@ impl Render for ExportDialogContent { ) } } + +// --------------------------------------------------------------------------- +// Proxy settings (the C++ Tools > Proxy Settings dialog) +// --------------------------------------------------------------------------- + +/// The ffmpeg encoder presets the proxy dialog offers (the C++ +/// `ProxyDialog` preset combo, same order). +pub const PROXY_PRESETS: &[&str] = &[ + "ultrafast", + "superfast", + "veryfast", + "faster", + "fast", + "medium", + "slow", + "slower", + "veryslow", +]; + +/// The proxy settings dialog content: the global generation settings +/// plus the per-footage proxy list (the gpui port of the C++ +/// `ProxyDialog`). All footage of the open project is listed — the gpui +/// shell opens the dialog from the Tools menu without a footage +/// selection, so the C++ "selected footage" group becomes "footage". +pub struct ProxyDialogContent { + engine: Entity, + divider: Entity, + width: Entity, + height: Entity, + crf: Entity, + preset: Entity, + include_audio: Entity, + ffmpeg_path: Entity, + custom_params: Entity, + /// Snapshot of the footage rows (refreshed after generate / delete). + rows: Vec, + /// The divider values in dropdown order (1 = custom size). + dividers: Vec, +} + +impl ProxyDialogContent { + /// Builds the content seeded from the global config params (the C++ + /// `oakengine_proxy_params_from_config` defaults). + pub fn new(engine: Entity, window: &mut Window, cx: &mut Context) -> Self { + let params = crate::oakui::engine::proxy_params_from_config(); + + let dividers: Vec = vec![1, 2, 4, 8]; + let divider_options = dividers + .iter() + .enumerate() + .map(|(i, d)| ComboBoxOption::new(i, divider_label(*d))) + .collect(); + let divider = cx.new(|cx| ComboBox::new(21, divider_options, window, cx)); + let divider_selected = dividers + .iter() + .position(|d| *d == params.divider) + .unwrap_or(0); + divider.update(cx, |combo, cx| combo.set_selected(Some(divider_selected), cx)); + + let width = cx.new(|cx| { + SpinBox::new( + 22, + SliderModel::new( + ValueKind::Integer, + 160.0, + 4096.0, + 16.0, + f64::from(params.width), + ), + window, + cx, + ) + }); + let height = cx.new(|cx| { + SpinBox::new( + 23, + SliderModel::new( + ValueKind::Integer, + 120.0, + 2160.0, + 8.0, + f64::from(params.height), + ), + window, + cx, + ) + }); + let crf = cx.new(|cx| { + SpinBox::new( + 24, + SliderModel::new(ValueKind::Integer, 0.0, 51.0, 1.0, f64::from(params.crf)), + window, + cx, + ) + }); + + let preset_options = PROXY_PRESETS + .iter() + .enumerate() + .map(|(i, name)| ComboBoxOption::new(i, *name)) + .collect(); + let preset = cx.new(|cx| ComboBox::new(25, preset_options, window, cx)); + let preset_selected = PROXY_PRESETS + .iter() + .position(|name| *name == params.preset) + .unwrap_or(2); + preset.update(cx, |combo, cx| combo.set_selected(Some(preset_selected), cx)); + + let include_audio = cx.new(|cx| { + CheckBox::new( + 26, + if params.include_audio { + CheckState::Checked + } else { + CheckState::Unchecked + }, + window, + cx, + ) + .with_label(i18n::tr("proxydialog.include_audio")) + }); + + let ffmpeg_path = cx.new(|cx| { + let editor = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx)); + PathField { editor } + }); + ffmpeg_path.update(cx, |field, cx| { + field.set_path(config_get_string(CONFIG_KEY_FFMPEG_PATH), cx) + }); + + let rows = engine.read(cx).proxy_rows(); + let any_custom = rows.iter().any(|row| row.has_custom); + let custom_params = cx.new(|cx| { + CheckBox::new( + 27, + if any_custom { + CheckState::Checked + } else { + CheckState::Unchecked + }, + window, + cx, + ) + .with_label(i18n::tr("proxydialog.custom")) + }); + + Self { + engine, + divider, + width, + height, + crf, + preset, + include_audio, + ffmpeg_path, + custom_params, + rows, + dividers, + } + } + + /// The generation params currently edited in the dialog (the C++ + /// `current_params`). + pub fn current_params(&self, cx: &App) -> crate::oakui::engine::ProxyParamsUi { + let divider = self + .divider + .read(cx) + .selected() + .and_then(|i| self.dividers.get(i)) + .copied() + .unwrap_or(1); + let preset = self + .preset + .read(cx) + .selected() + .and_then(|i| PROXY_PRESETS.get(i)) + .unwrap_or(&"veryfast") + .to_string(); + crate::oakui::engine::ProxyParamsUi { + width: self.width.read(cx).value().to_f64() as i32, + height: self.height.read(cx).value().to_f64() as i32, + divider, + crf: self.crf.read(cx).value().to_f64() as i32, + preset, + include_audio: self.include_audio.read(cx).state() == CheckState::Checked, + } + } + + /// Writes the global settings into the config store (the C++ + /// `save_global_settings`). + pub fn save_global_settings(&self, cx: &App) { + let params = self.current_params(cx); + config_set_int("ProxyWidth", i64::from(params.width)); + config_set_int("ProxyHeight", i64::from(params.height)); + config_set_int("ProxyDivider", i64::from(params.divider)); + config_set_int("ProxyCRF", i64::from(params.crf)); + config_set_string("ProxyPreset", ¶ms.preset); + config_set_bool("ProxyIncludeAudio", params.include_audio); + config_set_string( + CONFIG_KEY_FFMPEG_PATH, + self.ffmpeg_path.read(cx).path(cx).trim(), + ); + } + + /// Generates proxies for every footage row with a video stream (the + /// C++ Generate Proxies button): with the custom checkbox set, the + /// edited params become each footage's custom params first. + pub fn generate(&mut self, cx: &mut Context) { + let custom = self.custom_params.read(cx).state() == CheckState::Checked; + let params = self.current_params(cx); + let ids: Vec<(u64, bool)> = self + .rows + .iter() + .map(|row| (row.id, row.can_generate)) + .collect(); + for (id, can_generate) in ids { + if !can_generate { + continue; + } + if custom { + self.engine.update(cx, |engine, cx| { + engine.proxy_set_custom_params(id, params.clone(), cx) + }); + } + if let Err(err) = self.engine.update(cx, |engine, cx| engine.proxy_generate(id, cx)) + { + println!("[proxy] generate failed for {id}: {err}"); + } + } + self.refresh(cx); + } + + /// Deletes every footage row's proxy (the C++ Delete Proxies button). + pub fn delete(&mut self, cx: &mut Context) { + let ids: Vec = self.rows.iter().map(|row| row.id).collect(); + for id in ids { + self.engine.update(cx, |engine, cx| engine.proxy_delete(id, cx)); + } + self.refresh(cx); + } + + /// Applies the dialog (the C++ `accept`): saves the global settings + /// and sets or clears each footage's custom params per the checkbox. + pub fn accept(&mut self, cx: &mut Context) { + self.save_global_settings(cx); + let custom = self.custom_params.read(cx).state() == CheckState::Checked; + let params = self.current_params(cx); + let ids: Vec = self.rows.iter().map(|row| row.id).collect(); + for id in ids { + if custom { + self.engine.update(cx, |engine, cx| { + engine.proxy_set_custom_params(id, params.clone(), cx) + }); + } else { + self.engine + .update(cx, |engine, cx| engine.proxy_clear_custom_params(id, cx)); + } + } + self.refresh(cx); + } + + /// Re-reads the footage rows (after generate / delete / accept). + pub fn refresh(&mut self, cx: &mut Context) { + self.rows = self.engine.read(cx).proxy_rows(); + cx.notify(); + } +} + +/// The dropdown label of a resolution divider (the C++ combo labels). +fn divider_label(divider: i32) -> String { + match divider { + 1 => i18n::tr("proxydialog.resolution.custom").into(), + 2 => i18n::tr("proxydialog.resolution.half").into(), + 4 => i18n::tr("proxydialog.resolution.quarter").into(), + 8 => i18n::tr("proxydialog.resolution.eighth").into(), + other => format!("1/{other}"), + } +} + +/// The display string of a proxy lifecycle state. +fn proxy_state_label(state: crate::oakui::engine::ProxyMediaState) -> String { + use crate::oakui::engine::ProxyMediaState; + match state { + ProxyMediaState::Missing => i18n::tr("proxydialog.state.missing"), + ProxyMediaState::Generating => i18n::tr("proxydialog.state.generating"), + ProxyMediaState::Ready => i18n::tr("proxydialog.state.ready"), + ProxyMediaState::Failed => i18n::tr("proxydialog.state.failed"), + } + .into() +} + +impl Render for ProxyDialogContent { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.default_colors().clone(); + + let footage_list = div().flex().flex_col().gap_1().children( + self.rows + .iter() + .map(|row| { + let mut state = proxy_state_label(row.state); + if row.has_custom { + state.push_str(&i18n::tr("proxydialog.custom_suffix")); + } + let enabled = row.enabled && row.state == crate::oakui::engine::ProxyMediaState::Ready; + let dot = if enabled { + colors.selected + } else { + colors.disabled + }; + div() + .flex() + .items_center() + .gap_2() + .child(div().w(px(8.0)).h(px(8.0)).rounded_full().bg(dot)) + .child( + div() + .flex_1() + .overflow_hidden() + .text_ellipsis() + .text_color(colors.text) + .child(row.name.clone()), + ) + .child(div().text_color(colors.disabled).text_xs().child(state)) + }) + .collect::>(), + ); + + let footage_group = div() + .flex() + .flex_col() + .gap_2() + .child(section_header( + &colors, + i18n::tr("proxydialog.footage_group").into(), + )) + .child( + div() + .id("proxy-footage-list") + .max_h(px(160.0)) + .overflow_y_scroll() + .child(if self.rows.is_empty() { + div() + .text_color(colors.disabled) + .text_xs() + .child(i18n::tr("proxydialog.no_footage")) + } else { + footage_list + }), + ) + .child(self.custom_params.clone()); + + let settings_group = div() + .flex() + .flex_col() + .gap_2() + .child(section_header( + &colors, + i18n::tr("proxydialog.global").into(), + )) + .child(form_row( + &colors, + i18n::tr("proxydialog.resolution").into(), + self.divider.clone(), + )) + .child( + div() + .flex() + .gap_3() + .child(form_row( + &colors, + i18n::tr("proxydialog.width").into(), + self.width.clone(), + )) + .child(form_row( + &colors, + i18n::tr("proxydialog.height").into(), + self.height.clone(), + )), + ) + .child( + div() + .flex() + .gap_3() + .child(form_row( + &colors, + i18n::tr("proxydialog.crf").into(), + self.crf.clone(), + )) + .child(form_row( + &colors, + i18n::tr("proxydialog.preset").into(), + self.preset.clone(), + )), + ) + .child(self.include_audio.clone()) + .child(form_row( + &colors, + i18n::tr("proxydialog.ffmpeg").into(), + self.ffmpeg_path.clone(), + )); + + div() + .flex() + .flex_col() + .gap_3() + .w_full() + .child(footage_group) + .child(settings_group) + } +} diff --git a/src/i18n.rs b/src/i18n.rs index 96d141f7e..7b7f97a96 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -445,6 +445,31 @@ const EN: &[(&str, &str)] = &[ ("export.hint", "The sequence is exported through the oaktask export path; progress is shown in the dialog."), ("export.progress.title", "Exporting"), ("export.progress.label", "Rendering frames…"), + // --- proxy settings dialog --- + ("proxydialog.title", "Proxy Settings"), + ("proxydialog.footage_group", "Footage"), + ("proxydialog.no_footage", "No footage in the project"), + ("proxydialog.custom", "Use custom settings for footage"), + ("proxydialog.custom_suffix", " (custom settings)"), + ("proxydialog.global", "Global Proxy Settings"), + ("proxydialog.resolution", "Proxy Resolution"), + ("proxydialog.resolution.custom", "Custom size"), + ("proxydialog.resolution.half", "1/2 of source"), + ("proxydialog.resolution.quarter", "1/4 of source"), + ("proxydialog.resolution.eighth", "1/8 of source"), + ("proxydialog.width", "Proxy Width"), + ("proxydialog.height", "Proxy Height"), + ("proxydialog.crf", "Proxy CRF"), + ("proxydialog.preset", "Proxy Preset"), + ("proxydialog.include_audio", "Include audio in proxies"), + ("proxydialog.ffmpeg", "ffmpeg Executable"), + ("proxydialog.generate", "Generate Proxies"), + ("proxydialog.delete", "Delete Proxies"), + ("proxydialog.close", "Close"), + ("proxydialog.state.missing", "Missing"), + ("proxydialog.state.generating", "Generating"), + ("proxydialog.state.ready", "Ready"), + ("proxydialog.state.failed", "Failed"), // --- color labels --- ("menu.color.label", "Color Label"), ("menu.color.red", "Red"), @@ -847,6 +872,31 @@ const ZH: &[(&str, &str)] = &[ ), ("export.progress.title", "正在导出"), ("export.progress.label", "正在渲染帧…"), + // --- 代理设置对话框 --- + ("proxydialog.title", "代理设置"), + ("proxydialog.footage_group", "素材"), + ("proxydialog.no_footage", "项目中没有素材"), + ("proxydialog.custom", "对素材使用自定义设置"), + ("proxydialog.custom_suffix", "(自定义设置)"), + ("proxydialog.global", "全局代理设置"), + ("proxydialog.resolution", "代理分辨率"), + ("proxydialog.resolution.custom", "自定义尺寸"), + ("proxydialog.resolution.half", "源分辨率的 1/2"), + ("proxydialog.resolution.quarter", "源分辨率的 1/4"), + ("proxydialog.resolution.eighth", "源分辨率的 1/8"), + ("proxydialog.width", "代理宽度"), + ("proxydialog.height", "代理高度"), + ("proxydialog.crf", "代理 CRF"), + ("proxydialog.preset", "代理预设"), + ("proxydialog.include_audio", "代理包含音频"), + ("proxydialog.ffmpeg", "ffmpeg 可执行文件"), + ("proxydialog.generate", "生成代理"), + ("proxydialog.delete", "删除代理"), + ("proxydialog.close", "关闭"), + ("proxydialog.state.missing", "缺失"), + ("proxydialog.state.generating", "生成中"), + ("proxydialog.state.ready", "就绪"), + ("proxydialog.state.failed", "失败"), // --- 颜色标签 --- ("menu.color.label", "颜色标签"), ("menu.color.red", "红色"), diff --git a/src/oakui/engine.rs b/src/oakui/engine.rs index 3045ad9ff..cec2f4c92 100644 --- a/src/oakui/engine.rs +++ b/src/oakui/engine.rs @@ -645,11 +645,274 @@ pub trait AppEngine: /// [`ExportSession`] carries the event channel and the cancel handle. fn start_export(&mut self, format: i32, path: PathBuf) -> Result; + // ------------------------------------------------------------------- + // Proxy media (the C++ Tools > proxy pipeline): global switch, per + // footage state and the generate / delete / reveal entries. Defaults + // degrade to "no proxy support" so engines without a footage surface + // keep compiling. + // ------------------------------------------------------------------- + + /// The global "Use Proxy Media" switch (the C++ `UseProxyMedia` + /// config; preview-only — exports always decode the original media). + fn use_proxy_media(&self) -> bool { + oakcommon::configstore::ConfigStore::instance() + .get_bool(None, "UseProxyMedia", 1) + != 0 + } + + /// Toggles the global "Use Proxy Media" switch and invalidates every + /// footage's rendered frames (the C++ toggles the config and + /// re-renders; the preview path reads the switch on every montage). + fn set_use_proxy_media(&mut self, enabled: bool, cx: &mut Context) { + oakcommon::configstore::ConfigStore::instance().set( + None, + "UseProxyMedia", + if enabled { "true" } else { "false" }, + ); + let _ = cx; + } + + /// The footage rows the proxy dialog's footage mode lists (every + /// footage node in the open project). + fn proxy_rows(&self) -> Vec { + Vec::new() + } + + /// The proxy state of footage `id` (a project-explorer entry id / + /// node identity), or `None` when the entry is not footage. + fn proxy_state(&self, id: u64) -> Option { + let _ = id; + None + } + + /// The full proxy row of footage `id` (the project explorer's + /// per-entry proxy submenu state), or `None` when the entry is not + /// footage. + fn proxy_row(&self, id: u64) -> Option { + let _ = id; + None + } + + /// Starts generating the proxy of footage `id` (a background ffmpeg + /// transcode through `oaktask::ProxyTask`). Progress is reported + /// through [`proxy_task_progress`](Self::proxy_task_progress) and + /// drained on the engine tick; completion invalidates the footage's + /// rendered frames. + fn proxy_generate(&mut self, id: u64, cx: &mut Context) -> Result<(), String> { + let _ = (id, cx); + Err("proxy generation not supported".into()) + } + + /// The in-flight proxy task's label and progress (`0.0..=1.0`), when + /// one is running (the status bar's proxy segment). + fn proxy_task_progress(&self) -> Option<(String, f64)> { + None + } + + /// Deletes footage `id`'s proxy file and clears its proxy fields. + fn proxy_delete(&mut self, id: u64, cx: &mut Context) { + let _ = (id, cx); + } + + /// Toggles footage `id`'s per-footage proxy-use flag (the C++ + /// `Footage::set_proxy_enabled`). + fn proxy_set_enabled(&mut self, id: u64, enabled: bool, cx: &mut Context) { + let _ = (id, enabled, cx); + } + + /// Reveals footage `id`'s proxy file in the file manager + /// (macOS `open -R`); no-op when there is no proxy yet. + fn proxy_reveal(&self, id: u64) { + let _ = id; + } + + /// Sets footage `id`'s custom proxy generation params (the proxy + /// dialog's per-footage "custom" checkbox path). + fn proxy_set_custom_params( + &mut self, + id: u64, + params: ProxyParamsUi, + cx: &mut Context, + ) { + let _ = (id, params, cx); + } + + /// Clears footage `id`'s custom proxy generation params (the footage + /// falls back to the global settings). + fn proxy_clear_custom_params(&mut self, id: u64, cx: &mut Context) { + let _ = (id, cx); + } + + /// The custom proxy params of footage `id` (`None` = global params). + fn proxy_custom_params(&self, id: u64) -> Option { + let _ = id; + None + } + + /// The proxy generation params that would apply to footage `id` + /// (custom when set, otherwise the global config params). + fn proxy_effective_params(&self, id: u64) -> ProxyParamsUi { + let _ = id; + proxy_params_from_config() + } + + /// The distinct footage entries feeding `clips`, as proxy rows (the + /// timeline clip menu's proxy group targets; duplicates collapse). + fn clip_footage_entries(&self, clips: &[ClipId]) -> Vec { + let _ = clips; + Vec::new() + } + + // ------------------------------------------------------------------- + // Audio/video synchronization (the C++ timeline Synchronize menu): + // eligibility counts for the context menu plus the two apply paths. + // ------------------------------------------------------------------- + + /// How many of `clips` can sync by source timecode / by waveform + /// (the context menu enables each entry at ≥ 2). + fn sync_eligibility(&self, clips: &[ClipId]) -> SyncEligibility { + let _ = clips; + SyncEligibility::default() + } + + /// Synchronizes `clips` by their footage's source start timecode + /// (one multi-undo; the C++ `Synchronize Clips by Source Time`). + fn sync_clips_by_source_time(&mut self, clips: Vec, cx: &mut Context) { + let _ = (clips, cx); + } + + /// Synchronizes `clips` by waveform correlation, optionally adjusting + /// speed (one multi-undo; the C++ `Synchronize Clips by Waveform`). + fn sync_clips_by_waveform( + &mut self, + clips: Vec, + adjust_speed: bool, + cx: &mut Context, + ) { + let _ = (clips, adjust_speed, cx); + } + /// The display name of the engine backend ("mock" / "real"), shown in /// the status bar. fn backend_name(&self) -> &'static str; } +/// The lifecycle state of one footage's proxy (the UI mirror of +/// `oakcodec::proxymanager::ProxyState`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProxyMediaState { + /// No proxy on disk (the menu offers "Generate"). + Missing, + /// A transcode is running (or a stale working file remains). + Generating, + /// The proxy file is on disk and usable. + Ready, + /// The last generation attempt failed. + Failed, +} + +/// The proxy generation parameters the proxy dialog edits (the app-side +/// mirror of `oakcodec::proxymanager::ProxyParams`, minus the fixed +/// version / extension). +#[derive(Debug, Clone, PartialEq)] +pub struct ProxyParamsUi { + /// Absolute target width (ignored when `divider > 1`). + pub width: i32, + /// Absolute target height (ignored when `divider > 1`). + pub height: i32, + /// Source resolution divider (1 = absolute width/height, 2/4/8). + pub divider: i32, + /// x264 crf. + pub crf: i32, + /// ffmpeg encoder preset name (e.g. "veryfast"). + pub preset: String, + /// Include the audio track. + pub include_audio: bool, +} + +/// The proxy generation params from the global config (the dialog's +/// default values): `ProxyWidth`/`ProxyHeight`/`ProxyDivider`/`ProxyCRF`/ +/// `ProxyPreset`/`ProxyIncludeAudio`. +pub fn proxy_params_from_config() -> ProxyParamsUi { + let codec = oakcodec::proxymanager::ProxyManager::proxy_params_from_config(); + let end = |a: &[u8; 32]| a.iter().position(|&b| b == 0).unwrap_or(a.len()); + let preset = std::str::from_utf8(&codec.preset[..end(&codec.preset)]) + .unwrap_or("") + .to_string(); + ProxyParamsUi { + width: codec.width, + height: codec.height, + divider: codec.divider, + crf: codec.crf, + preset, + include_audio: codec.include_audio != 0, + } +} + +impl ProxyParamsUi { + /// The codec-side params these UI params stand for. + pub fn to_codec(&self) -> oakcodec::proxymanager::ProxyParams { + let mut p = oakcodec::proxymanager::ProxyParams::default(); + p.width = self.width; + p.height = self.height; + p.divider = self.divider; + p.crf = self.crf; + p.include_audio = if self.include_audio { 1 } else { 0 }; + let bytes = self.preset.as_bytes(); + let n = bytes.len().min(31); + p.preset[..n].copy_from_slice(&bytes[..n]); + p + } + + /// UI params from codec-side params. + pub fn from_codec(codec: &oakcodec::proxymanager::ProxyParams) -> ProxyParamsUi { + let end = |a: &[u8; 32]| a.iter().position(|&b| b == 0).unwrap_or(a.len()); + ProxyParamsUi { + width: codec.width, + height: codec.height, + divider: codec.divider, + crf: codec.crf, + preset: std::str::from_utf8(&codec.preset[..end(&codec.preset)]) + .unwrap_or("") + .to_string(), + include_audio: codec.include_audio != 0, + } + } +} + +/// One footage row of the proxy dialog's footage-mode list. +#[derive(Debug, Clone, PartialEq)] +pub struct ProxyFootageRow { + /// The footage's project-explorer entry id (its node identity). + pub id: u64, + /// The footage's display name. + pub name: String, + /// The proxy's lifecycle state. + pub state: ProxyMediaState, + /// Whether preview playback uses this footage's proxy (the per + /// footage switch). + pub enabled: bool, + /// Whether the footage carries custom generation params. + pub has_custom: bool, + /// Whether the footage has a valid video stream (the proxy pipeline + /// only applies to video-bearing footage). + pub can_generate: bool, + /// Whether the footage has a proxy path recorded (the timeline proxy + /// menu's Reveal / Delete enable condition; a superset of `state` + /// being `Ready`, since a recorded path can outlive its file). + pub has_proxy: bool, +} + +/// The number of clips eligible for each synchronization mode (the +/// context menu enables an entry at ≥ 2, the C++ enable conditions). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct SyncEligibility { + /// Clips with a footage source start timecode. + pub source_time: usize, + /// Clips with at least one validated waveform window. + pub waveform: usize, +} + /// A single progress event from a running export task. #[derive(Debug, Clone, PartialEq)] pub enum ExportEvent { diff --git a/src/oakui/mock.rs b/src/oakui/mock.rs index 2278288c0..49e281240 100644 --- a/src/oakui/mock.rs +++ b/src/oakui/mock.rs @@ -523,6 +523,16 @@ pub struct MockEngine { undo_calls: u64, /// See [`MockEngine::undo_calls`]. redo_calls: u64, + /// Per-footage proxy lifecycle state (the demo proxy pipeline; ids are + /// the explorer entry ids, `Missing` when absent). + proxy_states: HashMap, + /// Per-footage proxy-use flag (the demo's per-footage switch). + proxy_enabled: HashMap, + /// Per-footage custom proxy generation params (the proxy dialog's + /// custom checkbox path). + proxy_custom: HashMap, + /// The demo's global "Use Proxy Media" switch. + use_proxy: bool, } impl MockEngine { @@ -777,6 +787,10 @@ impl MockEngine { workarea: None, undo_calls: 0, redo_calls: 0, + proxy_states: HashMap::new(), + proxy_enabled: HashMap::new(), + proxy_custom: HashMap::new(), + use_proxy: true, }; // The demo graph is born connected: derive every port's `connected` // flag from the edge list. @@ -1022,6 +1036,35 @@ impl MockEngine { }) } + /// Demo synchronization: moves every selected clip so its in point + /// lines up with the earliest selected in point (the real engine's + /// source-timecode alignment, approximated on the mock's flat track + /// list). Locked tracks are skipped; clips keep their length. + fn mock_sync_clips(&mut self, clips: &[ClipId]) { + let mut positions: Vec<(usize, usize, i64, i64)> = Vec::new(); + for clip in clips { + if let Some((track, index)) = self.mock_clip_position(*clip) { + if self.tracks[track].locked { + continue; + } + let range = self.tracks[track].clips[index].range; + positions.push((track, index, range.start.0, range.end.0)); + } + } + if positions.len() < 2 { + return; + } + let anchor = positions.iter().map(|p| p.2).min().unwrap_or(0); + for (track, index, start, end) in positions { + if start == anchor { + continue; + } + let length = end - start; + self.tracks[track].clips[index].range = + FrameRange::new(Frame(anchor), Frame(anchor + length)); + } + } + /// A clip id larger than every existing one (for splits). fn next_mock_clip_id(&self) -> u64 { self.tracks @@ -1753,6 +1796,162 @@ impl AppEngine for MockEngine { }) } + fn use_proxy_media(&self) -> bool { + self.use_proxy + } + + fn set_use_proxy_media(&mut self, enabled: bool, cx: &mut Context) { + self.use_proxy = enabled; + // The mock's viewer frames are synthetic; still drop the cache so + // the toggle behaves like the real engine. + self.cpu_frame_cache.lock().unwrap().clear(); + cx.notify(); + } + + fn proxy_rows(&self) -> Vec { + // Every explorer footage entry gets a row; audio-only entries keep + // `can_generate` off (the proxy pipeline is video-only). + let mut rows = Vec::new(); + for root in self.roots() { + for entry in std::iter::once(root.clone()).chain(self.children(root.id)) { + if entry.is_dir { + continue; + } + let is_audio = crate::oakui::filename_is_audio(&entry.name.to_string()); + let state = self + .proxy_states + .get(&entry.id) + .copied() + .unwrap_or(crate::oakui::engine::ProxyMediaState::Missing); + rows.push(crate::oakui::engine::ProxyFootageRow { + id: entry.id, + name: entry.name.to_string(), + state, + enabled: self.proxy_enabled.get(&entry.id).copied().unwrap_or(false), + has_custom: self.proxy_custom.contains_key(&entry.id), + can_generate: !is_audio, + has_proxy: state != crate::oakui::engine::ProxyMediaState::Missing, + }); + } + } + rows + } + + fn proxy_state(&self, id: u64) -> Option { + self.footage_entry_name(id)?; + Some( + self.proxy_states + .get(&id) + .copied() + .unwrap_or(crate::oakui::engine::ProxyMediaState::Missing), + ) + } + + fn proxy_row(&self, id: u64) -> Option { + self.proxy_rows().into_iter().find(|row| row.id == id) + } + + fn proxy_generate(&mut self, id: u64, cx: &mut Context) -> Result<(), String> { + let Some(name) = self.footage_entry_name(id) else { + return Err("entry is not footage".into()); + }; + if crate::oakui::filename_is_audio(&name) { + return Err("the footage has no video stream".into()); + } + // The mock has no ffmpeg pipeline: the proxy is instantly ready. + self.proxy_states + .insert(id, crate::oakui::engine::ProxyMediaState::Ready); + self.proxy_enabled.insert(id, true); + self.cpu_frame_cache.lock().unwrap().clear(); + cx.notify(); + Ok(()) + } + + fn proxy_delete(&mut self, id: u64, cx: &mut Context) { + self.proxy_states.remove(&id); + self.proxy_enabled.remove(&id); + self.cpu_frame_cache.lock().unwrap().clear(); + cx.notify(); + } + + fn proxy_set_enabled(&mut self, id: u64, enabled: bool, cx: &mut Context) { + self.proxy_enabled.insert(id, enabled); + self.cpu_frame_cache.lock().unwrap().clear(); + cx.notify(); + } + + fn proxy_reveal(&self, id: u64) { + println!("[mock engine] reveal proxy for entry {id} (no files in mock mode)"); + } + + fn proxy_set_custom_params( + &mut self, + id: u64, + params: crate::oakui::engine::ProxyParamsUi, + cx: &mut Context, + ) { + self.proxy_custom.insert(id, params); + cx.notify(); + } + + fn proxy_clear_custom_params(&mut self, id: u64, cx: &mut Context) { + self.proxy_custom.remove(&id); + cx.notify(); + } + + fn proxy_custom_params(&self, id: u64) -> Option { + self.proxy_custom.get(&id).cloned() + } + + fn sync_eligibility(&self, clips: &[ClipId]) -> crate::oakui::engine::SyncEligibility { + // Demo semantics: every selected video clip carries a source start + // timecode; the mock keeps no waveform cache, so waveform sync stays + // unavailable. + let mut eligibility = crate::oakui::engine::SyncEligibility::default(); + for clip in clips { + let label = self.tracks.iter().find_map(|track| { + track + .clips + .iter() + .find(|c| c.id() == *clip) + .map(|c| c.label.clone()) + }); + match label { + Some(label) if !crate::oakui::filename_is_audio(&label) => { + eligibility.source_time += 1; + } + _ => {} + } + } + eligibility + } + + fn sync_clips_by_source_time(&mut self, clips: Vec, cx: &mut Context) { + self.mock_sync_clips(&clips); + cx.notify(); + } + + fn sync_clips_by_waveform( + &mut self, + clips: Vec, + _adjust_speed: bool, + cx: &mut Context, + ) { + // The mock has no waveform data: align by in point like the source + // timecode path (demo approximation). + self.mock_sync_clips(&clips); + cx.notify(); + } + + fn clip_footage_entries(&self, clips: &[ClipId]) -> Vec { + // The demo's clip ids ARE the explorer entry ids. + let rows = self.proxy_rows(); + clips + .iter() + .filter_map(|clip| rows.iter().find(|row| row.id == clip.0).cloned()) + .collect() + } + fn backend_name(&self) -> &'static str { "mock" } diff --git a/src/oakui/mod.rs b/src/oakui/mod.rs index ccf24ba08..1f1eecb0e 100644 --- a/src/oakui/mod.rs +++ b/src/oakui/mod.rs @@ -54,6 +54,7 @@ pub mod scopes; pub mod timecode; pub mod transport; pub mod waveform; +pub mod waveformsync; pub use engine::{ AppEngine, EngineClock, EngineGateway, ExportEvent, ExportSession, HistoryEntry, diff --git a/src/oakui/real.rs b/src/oakui/real.rs index fb7ad83ce..c2ff89aa5 100644 --- a/src/oakui/real.rs +++ b/src/oakui/real.rs @@ -458,6 +458,20 @@ impl ClipData for RealClip { } } +/// A running proxy transcode the engine drains on the tick loop (the +/// task thread reports through the event channel; completion applies the +/// footage's proxy fields and invalidates the rendered frames). +struct ProxyRun { + /// The footage node. + footage: NodeId, + /// The task's display label (the status bar's proxy segment). + label: String, + /// Last reported progress (`0.0..=1.0`). + progress: f64, + /// The task thread's event channel. + events: mpsc::Receiver, +} + /// One card of the real effect stack: the chain node's identity, its /// factory display name, its enabled flag, and the app-owned expansion /// state ([`RealEngine::expanded_effects`]). No source/output cards: the @@ -685,6 +699,7 @@ pub struct RealEngine { thumb_rx: Mutex>, /// The sending half of `thumb_rx` (cloned into every job). thumb_tx: Mutex>, + proxy_runs: Vec, } impl RealEngine { @@ -762,6 +777,7 @@ impl RealEngine { thumb_generation: 0, thumb_rx: Mutex::new(thumb_rx), thumb_tx: Mutex::new(thumb_tx), + proxy_runs: Vec::new(), } } @@ -1132,6 +1148,488 @@ impl RealEngine { } } + /// Invalidates every monitor's rendered frames (the preview media + /// changed — proxy toggled, generated or deleted): the CPU cache is + /// cleared and the full-res generation bumped so in-flight fills are + /// discarded on arrival. No timeline rebuild is needed — the montage + /// resolution reads the proxy switch on every pull. + fn invalidate_preview_frames(&mut self, cx: &mut Context) { + self.cpu_frame_cache.lock().unwrap().clear(); + self.full_res_generation = self.full_res_generation.wrapping_add(1); + cx.notify(); + } + + /// The disk cache directory the proxy files live in (the config + /// override, else the platform default). + fn proxy_cache_path() -> String { + let configured = config_get_string(CONFIG_KEY_DISK_CACHE_PATH); + if configured.trim().is_empty() { + oakcommon::filefunctions::default_disk_cache_path() + } else { + configured + } + } + + /// The footage node behind a project-explorer entry id, when it is a + /// footage node of the open project. + fn footage_of(&self, id: u64) -> Option { + let project = self.project.as_ref()?; + let node = graphops::id_of(id)?; + let guard = graphops::lock(project); + graphops::footage_behavior(&guard.graph, node)?; + Some(node) + } + + /// Drains the in-flight proxy transcodes (called from the tick loop): + /// progress events update the status-bar segment, completion applies + /// the footage's proxy fields and invalidates the rendered frames. + fn drain_proxy_runs(&mut self, cx: &mut Context) { + if self.proxy_runs.is_empty() { + return; + } + let mut finished: Vec<(NodeId, bool)> = Vec::new(); + let mut changed = false; + for run in self.proxy_runs.iter_mut() { + while let Ok(event) = run.events.try_recv() { + match event { + super::engine::ExportEvent::Started => {} + super::engine::ExportEvent::Progress(value) => { + run.progress = value.clamp(0.0, 1.0); + changed = true; + } + super::engine::ExportEvent::Finished(ok, _) => { + finished.push((run.footage, ok)); + } + } + } + } + self.proxy_runs.retain(|run| { + !finished.iter().any(|(footage, _)| *footage == run.footage) + }); + if let Some(project) = self.project.clone() { + for (footage, ok) in finished { + let mut guard = graphops::lock(&project); + if let Some(f) = guard + .graph + .get_mut(footage) + .and_then(|e| e.behavior.as_any_mut()) + .and_then(|a| a.downcast_mut::()) + { + if ok { + // The transcode wrote the final file: mark the + // proxy ready and usable (state 2, enabled). + f.proxy_state = 2; + f.proxy_enabled = true; + } else { + f.proxy_state = 3; + } + } + changed = true; + } + } + if changed { + self.invalidate_preview_frames(cx); + } + } + + /// The proxy lifecycle state of one footage row: an in-flight run + /// wins, then the disk state of the recorded proxy path, with a + /// recorded failure (state 3) preserved while no file is on disk. + fn proxy_state_of( + &self, + f: &oaknode::footage::FootageBehavior, + node: NodeId, + ) -> super::engine::ProxyMediaState { + use super::engine::ProxyMediaState; + if self.proxy_runs.iter().any(|run| run.footage == node) { + return ProxyMediaState::Generating; + } + if f.proxy.is_empty() { + return ProxyMediaState::Missing; + } + match oakcodec::proxymanager::ProxyManager::get_proxy_state(&f.proxy) { + oakcodec::proxymanager::ProxyState::Ready => ProxyMediaState::Ready, + oakcodec::proxymanager::ProxyState::Generating => ProxyMediaState::Generating, + _ => { + if f.proxy_state == 3 { + ProxyMediaState::Failed + } else { + ProxyMediaState::Missing + } + } + } + } + + /// Synchronize the selected clips by their footage's source start + /// timecode (the C++ `synchronize_selected_clips_by_source_time`): + /// the clip whose source head (start time + media in) is earliest is + /// the reference, the earliest selected in point is the anchor, and + /// every clip is re-placed so its source head lines up with the + /// reference's at the anchor (one multi-undo). + fn sync_clips_by_source_time_internal(&mut self, clips: &[ClipId]) { + use oakaudio::synchronizer::{place_by_source_time, SourceClip}; + + let Some(project) = self.project.clone() else { + return; + }; + + struct SourceTarget { + node: NodeId, + track: NodeId, + list: NodeId, + track_index: i32, + source: SourceClip, + source_head: oakcore_rs::Rational, + block_in: oakcore_rs::Rational, + } + + let mut targets: Vec = Vec::new(); + { + let guard = graphops::lock(&project); + for clip in clips { + let Some(node) = graphops::id_of(clip.0) else { + continue; + }; + let Some((block_in, _, media_in)) = graphops::clip_range(&guard.graph, node) + else { + continue; + }; + let Some(track) = graphops::clip_track(&guard.graph, node) else { + continue; + }; + let Some(t) = graphops::track_behavior(&guard.graph, track) else { + continue; + }; + let Some(list) = t.track_list else { + continue; + }; + let Some(f) = graphops::find_input_footage(&guard.graph, node) + .and_then(|f| graphops::footage_behavior(&guard.graph, f)) + else { + continue; + }; + if !f.has_source_start_time { + continue; + } + targets.push(SourceTarget { + node, + track, + list, + track_index: t.index, + source_head: f.source_start_time + media_in, + block_in, + source: SourceClip { + source_start_time: f.source_start_time, + media_in, + has_source_start_time: true, + }, + }); + } + } + if targets.len() < 2 { + return; + } + + let mut reference = &targets[0]; + let mut anchor_in = targets[0].block_in; + for target in &targets[1..] { + if target.source_head < reference.source_head { + reference = target; + } + if target.block_in < anchor_in { + anchor_in = target.block_in; + } + } + + // (node, track, list, track index, placement) for every valid + // placement (the reference itself lands on the anchor). + let mut placements: Vec<(NodeId, NodeId, NodeId, i32, oakcore_rs::Rational)> = + Vec::new(); + for target in &targets { + let placement = place_by_source_time(&reference.source, &target.source, anchor_in); + if placement.valid { + placements.push(( + target.node, + target.track, + target.list, + target.track_index, + placement.timeline_in, + )); + } + } + if placements.len() < 2 { + return; + } + + let mut children: Vec = Vec::new(); + for (node, track, _, _, _) in &placements { + children.push( + oaktimeline::undogeneral::TrackReplaceBlockWithGapCommand::new( + graphops::node_ref(&project, *track), + graphops::node_ref(&project, *node), + false, + ) + .to_command(), + ); + } + for (node, _, list, track_index, timeline_in) in &placements { + children.push( + oaktimeline::undopointer::TrackPlaceBlockCommand::new( + graphops::node_ref(&project, *list), + *track_index, + graphops::node_ref(&project, *node), + *timeline_in, + ) + .to_command(), + ); + } + let _ = graphops::push_multi_command(children, "Synchronize Clips by Source Time"); + } + + /// Synchronize the selected clips by waveform correlation (the C++ + /// `synchronize_selected_clips_by_waveform_internal`): the leftmost + /// clip is the reference, every other clip is shifted by the + /// estimated envelope offset; with `allow_speed`, an inconclusive + /// offset triggers a rate search whose winner also rescales the clip + /// speed (one multi-undo). + fn sync_clips_by_waveform_internal(&mut self, clips: &[ClipId], allow_speed: bool) { + use oakaudio::synchronizer::place_by_waveform_offset; + use oakaudio::waveformsync::{estimate_envelope_offset_valid, estimate_stretch_and_offset}; + + let Some(cache) = self.waveform_cache() else { + return; + }; + let Some(project) = self.project.clone() else { + return; + }; + + struct WaveTarget { + node: NodeId, + track: NodeId, + list: NodeId, + track_index: i32, + block_in: oakcore_rs::Rational, + speed: f64, + media_in_s: f64, + media_len_s: f64, + waveform: Arc, + } + + let mut targets: Vec = Vec::new(); + { + let guard = graphops::lock(&project); + for clip in clips { + let Some(node) = graphops::id_of(clip.0) else { + continue; + }; + let Some((block_in, block_out, media_in)) = + graphops::clip_range(&guard.graph, node) + else { + continue; + }; + let Some(behavior) = graphops::clip_behavior(&guard.graph, node) else { + continue; + }; + let Some(track) = graphops::clip_track(&guard.graph, node) else { + continue; + }; + let Some(t) = graphops::track_behavior(&guard.graph, track) else { + continue; + }; + let Some(list) = t.track_list else { + continue; + }; + let Some(waveform) = cache.get(clip.0) else { + continue; + }; + // The C++ media range is media_in + timeline length + // (speed/reverse ignored there); the Rust cache covers the + // whole file from sample 0. + let media_len_s = (block_out - block_in).to_f64(); + if !super::waveformsync::waveform_sync_eligible(&waveform, media_len_s) { + continue; + } + targets.push(WaveTarget { + node, + track, + list, + track_index: t.index, + block_in, + speed: behavior.core.speed, + media_in_s: media_in.to_f64(), + media_len_s, + waveform, + }); + } + } + if targets.len() < 2 { + return; + } + + let mut ref_index = 0usize; + for (i, target) in targets.iter().enumerate() { + if target.block_in < targets[ref_index].block_in { + ref_index = i; + } + } + let sample_rate = targets[ref_index].waveform.sample_rate; + if sample_rate <= 0 { + return; + } + let window_samples = (sample_rate / 20).max(1) as usize; + let max_offset_windows = (i64::from(sample_rate) * 600) / window_samples as i64; + + let reference = &targets[ref_index]; + let (ref_envelope, ref_valid) = super::waveformsync::extract_cache_envelope( + &reference.waveform, + reference.media_in_s, + reference.media_len_s, + window_samples, + ); + + // (node, track, list, track index, placement, speed, old speed). + let mut placements: Vec<( + NodeId, + NodeId, + NodeId, + i32, + oakcore_rs::Rational, + f64, + f64, + )> = vec![( + reference.node, + reference.track, + reference.list, + reference.track_index, + reference.block_in, + 1.0, + reference.speed, + )]; + + for (i, target) in targets.iter().enumerate() { + if i == ref_index { + continue; + } + let (cand_envelope, cand_valid) = super::waveformsync::extract_cache_envelope( + &target.waveform, + target.media_in_s, + target.media_len_s, + window_samples, + ); + let mut offset = estimate_envelope_offset_valid( + &ref_envelope, + &cand_envelope, + &ref_valid, + &cand_valid, + window_samples, + max_offset_windows, + ); + let mut speed = 1.0; + if allow_speed && (!offset.valid || offset.confidence < 0.6) { + // Inconclusive: the clips may run at different speeds — + // search a rate range with a tighter offset radius. + let radius = max_offset_windows.min( + (i64::from(sample_rate) * 30) / window_samples as i64, + ); + let stretch = estimate_stretch_and_offset( + &ref_envelope, + &cand_envelope, + &ref_valid, + &cand_valid, + window_samples, + radius, + 0.75, + 1.34, + 0.005, + ); + if stretch.valid + && stretch.confidence > (if offset.valid { offset.confidence } else { 0.0 }) + { + speed = stretch.rate; + offset.valid = true; + offset.confidence = stretch.confidence; + offset.offset_samples = stretch.offset_samples; + } + } + if !offset.valid { + continue; + } + let placement = + place_by_waveform_offset(reference.block_in, offset.offset_samples, sample_rate); + if placement.valid && placement.timeline_in >= oakcore_rs::Rational::new(0, 1) { + placements.push(( + target.node, + target.track, + target.list, + target.track_index, + placement.timeline_in, + speed, + target.speed, + )); + } + } + if placements.len() < 2 { + return; + } + + let mut children: Vec = Vec::new(); + for (node, track, _, _, _, speed, old_speed) in &placements { + children.push( + oaktimeline::undogeneral::TrackReplaceBlockWithGapCommand::new( + graphops::node_ref(&project, *track), + graphops::node_ref(&project, *node), + false, + ) + .to_command(), + ); + if (*speed - 1.0).abs() > f64::EPSILON { + // The C++ multiplies the clip's current speed by the + // estimated rate (`clip_speed * placement.speed`); the + // Rust clip keeps its speed on the block core. + let new_speed = *old_speed * speed; + let old_speed = *old_speed; + let node = *node; + let (p1, p2) = (project.clone(), project.clone()); + children.push(oakundo::undocommand::UndoCommand::from_closures( + move || { + let mut g = graphops::lock(&p1); + if let Some(c) = g + .graph + .get_mut(node) + .and_then(|e| e.behavior.as_any_mut()) + .and_then(|a| a.downcast_mut::()) + { + c.core.speed = new_speed; + } + }, + move || { + let mut g = graphops::lock(&p2); + if let Some(c) = g + .graph + .get_mut(node) + .and_then(|e| e.behavior.as_any_mut()) + .and_then(|a| a.downcast_mut::()) + { + c.core.speed = old_speed; + } + }, + )); + } + } + for (node, _, list, track_index, timeline_in, _, _) in &placements { + children.push( + oaktimeline::undopointer::TrackPlaceBlockCommand::new( + graphops::node_ref(&project, *list), + *track_index, + graphops::node_ref(&project, *node), + *timeline_in, + ) + .to_command(), + ); + } + let _ = graphops::push_multi_command(children, "Synchronize Clips by Waveform"); + } + /// Adopts a newly created/loaded project, dropping any previous one, /// and rebuilds every snapshot. The undo stack is cleared (a project /// switch starts a fresh history, mirroring the facade's @@ -1479,6 +1977,17 @@ impl RealEngine { // EngineGateway // --------------------------------------------------------------------------- +/// The clip's timeline length in seconds — the media-range length the C++ +/// waveform sync extracts its envelope over (`media_in + length`, speed +/// and reverse ignored there; parity with +/// `oakengine_clip_get_media_range_rational`). +fn clip_media_length_seconds(g: &oaknode::graph::Graph, node: NodeId) -> f64 { + match graphops::clip_range(g, node) { + Some((in_r, out_r, _)) => (out_r - in_r).to_f64(), + None => 0.0, + } +} + impl EngineGateway for RealEngine { fn project(&self) -> Option<&Project> { self.project.as_ref().map(|_| &self.project_info) @@ -1576,6 +2085,7 @@ impl EngineGateway for RealEngine { // playing monitors, so playback keeps the proxy path). self.drain_full_res(); self.drain_thumbnails(); + self.drain_proxy_runs(cx); self.schedule_full_res(Monitor::Source, cx); self.schedule_full_res(Monitor::Program, cx); cx.notify(); @@ -2565,6 +3075,406 @@ impl AppEngine for RealEngine { .map_err(|e| format!("failed to export the project to \"{}\": {e}", path.display())) } + fn set_use_proxy_media(&mut self, enabled: bool, cx: &mut Context) { + oakcommon::configstore::ConfigStore::instance().set( + None, + CONFIG_KEY_USE_PROXY, + if enabled { "true" } else { "false" }, + ); + // Every footage's preview media may change: drop the rendered + // frames so the next pull re-resolves original/proxy. + self.invalidate_preview_frames(cx); + } + + fn proxy_rows(&self) -> Vec { + let Some(project) = self.project.as_ref() else { + return Vec::new(); + }; + let guard = graphops::lock(project); + graphops::footage_ids(&guard) + .into_iter() + .filter_map(|node| { + let f = graphops::footage_behavior(&guard.graph, node)?; + let name = graphops::node_label(&guard.graph, node); + Some(super::engine::ProxyFootageRow { + id: node.identity(), + name, + state: self.proxy_state_of(f, node), + enabled: f.proxy_enabled, + has_custom: f.has_custom_proxy_params(), + can_generate: f.valid && f.streams.iter().any(|s| s.is_video), + has_proxy: !f.proxy.is_empty(), + }) + }) + .collect() + } + + fn proxy_state(&self, id: u64) -> Option { + let project = self.project.as_ref()?; + let node = graphops::id_of(id)?; + let guard = graphops::lock(project); + let f = graphops::footage_behavior(&guard.graph, node)?; + Some(self.proxy_state_of(f, node)) + } + + fn proxy_row(&self, id: u64) -> Option { + self.proxy_rows().into_iter().find(|row| row.id == id) + } + + fn proxy_generate(&mut self, id: u64, cx: &mut Context) -> Result<(), String> { + let footage = self.footage_of(id).ok_or("entry is not footage")?; + if self.proxy_runs.iter().any(|run| run.footage == footage) { + return Err("a proxy is already generating for this footage".into()); + } + let (filename, stream_index, params) = { + let project = self.project.as_ref().ok_or("no project open")?; + let guard = graphops::lock(project); + let f = graphops::footage_behavior(&guard.graph, footage) + .ok_or("entry is not footage")?; + if f.filename.is_empty() { + return Err("the footage has no media file".into()); + } + let stream = f + .streams + .iter() + .find(|s| s.is_video) + .map(|s| s.index) + .ok_or("the footage has no video stream")?; + (f.filename.clone(), stream, f.effective_proxy_params()) + }; + + let proxy_path = oakcodec::proxymanager::ProxyManager::get_proxy_filename( + &Self::proxy_cache_path(), + &filename, + stream_index, + ¶ms, + ) + .map_err(|e| format!("failed to build the proxy filename: {e}"))?; + + // Absolute targets only apply in custom-size mode; divider mode + // scales from the source (the request mirrors the C++ submission). + let (proxy_width, proxy_height) = if params.divider <= 1 { + (params.width, params.height) + } else { + (0, 0) + }; + let request = oakcodec::task::TaskRequest { + kind: oakcodec::task::TaskKind::Proxy, + input_filename: &filename, + output_filename: &proxy_path, + stream_index, + sample_rate: 0, + channel_layout: 0, + sample_format: 0, + proxy_width, + proxy_height, + }; + let task_params = oaktask::proxy::ProxyParams { + width: params.width, + height: params.height, + divider: params.divider, + version: params.version, + crf: params.crf, + include_audio: params.include_audio != 0, + extension: { + let end = params + .extension + .iter() + .position(|&b| b == 0) + .unwrap_or(params.extension.len()); + String::from_utf8_lossy(¶ms.extension[..end]).into_owned() + }, + preset: { + let end = params + .preset + .iter() + .position(|&b| b == 0) + .unwrap_or(params.preset.len()); + String::from_utf8_lossy(¶ms.preset[..end]).into_owned() + }, + }; + + let label = format!("Generating Proxy {}", filename); + let (tx, rx) = mpsc::channel::(); + let mut driver = oaktask::task::Task::new(&label, None); + { + let tx = tx.clone(); + driver.set_event_listener(Box::new(move |event| { + let event = match event { + oaktask::task::TaskEvent::Started => super::engine::ExportEvent::Started, + oaktask::task::TaskEvent::Progress(value) => { + super::engine::ExportEvent::Progress(value) + } + oaktask::task::TaskEvent::Finished => return, + }; + let _ = tx.send(event); + })); + } + driver.set_behavior(Box::new(oaktask::proxy::ProxyTask::new( + &request, + task_params, + ))); + std::thread::spawn(move || { + let result = driver.start(); + let error = if result.is_ok() { + String::new() + } else { + driver + .error() + .map(|s| s.to_string()) + .unwrap_or_else(|| "proxy generation failed".to_string()) + }; + let _ = tx.send(super::engine::ExportEvent::Finished(result.is_ok(), error)); + }); + + // Mark the footage generating immediately (state 1), exactly like + // the C++ dialog's set_proxy(..., proxy.state, ...) after + // get_or_start; the tick drain finalizes it. + if let Some(project) = self.project.as_ref() { + let mut guard = graphops::lock(project); + if let Some(f) = guard + .graph + .get_mut(footage) + .and_then(|e| e.behavior.as_any_mut()) + .and_then(|a| a.downcast_mut::()) + { + f.set_proxy(&proxy_path, 1, stream_index, params.version, true); + } + } + self.proxy_runs.push(ProxyRun { + footage, + label, + progress: 0.0, + events: rx, + }); + cx.notify(); + Ok(()) + } + + fn proxy_task_progress(&self) -> Option<(String, f64)> { + self.proxy_runs + .first() + .map(|run| (run.label.clone(), run.progress)) + } + + fn proxy_delete(&mut self, id: u64, cx: &mut Context) { + let Some(footage) = self.footage_of(id) else { + return; + }; + let proxy_path = { + let Some(project) = self.project.as_ref() else { + return; + }; + let guard = graphops::lock(project); + match graphops::footage_behavior(&guard.graph, footage) { + Some(f) if !f.proxy.is_empty() => f.proxy.clone(), + _ => return, + } + }; + let _ = std::fs::remove_file(&proxy_path); + if let Ok(working) = oakcodec::proxymanager::ProxyManager::get_working_filename(&proxy_path) + { + let _ = std::fs::remove_file(&working); + } + if let Some(project) = self.project.as_ref() { + let mut guard = graphops::lock(project); + if let Some(f) = guard + .graph + .get_mut(footage) + .and_then(|e| e.behavior.as_any_mut()) + .and_then(|a| a.downcast_mut::()) + { + f.clear_proxy(); + } + } + self.invalidate_preview_frames(cx); + } + + fn proxy_set_enabled(&mut self, id: u64, enabled: bool, cx: &mut Context) { + let Some(footage) = self.footage_of(id) else { + return; + }; + if let Some(project) = self.project.as_ref() { + let mut guard = graphops::lock(project); + if let Some(f) = guard + .graph + .get_mut(footage) + .and_then(|e| e.behavior.as_any_mut()) + .and_then(|a| a.downcast_mut::()) + { + f.proxy_enabled = enabled; + } + } + self.invalidate_preview_frames(cx); + } + + fn proxy_reveal(&self, id: u64) { + let Some(footage) = self.footage_of(id) else { + return; + }; + let proxy_path = { + let Some(project) = self.project.as_ref() else { + return; + }; + let guard = graphops::lock(project); + match graphops::footage_behavior(&guard.graph, footage) { + Some(f) if !f.proxy.is_empty() => f.proxy.clone(), + _ => return, + } + }; + let path = std::path::Path::new(&proxy_path); + if path.exists() { + let _ = std::process::Command::new("open") + .arg("-R") + .arg(path) + .spawn(); + } + } + + fn proxy_set_custom_params( + &mut self, + id: u64, + params: super::engine::ProxyParamsUi, + cx: &mut Context, + ) { + let Some(footage) = self.footage_of(id) else { + return; + }; + if let Some(project) = self.project.as_ref() { + let mut guard = graphops::lock(project); + if let Some(f) = guard + .graph + .get_mut(footage) + .and_then(|e| e.behavior.as_any_mut()) + .and_then(|a| a.downcast_mut::()) + { + f.set_custom_proxy_params(params.to_codec()); + } + } + cx.notify(); + } + + fn proxy_clear_custom_params(&mut self, id: u64, cx: &mut Context) { + let Some(footage) = self.footage_of(id) else { + return; + }; + if let Some(project) = self.project.as_ref() { + let mut guard = graphops::lock(project); + if let Some(f) = guard + .graph + .get_mut(footage) + .and_then(|e| e.behavior.as_any_mut()) + .and_then(|a| a.downcast_mut::()) + { + f.clear_custom_proxy_params(); + } + } + cx.notify(); + } + + fn proxy_custom_params(&self, id: u64) -> Option { + let project = self.project.as_ref()?; + let node = graphops::id_of(id)?; + let guard = graphops::lock(project); + let f = graphops::footage_behavior(&guard.graph, node)?; + f.custom_proxy_params + .as_ref() + .map(super::engine::ProxyParamsUi::from_codec) + } + + fn proxy_effective_params(&self, id: u64) -> super::engine::ProxyParamsUi { + let Some(project) = self.project.as_ref() else { + return super::engine::proxy_params_from_config(); + }; + let Some(node) = graphops::id_of(id) else { + return super::engine::proxy_params_from_config(); + }; + let guard = graphops::lock(project); + match graphops::footage_behavior(&guard.graph, node) { + Some(f) => super::engine::ProxyParamsUi::from_codec(&f.effective_proxy_params()), + None => super::engine::proxy_params_from_config(), + } + } + + fn clip_footage_entries(&self, clips: &[ClipId]) -> Vec { + let Some(project) = self.project.as_ref() else { + return Vec::new(); + }; + let guard = graphops::lock(project); + let mut seen: Vec = Vec::new(); + let mut rows: Vec = Vec::new(); + for clip in clips { + let Some(node) = graphops::id_of(clip.0) else { + continue; + }; + let Some(footage) = graphops::find_input_footage(&guard.graph, node) else { + continue; + }; + if seen.contains(&footage) { + continue; + } + let Some(f) = graphops::footage_behavior(&guard.graph, footage) else { + continue; + }; + seen.push(footage); + rows.push(super::engine::ProxyFootageRow { + id: footage.identity(), + name: graphops::node_label(&guard.graph, footage), + state: self.proxy_state_of(f, footage), + enabled: f.proxy_enabled, + has_custom: f.has_custom_proxy_params(), + can_generate: f.valid && f.streams.iter().any(|s| s.is_video), + has_proxy: !f.proxy.is_empty(), + }); + } + rows + } + + fn sync_eligibility(&self, clips: &[ClipId]) -> super::engine::SyncEligibility { + let mut eligibility = super::engine::SyncEligibility::default(); + let (Some(project), Some(cache)) = (self.project.as_ref(), self.waveform_cache()) else { + return eligibility; + }; + let guard = graphops::lock(project); + for clip in clips { + let Some(node) = graphops::id_of(clip.0) else { + continue; + }; + if graphops::clip_range(&guard.graph, node).is_none() { + continue; + } + if let Some(footage) = graphops::find_input_footage(&guard.graph, node) + .and_then(|f| graphops::footage_behavior(&guard.graph, f)) + { + if footage.has_source_start_time { + eligibility.source_time += 1; + } + } + if let Some(waveform) = cache.get(clip.0) { + let media_len = clip_media_length_seconds(&guard.graph, node); + if super::waveformsync::waveform_sync_eligible(&waveform, media_len) { + eligibility.waveform += 1; + } + } + } + eligibility + } + + fn sync_clips_by_source_time(&mut self, clips: Vec, cx: &mut Context) { + self.sync_clips_by_source_time_internal(&clips); + cx.notify(); + } + + fn sync_clips_by_waveform( + &mut self, + clips: Vec, + adjust_speed: bool, + cx: &mut Context, + ) { + self.sync_clips_by_waveform_internal(&clips, adjust_speed); + cx.notify(); + } + fn start_export(&mut self, format: i32, path: PathBuf) -> Result { let (Some(project), Some(seq)) = (self.project.clone(), self.sequence) else { return Err("no sequence open".into()); @@ -2794,6 +3704,10 @@ pub const CONFIG_KEY_THEME: &str = "Theme"; pub const CONFIG_KEY_DISK_CACHE_PATH: &str = "DiskCachePath"; /// The config key toggling proxy media use (`UseProxyMedia`, bool). pub const CONFIG_KEY_USE_PROXY: &str = "UseProxyMedia"; +/// The config key holding the explicit ffmpeg executable path the proxy +/// transcode should use (`FFmpegPath`; empty = auto-detect on PATH and +/// the common install locations). +pub const CONFIG_KEY_FFMPEG_PATH: &str = "FFmpegPath"; /// The config key holding the proxy resolution divider (`ProxyDivider`, /// int; 1 = full resolution, 2/4/8/16 = 1/2 … 1/16). oakcodec's /// `ProxyManager::proxy_params_from_config` reads it for generation. diff --git a/src/oakui/renderops.rs b/src/oakui/renderops.rs index 0832bd3a5..2fec36f9b 100644 --- a/src/oakui/renderops.rs +++ b/src/oakui/renderops.rs @@ -66,10 +66,63 @@ pub fn ensure_render_manager() -> bool { // over the module graph) // --------------------------------------------------------------------------- -/// The media `(filename, stream_index)` feeding a clip (upstream BFS + -/// footage behavior); video stream 0. -fn clip_media(g: &oaknode::graph::Graph, block_id: NodeId) -> Option { - super::graphops::clip_media_filename(g, block_id) +/// Whether preview decoding may substitute proxy media: the global +/// `UseProxyMedia` config switch (C++ `Tools > Use Proxy Media`; the +/// export path never consults it — exports always decode the original). +pub fn use_proxy_media() -> bool { + oakcommon::configstore::ConfigStore::instance().get_bool(None, "UseProxyMedia", 1) != 0 +} + +/// The preview media of a footage node with the three-level proxy switch +/// applied (C++ `Footage::value` proxy arms + `FootageJob::should_use_proxy`): +/// the global switch, the footage's proxy-enabled flag and an on-disk +/// `Ready` proxy all have to line up, otherwise the original is returned. +/// Video decodes from the proxy's stream 0 (the proxy's only video +/// stream); audio from stream 1 when the proxy was generated with audio +/// (the first source audio stream's rank + 1). A missing proxy file falls +/// back to the original. +pub fn preview_footage_media( + f: &oaknode::footage::FootageBehavior, + is_video: bool, +) -> (String, i32) { + let original_stream = if is_video { 0 } else { 1 }; + let original = (f.filename.clone(), original_stream); + if !use_proxy_media() || !f.proxy_enabled || f.proxy.is_empty() { + return original; + } + if oakcodec::proxymanager::ProxyManager::get_proxy_state(&f.proxy) + != oakcodec::proxymanager::ProxyState::Ready + { + return original; + } + if is_video { + // The proxy stands in for the footage's first video stream only + // (C++ matches proxy_video_stream_index against the stream index). + let first_video = f.streams.iter().find(|s| s.is_video); + match first_video { + Some(stream) if f.proxy_video_stream_index == stream.index => (f.proxy.clone(), 0), + _ => original, + } + } else if oakcodec::proxymanager::ProxyManager::proxy_filename_has_audio(&f.proxy) { + (f.proxy.clone(), 1) + } else { + original + } +} + +/// The preview media feeding a clip: the clip's footage with the proxy +/// switch applied ([`preview_footage_media`]). +fn clip_preview_media( + g: &oaknode::graph::Graph, + block_id: NodeId, + is_video: bool, +) -> Option<(String, i32)> { + let footage_id = super::graphops::find_input_footage(g, block_id)?; + let f = super::graphops::footage_behavior(g, footage_id)?; + if f.filename.is_empty() { + return None; + } + Some(preview_footage_media(f, is_video)) } /// The video montage at sequence time `time`: every clip covering `time` @@ -105,12 +158,14 @@ pub fn video_montage(p: &ProjectRef, seq: NodeId, time: Rational) -> Vec= out { continue; } - let Some(filename) = clip_media(&g.graph, block_id) else { + let Some((filename, stream_index)) = + clip_preview_media(&g.graph, block_id, true) + else { continue; }; clips.push(MontageClip { filename, - stream_index: 0, + stream_index, in_time: in_, out_time: out, media_in: clip.core.media_in, @@ -154,12 +209,14 @@ pub fn audio_montage(p: &ProjectRef, seq: NodeId, range: TimeRange) -> Vec= range.out() { continue; } - let Some(filename) = clip_media(&g.graph, block_id) else { + let Some((filename, stream_index)) = + clip_preview_media(&g.graph, block_id, false) + else { continue; }; clips.push(MontageClip { filename, - stream_index: 1, + stream_index, in_time: in_, out_time: out, media_in: clip.core.media_in, @@ -261,10 +318,10 @@ pub fn render_footage_frame( height: i32, ) -> Result { validate_geometry(width, height, tb)?; - let filename = { + let (filename, stream_index) = { let g = lock(p); super::graphops::footage_behavior(&g.graph, footage) - .map(|f| f.filename.clone()) + .map(|f| preview_footage_media(f, true)) .ok_or_else(|| "the node is not footage".to_string())? }; let time = Rational::new(frame_ts * tb.0, tb.1); @@ -277,7 +334,7 @@ pub fn render_footage_frame( cache_dir: None, cache_id: None, cache_timebase: None, - footage: Some((filename, 0)), + footage: Some((filename, stream_index)), montage: Vec::new(), }) } diff --git a/src/oakui/waveformsync.rs b/src/oakui/waveformsync.rs new file mode 100644 index 000000000..b209bfa2b --- /dev/null +++ b/src/oakui/waveformsync.rs @@ -0,0 +1,172 @@ +// 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 . + +//! Windowed peak envelopes over the cached clip waveforms, the input of +//! `oakaudio::waveformsync`'s offset / stretch correlation (the app-side +//! mirror of the C++ `extract_waveform_cache_envelope` in +//! `timelinewidgetwaveformsync.cpp`). +//! +//! Two deliberate Rust-side adaptations: +//! +//! * The Rust [`super::waveform::WaveformCache`] extracts the WHOLE media +//! file per clip (no trimmed, partially validated cache regions), so the +//! envelope windows map absolute media-file time onto the cached peaks +//! and every window is considered validated (the mask is all-true). +//! * The window peak is the max of `max(|min|, |max|)` over every cached +//! peak point overlapping the window (the C++ +//! `get_summary_from_time()` equivalent), taken from the first channel +//! the cache keeps. + +use super::waveform::ClipWaveform; + +/// Whether a clip carrying this cached waveform can take part in waveform +/// sync (the C++ `get_waveform_sync_clip` gate: a waveform exists, its +/// sample rate is valid and the media range is non-null — the whole-file +/// Rust cache always "intersects" the clip's media range once present). +pub fn waveform_sync_eligible(waveform: &ClipWaveform, media_len_s: f64) -> bool { + waveform.sample_rate > 0 && !waveform.peaks.is_empty() && media_len_s > 0.0 +} + +/// Extract the windowed peak envelope of the clip's media range +/// `[media_in_s, media_in_s + media_len_s)` (seconds of media-file time) +/// from the cached peaks. +/// +/// Each window is `window_samples` samples wide; the returned envelope +/// holds one peak per window and the mask one validity flag per window +/// (all-true — see the module docs). The mapping runs in integer sample +/// space (the boundaries round like the C++ `time_to_timestamp(..., +/// k_round)`), so whole-second ranges at the cache sample rate land on +/// exact window counts. +/// +/// CPP-PARITY: app/widget/timelinewidget/timelinewidgetwaveformsync.cpp +/// (`extract_waveform_cache_envelope`) +pub fn extract_cache_envelope( + waveform: &ClipWaveform, + media_in_s: f64, + media_len_s: f64, + window_samples: usize, +) -> (Vec, Vec) { + let mut envelope: Vec = Vec::new(); + let mut valid: Vec = Vec::new(); + let sample_rate = waveform.sample_rate; + if sample_rate <= 0 || window_samples == 0 || media_len_s <= 0.0 { + return (envelope, valid); + } + + let spp = waveform.samples_per_point.max(1) as i64; + let start_sample = (media_in_s * f64::from(sample_rate)).round() as i64; + let end_sample = ((media_in_s + media_len_s) * f64::from(sample_rate)).round() as i64; + let window = window_samples as i64; + + let mut sample = start_sample.max(0); + while sample < end_sample { + let window_end = (sample + window).min(end_sample); + // The cached peak points overlapping [sample, window_end): a + // point i covers samples [i * spp, (i + 1) * spp). + let first_point = sample / spp; + let last_point = ((window_end + spp - 1) / spp).min(waveform.peaks.len() as i64); + let mut peak = 0.0f64; + for i in first_point.max(0)..last_point { + let p = &waveform.peaks[i as usize]; + let amplitude = f64::max(f64::from(p.min).abs(), f64::from(p.max).abs()); + peak = peak.max(amplitude); + } + envelope.push(peak); + valid.push(true); + sample += window; + } + (envelope, valid) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::oakui::waveform::MinMax; + + /// A synthetic 3 s cache at 48 kHz whose peaks are 1.0 exactly inside + /// `[1, 2)` seconds and 0 elsewhere (240 samples per point makes the + /// 1/20 s windows land on whole peak runs: 10 peaks per window). + fn synthetic_waveform() -> ClipWaveform { + const SAMPLE_RATE: i32 = 48_000; + const SPP: i32 = 240; + let points = (SAMPLE_RATE * 3) / SPP; // 600 points for 3 s + let peaks: Vec = (0..points) + .map(|i| { + let sample = i64::from(i) * i64::from(SPP); + let inside = sample >= 48_000 && sample < 96_000; + if inside { + MinMax { min: -1.0, max: 1.0 } + } else { + MinMax::default() + } + }) + .collect(); + ClipWaveform { + peaks, + channel_count: 1, + sample_rate: SAMPLE_RATE, + samples_per_point: SPP, + duration_frames: 60, + } + } + + #[test] + fn envelope_shape_follows_the_cached_peaks() { + let waveform = synthetic_waveform(); + // Window = 1/20 s at 48 kHz -> 2400 samples -> 60 windows for 3 s. + let (envelope, valid) = extract_cache_envelope(&waveform, 0.0, 3.0, 2400); + assert_eq!(envelope.len(), 60, "3 s at 20 windows/s"); + assert_eq!(valid.len(), 60); + for (i, (peak, ok)) in envelope.iter().zip(valid.iter()).enumerate() { + let expected = if (20..40).contains(&i) { 1.0 } else { 0.0 }; + assert_eq!(*peak, expected, "window {i}"); + assert!(*ok, "window {i} validated (whole-file cache)"); + } + } + + #[test] + fn envelope_respects_the_media_in_offset() { + let waveform = synthetic_waveform(); + // The clip's trimmed media range [1, 2) covers only the loud run. + let (envelope, _) = extract_cache_envelope(&waveform, 1.0, 1.0, 2400); + assert_eq!(envelope.len(), 20); + assert!(envelope.iter().all(|&p| p == 1.0), "all windows loud"); + } + + #[test] + fn envelope_edges_are_empty_or_silent() { + let waveform = synthetic_waveform(); + let (envelope, _) = extract_cache_envelope(&waveform, 0.0, 0.0, 2400); + assert!(envelope.is_empty(), "null media range -> no windows"); + + // A range past the cached peaks yields zero windows' peaks. + let (envelope, _) = extract_cache_envelope(&waveform, 10.0, 1.0, 2400); + assert_eq!(envelope.len(), 20); + assert!(envelope.iter().all(|&p| p == 0.0)); + } + + #[test] + fn eligibility_requires_peaks_rate_and_length() { + let mut waveform = synthetic_waveform(); + assert!(waveform_sync_eligible(&waveform, 3.0)); + assert!(!waveform_sync_eligible(&waveform, 0.0), "null media range"); + waveform.sample_rate = 0; + assert!(!waveform_sync_eligible(&waveform, 3.0), "invalid rate"); + waveform.sample_rate = 48_000; + waveform.peaks.clear(); + assert!(!waveform_sync_eligible(&waveform, 3.0), "no peaks"); + } +} diff --git a/src/panels/commands.rs b/src/panels/commands.rs index 8e48320ba..c7941a06a 100644 --- a/src/panels/commands.rs +++ b/src/panels/commands.rs @@ -181,6 +181,17 @@ pub trait PanelCommandHandler: Sized { false } + // --- synchronization (the timeline clip menu's Synchronize group) ------ + fn sync_by_source_time(&mut self, _cx: &mut Context) -> bool { + false + } + fn sync_by_waveform(&mut self, _cx: &mut Context) -> bool { + false + } + fn sync_by_waveform_speed(&mut self, _cx: &mut Context) -> bool { + false + } + // --- view ---------------------------------------------------------------- fn zoom_in(&mut self, _cx: &mut Context) -> bool { false @@ -253,6 +264,9 @@ pub fn dispatch_to( ActionId::DeleteInOut => panel.delete_in_to_out(cx), ActionId::RippleDeleteInOut => panel.ripple_delete_in_to_out(cx), ActionId::Marker => panel.set_marker(cx), + ActionId::SyncBySourceTime => panel.sync_by_source_time(cx), + ActionId::SyncByWaveform => panel.sync_by_waveform(cx), + ActionId::SyncByWaveformSpeed => panel.sync_by_waveform_speed(cx), ActionId::ZoomIn => panel.zoom_in(cx), ActionId::ZoomOut => panel.zoom_out(cx), ActionId::IncreaseTrackHeight => panel.increase_track_height(cx), diff --git a/src/panels/project_explorer.rs b/src/panels/project_explorer.rs index f8703fa0a..6f0fa3a7e 100644 --- a/src/panels/project_explorer.rs +++ b/src/panels/project_explorer.rs @@ -105,7 +105,8 @@ impl ProjectExplorerPanel { None => blank_menu(), Some(id) => { if self.engine.read(cx).entry_path(id).is_some() { - footage_menu(true) + let proxy = self.engine.read(cx).proxy_row(id); + footage_menu(true, proxy.as_ref()) } else { entry_menu() } @@ -154,7 +155,35 @@ impl ProjectExplorerPanel { println!("[project explorer] menu action {item} (not implemented yet)"); } LOCAL_PROXY_GENERATE | LOCAL_PROXY_USE | LOCAL_PROXY_REVEAL | LOCAL_PROXY_DELETE => { - println!("[project explorer] proxy action {item} (not implemented yet)"); + let Some(id) = self.context_entry else { + return; + }; + match item { + LOCAL_PROXY_GENERATE => { + if let Err(err) = self.engine.update(cx, |engine, cx| { + engine.proxy_generate(id, cx) + }) { + println!("[project explorer] proxy generate failed: {err}"); + } + } + LOCAL_PROXY_USE => { + let enabled = self + .engine + .read(cx) + .proxy_row(id) + .is_some_and(|row| row.enabled); + self.engine.update(cx, |engine, cx| { + engine.proxy_set_enabled(id, !enabled, cx) + }); + } + LOCAL_PROXY_REVEAL => { + self.engine.read(cx).proxy_reveal(id); + } + LOCAL_PROXY_DELETE => { + self.engine.update(cx, |engine, cx| engine.proxy_delete(id, cx)); + } + _ => {} + } } _ => { println!("[project explorer] unhandled local menu item {item}"); @@ -248,18 +277,40 @@ const LOCAL_RENAME: usize = 2209; const LOCAL_DELETE: usize = 2210; const LOCAL_PROPERTIES: usize = 2211; -/// The proxy submenu (shared shape with the timeline's; the entries stay -/// disabled until the proxy pipeline lands, the settings entry is the real -/// registry action). -fn proxy_submenu() -> Menu { +/// The proxy submenu (shared shape with the timeline's): enable state +/// follows the footage's proxy fields (the C++ project explorer gates +/// Generate on a video stream, Use/Reveal/Delete on the proxy path); the +/// settings entry is the real registry action. `row` is `None` when the +/// entry is not footage — every entry but the settings stays disabled. +fn proxy_submenu(row: Option<&crate::oakui::engine::ProxyFootageRow>) -> Menu { + let can_generate = row.is_some_and(|row| row.can_generate); + let enabled = row.is_some_and(|row| row.enabled); + let has_proxy = row.is_some_and(|row| row.has_proxy); + let mut generate = + MenuItem::new(LOCAL_PROXY_GENERATE, crate::i18n::tr("timeline.context.generate_proxy")); + if !can_generate { + generate = generate.disabled(); + } + let mut use_proxy = MenuItem::new(LOCAL_PROXY_USE, crate::i18n::tr("timeline.context.use_proxy")) + .with_checked(enabled); + if row.is_none() { + use_proxy = use_proxy.disabled(); + } + let mut reveal = + MenuItem::new(LOCAL_PROXY_REVEAL, crate::i18n::tr("timeline.context.reveal_proxy")); + if !has_proxy { + reveal = reveal.disabled(); + } + let mut delete = + MenuItem::new(LOCAL_PROXY_DELETE, crate::i18n::tr("timeline.context.delete_proxy")); + if !has_proxy { + delete = delete.disabled(); + } Menu::new(vec![ - MenuItem::new(LOCAL_PROXY_GENERATE, crate::i18n::tr("timeline.context.generate_proxy")) - .disabled(), - MenuItem::new(LOCAL_PROXY_USE, crate::i18n::tr("timeline.context.use_proxy")).disabled(), - MenuItem::new(LOCAL_PROXY_REVEAL, crate::i18n::tr("timeline.context.reveal_proxy")) - .disabled(), - MenuItem::new(LOCAL_PROXY_DELETE, crate::i18n::tr("timeline.context.delete_proxy")) - .disabled(), + generate, + use_proxy, + reveal, + delete, shared::action_item(ActionId::ProxySettings).separated(), ]) } @@ -274,8 +325,12 @@ pub(crate) fn blank_menu() -> Menu { } /// A footage entry's context menu: reveal + replace, the proxy submenu, -/// then rename / delete / properties. -pub(crate) fn footage_menu(reveal_enabled: bool) -> Menu { +/// then rename / delete / properties. `proxy` carries the entry's proxy +/// state so the submenu enables Generate/Use/Reveal/Delete correctly. +pub(crate) fn footage_menu( + reveal_enabled: bool, + proxy: Option<&crate::oakui::engine::ProxyFootageRow>, +) -> Menu { let mut reveal = MenuItem::new(LOCAL_REVEAL_IN_FINDER, crate::i18n::tr("project.context.reveal_in_finder")); if !reveal_enabled { @@ -285,7 +340,8 @@ pub(crate) fn footage_menu(reveal_enabled: bool) -> Menu { reveal, MenuItem::new(LOCAL_REPLACE_FOOTAGE, crate::i18n::tr("project.context.replace_footage")) .separated(), - MenuItem::new(0, crate::i18n::tr("timeline.context.proxy")).with_submenu(proxy_submenu()), + MenuItem::new(0, crate::i18n::tr("timeline.context.proxy")) + .with_submenu(proxy_submenu(proxy)), MenuItem::new(LOCAL_RENAME, crate::i18n::tr("project.context.rename")).separated(), MenuItem::new(LOCAL_DELETE, crate::i18n::tr("project.context.delete")), MenuItem::new(LOCAL_PROPERTIES, crate::i18n::tr("menu.context.properties")).separated(), @@ -343,11 +399,12 @@ mod tests { } /// The footage menu gates only the reveal entry on `reveal_enabled`; - /// every other entry keeps its state. + /// without proxy state every proxy entry but the settings action stays + /// disabled. #[test] fn footage_menu_gates_the_reveal_entry() { for reveal_enabled in [true, false] { - let menu = footage_menu(reveal_enabled); + let menu = footage_menu(reveal_enabled, None); let reveal = menu .items .iter() diff --git a/src/panels/status_bar.rs b/src/panels/status_bar.rs index 589129de2..cb732f951 100644 --- a/src/panels/status_bar.rs +++ b/src/panels/status_bar.rs @@ -87,6 +87,13 @@ impl Render for StatusBar { ) }; + // The proxy segment doubles as the in-flight proxy transcode's + // progress readout (the C++ status bar shows the running task). + let proxy_text = match engine.proxy_task_progress() { + Some((label, progress)) => format!("{label} {}%", (progress * 100.0) as i32), + None => crate::i18n::tr("status.proxy").into(), + }; + div() .h_6() .flex() @@ -97,7 +104,7 @@ impl Render for StatusBar { .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, proxy_text)) .child( div() .px_2() diff --git a/src/panels/timeline.rs b/src/panels/timeline.rs index 98c217d64..2870d7880 100644 --- a/src/panels/timeline.rs +++ b/src/panels/timeline.rs @@ -219,7 +219,15 @@ impl TimelinePanel { cx: &mut Context, ) { let menu = match &hit { - TimelineHit::Clip(_) => clip_menu(), + TimelineHit::Clip(_) => { + let ids: Vec = + self.timeline.read(cx).selection().iter().copied().collect(); + let (sync, proxy) = { + let engine = self.engine.read(cx); + (engine.sync_eligibility(&ids), engine.clip_footage_entries(&ids)) + }; + clip_menu(sync, &proxy) + } TimelineHit::Empty { .. } => empty_area_menu(), TimelineHit::TrackHead(track) => { self.context_track = Some(*track); @@ -255,6 +263,48 @@ impl TimelinePanel { LOCAL_CACHE_ALL | LOCAL_CACHE_IN_OUT | LOCAL_CACHE_DISCARD => { println!("[timeline] cache action {item} (not implemented yet)"); } + LOCAL_PROXY_GENERATE + | LOCAL_PROXY_USE + | LOCAL_PROXY_REVEAL + | LOCAL_PROXY_DELETE => { + let ids: Vec = + self.timeline.read(cx).selection().iter().copied().collect(); + let rows = self.engine.read(cx).clip_footage_entries(&ids); + match item { + LOCAL_PROXY_GENERATE => { + for row in rows.into_iter().filter(|row| row.can_generate) { + if let Err(err) = self.engine.update(cx, |engine, cx| { + engine.proxy_generate(row.id, cx) + }) { + println!("[timeline] proxy generate failed: {err}"); + } + } + } + LOCAL_PROXY_USE => { + // The C++ flips the group: every footage enabled + // turns the whole selection off, anything less + // turns it on. + let enable = !rows.iter().all(|row| row.enabled); + for row in rows { + self.engine.update(cx, |engine, cx| { + engine.proxy_set_enabled(row.id, enable, cx) + }); + } + } + LOCAL_PROXY_REVEAL => { + for row in rows.into_iter().filter(|row| row.has_proxy) { + self.engine.read(cx).proxy_reveal(row.id); + } + } + _ => { + for row in rows.into_iter().filter(|row| row.has_proxy) { + self.engine.update(cx, |engine, cx| { + engine.proxy_delete(row.id, cx) + }); + } + } + } + } LOCAL_TIMECODE_DROP_FRAME | LOCAL_TIMECODE_NON_DROP_FRAME | LOCAL_TIMECODE_SECONDS @@ -550,6 +600,26 @@ impl PanelCommandHandler for TimelinePanel { true } + // --- synchronization --- + fn sync_by_source_time(&mut self, cx: &mut Context) -> bool { + let ids: Vec = self.timeline.read(cx).selection().iter().copied().collect(); + self.engine + .update(cx, |engine, cx| engine.sync_clips_by_source_time(ids, cx)); + true + } + fn sync_by_waveform(&mut self, cx: &mut Context) -> bool { + let ids: Vec = self.timeline.read(cx).selection().iter().copied().collect(); + self.engine + .update(cx, |engine, cx| engine.sync_clips_by_waveform(ids, false, cx)); + true + } + fn sync_by_waveform_speed(&mut self, cx: &mut Context) -> bool { + let ids: Vec = self.timeline.read(cx).selection().iter().copied().collect(); + self.engine + .update(cx, |engine, cx| engine.sync_clips_by_waveform(ids, true, cx)); + true + } + // --- view --- fn zoom_in(&mut self, cx: &mut Context) -> bool { self.zoom_timeline(1.25, cx); @@ -803,9 +873,6 @@ const LOCAL_SHOW_WAVEFORMS: usize = 2102; const LOCAL_THUMBNAIL_OFF: usize = 2103; const LOCAL_THUMBNAIL_IN_OUT: usize = 2104; const LOCAL_THUMBNAIL_ON: usize = 2105; -const LOCAL_SYNC_SOURCE_TIME: usize = 2106; -const LOCAL_SYNC_WAVEFORM: usize = 2107; -const LOCAL_SYNC_WAVEFORM_SPEED: usize = 2108; const LOCAL_CACHE_AUTO: usize = 2109; const LOCAL_CACHE_ALL: usize = 2110; const LOCAL_CACHE_IN_OUT: usize = 2111; @@ -841,8 +908,14 @@ fn properties_item(action: ActionId) -> MenuItem { /// The clip context menu (`TimelineWidget::show_context_menu` with a /// selection): the shared clip-edit section, color labels, the synchronize -/// / cache / proxy groups, reveal entries and "Properties". -pub(crate) fn clip_menu() -> Menu { +/// / cache / proxy groups, reveal entries and "Properties". `sync` and +/// `proxy` carry the selection-derived enable state (the C++ enables the +/// synchronize entries at ≥ 2 eligible clips and the proxy entries per +/// the selected footage's proxy fields). +pub(crate) fn clip_menu( + sync: crate::oakui::engine::SyncEligibility, + proxy: &[crate::oakui::engine::ProxyFootageRow], +) -> Menu { let mut items = shared::edit_section(true); // The C++ puts a separator between the edit section and the color // labels, and another after them. @@ -850,23 +923,26 @@ pub(crate) fn clip_menu() -> Menu { last.separator_after = true; } items.push(shared::color_label_item(None).separated()); - // Synchronize group: needs ≥ 2 clips with matching media in the C++; - // the engine has no sync surface yet, so the entries stay disabled. - items.push( - MenuItem::new(LOCAL_SYNC_SOURCE_TIME, i18n::tr("timeline.context.sync_source_time")) - .disabled(), - ); - items.push( - MenuItem::new(LOCAL_SYNC_WAVEFORM, i18n::tr("timeline.context.sync_waveform")).disabled(), - ); - items.push( - MenuItem::new( - LOCAL_SYNC_WAVEFORM_SPEED, - i18n::tr("timeline.context.sync_waveform_speed"), - ) - .disabled() - .separated(), - ); + // Synchronize group (registry actions; enabled at ≥ 2 eligible clips, + // the C++ `get_selected_source_sync_clips` / `_waveform_sync_clips` + // counts). + let sync_enabled = sync.source_time >= 2; + let wave_enabled = sync.waveform >= 2; + let mut source_time = shared::action_item(ActionId::SyncBySourceTime); + if !sync_enabled { + source_time = source_time.disabled(); + } + items.push(source_time); + let mut waveform = shared::action_item(ActionId::SyncByWaveform); + if !wave_enabled { + waveform = waveform.disabled(); + } + items.push(waveform); + let mut waveform_speed = shared::action_item(ActionId::SyncByWaveformSpeed).separated(); + if !wave_enabled { + waveform_speed = waveform_speed.disabled(); + } + items.push(waveform_speed); // Cache group (placeholders: the engine has no cache surface yet). let cache_menu = Menu::new(vec![ MenuItem::new(LOCAL_CACHE_AUTO, i18n::tr("timeline.context.auto_cache")) @@ -877,14 +953,35 @@ pub(crate) fn clip_menu() -> Menu { MenuItem::new(LOCAL_CACHE_DISCARD, i18n::tr("timeline.context.cache_discard")), ]); items.push(MenuItem::new(0, i18n::tr("timeline.context.cache")).with_submenu(cache_menu)); - // Proxy group: disabled until the proxy pipeline lands; the settings - // entry is the real registry action. + // Proxy group: the enable state mirrors the C++ + // `get_selected_proxy_footage` conditions over the selected clips' + // footage; the settings entry is the real registry action. + let has_footage = !proxy.is_empty(); + let can_generate = proxy.iter().any(|row| row.can_generate); + let any_proxy = proxy.iter().any(|row| row.has_proxy); + let all_enabled = has_footage && proxy.iter().all(|row| row.enabled); + let mut generate = MenuItem::new(LOCAL_PROXY_GENERATE, i18n::tr("timeline.context.generate_proxy")); + if !can_generate { + generate = generate.disabled(); + } + let mut use_proxy = MenuItem::new(LOCAL_PROXY_USE, i18n::tr("timeline.context.use_proxy")) + .with_checked(all_enabled); + if !has_footage { + use_proxy = use_proxy.disabled(); + } + let mut reveal = MenuItem::new(LOCAL_PROXY_REVEAL, i18n::tr("timeline.context.reveal_proxy")); + if !any_proxy { + reveal = reveal.disabled(); + } + let mut delete = MenuItem::new(LOCAL_PROXY_DELETE, i18n::tr("timeline.context.delete_proxy")); + if !any_proxy { + delete = delete.disabled(); + } let proxy_menu = Menu::new(vec![ - MenuItem::new(LOCAL_PROXY_GENERATE, i18n::tr("timeline.context.generate_proxy")) - .disabled(), - MenuItem::new(LOCAL_PROXY_USE, i18n::tr("timeline.context.use_proxy")).disabled(), - MenuItem::new(LOCAL_PROXY_REVEAL, i18n::tr("timeline.context.reveal_proxy")).disabled(), - MenuItem::new(LOCAL_PROXY_DELETE, i18n::tr("timeline.context.delete_proxy")).disabled(), + generate, + use_proxy, + reveal, + delete, shared::action_item(ActionId::ProxySettings).separated(), ]); items.push(MenuItem::new(0, i18n::tr("timeline.context.proxy")).with_submenu(proxy_menu)); @@ -1133,11 +1230,15 @@ mod tests { } /// The clip menu keeps the C++ shape: edit section, color labels, the - /// three (disabled) synchronize entries, cache and proxy submenus, the - /// reveal/multi-cam entries and a registry-backed "Properties". + /// three synchronize entries (registry actions; disabled without ≥ 2 + /// eligible clips), cache and proxy submenus, the reveal/multi-cam + /// entries and a registry-backed "Properties". #[test] fn clip_menu_keeps_the_cpp_shape() { - let menu = clip_menu(); + let menu = clip_menu( + crate::oakui::engine::SyncEligibility::default(), + &[], + ); // Color label item sits right after the edit section and carries a // submenu of all 16 labels. let color = menu @@ -1150,10 +1251,21 @@ mod tests { shared::COLOR_LABEL_COUNT ); + // The synchronize entries are the registry actions and stay + // disabled while fewer than 2 clips are eligible. + for action in [ + ActionId::SyncBySourceTime, + ActionId::SyncByWaveform, + ActionId::SyncByWaveformSpeed, + ] { + let id = action.entry().menu_id(); + let item = menu.items.iter().find(|item| item.id == id).unwrap_or_else(|| { + panic!("clip menu missing synchronize entry {id}") + }); + assert!(!item.enabled, "synchronize {id} should be disabled"); + } + for id in [ - LOCAL_SYNC_SOURCE_TIME, - LOCAL_SYNC_WAVEFORM, - LOCAL_SYNC_WAVEFORM_SPEED, LOCAL_REVEAL_FOOTAGE_VIEWER, LOCAL_REVEAL_PROJECT, LOCAL_MULTICAM, @@ -1164,8 +1276,8 @@ mod tests { assert!(!item.enabled, "placeholder {id} should be disabled"); } - // Cache and proxy are submenus; every proxy entry but the settings - // action is disabled. + // Cache and proxy are submenus; with no footage selected every + // proxy entry but the settings action is disabled. let cache = menu .items .iter() @@ -1190,6 +1302,68 @@ mod tests { ); } + /// The synchronize / proxy enable state follows the selection (the C++ + /// `get_selected_*_sync_clips` counts and the proxy-footage flags). + #[test] + fn clip_menu_enables_sync_and_proxy_from_selection() { + use crate::oakui::engine::{ProxyFootageRow, ProxyMediaState, SyncEligibility}; + let rows = vec![ + ProxyFootageRow { + id: 1, + name: "a.mp4".into(), + state: ProxyMediaState::Ready, + enabled: true, + has_custom: false, + can_generate: true, + has_proxy: true, + }, + ProxyFootageRow { + id: 2, + name: "b.mp4".into(), + state: ProxyMediaState::Missing, + enabled: false, + has_custom: false, + can_generate: true, + has_proxy: false, + }, + ]; + let menu = clip_menu( + SyncEligibility { + source_time: 2, + waveform: 1, + }, + &rows, + ); + let find = |id: usize| { + menu.items + .iter() + .find(|item| item.id == id) + .unwrap_or_else(|| panic!("missing item {id}")) + }; + assert!( + find(ActionId::SyncBySourceTime.entry().menu_id()).enabled, + "2 eligible clips enable source-time sync" + ); + assert!( + !find(ActionId::SyncByWaveform.entry().menu_id()).enabled, + "1 eligible clip keeps waveform sync disabled" + ); + let proxy = menu + .items + .iter() + .find(|item| item.label == i18n::tr("timeline.context.proxy")) + .expect("proxy submenu"); + let proxy_items = &proxy.submenu.as_ref().unwrap().items; + assert!(proxy_items[0].enabled, "generate: footage can generate"); + assert!(proxy_items[1].enabled, "use: footage present"); + assert!( + !proxy_items[1].checked.unwrap_or(false), + "use: not every footage has its proxy enabled" + ); + assert!(proxy_items[2].enabled, "reveal: one footage has a proxy"); + assert!(proxy_items[3].enabled, "delete: one footage has a proxy"); + } + /// The empty-area menu exposes the view toggles plus the sequence /// settings "Properties" entry. #[test]