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:
@@ -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)
|
||||
|
||||
@@ -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(¶ms.preset[..end]).unwrap_or("")
|
||||
}
|
||||
|
||||
/// View a proxy param's NUL-terminated extension bytes as a `&str`.
|
||||
fn extension_str(params: &oakcodec::proxymanager::ProxyParams) -> &str {
|
||||
let end = params
|
||||
.extension
|
||||
.iter()
|
||||
.position(|&b| b == 0)
|
||||
.unwrap_or(params.extension.len());
|
||||
std::str::from_utf8(¶ms.extension[..end]).unwrap_or("")
|
||||
}
|
||||
|
||||
/// A stream duration in timebase ticks as rational seconds. `0/1` when
|
||||
/// the duration or the timebase is unusable (FFmpeg reports
|
||||
/// `AV_NOPTS_VALUE` for streams without a duration).
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
@@ -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
@@ -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", ¶ms.preset);
|
||||
config_set_bool("ProxyIncludeAudio", params.include_audio);
|
||||
config_set_string(
|
||||
CONFIG_KEY_FFMPEG_PATH,
|
||||
self.ffmpeg_path.read(cx).path(cx).trim(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Generates proxies for every footage row with a video stream (the
|
||||
/// C++ Generate Proxies button): with the custom checkbox set, the
|
||||
/// edited params become each footage's custom params first.
|
||||
pub fn generate(&mut self, cx: &mut Context<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
@@ -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", "红色"),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
¶ms,
|
||||
)
|
||||
.map_err(|e| format!("failed to build the proxy filename: {e}"))?;
|
||||
|
||||
// Absolute targets only apply in custom-size mode; divider mode
|
||||
// scales from the source (the request mirrors the C++ submission).
|
||||
let (proxy_width, proxy_height) = if params.divider <= 1 {
|
||||
(params.width, params.height)
|
||||
} else {
|
||||
(0, 0)
|
||||
};
|
||||
let request = oakcodec::task::TaskRequest {
|
||||
kind: oakcodec::task::TaskKind::Proxy,
|
||||
input_filename: &filename,
|
||||
output_filename: &proxy_path,
|
||||
stream_index,
|
||||
sample_rate: 0,
|
||||
channel_layout: 0,
|
||||
sample_format: 0,
|
||||
proxy_width,
|
||||
proxy_height,
|
||||
};
|
||||
let task_params = oaktask::proxy::ProxyParams {
|
||||
width: params.width,
|
||||
height: params.height,
|
||||
divider: params.divider,
|
||||
version: params.version,
|
||||
crf: params.crf,
|
||||
include_audio: params.include_audio != 0,
|
||||
extension: {
|
||||
let end = params
|
||||
.extension
|
||||
.iter()
|
||||
.position(|&b| b == 0)
|
||||
.unwrap_or(params.extension.len());
|
||||
String::from_utf8_lossy(¶ms.extension[..end]).into_owned()
|
||||
},
|
||||
preset: {
|
||||
let end = params
|
||||
.preset
|
||||
.iter()
|
||||
.position(|&b| b == 0)
|
||||
.unwrap_or(params.preset.len());
|
||||
String::from_utf8_lossy(¶ms.preset[..end]).into_owned()
|
||||
},
|
||||
};
|
||||
|
||||
let label = format!("Generating Proxy {}", filename);
|
||||
let (tx, rx) = mpsc::channel::<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
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user