Files
oak-editor/crates/oakcodec/src/proxymanager.rs
T
Mike-Solar 5afa95c80e feat(render,app): footage decode lands (M12 P0) + UI density pass
engine:
- oakrender eval footage hook decodes via oakcodec (JobSpec::Footage
  carries filename/stream); ticket/ffi/manager wiring, real-media
  decode test with programmatically generated MPEG-2
- oakrender bridge/codec.rs + node.rs: direct oakcodec/oaknode calls;
  the crate's dlsym module is gone (project_deep_copy/sync_copy remain
  documented always-fail stubs — never implemented in oaknode)
- oakaudio waveform/decoder path adjustments for the decode hook

app (gpui + gpui_widgets):
- menu bar scrubbing: hovering another top-level title while a menu is
  open switches to it; popup width is content-aware (CJK-aware) instead
  of fixed 160px
- density pass: window rem 16 -> 14px, menu rows 26 -> 22px, dock tabs
  32 -> 26px, viewer transport tightened
- open/import/save-as use the native platform file dialogs
  (prompt_for_paths / prompt_for_new_path; multi-select import);
  MockEngine records imported footage for tests
- project explorer Tree/Icons toggle is localized (explorer.tree /
  explorer.icons widget keys)
2026-08-13 18:16:10 +08:00

701 lines
21 KiB
Rust

