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.
This commit is contained in:
2026-08-18 18:58:26 +08:00
parent 2db1615453
commit f2af92958a
17 changed files with 2696 additions and 82 deletions
+35
View File
@@ -41,6 +41,20 @@ pub enum ProxyState {
Failed = 3,
}
impl TryFrom<i32> for ProxyState {
type Error = ();
fn try_from(value: i32) -> Result<Self, Self::Error> {
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<String> {
Ok(Path::new(cache_path)
+168 -7
View File
@@ -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<oakcodec::proxymanager::ProxyParams>,
/// 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 `<filename>` element (the C++ convention; `<filename>` is a
/// Rust addition).
/// (`viewer` workarea/markers) are skipped. The filename falls back
/// to the `file_in` input when the file carries no `<filename>`
/// element (the C++ convention; `<filename>` 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::<i64>().ok()),
parts.next().and_then(|v| v.trim().parse::<i64>().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(&params.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(&params.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).
+6 -2
View File
@@ -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.",
+14
View File
@@ -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()),
+83 -2
View File
@@ -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<E: AppEngine> {
},
/// The manager's delete confirmation.
ManagerDelete { modal: Entity<Modal>, uuid: String },
/// The proxy settings dialog (Tools > Proxy Settings).
Proxy {
modal: Entity<Modal>,
content: Entity<crate::dialogs::ProxyDialogContent<E>>,
},
}
/// A running export: the session the tick loop drains for progress.
@@ -168,7 +174,8 @@ impl<E: AppEngine> ModalState<E> {
| 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<E: AppEngine> OakApp<E> {
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<E: AppEngine> OakApp<E> {
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<E: AppEngine> OakApp<E> {
}
}
/// 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<Self>) {
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<Self>) {
if self.engine.read(cx).current_sequence().is_none() {
@@ -1773,6 +1825,19 @@ impl<E: AppEngine> OakApp<E> {
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<E: AppEngine> OakApp<E> {
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<MenuBarEntry> {
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;
+414 -3
View File
@@ -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<E: crate::oakui::engine::AppEngine> {
engine: Entity<E>,
divider: Entity<ComboBox>,
width: Entity<SpinBox>,
height: Entity<SpinBox>,
crf: Entity<SpinBox>,
preset: Entity<ComboBox>,
include_audio: Entity<CheckBox>,
ffmpeg_path: Entity<PathField>,
custom_params: Entity<CheckBox>,
/// Snapshot of the footage rows (refreshed after generate / delete).
rows: Vec<crate::oakui::engine::ProxyFootageRow>,
/// The divider values in dropdown order (1 = custom size).
dividers: Vec<i32>,
}
impl<E: crate::oakui::engine::AppEngine> ProxyDialogContent<E> {
/// Builds the content seeded from the global config params (the C++
/// `oakengine_proxy_params_from_config` defaults).
pub fn new(engine: Entity<E>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let params = crate::oakui::engine::proxy_params_from_config();
let dividers: Vec<i32> = 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", &params.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<Self>) {
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<Self>) {
let ids: Vec<u64> = 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>) {
self.save_global_settings(cx);
let custom = self.custom_params.read(cx).state() == CheckState::Checked;
let params = self.current_params(cx);
let ids: Vec<u64> = 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>) {
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<E: crate::oakui::engine::AppEngine> Render for ProxyDialogContent<E> {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> 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::<Vec<_>>(),
);
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)
}
}
+50
View File
@@ -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", "红色"),
+263
View File
@@ -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<ExportSession, String>;
// -------------------------------------------------------------------
// 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<Self>) {
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<ProxyFootageRow> {
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<ProxyMediaState> {
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<ProxyFootageRow> {
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<Self>) -> 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<Self>) {
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<Self>) {
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<Self>,
) {
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<Self>) {
let _ = (id, cx);
}
/// The custom proxy params of footage `id` (`None` = global params).
fn proxy_custom_params(&self, id: u64) -> Option<ProxyParamsUi> {
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<ProxyFootageRow> {
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<ClipId>, cx: &mut Context<Self>) {
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<ClipId>,
adjust_speed: bool,
cx: &mut Context<Self>,
) {
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 {
+199
View File
@@ -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<u64, crate::oakui::engine::ProxyMediaState>,
/// Per-footage proxy-use flag (the demo's per-footage switch).
proxy_enabled: HashMap<u64, bool>,
/// Per-footage custom proxy generation params (the proxy dialog's
/// custom checkbox path).
proxy_custom: HashMap<u64, crate::oakui::engine::ProxyParamsUi>,
/// 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>) {
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<crate::oakui::engine::ProxyFootageRow> {
// 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<crate::oakui::engine::ProxyMediaState> {
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<crate::oakui::engine::ProxyFootageRow> {
self.proxy_rows().into_iter().find(|row| row.id == id)
}
fn proxy_generate(&mut self, id: u64, cx: &mut Context<Self>) -> 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>) {
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>) {
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>,
) {
self.proxy_custom.insert(id, params);
cx.notify();
}
fn proxy_clear_custom_params(&mut self, id: u64, cx: &mut Context<Self>) {
self.proxy_custom.remove(&id);
cx.notify();
}
fn proxy_custom_params(&self, id: u64) -> Option<crate::oakui::engine::ProxyParamsUi> {
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<ClipId>, cx: &mut Context<Self>) {
self.mock_sync_clips(&clips);
cx.notify();
}
fn sync_clips_by_waveform(
&mut self,
clips: Vec<ClipId>,
_adjust_speed: bool,
cx: &mut Context<Self>,
) {
// 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<crate::oakui::engine::ProxyFootageRow> {
// 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"
}
+1
View File
@@ -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,
+914
View File
@@ -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<super::engine::ExportEvent>,
}
/// 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<mpsc::Receiver<ThumbEvent>>,
/// The sending half of `thumb_rx` (cloned into every job).
thumb_tx: Mutex<mpsc::Sender<ThumbEvent>>,
proxy_runs: Vec<ProxyRun>,
}
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>) {
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<NodeId> {
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<Self>) {
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::<oaknode::footage::FootageBehavior>())
{
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<SourceTarget> = 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<oakundo::undocommand::UndoCommand> = 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<crate::oakui::waveform::ClipWaveform>,
}
let mut targets: Vec<WaveTarget> = 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<oakundo::undocommand::UndoCommand> = 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::<oaknode::block::ClipBlockBehavior>())
{
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::<oaknode::block::ClipBlockBehavior>())
{
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<Self>) {
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<super::engine::ProxyFootageRow> {
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<super::engine::ProxyMediaState> {
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<super::engine::ProxyFootageRow> {
self.proxy_rows().into_iter().find(|row| row.id == id)
}
fn proxy_generate(&mut self, id: u64, cx: &mut Context<Self>) -> 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,
&params,
)
.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(&params.extension[..end]).into_owned()
},
preset: {
let end = params
.preset
.iter()
.position(|&b| b == 0)
.unwrap_or(params.preset.len());
String::from_utf8_lossy(&params.preset[..end]).into_owned()
},
};
let label = format!("Generating Proxy {}", filename);
let (tx, rx) = mpsc::channel::<super::engine::ExportEvent>();
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::<oaknode::footage::FootageBehavior>())
{
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<Self>) {
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::<oaknode::footage::FootageBehavior>())
{
f.clear_proxy();
}
}
self.invalidate_preview_frames(cx);
}
fn proxy_set_enabled(&mut self, id: u64, enabled: bool, cx: &mut Context<Self>) {
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::<oaknode::footage::FootageBehavior>())
{
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<Self>,
) {
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::<oaknode::footage::FootageBehavior>())
{
f.set_custom_proxy_params(params.to_codec());
}
}
cx.notify();
}
fn proxy_clear_custom_params(&mut self, id: u64, cx: &mut Context<Self>) {
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::<oaknode::footage::FootageBehavior>())
{
f.clear_custom_proxy_params();
}
}
cx.notify();
}
fn proxy_custom_params(&self, id: u64) -> Option<super::engine::ProxyParamsUi> {
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<super::engine::ProxyFootageRow> {
let Some(project) = self.project.as_ref() else {
return Vec::new();
};
let guard = graphops::lock(project);
let mut seen: Vec<NodeId> = Vec::new();
let mut rows: Vec<super::engine::ProxyFootageRow> = 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<ClipId>, cx: &mut Context<Self>) {
self.sync_clips_by_source_time_internal(&clips);
cx.notify();
}
fn sync_clips_by_waveform(
&mut self,
clips: Vec<ClipId>,
adjust_speed: bool,
cx: &mut Context<Self>,
) {
self.sync_clips_by_waveform_internal(&clips, adjust_speed);
cx.notify();
}
fn start_export(&mut self, format: i32, path: PathBuf) -> Result<ExportSession, String> {
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.
+68 -11
View File
@@ -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<String> {
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<Montage
if time < in_ || time >= 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<Monta
if out <= range.in_() || in_ >= 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<RenderedFrame, String> {
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(),
})
}
+172
View File
@@ -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 <http://www.gnu.org/licenses/>.
//! 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<f64>, Vec<bool>) {
let mut envelope: Vec<f64> = Vec::new();
let mut valid: Vec<bool> = 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<MinMax> = (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");
}
}
+14
View File
@@ -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<Self>) -> bool {
false
}
fn sync_by_waveform(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn sync_by_waveform_speed(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
// --- view ----------------------------------------------------------------
fn zoom_in(&mut self, _cx: &mut Context<Self>) -> bool {
false
@@ -253,6 +264,9 @@ pub fn dispatch_to<P: PanelCommandHandler>(
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),
+75 -18
View File
@@ -105,7 +105,8 @@ impl<E: AppEngine> ProjectExplorerPanel<E> {
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<E: AppEngine> ProjectExplorerPanel<E> {
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()
+8 -1
View File
@@ -87,6 +87,13 @@ impl<E: AppEngine> Render for StatusBar<E> {
)
};
// 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<E: AppEngine> Render for StatusBar<E> {
.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()
+212 -38
View File
@@ -219,7 +219,15 @@ impl<E: AppEngine> TimelinePanel<E> {
cx: &mut Context<Self>,
) {
let menu = match &hit {
TimelineHit::Clip(_) => clip_menu(),
TimelineHit::Clip(_) => {
let ids: Vec<ClipId> =
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<E: AppEngine> TimelinePanel<E> {
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<ClipId> =
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<E: AppEngine> PanelCommandHandler for TimelinePanel<E> {
true
}
// --- synchronization ---
fn sync_by_source_time(&mut self, cx: &mut Context<Self>) -> bool {
let ids: Vec<ClipId> = 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<Self>) -> bool {
let ids: Vec<ClipId> = 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<Self>) -> bool {
let ids: Vec<ClipId> = 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<Self>) -> 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]