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)
701 lines
21 KiB
Rust
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
|
|
);
|
|
}
|
|
}
|