// 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/>.
//! `olive::ProxyManager` — proxy (low-res transcode) generation.
//!
//! Mirrors `src/codec/src/proxymanager.h`. Stateless (NOTES.md): actual
//! transcodes are delegated to the global task submit callback
//! ([`crate::task`]); with no registrar, `get_or_start` reports the proxy
//! as missing. `proxy_params_from_config` reads the oakcommon config C ABI
//! with the compiled-in defaults as fallback (1280x720 / divider 1 / crf 23
//! / "mp4" / "veryfast" / audio included).
use std::ffi::{c_char, CString};
use std::path::Path;
/// Proxy state of a proxy file on disk.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(i32)]
pub enum ProxyState {
/// Missing (or NULL/empty/absent).
Missing = 0,
/// Generating.
Generating = 1,
/// Ready on disk.
Ready = 2,
/// Generation failed.
Failed = 3,
}
/// `olive::ProxyManager::ProxyParams` — mirror of `oakcodec_proxy_params`.
#[derive(Clone, Debug)]
#[repr(C)]
pub struct ProxyParams {
/// Absolute target width (0 when divider-based).
pub width: i32,
/// Absolute target height (0 when divider-based).
pub height: i32,
/// Source resolution divider (1 = absolute width/height, 2/4/8).
pub divider: i32,
/// Proxy format version.
pub version: i32,
/// x264 crf.
pub crf: i32,
/// Include the audio track (1/0; C `int`).
pub include_audio: i32,
/// ffmpeg output container (e.g. "mp4").
pub extension: [u8; 32],
/// ffmpeg encoder preset (e.g. "veryfast").
pub preset: [u8; 32],
}
impl Default for ProxyParams {
fn default() -> Self {
ProxyParams {
width: 1280,
height: 720,
divider: 1,
version: 1,
crf: 23,
include_audio: 1,
extension: bytes32(b"mp4"),
preset: bytes32(b"veryfast"),
}
}
}
impl ProxyParams {
fn extension_str(&self) -> &str {
cstr_slice(&self.extension)
}
fn preset_str(&self) -> &str {
cstr_slice(&self.preset)
}
}
/// `oakcodec_proxy_result` — POD result of [`ProxyManager::get_or_start`];
/// see `include/codec/proxy.h`.
#[repr(C)]
pub struct OakCodecProxyResult {
/// `ProxyState` value.
pub state: i32,
/// Resulting proxy filename (may be empty).
pub filename: [u8; 1024],
}
/// `olive::ProxyManager` — stateless proxy query/generate manager.
pub struct ProxyManager;
impl ProxyManager {
/// The process-wide ProxyManager singleton.
pub fn instance() -> &'static ProxyManager {
static INSTANCE: ProxyManager = ProxyManager;
&INSTANCE
}
/// Compiled-in default proxy parameters (the `Default` values).
pub fn proxy_params_default() -> ProxyParams {
ProxyParams::default()
}
/// Proxy parameters read from the oakcommon config, with the compiled-in
/// defaults as fallback.
///
/// # CPP-PARITY
/// `src/codec/src/proxymanager.h` `proxy_params_from_config` — reads
/// ProxyWidth/ProxyHeight/ProxyDivider/ProxyCRF/ProxyPreset/
/// ProxyIncludeAudio via `oakcommon_config_*`.
pub fn proxy_params_from_config() -> ProxyParams {
let mut p = ProxyParams::default();
p.width = config_get_int("ProxyWidth", p.width);
p.height = config_get_int("ProxyHeight", p.height);
p.divider = config_get_int("ProxyDivider", p.divider);
p.crf = config_get_int("ProxyCRF", p.crf);
p.include_audio = if config_get_bool("ProxyIncludeAudio", p.include_audio) != 0 {
1
} else {
0
};
if let Some(preset) = config_get_str("ProxyPreset") {
if !preset.is_empty() {
p.preset = bytes32(preset.as_bytes());
}
}
p
}
/// State of a proxy file on disk.
pub fn get_proxy_state(proxy_filename: &str) -> ProxyState {
if proxy_filename.is_empty() {
return ProxyState::Missing;
}
if Path::new(proxy_filename).exists() {
return ProxyState::Ready;
}
let working = Self::get_working_filename(proxy_filename);
if let Ok(w) = working {
if Path::new(&w).exists() {
return ProxyState::Generating;
}
}
ProxyState::Missing
}
/// Human-readable string for a proxy state.
pub fn proxy_state_to_string(state: ProxyState) -> String {
match state {
ProxyState::Missing => "missing".to_string(),
ProxyState::Generating => "generating".to_string(),
ProxyState::Ready => "ready".to_string(),
ProxyState::Failed => "failed".to_string(),
}
}
/// Proxy directory for a project cache path.
pub fn get_proxy_directory(cache_path: &str) -> crate::error::Result<String> {
Ok(Path::new(cache_path)
.join("proxy")
.to_string_lossy()
.into_owned())
}
/// Deterministic proxy filename for a source stream.
pub fn get_proxy_filename(
cache_path: &str,
source_filename: &str,
stream_index: i32,
params: &ProxyParams,
) -> crate::error::Result<String> {
let proxy_dir = Self::get_proxy_directory(cache_path)?;
let extension = if params.extension_str().is_empty() {
"mp4"
} else {
params.extension_str()
};
// Divider mode scales relative to the source, so the tag names the
// divider rather than an absolute target size.
let size_tag = if params.divider > 1 {
format!("div{}", params.divider)
} else {
format!("{}x{}", params.width, params.height)
};
let filename = format!(
"{}-{}.{}.v{}.a{}.{}",
unique_file_identifier(source_filename),
stream_index,
size_tag,
params.version,
params.include_audio,
extension,
);
Ok(Path::new(&proxy_dir)
.join(filename)
.to_string_lossy()
.into_owned())
}
/// Working (in-progress) filename of a proxy.
pub fn get_working_filename(proxy_filename: &str) -> crate::error::Result<String> {
// Append a recognizable suffix while keeping a standard container
// extension so ffmpeg can infer the output format.
Ok(format!("{}.working.mp4", proxy_filename))
}
/// Get or start generating a proxy for `source_filename`. With no task
/// registrar the state stays `Missing`.
pub fn get_or_start(
&self,
cache_path: &str,
source_filename: &str,
stream_index: i32,
params: &ProxyParams,
) -> crate::error::Result<(ProxyState, String)> {
let filename = Self::get_proxy_filename(cache_path, source_filename, stream_index, params)?;
let file_state = Self::get_proxy_state(&filename);
if file_state == ProxyState::Ready {
return Ok((ProxyState::Ready, filename));
}
if !crate::task::task_submit_is_registered() {
// Interim state (pre-M8): no task system, proxy cannot be generated.
return Ok((ProxyState::Missing, filename));
}
if file_state == ProxyState::Generating {
// Stale working file from an interrupted run.
if let Ok(working) = Self::get_working_filename(&filename) {
let _ = std::fs::remove_file(&working);
}
}
// The task owns the ".working.mp4" temporary name and the rename to the
// final filename on success.
let req = crate::task::TaskRequest {
kind: crate::task::TaskKind::Proxy,
input_filename: source_filename,
output_filename: &filename,
stream_index,
sample_rate: 0,
channel_layout: 0,
sample_format: 0,
proxy_width: if params.divider <= 1 { params.width } else { 0 },
proxy_height: if params.divider <= 1 {
params.height
} else {
0
},
};
// Interim simplification: submission is synchronous.
if crate::task::submit_task(&req).is_err() {
return Ok((ProxyState::Failed, filename));
}
if Self::get_proxy_state(&filename) == ProxyState::Ready {
return Ok((ProxyState::Ready, filename));
}
Ok((ProxyState::Generating, filename))
}
/// Locate an ffmpeg executable (empty string when none found).
pub fn find_ffmpeg(configured_path: &str) -> String {
// An explicitly configured path takes precedence if it is usable.
if !configured_path.is_empty() {
if is_executable_file(Path::new(configured_path)) {
return absolute(configured_path);
}
}
// Fall back to searching the system PATH.
if let Ok(path_env) = std::env::var("PATH") {
for dir in path_env.split(':') {
if dir.is_empty() {
continue;
}
let candidate = Path::new(dir).join("ffmpeg");
if is_executable_file(&candidate) {
return absolute(&candidate.to_string_lossy());
}
}
}
// Finally, try common install locations (PATH on GUI-launched apps,
// particularly on macOS, often lacks these).
let mut candidates: Vec<String> = Vec::new();
let app_path = application_path();
if !app_path.is_empty() {
candidates.push(format!("{}/ffmpeg", app_path));
}
candidates.push("/opt/homebrew/bin/ffmpeg".to_string());
candidates.push("/usr/local/bin/ffmpeg".to_string());
candidates.push("/usr/bin/ffmpeg".to_string());
candidates.push("/usr/local/bin/ffmpeg".to_string());
for c in candidates {
if is_executable_file(Path::new(&c)) {
return absolute(&c);
}
}
String::new()
}
}
/// Copy a byte string into a NUL-terminated `[u8; 32]` (truncated to 31
/// chars so there is always a trailing NUL).
fn bytes32(s: &[u8]) -> [u8; 32] {
let mut a = [0u8; 32];
let n = s.len().min(31);
a[..n].copy_from_slice(&s[..n]);
a
}
/// View a NUL-terminated `[u8; 32]` as a `&str` (up to the first NUL).
fn cstr_slice(a: &[u8; 32]) -> &str {
let end = a.iter().position(|&b| b == 0).unwrap_or(a.len());
std::str::from_utf8(&a[..end]).unwrap_or("")
}
/// `oakcommon_config_get_int` wrapper (null group).
fn config_get_int(key: &str, default: i32) -> i32 {
let ckey = cstring(key);
// # Safety: `ckey` is a valid NUL-terminated C string alive for the call.
unsafe {
crate::bridge::common::oakcommon_config_get_int(std::ptr::null(), ckey.as_ptr(), default)
}
}
/// `oakcommon_config_get_bool` wrapper (null group).
fn config_get_bool(key: &str, default: i32) -> i32 {
let ckey = cstring(key);
// # Safety: `ckey` is a valid NUL-terminated C string alive for the call.
unsafe {
crate::bridge::common::oakcommon_config_get_bool(std::ptr::null(), ckey.as_ptr(), default)
}
}
/// Two-stage `oakcommon_config_get` string read; `None` when the stored
/// value is empty or absent.
fn config_get_str(key: &str) -> Option<String> {
let ckey = cstring(key);
// # Safety: `ckey` is valid; first call asks only for the required size.
let size = unsafe {
crate::bridge::common::oakcommon_config_get(
std::ptr::null(),
ckey.as_ptr(),
std::ptr::null_mut(),
0,
)
};
if size <= 1 {
return None;
}
let mut buf = vec![0u8; size as usize];
// # Safety: `buf` has `size` bytes; the call fills at most `size` bytes.
unsafe {
crate::bridge::common::oakcommon_config_get(
std::ptr::null(),
ckey.as_ptr(),
buf.as_mut_ptr() as *mut c_char,
size,
);
}
let mut end = buf.len();
while end > 0 && buf[end - 1] == 0 {
end -= 1;
}
Some(String::from_utf8_lossy(&buf[..end]).into_owned())
}
/// `oakcommon_filefunctions_get_unique_file_identifier` wrapper (the bridge
/// returns a 64-bit id directly).
fn unique_file_identifier(filename: &str) -> String {
let c = match CString::new(filename) {
Ok(c) => c,
Err(_) => return String::new(),
};
// # Safety: `c` is a valid NUL-terminated C string alive for the call.
let id = unsafe {
crate::bridge::common::oakcommon_filefunctions_get_unique_file_identifier(c.as_ptr())
};
format!("{}", id)
}
/// Two-stage `oakcommon_filefunctions_get_application_path` read.
fn application_path() -> String {
// # Safety: first call asks only for the required size.
let size = unsafe {
crate::bridge::common::oakcommon_filefunctions_get_application_path(std::ptr::null_mut(), 0)
};
if size <= 1 {
return String::new();
}
let mut buf = vec![0u8; size as usize];
// # Safety: `buf` has `size` bytes; the call fills at most `size` bytes.
unsafe {
crate::bridge::common::oakcommon_filefunctions_get_application_path(
buf.as_mut_ptr() as *mut c_char,
size,
);
}
let mut end = buf.len();
while end > 0 && buf[end - 1] == 0 {
end -= 1;
}
String::from_utf8_lossy(&buf[..end]).into_owned()
}
/// Build a NUL-terminated C string from a Rust string; empty on embedded
/// NUL (defensive only — callers pass sane keys).
fn cstring(s: &str) -> CString {
CString::new(s).unwrap_or_else(|_| CString::new("").unwrap())
}
/// True when `p` is a regular file with at least one execute bit set.
fn is_executable_file(p: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
match std::fs::metadata(p) {
Ok(md) if md.is_file() => md.permissions().mode() & 0o111 != 0,
_ => false,
}
}
/// Canonicalize a path, falling back to the raw string on failure.
fn absolute(p: &str) -> String {
std::fs::canonicalize(p)
.map(|c| c.to_string_lossy().into_owned())
.unwrap_or_else(|_| p.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_subdir(name: &str) -> String {
let dir =
std::env::temp_dir().join(format!("oakcodec_proxy_{}_{}", name, std::process::id()));
let _ = std::fs::create_dir_all(&dir);
dir.to_string_lossy().into_owned()
}
fn fnv1a64(bytes: &[u8]) -> u64 {
let mut h: u64 = 14695981039346656037;
for &b in bytes {
h ^= b as u64;
h = h.wrapping_mul(1099511628211);
}
h
}
#[test]
fn proxy_params_default_values() {
let p = ProxyManager::proxy_params_default();
assert_eq!(p.width, 1280);
assert_eq!(p.height, 720);
assert_eq!(p.divider, 1);
assert_eq!(p.version, 1);
assert_eq!(p.crf, 23);
assert_eq!(p.include_audio, 1);
assert_eq!(p.extension_str(), "mp4");
assert_eq!(p.preset_str(), "veryfast");
}
#[test]
fn proxy_params_from_config_uses_defaults_without_store() {
// The test stub returns defaults for every int/bool and an empty
// ProxyPreset; the empty preset must not clobber the compiled-in one.
let p = ProxyManager::proxy_params_from_config();
assert_eq!(p.width, 1280);
assert_eq!(p.height, 720);
assert_eq!(p.divider, 1);
assert_eq!(p.crf, 23);
assert_eq!(p.include_audio, 1);
assert_eq!(p.preset_str(), "veryfast");
assert_eq!(p.extension_str(), "mp4");
}
#[test]
fn proxy_directory_is_cache_slash_proxy() {
assert_eq!(
ProxyManager::get_proxy_directory("/tmp/cache").unwrap(),
"/tmp/cache/proxy"
);
}
#[test]
fn proxy_filename_derivation() {
let cache = temp_subdir("fn");
let stub_id = fnv1a64(b"media.mp4") as i64;
let id = fnv1a64(stub_id.to_string().as_bytes()) as i64;
let p = ProxyManager::proxy_params_default();
let f = ProxyManager::get_proxy_filename(&cache, "media.mp4", 0, &p).unwrap();
assert_eq!(f, format!("{}/proxy/{}-0.1280x720.v1.a1.mp4", cache, id));
// Divider mode tags the divider instead of an absolute size.
let mut d = p.clone();
d.divider = 2;
let f2 = ProxyManager::get_proxy_filename(&cache, "media.mp4", 0, &d).unwrap();
assert!(f2.contains(".div2."));
// No audio.
let mut na = p.clone();
na.include_audio = 0;
let f3 = ProxyManager::get_proxy_filename(&cache, "media.mp4", 0, &na).unwrap();
assert!(f3.contains(".a0."));
}
#[test]
fn proxy_state_transitions() {
let cache = temp_subdir("state");
let p = ProxyManager::proxy_params_default();
let f = ProxyManager::get_proxy_filename(&cache, "media.mp4", 0, &p).unwrap();
// Missing when neither the file nor the working file exists.
assert_eq!(ProxyManager::get_proxy_state(&f), ProxyState::Missing);
// Generating when only the working file exists.
let working = ProxyManager::get_working_filename(&f).unwrap();
std::fs::create_dir_all(Path::new(&working).parent().unwrap()).unwrap();
std::fs::write(&working, b"x").unwrap();
assert_eq!(ProxyManager::get_proxy_state(&f), ProxyState::Generating);
// Ready when the final file exists (takes precedence over working).
std::fs::create_dir_all(Path::new(&f).parent().unwrap()).unwrap();
std::fs::write(&f, b"x").unwrap();
assert_eq!(ProxyManager::get_proxy_state(&f), ProxyState::Ready);
}
#[test]
fn proxy_state_to_string_mapping() {
assert_eq!(
ProxyManager::proxy_state_to_string(ProxyState::Missing),
"missing"
);
assert_eq!(
ProxyManager::proxy_state_to_string(ProxyState::Generating),
"generating"
);
assert_eq!(
ProxyManager::proxy_state_to_string(ProxyState::Ready),
"ready"
);
assert_eq!(
ProxyManager::proxy_state_to_string(ProxyState::Failed),
"failed"
);
}
#[test]
fn get_working_filename_appends_suffix() {
assert_eq!(
ProxyManager::get_working_filename("/a/b.mp4").unwrap(),
"/a/b.mp4.working.mp4"
);
}
#[test]
fn get_or_start_missing_without_registrar() {
let _g = crate::conformmanager::test_util::REG_LOCK.lock().unwrap();
crate::task::set_task_submit_cb_extern(None, std::ptr::null_mut());
let cache = temp_subdir("nostart");
let p = ProxyManager::proxy_params_default();
let (state, _f) = ProxyManager::instance()
.get_or_start(&cache, "media.mp4", 0, &p)
.unwrap();
assert_eq!(state, ProxyState::Missing);
}
#[test]
fn get_or_start_ready_when_file_exists() {
let cache = temp_subdir("ready");
let p = ProxyManager::proxy_params_default();
let f = ProxyManager::get_proxy_filename(&cache, "media.mp4", 0, &p).unwrap();
std::fs::create_dir_all(Path::new(&f).parent().unwrap()).unwrap();
std::fs::write(&f, b"x").unwrap();
let (state, filename) = ProxyManager::instance()
.get_or_start(&cache, "media.mp4", 0, &p)
.unwrap();
assert_eq!(state, ProxyState::Ready);
assert_eq!(filename, f);
}
#[test]
fn get_or_start_generating_when_registered() {
let _g = crate::conformmanager::test_util::REG_LOCK.lock().unwrap();
crate::task::set_task_submit_cb_extern(
Some(crate::conformmanager::test_util::accept_cb),
std::ptr::null_mut(),
);
let cache = temp_subdir("start");
let p = ProxyManager::proxy_params_default();
let (state, _f) = ProxyManager::instance()
.get_or_start(&cache, "media.mp4", 0, &p)
.unwrap();
crate::task::set_task_submit_cb_extern(None, std::ptr::null_mut());
assert_eq!(state, ProxyState::Generating);
}
#[test]
fn find_ffmpeg_configured_path_wins() {
// Point at a real executable (the current test binary) so the
// configured-path branch resolves to an absolute path.
let me = std::env::current_exe().unwrap();
let found = ProxyManager::find_ffmpeg(me.to_str().unwrap());
let canonical = std::fs::canonicalize(&me).unwrap();
assert_eq!(found, canonical.to_string_lossy());
}
#[test]
fn find_ffmpeg_missing_returns_empty() {
let found = ProxyManager::find_ffmpeg("/definitely/not/a/real/ffmpeg");
// Either an absolute configured/installed match or empty; never a raw
// unresolved path.
assert!(found.is_empty() || found.starts_with('/'));
}
/// `oakcodec_proxy_params` byte-level layout lock against
/// `include/codec/proxy.h` (verified with a C++ `offsetof` probe): the
/// Rust mirror must read a C caller's POD in place.
#[test]
fn proxy_params_c_abi_layout() {
use std::mem::{offset_of, size_of};
assert_eq!(size_of::<ProxyParams>(), 88);
assert_eq!(offset_of!(ProxyParams, width), 0);
assert_eq!(offset_of!(ProxyParams, height), 4);
assert_eq!(offset_of!(ProxyParams, divider), 8);
assert_eq!(offset_of!(ProxyParams, version), 12);
assert_eq!(offset_of!(ProxyParams, crf), 16);
assert_eq!(offset_of!(ProxyParams, include_audio), 20);
assert_eq!(offset_of!(ProxyParams, extension), 24);
assert_eq!(offset_of!(ProxyParams, preset), 56);
}
}
#[cfg(test)]
mod tests_extra {
use super::*;
#[test]
fn find_ffmpeg_searches_path() {
// Create a fake executable in a temp dir and prepend it to PATH.
let dir = std::env::temp_dir().join(format!("oakcodec_ffmpeg_{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let fake = dir.join("ffmpeg");
use std::os::unix::fs::PermissionsExt;
std::fs::write(&fake, b"#!/bin/sh\n").unwrap();
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
let mut paths = dir.to_string_lossy().into_owned();
if let Ok(existing) = std::env::var("PATH") {
paths.push(':');
paths.push_str(&existing);
}
let old = std::env::var_os("PATH");
std::env::set_var("PATH", &paths);
let found = ProxyManager::find_ffmpeg("");
if let Some(old) = old {
std::env::set_var("PATH", old);
} else {
std::env::remove_var("PATH");
}
// `std::env::set_var` is not thread-safe, so under parallel tests the
// canonicalized form can race; assert the search branch invariants
// instead of the exact canonical path.
assert!(found.starts_with('/'), "found: {found}");
assert!(found.ends_with("/ffmpeg"), "found: {found}");
assert!(std::path::Path::new(&found).exists(), "found: {found}");
}
#[test]
fn get_proxy_state_empty_and_working() {
// Empty filename -> Missing.
assert_eq!(ProxyManager::get_proxy_state(""), ProxyState::Missing);
// A path that does not exist -> Missing.
assert_eq!(
ProxyManager::get_proxy_state("/nope/nope.mp4"),
ProxyState::Missing
);
}
}