refactor: workspace layout — crates/, app at root, legacy C++ removed
Single mechanical restructure commit: - root Cargo.toml = oakapp bin + workspace; one cargo build produces oakapp, oak-cli, oak-worker, liboakengine.dylib - app/rust/src -> src/ (app at repo root, no rust/ nesting) - src/<mod>/rust -> crates/oak<mod>; src/oakcore-rs -> crates/oakcore; src/bindings/oakotio -> crates/oakotio; src/engine/rust -> crates/oakengine (keeps cdylib+staticlib+rlib) - public C headers include/<mod>/ -> crates/oakengine/include/<mod>/ - OFX SDK headers vendored into crates/oakplugin/ofx/ (HostSupport gone) - legacy deleted: old src/ C++ modules, engine/, core/, ffmpeg_bridge/, app/ (Qt), cli/worker C++, root CMakeLists, third_party/KDDockWidgets submodule, otio-install, all build-* output (~40GB) - oakstorage kept but excluded from the workspace (skeleton w/ todos); gpui excluded (own workspace) - verified: cargo build green, cargo test --workspace 1845/0 (with the documented OCIO_RS_* env override for the homebrew OCIO)
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
// 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/>.
|
||||
|
||||
//! The cancellation primitive (`olive::CancelAtom`): a thread-safe cancel
|
||||
//! flag shared between a render/encode caller and its worker.
|
||||
//!
|
||||
//! Single-lib unification (see `docs/zh/plans/riir/single-lib.md`): moved
|
||||
//! here from the oakrender crate so the oakcodec crate can use it without
|
||||
//! depending on oakrender (the oaknode/oakcodec/oakrender call cycle).
|
||||
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
|
||||
/// A cancellation atom (C++ `CancelAtom`). Reading a set flag also records
|
||||
/// that a consumer heard the cancellation.
|
||||
#[derive(Default)]
|
||||
pub struct CancelAtom {
|
||||
state: Mutex<AtomState>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct AtomState {
|
||||
cancelled: bool,
|
||||
heard: bool,
|
||||
}
|
||||
|
||||
fn lock(m: &Mutex<AtomState>) -> MutexGuard<'_, AtomState> {
|
||||
m.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
impl CancelAtom {
|
||||
/// A not-cancelled atom.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Set the cancel flag (C++ `CancelAtom::cancel()`).
|
||||
pub fn cancel(&self) {
|
||||
lock(&self.state).cancelled = true;
|
||||
}
|
||||
|
||||
/// Read the cancel flag; reading a set flag records that the
|
||||
/// cancellation was heard (C++ `is_cancelled()`).
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
let mut s = lock(&self.state);
|
||||
if s.cancelled {
|
||||
s.heard = true;
|
||||
}
|
||||
s.cancelled
|
||||
}
|
||||
|
||||
/// Whether any consumer has observed the cancel flag through
|
||||
/// [`CancelAtom::is_cancelled`] (C++ `heard_cancel()`).
|
||||
pub fn heard_cancel(&self) -> bool {
|
||||
lock(&self.state).heard
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fresh_atom_is_not_cancelled() {
|
||||
let a = CancelAtom::new();
|
||||
assert!(!a.is_cancelled());
|
||||
assert!(!a.heard_cancel());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_sets_flag_and_reading_marks_heard() {
|
||||
let a = CancelAtom::new();
|
||||
a.cancel();
|
||||
assert!(!a.heard_cancel(), "not heard until read");
|
||||
assert!(a.is_cancelled());
|
||||
assert!(a.heard_cancel(), "reading a set flag marks it heard");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_cancel_is_idempotent() {
|
||||
let a = CancelAtom::new();
|
||||
a.cancel();
|
||||
a.cancel();
|
||||
assert!(a.is_cancelled());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// 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::ColorTransform` — a color transform description. Mirrors
|
||||
//! `src/common/src/colortransform.h` and `include/common/colortransform.h`.
|
||||
//!
|
||||
//! A transform is either an output-colorspace transform or a
|
||||
//! display/view/look transform. The C++ object is passed through the C ABI
|
||||
//! as a refcounted handle ([`OakColorTransform`] in `crate::ffi`); this
|
||||
//! module owns the plain-data description behind the handle.
|
||||
//!
|
||||
//! The C++-only functions `oakcommon_colortransform_init_from_native` /
|
||||
//! `get_native` take or return `olive::ColorTransform` and cannot be
|
||||
//! expressed from Rust; they are served by the C++ adapter layer, not here.
|
||||
|
||||
/// `olive::ColorTransform` — the plain-data description behind the handle.
|
||||
pub struct ColorTransform {
|
||||
/// Output colorspace name (empty when this is a display transform).
|
||||
output: String,
|
||||
/// Display name (empty when this is an output-colorspace transform).
|
||||
display: String,
|
||||
/// Whether this is a display/view/look transform.
|
||||
// CPP-PARITY: the C++ class tracks this with an explicit `is_display_`
|
||||
// member (`src/common/src/colortransform.h`). We keep it as a separate
|
||||
// bool as well so an empty display name on a display transform is still
|
||||
// recognized as a display transform.
|
||||
is_display: bool,
|
||||
/// View name (display transforms only).
|
||||
view: String,
|
||||
/// Look name (display transforms only).
|
||||
look: String,
|
||||
}
|
||||
|
||||
impl ColorTransform {
|
||||
/// New output-colorspace transform.
|
||||
pub fn new_output(output: &str) -> Self {
|
||||
Self {
|
||||
output: output.to_string(),
|
||||
display: String::new(),
|
||||
is_display: false,
|
||||
view: String::new(),
|
||||
look: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// New display/view/look transform.
|
||||
pub fn new_display(display: &str, view: &str, look: &str) -> Self {
|
||||
// CPP-PARITY: the C++ display constructor stores the display name in
|
||||
// the shared `output_` member and sets `is_display_ = true`; here the
|
||||
// display name lives in its own `display` field (the skeleton's public
|
||||
// `display()` / `output()` accessors read the respective field).
|
||||
Self {
|
||||
output: String::new(),
|
||||
display: display.to_string(),
|
||||
is_display: true,
|
||||
view: view.to_string(),
|
||||
look: look.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this is a display/view/look transform.
|
||||
pub fn is_display(&self) -> bool {
|
||||
self.is_display
|
||||
}
|
||||
|
||||
/// Display name (empty if this is an output transform).
|
||||
pub fn display(&self) -> &str {
|
||||
&self.display
|
||||
}
|
||||
|
||||
/// Output colorspace name (empty if this is a display transform).
|
||||
pub fn output(&self) -> &str {
|
||||
&self.output
|
||||
}
|
||||
|
||||
/// View name.
|
||||
pub fn view(&self) -> &str {
|
||||
&self.view
|
||||
}
|
||||
|
||||
/// Look name.
|
||||
pub fn look(&self) -> &str {
|
||||
&self.look
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ColorTransform;
|
||||
|
||||
#[test]
|
||||
fn output_transform_defaults() {
|
||||
let t = ColorTransform::new_output("sRGB");
|
||||
assert!(!t.is_display());
|
||||
assert_eq!(t.output(), "sRGB");
|
||||
assert_eq!(t.display(), "");
|
||||
assert_eq!(t.view(), "");
|
||||
assert_eq!(t.look(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_transform_defaults() {
|
||||
let t = ColorTransform::new_display("DCI-P3", "standard", "soft");
|
||||
assert!(t.is_display());
|
||||
assert_eq!(t.display(), "DCI-P3");
|
||||
assert_eq!(t.view(), "standard");
|
||||
assert_eq!(t.look(), "soft");
|
||||
assert_eq!(t.output(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_transform_empty_strings() {
|
||||
// Mirrors the C++ default constructor: an output transform built from
|
||||
// an empty string still has `is_display == false`.
|
||||
let t = ColorTransform::new_output("");
|
||||
assert!(!t.is_display());
|
||||
assert_eq!(t.output(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_transform_recognized_even_with_empty_display_name() {
|
||||
// The explicit is_display flag (vs. deriving from a non-empty display
|
||||
// name) keeps an empty-named display transform identifiable.
|
||||
let t = ColorTransform::new_display("", "view", "");
|
||||
assert!(t.is_display());
|
||||
assert_eq!(t.display(), "");
|
||||
assert_eq!(t.view(), "view");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_return_borrowed_slices() {
|
||||
let t = ColorTransform::new_display("Display", "View", "Look");
|
||||
let d: &str = t.display();
|
||||
let v: &str = t.view();
|
||||
let l: &str = t.look();
|
||||
let o: &str = t.output();
|
||||
assert_eq!(d, "Display");
|
||||
assert_eq!(v, "View");
|
||||
assert_eq!(l, "Look");
|
||||
assert_eq!(o, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c_api_string_sizes() {
|
||||
// The c_api string getters return the required size including the NUL
|
||||
// (value.size() + 1) and, being NON-TRUNCATING, only copy when the
|
||||
// buffer is large enough. Verify the values the domain layer exposes
|
||||
// line up with those sizes.
|
||||
let t = ColorTransform::new_display("RGB", "ACES", "none");
|
||||
assert_eq!(t.display().len() + 1, 4);
|
||||
assert_eq!(t.view().len() + 1, 5);
|
||||
assert_eq!(t.look().len() + 1, 5);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,873 @@
|
||||
// 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/>.
|
||||
|
||||
//! Command-line option parser, mirroring
|
||||
//! `src/common/src/commandlineparser.h` and
|
||||
//! `include/common/commandlineparser.h`.
|
||||
//!
|
||||
//! A parser owns a list of registered options and positional arguments;
|
||||
//! option/argument handles returned at registration time are owned by the
|
||||
//! parser and stay valid until it is destroyed.
|
||||
|
||||
use std::ffi::CString;
|
||||
use std::io::{self, Write};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// `olive::CommandLineParser` — owns registered options and positional
|
||||
/// arguments.
|
||||
pub struct CommandLineParser {
|
||||
/// Application name shown by `print_help`.
|
||||
app_name: String,
|
||||
/// Application version shown by `print_help`.
|
||||
app_version: String,
|
||||
/// Registered named options.
|
||||
///
|
||||
/// Boxed so that the option objects keep a stable address when the
|
||||
/// vector grows; option handles borrowed by the C ABI point at the
|
||||
/// boxed value. // CPP-PARITY: the C++ code heap-allocates each
|
||||
/// `Option` (`new Option()`); `Vec<Box<..>>` reproduces that stability
|
||||
/// against reallocation.
|
||||
options: Vec<Box<CommandLineOption>>,
|
||||
/// Registered positional arguments (see `options` for the boxing note).
|
||||
positionals: Vec<Box<CommandLinePositionalArgument>>,
|
||||
}
|
||||
|
||||
impl CommandLineParser {
|
||||
/// New empty parser.
|
||||
pub fn new() -> Self {
|
||||
// CPP-PARITY: `app_name_` defaults to "oak" in the C++ header
|
||||
// (`std::string app_name_ = "oak"`), `app_version_` to "".
|
||||
Self {
|
||||
app_name: "oak".to_string(),
|
||||
app_version: String::new(),
|
||||
options: Vec::new(),
|
||||
positionals: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the application name/version shown by `print_help`.
|
||||
pub fn set_app_info(&mut self, name: &str, version: &str) {
|
||||
self.app_name = name.to_string();
|
||||
self.app_version = version.to_string();
|
||||
}
|
||||
|
||||
/// Register an option with one or more name strings.
|
||||
///
|
||||
/// Returns the option, which remains owned by the parser.
|
||||
pub fn add_option(
|
||||
&mut self,
|
||||
names: &[CString],
|
||||
description: &str,
|
||||
takes_arg: bool,
|
||||
arg_placeholder: &str,
|
||||
hidden: bool,
|
||||
) -> Result<()> {
|
||||
let mut strings = Vec::with_capacity(names.len());
|
||||
for n in names {
|
||||
// CPP-PARITY: C++ stores raw byte strings and accepts any bytes;
|
||||
// Rust `String` is UTF-8, so a non-UTF-8 option name is rejected
|
||||
// here with E_INVALID. The C ABI `add_option` already rejects
|
||||
// null/empty names before reaching this point.
|
||||
strings.push(n.to_str().map_err(|_| Error::Invalid)?.to_string());
|
||||
}
|
||||
|
||||
self.options.push(Box::new(CommandLineOption {
|
||||
names: strings,
|
||||
description: description.to_string(),
|
||||
takes_arg,
|
||||
arg_placeholder: arg_placeholder.to_string(),
|
||||
hidden,
|
||||
is_set: false,
|
||||
setting: None,
|
||||
}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Register a positional argument.
|
||||
pub fn add_positional_argument(
|
||||
&mut self,
|
||||
name: &str,
|
||||
description: &str,
|
||||
required: bool,
|
||||
) -> Result<()> {
|
||||
self.positionals.push(Box::new(CommandLinePositionalArgument {
|
||||
name: name.to_string(),
|
||||
description: description.to_string(),
|
||||
required,
|
||||
setting: None,
|
||||
}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse an argv-style argument list (argv[0] is the program name and
|
||||
/// is skipped).
|
||||
pub fn process(&mut self, argv: &[CString]) -> Result<()> {
|
||||
let mut positional_index = 0usize;
|
||||
let mut i = 1usize; // CPP-PARITY: argv[0] is the program name, skipped.
|
||||
|
||||
while i < argv.len() {
|
||||
let arg_str = match argv[i].to_str() {
|
||||
Ok(s) => s.to_string(),
|
||||
Err(_) => {
|
||||
// CPP-PARITY: C++ compares raw bytes; a non-UTF-8 arg
|
||||
// cannot match a known option/positional here, so treat
|
||||
// it as an unknown parameter (best effort for the log).
|
||||
eprintln!("Unknown parameter: {}", argv[i].to_string_lossy());
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if !arg_str.is_empty() && arg_str.starts_with('-') {
|
||||
// Must be an option. Skip past the first dash.
|
||||
let arg_basename = &arg_str[1..];
|
||||
|
||||
let mut matched_known = false;
|
||||
let mut consume_next = false;
|
||||
|
||||
// CPP-PARITY: the C++ `goto found_flag` is a labelled break
|
||||
// out of the double loop; the match is case-insensitive.
|
||||
'find: for opt in self.options.iter_mut() {
|
||||
for name in &opt.names {
|
||||
if name.eq_ignore_ascii_case(arg_basename) {
|
||||
opt.is_set = true;
|
||||
if opt.takes_arg && i + 1 < argv.len() {
|
||||
// CPP-PARITY: the argument value is stored as
|
||||
// a byte string in C++; Rust only keeps it when
|
||||
// it is valid UTF-8, otherwise it is left unset.
|
||||
opt.setting = argv[i + 1].to_str().ok().map(str::to_owned);
|
||||
consume_next = true;
|
||||
}
|
||||
matched_known = true;
|
||||
break 'find;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !matched_known {
|
||||
eprintln!("Unknown parameter: {}", arg_str);
|
||||
}
|
||||
// CPP-PARITY: when a `takes_arg` option consumes the following
|
||||
// argument, C++ advances `i` twice (the inner `i++` plus the
|
||||
// loop increment); otherwise once.
|
||||
i += if consume_next { 2 } else { 1 };
|
||||
} else {
|
||||
// Must be a positional argument.
|
||||
if positional_index < self.positionals.len() {
|
||||
self.positionals[positional_index].setting = Some(arg_str);
|
||||
positional_index += 1;
|
||||
} else {
|
||||
eprintln!("Unknown parameter: {}", arg_str);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Print usage/help text to stdout.
|
||||
pub fn print_help(&self, filename: &str) -> Result<()> {
|
||||
let stdout = io::stdout();
|
||||
let mut out = stdout.lock();
|
||||
self.write_help(&mut out, filename)
|
||||
}
|
||||
|
||||
/// Render the usage/help text into `out`, mirroring
|
||||
/// `CommandLineParser::print_help`. Split out so tests can capture the
|
||||
/// output in a buffer.
|
||||
fn write_help<W: Write>(&self, out: &mut W, filename: &str) -> Result<()> {
|
||||
let r = (|| -> io::Result<()> {
|
||||
// CPP-PARITY: `printf("%s %s\n")` always emits the separating
|
||||
// space, so "oak " + version + "\n" when the version is empty.
|
||||
writeln!(out, "{} {}", self.app_name, self.app_version)?;
|
||||
writeln!(out, "Copyright (C) 2018-2022 Oak Video Editor Team")?;
|
||||
|
||||
// Build the "[name] [name] ..." positional list.
|
||||
let mut positional_args = String::new();
|
||||
for (i, p) in self.positionals.iter().enumerate() {
|
||||
if i > 0 {
|
||||
positional_args.push(' ');
|
||||
}
|
||||
positional_args.push('[');
|
||||
positional_args.push_str(&p.name);
|
||||
positional_args.push(']');
|
||||
}
|
||||
|
||||
// CPP-PARITY: on POSIX the basename is everything after the last
|
||||
// '/'; without a slash the whole string is used.
|
||||
let basename = match filename.rfind('/') {
|
||||
Some(pos) => &filename[pos + 1..],
|
||||
None => filename,
|
||||
};
|
||||
|
||||
writeln!(out, "Usage: {} [options] {}\n", basename, positional_args)?;
|
||||
|
||||
for opt in &self.options {
|
||||
if opt.hidden {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut all_args = String::new();
|
||||
for (i, name) in opt.names.iter().enumerate() {
|
||||
if i > 0 {
|
||||
all_args.push_str(", ");
|
||||
}
|
||||
all_args.push('-');
|
||||
all_args.push_str(name);
|
||||
}
|
||||
|
||||
if opt.arg_placeholder.is_empty() {
|
||||
writeln!(out, " {}", all_args)?;
|
||||
} else {
|
||||
writeln!(out, " {} <{}>", all_args, opt.arg_placeholder)?;
|
||||
}
|
||||
writeln!(out, " {}\n", opt.description)?;
|
||||
}
|
||||
|
||||
writeln!(out)?;
|
||||
Ok(())
|
||||
})();
|
||||
r.map_err(|e| Error::Failed(e.to_string()))
|
||||
}
|
||||
|
||||
// The six accessors below are `pub(crate)` so the C ABI layer
|
||||
// (`crate::ffi::commandlineparser`) can hand out stable borrowed handles
|
||||
// to the boxed options/arguments; they are currently unused until that
|
||||
// layer is implemented.
|
||||
#[allow(dead_code)]
|
||||
/// Number of registered options. `pub(crate)`: used by the C ABI layer
|
||||
/// to look up the option just appended by [`Self::add_option`].
|
||||
pub(crate) fn option_count(&self) -> usize {
|
||||
self.options.len()
|
||||
}
|
||||
|
||||
/// Borrow a registered option by index. `pub(crate)`: hands the C ABI
|
||||
/// layer a stable pointer to the boxed option for a borrowed handle.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn option(&self, index: usize) -> Option<&CommandLineOption> {
|
||||
self.options.get(index).map(|b| b.as_ref())
|
||||
}
|
||||
|
||||
/// Mutably borrow a registered option by index.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn option_mut(&mut self, index: usize) -> Option<&mut CommandLineOption> {
|
||||
self.options.get_mut(index).map(|b| b.as_mut())
|
||||
}
|
||||
|
||||
/// Number of registered positional arguments.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn positional_count(&self) -> usize {
|
||||
self.positionals.len()
|
||||
}
|
||||
|
||||
/// Borrow a registered positional argument by index.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn positional(&self, index: usize) -> Option<&CommandLinePositionalArgument> {
|
||||
self.positionals.get(index).map(|b| b.as_ref())
|
||||
}
|
||||
|
||||
/// Mutably borrow a registered positional argument by index.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn positional_mut(
|
||||
&mut self,
|
||||
index: usize,
|
||||
) -> Option<&mut CommandLinePositionalArgument> {
|
||||
self.positionals.get_mut(index).map(|b| b.as_mut())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CommandLineParser {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// `olive::CommandLineOption` — a registered named option.
|
||||
pub struct CommandLineOption {
|
||||
/// Option name strings (without leading dash).
|
||||
names: Vec<String>,
|
||||
/// Help text.
|
||||
description: String,
|
||||
/// Whether the option consumes the following argument.
|
||||
takes_arg: bool,
|
||||
/// Placeholder shown in help when `takes_arg`.
|
||||
arg_placeholder: String,
|
||||
/// Whether to omit from help output.
|
||||
hidden: bool,
|
||||
/// Whether the option was present on the command line.
|
||||
is_set: bool,
|
||||
/// The argument value (present only when set).
|
||||
setting: Option<String>,
|
||||
}
|
||||
|
||||
impl CommandLineOption {
|
||||
/// Whether the option was present on the command line.
|
||||
pub fn is_set(&self) -> bool {
|
||||
self.is_set
|
||||
}
|
||||
|
||||
/// The option's argument value, if one was set.
|
||||
pub fn get_setting(&self) -> Result<&str> {
|
||||
match &self.setting {
|
||||
Some(v) => Ok(v.as_str()),
|
||||
// CPP-PARITY: C++ `get_setting()` returns the (possibly empty)
|
||||
// stored string and never fails; the "was it supplied" question
|
||||
// is answered by [`Self::is_set`]. Rust mirrors this by returning
|
||||
// an empty string when nothing was stored.
|
||||
None => Ok(""),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the option's argument value.
|
||||
pub fn set_setting(&mut self, value: &str) -> Result<()> {
|
||||
self.setting = Some(value.to_string());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// `olive::CommandLinePositionalArgument` — a registered positional
|
||||
/// argument.
|
||||
pub struct CommandLinePositionalArgument {
|
||||
/// Argument name.
|
||||
name: String,
|
||||
/// Help text.
|
||||
#[allow(dead_code)] // CPP-PARITY: stored but never read in C++ print_help/process.
|
||||
description: String,
|
||||
/// Whether the argument is required.
|
||||
#[allow(dead_code)] // CPP-PARITY: stored but never consumed in the C++ source.
|
||||
required: bool,
|
||||
/// The argument value (present only when parsed).
|
||||
setting: Option<String>,
|
||||
}
|
||||
|
||||
impl CommandLinePositionalArgument {
|
||||
/// The argument's value, if one was parsed.
|
||||
pub fn get_setting(&self) -> Result<&str> {
|
||||
match &self.setting {
|
||||
Some(v) => Ok(v.as_str()),
|
||||
None => Ok(""),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the argument's value.
|
||||
pub fn set_setting(&mut self, value: &str) -> Result<()> {
|
||||
self.setting = Some(value.to_string());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cstr(s: &str) -> CString {
|
||||
CString::new(s).unwrap()
|
||||
}
|
||||
|
||||
/// `new()` reproduces the C++ defaults: app name "oak", empty version,
|
||||
/// no registered options/arguments.
|
||||
#[test]
|
||||
fn defaults() {
|
||||
let p = CommandLineParser::new();
|
||||
assert_eq!(p.app_name, "oak");
|
||||
assert_eq!(p.app_version, "");
|
||||
assert_eq!(p.option_count(), 0);
|
||||
assert_eq!(p.positional_count(), 0);
|
||||
}
|
||||
|
||||
/// `set_app_info` overwrites the name and version.
|
||||
#[test]
|
||||
fn set_app_info() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.set_app_info("myapp", "2.5");
|
||||
assert_eq!(p.app_name, "myapp");
|
||||
assert_eq!(p.app_version, "2.5");
|
||||
}
|
||||
|
||||
/// Adding options/arguments increments the counts and they are
|
||||
/// retrievable by index.
|
||||
#[test]
|
||||
fn add_registers() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_option(&[cstr("h"), cstr("help")], "show help", false, "", false)
|
||||
.unwrap();
|
||||
p.add_option(&[cstr("o")], "output", true, "FILE", false).unwrap();
|
||||
p.add_option(&[cstr("secret")], "hidden opt", false, "", true)
|
||||
.unwrap();
|
||||
p.add_positional_argument("input", "input file", true).unwrap();
|
||||
p.add_positional_argument("output", "output file", false).unwrap();
|
||||
|
||||
assert_eq!(p.option_count(), 3);
|
||||
assert_eq!(p.positional_count(), 2);
|
||||
|
||||
let opt = p.option(0).unwrap();
|
||||
assert_eq!(opt.names, vec!["h".to_string(), "help".to_string()]);
|
||||
assert!(!opt.takes_arg);
|
||||
assert!(!opt.hidden);
|
||||
assert!(!opt.is_set());
|
||||
assert_eq!(opt.get_setting().unwrap(), "");
|
||||
|
||||
let opt = p.option(1).unwrap();
|
||||
assert!(opt.takes_arg);
|
||||
assert_eq!(opt.arg_placeholder, "FILE");
|
||||
|
||||
let opt = p.option(2).unwrap();
|
||||
assert!(opt.hidden);
|
||||
|
||||
let pos = p.positional(0).unwrap();
|
||||
assert_eq!(pos.name, "input");
|
||||
assert!(pos.required);
|
||||
assert_eq!(pos.get_setting().unwrap(), "");
|
||||
}
|
||||
|
||||
/// A non-UTF-8 option name is rejected with E_INVALID.
|
||||
#[test]
|
||||
fn add_option_rejects_non_utf8_name() {
|
||||
let mut p = CommandLineParser::new();
|
||||
let bad = CString::new(vec![b'x', 0xFF, 0xFE]).unwrap();
|
||||
assert!(matches!(
|
||||
p.add_option(&[bad], "", false, "", false),
|
||||
Err(Error::Invalid)
|
||||
));
|
||||
assert_eq!(p.option_count(), 0);
|
||||
}
|
||||
|
||||
/// get/set_setting round-trips on an option.
|
||||
#[test]
|
||||
fn option_setting_roundtrip() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_option(&[cstr("o")], "", true, "F", false).unwrap();
|
||||
let mut opt = p.option_mut(0).unwrap();
|
||||
assert_eq!(opt.get_setting().unwrap(), "");
|
||||
opt.set_setting("value").unwrap();
|
||||
assert_eq!(opt.get_setting().unwrap(), "value");
|
||||
}
|
||||
|
||||
/// get/set_setting round-trips on a positional argument.
|
||||
#[test]
|
||||
fn positional_setting_roundtrip() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_positional_argument("in", "", true).unwrap();
|
||||
let mut pos = p.positional_mut(0).unwrap();
|
||||
assert_eq!(pos.get_setting().unwrap(), "");
|
||||
pos.set_setting("file.mp4").unwrap();
|
||||
assert_eq!(pos.get_setting().unwrap(), "file.mp4");
|
||||
}
|
||||
|
||||
/// process(): simple flags, case-insensitive matching, value-consuming
|
||||
/// options, and positional assignment.
|
||||
#[test]
|
||||
fn process_flags_and_values() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_option(&[cstr("h"), cstr("help")], "", false, "", false)
|
||||
.unwrap();
|
||||
p.add_option(&[cstr("o")], "", true, "FILE", false).unwrap();
|
||||
p.add_option(&[cstr("V")], "", false, "", false).unwrap();
|
||||
p.add_positional_argument("input", "", true).unwrap();
|
||||
|
||||
let argv = [
|
||||
cstr("prog"),
|
||||
cstr("-help"),
|
||||
cstr("-o"),
|
||||
cstr("out.mov"),
|
||||
cstr("-v"),
|
||||
cstr("in.mp4"),
|
||||
];
|
||||
p.process(&argv).unwrap();
|
||||
|
||||
assert!(p.option(0).unwrap().is_set());
|
||||
assert_eq!(p.option(0).unwrap().get_setting().unwrap(), "");
|
||||
|
||||
// `-o` consumed `out.mov` as its argument.
|
||||
assert!(p.option(1).unwrap().is_set());
|
||||
assert_eq!(p.option(1).unwrap().get_setting().unwrap(), "out.mov");
|
||||
|
||||
// `-v` matched `-V` case-insensitively.
|
||||
assert!(p.option(2).unwrap().is_set());
|
||||
|
||||
// `in.mp4` filled the first positional.
|
||||
assert_eq!(p.positional(0).unwrap().get_setting().unwrap(), "in.mp4");
|
||||
}
|
||||
|
||||
/// process(): argv[0] is skipped; a leading empty string before the
|
||||
/// first arg does not disturb parsing.
|
||||
#[test]
|
||||
fn process_skips_program_name() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_option(&[cstr("x")], "", false, "", false).unwrap();
|
||||
let argv = [cstr("prog"), cstr("-x")];
|
||||
p.process(&argv).unwrap();
|
||||
assert!(p.option(0).unwrap().is_set());
|
||||
|
||||
// A bare program name with no args is fine.
|
||||
let argv = [cstr("prog")];
|
||||
p.process(&argv).unwrap();
|
||||
}
|
||||
|
||||
/// process(): a `takes_arg` option at the end of argv consumes nothing
|
||||
/// and stays set without a value.
|
||||
#[test]
|
||||
fn process_takes_arg_at_end() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_option(&[cstr("o")], "", true, "FILE", false).unwrap();
|
||||
let argv = [cstr("prog"), cstr("-o")];
|
||||
p.process(&argv).unwrap();
|
||||
assert!(p.option(0).unwrap().is_set());
|
||||
assert_eq!(p.option(0).unwrap().get_setting().unwrap(), "");
|
||||
}
|
||||
|
||||
/// process(): C++ `process` only ever sets state, never resets it, so a
|
||||
/// second call that does not mention an option leaves its previous
|
||||
/// `is_set`/setting intact.
|
||||
#[test]
|
||||
fn process_accumulates_state() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_option(&[cstr("o")], "", true, "F", false).unwrap();
|
||||
p.process(&[cstr("p"), cstr("-o"), cstr("a")]).unwrap();
|
||||
assert!(p.option(0).unwrap().is_set());
|
||||
assert_eq!(p.option(0).unwrap().get_setting().unwrap(), "a");
|
||||
|
||||
// CPP-PARITY: no reset between calls.
|
||||
p.process(&[cstr("p")]).unwrap();
|
||||
assert!(p.option(0).unwrap().is_set());
|
||||
assert_eq!(p.option(0).unwrap().get_setting().unwrap(), "a");
|
||||
}
|
||||
|
||||
/// process(): only a single leading dash is stripped, so `--o` is a
|
||||
/// distinct (unknown) argument rather than matching option `o`.
|
||||
#[test]
|
||||
fn process_strips_single_dash() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_option(&[cstr("o")], "", true, "F", false).unwrap();
|
||||
p.process(&[cstr("p"), cstr("--o"), cstr("v")]).unwrap();
|
||||
assert!(!p.option(0).unwrap().is_set());
|
||||
// `--o` was unknown, so `v` fell through to be ignored (no
|
||||
// positionals registered).
|
||||
assert_eq!(p.option(0).unwrap().get_setting().unwrap(), "");
|
||||
}
|
||||
|
||||
/// print_help() renders the exact text the C++ produces.
|
||||
#[test]
|
||||
fn print_help_exact() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.set_app_info("oak", "1.2.3");
|
||||
p.add_option(&[cstr("h"), cstr("help")], "Show this help message.", false, "", false)
|
||||
.unwrap();
|
||||
p.add_option(&[cstr("o")], "Output file.", true, "FILE", true)
|
||||
.unwrap(); // hidden, omitted
|
||||
p.add_option(&[cstr("t")], "Time.", true, "SEC", false).unwrap();
|
||||
p.add_positional_argument("input", "Input file", true).unwrap();
|
||||
|
||||
let mut buf = Vec::new();
|
||||
p.write_help(&mut buf, "/usr/local/bin/oak").unwrap();
|
||||
let text = String::from_utf8(buf).unwrap();
|
||||
|
||||
// CPP-PARITY: C++ emits " %s\n\n" for the last option and then
|
||||
// a final "\n", so the text ends with three newlines after "Time.".
|
||||
let expected = "\
|
||||
oak 1.2.3
|
||||
Copyright (C) 2018-2022 Oak Video Editor Team
|
||||
Usage: oak [options] [input]
|
||||
|
||||
-h, -help
|
||||
Show this help message.
|
||||
|
||||
-t <SEC>
|
||||
Time.
|
||||
|
||||
|
||||
";
|
||||
assert_eq!(text, expected);
|
||||
}
|
||||
|
||||
/// print_help() with a bare filename (no slash) uses it as-is; the
|
||||
/// default empty version yields a trailing-space header line.
|
||||
#[test]
|
||||
fn print_help_bare_filename_and_default_version() {
|
||||
let p = CommandLineParser::new();
|
||||
let mut buf = Vec::new();
|
||||
p.write_help(&mut buf, "oak").unwrap();
|
||||
let text = String::from_utf8(buf).unwrap();
|
||||
assert!(text.starts_with("oak \n"), "header was {:?}", &text[..12]);
|
||||
assert!(text.contains("Usage: oak [options] \n"));
|
||||
}
|
||||
|
||||
/// An option/argument handle stays valid (stable address) while more
|
||||
/// options are registered, because options are boxed.
|
||||
#[test]
|
||||
fn option_address_is_stable() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_option(&[cstr("a")], "", false, "", false).unwrap();
|
||||
let first = p.option(0).unwrap() as *const CommandLineOption;
|
||||
|
||||
// Push enough options to force reallocation.
|
||||
for i in 0..100 {
|
||||
p.add_option(&[cstr(&format!("x{}", i))], "", false, "", false)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(first, p.option(0).unwrap() as *const CommandLineOption);
|
||||
}
|
||||
|
||||
/// An option is matched by any of its registered names, not just the
|
||||
/// first.
|
||||
#[test]
|
||||
fn process_matches_any_registered_name() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_option(&[cstr("h"), cstr("help"), cstr("?")], "", false, "", false)
|
||||
.unwrap();
|
||||
|
||||
p.process(&[cstr("p"), cstr("-?")]).unwrap();
|
||||
assert!(p.option(0).unwrap().is_set());
|
||||
}
|
||||
|
||||
/// Matching is case-insensitive over the whole name (C++
|
||||
/// `string_equals_case_insensitive` applies tolower to every byte).
|
||||
#[test]
|
||||
fn process_case_insensitive_full_name() {
|
||||
for arg in ["-fullscreen", "-FULLSCREEN", "-Fullscreen"] {
|
||||
let mut q = CommandLineParser::new();
|
||||
q.add_option(&[cstr("FullScreen")], "", false, "", false)
|
||||
.unwrap();
|
||||
q.process(&[cstr("p"), cstr(arg)]).unwrap();
|
||||
assert!(q.option(0).unwrap().is_set(), "arg {:?} did not match", arg);
|
||||
}
|
||||
}
|
||||
|
||||
/// A `takes_arg` option consumes the following argument verbatim, even
|
||||
/// if it starts with a dash (C++ takes argv[i+1] unconditionally).
|
||||
#[test]
|
||||
fn process_takes_arg_consumes_dash_value() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_option(&[cstr("o")], "", true, "F", false).unwrap();
|
||||
p.add_option(&[cstr("h")], "", false, "", false).unwrap();
|
||||
|
||||
p.process(&[cstr("p"), cstr("-o"), cstr("-h")]).unwrap();
|
||||
assert_eq!(p.option(0).unwrap().get_setting().unwrap(), "-h");
|
||||
// `-h` was eaten as the value, never parsed as a flag.
|
||||
assert!(!p.option(1).unwrap().is_set());
|
||||
}
|
||||
|
||||
/// A non-`takes_arg` option does not consume the next argument; it
|
||||
/// falls through to the positional arguments.
|
||||
#[test]
|
||||
fn process_flag_does_not_consume_next() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_option(&[cstr("f")], "", false, "", false).unwrap();
|
||||
p.add_positional_argument("input", "", true).unwrap();
|
||||
|
||||
p.process(&[cstr("p"), cstr("-f"), cstr("in.mp4")]).unwrap();
|
||||
assert!(p.option(0).unwrap().is_set());
|
||||
assert_eq!(p.positional(0).unwrap().get_setting().unwrap(), "in.mp4");
|
||||
}
|
||||
|
||||
/// Repeating a `takes_arg` option overwrites the previous value; the
|
||||
/// option stays set.
|
||||
#[test]
|
||||
fn process_duplicate_option_last_value_wins() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_option(&[cstr("o")], "", true, "F", false).unwrap();
|
||||
p.process(&[cstr("p"), cstr("-o"), cstr("a"), cstr("-o"), cstr("b")])
|
||||
.unwrap();
|
||||
assert!(p.option(0).unwrap().is_set());
|
||||
assert_eq!(p.option(0).unwrap().get_setting().unwrap(), "b");
|
||||
}
|
||||
|
||||
/// Positional arguments fill in order; extras are reported as unknown
|
||||
/// and parsing continues.
|
||||
#[test]
|
||||
fn process_positional_overflow_is_unknown() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_positional_argument("first", "", true).unwrap();
|
||||
p.add_positional_argument("second", "", false).unwrap();
|
||||
|
||||
// "third" exceeds the registered positionals: C++ prints
|
||||
// "Unknown parameter" to stderr and moves on.
|
||||
p.process(&[cstr("p"), cstr("1"), cstr("2"), cstr("3"), cstr("-x")])
|
||||
.unwrap();
|
||||
assert_eq!(p.positional(0).unwrap().get_setting().unwrap(), "1");
|
||||
assert_eq!(p.positional(1).unwrap().get_setting().unwrap(), "2");
|
||||
}
|
||||
|
||||
/// An empty-string argument does not start with '-', so it is treated
|
||||
/// as a positional value (C++ checks `!argv[i].empty()` first).
|
||||
#[test]
|
||||
fn process_empty_string_is_positional() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_positional_argument("input", "", false).unwrap();
|
||||
p.process(&[cstr("p"), cstr("")]).unwrap();
|
||||
assert_eq!(p.positional(0).unwrap().get_setting().unwrap(), "");
|
||||
}
|
||||
|
||||
/// A bare "-" strips to an empty basename, matches nothing, and is
|
||||
/// reported unknown.
|
||||
#[test]
|
||||
fn process_bare_dash_is_unknown() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_option(&[cstr("o")], "", false, "", false).unwrap();
|
||||
p.add_positional_argument("input", "", false).unwrap();
|
||||
p.process(&[cstr("p"), cstr("-")]).unwrap();
|
||||
assert!(!p.option(0).unwrap().is_set());
|
||||
assert_eq!(p.positional(0).unwrap().get_setting().unwrap(), "");
|
||||
}
|
||||
|
||||
/// There is no `--` terminator and no `--opt=val` syntax in the C++
|
||||
/// parser: `--opt=val` strips one dash and fails to match `opt`.
|
||||
#[test]
|
||||
fn process_no_double_dash_or_equals_syntax() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_option(&[cstr("o")], "", true, "F", false).unwrap();
|
||||
p.process(&[cstr("p"), cstr("--o=v")]).unwrap();
|
||||
assert!(!p.option(0).unwrap().is_set());
|
||||
|
||||
let mut q = CommandLineParser::new();
|
||||
q.add_option(&[cstr("o")], "", true, "F", false).unwrap();
|
||||
q.add_positional_argument("in", "", false).unwrap();
|
||||
// `--` is just an unknown option, not a terminator.
|
||||
q.process(&[cstr("p"), cstr("--"), cstr("x")]).unwrap();
|
||||
assert_eq!(q.positional(0).unwrap().get_setting().unwrap(), "x");
|
||||
}
|
||||
|
||||
/// When two registered options share a name, the first registered one
|
||||
/// wins (C++ iterates `options_` in order and `goto found_flag` stops
|
||||
/// at the first match).
|
||||
#[test]
|
||||
fn process_first_matching_option_wins() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_option(&[cstr("x")], "", false, "", false).unwrap();
|
||||
p.add_option(&[cstr("x")], "", false, "", false).unwrap();
|
||||
p.process(&[cstr("p"), cstr("-x")]).unwrap();
|
||||
assert!(p.option(0).unwrap().is_set());
|
||||
assert!(!p.option(1).unwrap().is_set());
|
||||
}
|
||||
|
||||
/// is_set is false until the option appears; get_setting on an unset
|
||||
/// option returns "" rather than an error (C++ returns the stored
|
||||
/// string, which is default-constructed empty).
|
||||
#[test]
|
||||
fn unset_option_state() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_option(&[cstr("o")], "", true, "F", false).unwrap();
|
||||
let opt = p.option(0).unwrap();
|
||||
assert!(!opt.is_set());
|
||||
assert_eq!(opt.get_setting().unwrap(), "");
|
||||
}
|
||||
|
||||
/// set_setting on an option does NOT set is_set (C++ Option::set and
|
||||
/// set_setting are independent).
|
||||
#[test]
|
||||
fn set_setting_does_not_mark_is_set() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_option(&[cstr("o")], "", true, "F", false).unwrap();
|
||||
{
|
||||
let mut opt = p.option_mut(0).unwrap();
|
||||
opt.set_setting("v").unwrap();
|
||||
}
|
||||
let opt = p.option(0).unwrap();
|
||||
assert!(!opt.is_set());
|
||||
assert_eq!(opt.get_setting().unwrap(), "v");
|
||||
}
|
||||
|
||||
/// A `takes_arg` option set via process has is_set true AND a value;
|
||||
/// a non-takes_arg option has is_set true and an empty value.
|
||||
#[test]
|
||||
fn process_is_set_and_setting_combinations() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_option(&[cstr("f")], "", false, "", false).unwrap();
|
||||
p.add_option(&[cstr("o")], "", true, "F", false).unwrap();
|
||||
p.process(&[cstr("p"), cstr("-f"), cstr("-o"), cstr("v")]).unwrap();
|
||||
assert!(p.option(0).unwrap().is_set());
|
||||
assert_eq!(p.option(0).unwrap().get_setting().unwrap(), "");
|
||||
assert!(p.option(1).unwrap().is_set());
|
||||
assert_eq!(p.option(1).unwrap().get_setting().unwrap(), "v");
|
||||
}
|
||||
|
||||
/// The public print_help writes to stdout without error (smoke test;
|
||||
/// exact output is covered by write_help tests).
|
||||
#[test]
|
||||
fn print_help_smoke() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_option(&[cstr("h")], "help", false, "", false).unwrap();
|
||||
p.print_help("oak").unwrap();
|
||||
}
|
||||
|
||||
/// Help output lists multiple option names joined with ", " and shows
|
||||
/// the `<placeholder>` only when non-empty, regardless of takes_arg.
|
||||
#[test]
|
||||
fn print_help_placeholder_rules() {
|
||||
let mut p = CommandLineParser::new();
|
||||
// takes_arg but no placeholder: no <...> shown.
|
||||
p.add_option(&[cstr("a")], "arg without placeholder", true, "", false)
|
||||
.unwrap();
|
||||
// placeholder but not takes_arg: <...> still shown (C++ keys on
|
||||
// arg_placeholder.empty(), not takes_arg).
|
||||
p.add_option(&[cstr("b")], "placeholder without arg", false, "P", false)
|
||||
.unwrap();
|
||||
|
||||
let mut buf = Vec::new();
|
||||
p.write_help(&mut buf, "oak").unwrap();
|
||||
let text = String::from_utf8(buf).unwrap();
|
||||
assert!(text.contains(" -a\n"), "{}", text);
|
||||
assert!(text.contains(" -b <P>\n"), "{}", text);
|
||||
}
|
||||
|
||||
/// Multiple positionals render as "[a] [b]" in the usage line.
|
||||
#[test]
|
||||
fn print_help_multiple_positionals() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_positional_argument("in", "", true).unwrap();
|
||||
p.add_positional_argument("out", "", false).unwrap();
|
||||
let mut buf = Vec::new();
|
||||
p.write_help(&mut buf, "oak").unwrap();
|
||||
let text = String::from_utf8(buf).unwrap();
|
||||
assert!(text.contains("Usage: oak [options] [in] [out]\n"), "{}", text);
|
||||
}
|
||||
|
||||
/// Hidden options are omitted from help but still parse.
|
||||
#[test]
|
||||
fn hidden_option_parses_but_hidden_from_help() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_option(&[cstr("secret")], "shh", false, "", true).unwrap();
|
||||
let mut buf = Vec::new();
|
||||
p.write_help(&mut buf, "oak").unwrap();
|
||||
let text = String::from_utf8(buf).unwrap();
|
||||
assert!(!text.contains("secret"), "{}", text);
|
||||
|
||||
p.process(&[cstr("p"), cstr("-secret")]).unwrap();
|
||||
assert!(p.option(0).unwrap().is_set());
|
||||
}
|
||||
|
||||
/// Out-of-range index accessors return None.
|
||||
#[test]
|
||||
fn index_accessors_out_of_range() {
|
||||
let p = CommandLineParser::new();
|
||||
assert!(p.option(0).is_none());
|
||||
assert!(p.positional(0).is_none());
|
||||
}
|
||||
|
||||
/// add_option with zero names registers an option that can never
|
||||
/// match (the C++ ABI layer rejects name_count == 0 before reaching
|
||||
/// the domain type).
|
||||
#[test]
|
||||
fn add_option_empty_names_never_matches() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_option(&[], "no names", false, "", false).unwrap();
|
||||
assert_eq!(p.option_count(), 1);
|
||||
p.process(&[cstr("p"), cstr("-anything")]).unwrap();
|
||||
assert!(!p.option(0).unwrap().is_set());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
#[test]
|
||||
fn dbg_print() {
|
||||
let mut sp = crate::subtitleparams::SubtitleParams::new();
|
||||
sp.add_subtitle(1, 1, 2, 1, "a < b & \"c\" > d").unwrap();
|
||||
let xml = sp.save_xml().unwrap();
|
||||
println!("XML_OUTPUT_START>>>{}<<<XML_OUTPUT_END", xml);
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
// 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/>.
|
||||
|
||||
//! Leveled logging, mirroring `src/common/src/debug.h` and
|
||||
//! `include/common/debug.h`. De-Qt replacement for the old Qt message
|
||||
//! handler and the qDebug()/qInfo()/qWarning()/qCritical() call sites.
|
||||
//!
|
||||
//! Built on the `log` facade crate (crates.io `log`, MIT/Apache-2.0):
|
||||
//! filtering is `log::set_max_level` and the stderr sink is a `log::Log`
|
||||
//! implementation installed on first use. No hand-rolled filter state.
|
||||
//! The printf-style C ABI (`oakcommon_log`) is implemented in
|
||||
//! `crate::ffi` over this module's [`log`] helper.
|
||||
|
||||
use std::io::Write;
|
||||
use std::sync::atomic::{AtomicI32, Ordering};
|
||||
use std::sync::Once;
|
||||
|
||||
use log::{LevelFilter, Log, Metadata, Record};
|
||||
|
||||
/// Discriminant of [`Level::Info`], the default filter level (`k_debug_info`).
|
||||
const DEFAULT_LOG_LEVEL: Level = Level::Info;
|
||||
|
||||
/// Logger installation (first `log`/`log_raw` call).
|
||||
static LOGGER_INIT: Once = Once::new();
|
||||
|
||||
/// Mirror of the last level set through [`log_set_level`]. The `log`
|
||||
/// facade has no Fatal filter, so `max_level()` alone cannot round-trip
|
||||
/// Fatal; this mirror preserves the C++ get/set semantics.
|
||||
static LEVEL_MIRROR: AtomicI32 = AtomicI32::new(DEFAULT_LOG_LEVEL as i32);
|
||||
|
||||
/// The stderr logger behind the `log` facade. Level filtering is done
|
||||
/// by the facade's max-level; this sink only formats and writes.
|
||||
struct StderrLogger;
|
||||
|
||||
impl Log for StderrLogger {
|
||||
fn enabled(&self, metadata: &Metadata) -> bool {
|
||||
metadata.level() <= log::max_level()
|
||||
}
|
||||
|
||||
fn log(&self, record: &Record) {
|
||||
if !self.enabled(record.metadata()) {
|
||||
return;
|
||||
}
|
||||
let name = oak_level_name(record.level());
|
||||
let mut err = std::io::stderr();
|
||||
// Errors here are swallowed: a logger must not panic the caller.
|
||||
let _ = writeln!(err, "[{}] {}", name, record.args());
|
||||
let _ = err.flush();
|
||||
}
|
||||
|
||||
fn flush(&self) {
|
||||
let _ = std::io::stderr().flush();
|
||||
}
|
||||
}
|
||||
|
||||
/// The five Oak levels mapped onto the `log` facade's five levels.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Level {
|
||||
/// Verbose debug message.
|
||||
Debug,
|
||||
/// Informational message.
|
||||
Info,
|
||||
/// Warning message.
|
||||
Warning,
|
||||
/// Error message.
|
||||
Error,
|
||||
/// Fatal error message.
|
||||
Fatal,
|
||||
}
|
||||
|
||||
impl Level {
|
||||
/// One of [`Level`] for an integer code, or `None` out of range.
|
||||
///
|
||||
/// CPP-PARITY: matches `olive::DebugLevel` ordering; `k_debug_debug`=0
|
||||
/// through `k_debug_fatal`=4, values outside that range are invalid.
|
||||
pub fn from_code(code: i32) -> Option<Level> {
|
||||
match code {
|
||||
0 => Some(Level::Debug),
|
||||
1 => Some(Level::Info),
|
||||
2 => Some(Level::Warning),
|
||||
3 => Some(Level::Error),
|
||||
4 => Some(Level::Fatal),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Printable name ("DEBUG", "INFO", ...).
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Level::Debug => "DEBUG",
|
||||
Level::Info => "INFO",
|
||||
Level::Warning => "WARNING",
|
||||
Level::Error => "ERROR",
|
||||
Level::Fatal => "FATAL",
|
||||
}
|
||||
}
|
||||
|
||||
/// Facade filter level.
|
||||
fn to_filter(self) -> LevelFilter {
|
||||
match self {
|
||||
Level::Debug => LevelFilter::Debug,
|
||||
Level::Info => LevelFilter::Info,
|
||||
Level::Warning => LevelFilter::Warn,
|
||||
Level::Error => LevelFilter::Error,
|
||||
// `log` has no Fatal severity; Fatal maps to Error for
|
||||
// filtering purposes and keeps its name at the sink.
|
||||
Level::Fatal => LevelFilter::Error,
|
||||
}
|
||||
}
|
||||
|
||||
/// Facade record level.
|
||||
fn to_log_level(self) -> log::Level {
|
||||
match self {
|
||||
Level::Debug => log::Level::Debug,
|
||||
Level::Info => log::Level::Info,
|
||||
Level::Warning => log::Level::Warn,
|
||||
Level::Error | Level::Fatal => log::Level::Error,
|
||||
}
|
||||
}
|
||||
|
||||
/// From a facade filter level (for [`log_get_level`]).
|
||||
fn from_filter(f: LevelFilter) -> Level {
|
||||
match f {
|
||||
LevelFilter::Off | LevelFilter::Error => Level::Error,
|
||||
LevelFilter::Warn => Level::Warning,
|
||||
LevelFilter::Info => Level::Info,
|
||||
LevelFilter::Debug | LevelFilter::Trace => Level::Debug,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Oak-level name for a facade level (the sink path). `log::Level` has
|
||||
/// no Fatal; Fatal records arrive as Error, and the C ABI callers that
|
||||
/// need the FATAL tag pass through [`log`] which stamps the record
|
||||
/// target instead.
|
||||
fn oak_level_name(level: log::Level) -> &'static str {
|
||||
match level {
|
||||
log::Level::Debug => "DEBUG",
|
||||
log::Level::Info => "INFO",
|
||||
log::Level::Warn => "WARNING",
|
||||
log::Level::Error => "ERROR",
|
||||
log::Level::Trace => "FATAL",
|
||||
}
|
||||
}
|
||||
|
||||
/// Install the stderr logger once and apply the default filter.
|
||||
fn ensure_logger() {
|
||||
LOGGER_INIT.call_once(|| {
|
||||
// A host app may have installed its own logger first; in that
|
||||
// case ours yields (set_logger fails) and the facade records go
|
||||
// to the host's sink. Filtering still runs through max_level.
|
||||
let _ = log::set_logger(&StderrLogger);
|
||||
log::set_max_level(DEFAULT_LOG_LEVEL.to_filter());
|
||||
});
|
||||
}
|
||||
|
||||
/// Current minimum level emitted by [`log`]; the default is [`Level::Info`].
|
||||
pub fn log_get_level() -> Level {
|
||||
ensure_logger();
|
||||
Level::from_code(LEVEL_MIRROR.load(Ordering::Relaxed)).unwrap_or(Level::Info)
|
||||
}
|
||||
|
||||
/// Set the minimum level emitted by [`log`].
|
||||
pub fn log_set_level(level: Level) {
|
||||
ensure_logger();
|
||||
LEVEL_MIRROR.store(level as i32, Ordering::Relaxed);
|
||||
log::set_max_level(level.to_filter());
|
||||
}
|
||||
|
||||
/// Emit `msg` at `level` (below-threshold messages are dropped by the
|
||||
/// facade filter, C++ `log_message` semantics).
|
||||
pub fn log(level: Level, msg: &str) -> crate::error::Result<()> {
|
||||
ensure_logger();
|
||||
// Fatal goes out through Trace so the sink can print FATAL while the
|
||||
// filter keeps the Error floor.
|
||||
let facade_level = if level == Level::Fatal {
|
||||
log::Level::Trace
|
||||
} else {
|
||||
level.to_log_level()
|
||||
};
|
||||
log::log!(facade_level, "{}", msg);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Emit `msg` unconditionally, prefixing `level` ("UNKNOWN" for
|
||||
/// out-of-range codes).
|
||||
pub fn log_raw(level: i32, msg: &str) -> crate::error::Result<()> {
|
||||
match Level::from_code(level) {
|
||||
Some(l) => log(l, msg),
|
||||
None => {
|
||||
ensure_logger();
|
||||
// Unknown codes bypass the facade: write the raw line
|
||||
// directly (C++ prints them with an UNKNOWN tag).
|
||||
let mut err = std::io::stderr();
|
||||
let _ = writeln!(err, "[UNKNOWN] {}", msg);
|
||||
let _ = err.flush();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn level_from_code_valid() {
|
||||
assert_eq!(Level::from_code(0), Some(Level::Debug));
|
||||
assert_eq!(Level::from_code(1), Some(Level::Info));
|
||||
assert_eq!(Level::from_code(2), Some(Level::Warning));
|
||||
assert_eq!(Level::from_code(3), Some(Level::Error));
|
||||
assert_eq!(Level::from_code(4), Some(Level::Fatal));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_from_code_out_of_range() {
|
||||
assert_eq!(Level::from_code(-1), None);
|
||||
assert_eq!(Level::from_code(5), None);
|
||||
assert_eq!(Level::from_code(i32::MIN), None);
|
||||
assert_eq!(Level::from_code(i32::MAX), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_name() {
|
||||
assert_eq!(Level::Debug.name(), "DEBUG");
|
||||
assert_eq!(Level::Info.name(), "INFO");
|
||||
assert_eq!(Level::Warning.name(), "WARNING");
|
||||
assert_eq!(Level::Error.name(), "ERROR");
|
||||
assert_eq!(Level::Fatal.name(), "FATAL");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_discriminants_match_cpp_order() {
|
||||
// The enum must order by ascending severity so threshold
|
||||
// filtering works, exactly as `olive::DebugLevel`.
|
||||
assert!((Level::Debug as i32) < (Level::Info as i32));
|
||||
assert!((Level::Info as i32) < (Level::Warning as i32));
|
||||
assert!((Level::Warning as i32) < (Level::Error as i32));
|
||||
assert!((Level::Error as i32) < (Level::Fatal as i32));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_level_is_info() {
|
||||
// Other tests may shift the process-global filter; this test
|
||||
// asserts only that getting/setting round-trips.
|
||||
let before = log_get_level();
|
||||
let _ = before;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_get_level_round_trip() {
|
||||
for level in
|
||||
[Level::Debug, Level::Info, Level::Warning, Level::Error, Level::Fatal]
|
||||
{
|
||||
log_set_level(level);
|
||||
assert_eq!(log_get_level(), level);
|
||||
}
|
||||
log_set_level(Level::Info); // restore default for other tests
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_returns_ok() {
|
||||
log_set_level(Level::Error);
|
||||
assert!(log(Level::Info, "below threshold").is_ok());
|
||||
assert!(log(Level::Error, "at threshold").is_ok());
|
||||
assert!(log(Level::Fatal, "above threshold").is_ok());
|
||||
log_set_level(Level::Info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_raw_returns_ok_for_any_level() {
|
||||
for code in [-1, 0, 4, 5, i32::MIN, i32::MAX] {
|
||||
assert!(log_raw(code, "raw message").is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filtering_drops_below_threshold() {
|
||||
for threshold in
|
||||
[Level::Debug, Level::Info, Level::Warning, Level::Error, Level::Fatal]
|
||||
{
|
||||
log_set_level(threshold);
|
||||
assert!(log(Level::Debug, "x").is_ok());
|
||||
assert!(log(Level::Fatal, "y").is_ok());
|
||||
}
|
||||
log_set_level(Level::Info);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// 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/>.
|
||||
|
||||
//! Error codes, mirroring `include/common/error.h`; project-wide
|
||||
//! -MMCCCC scheme (module 01), pass-through untranslated.
|
||||
|
||||
/// Success.
|
||||
pub const OAKCOMMON_OK: i32 = 0;
|
||||
/// Empty handle or invalid argument.
|
||||
pub const OAKCOMMON_E_INVALID: i32 = -10001;
|
||||
/// Call not valid in the current state.
|
||||
pub const OAKCOMMON_E_STATE: i32 = -10002;
|
||||
/// The underlying operation failed.
|
||||
pub const OAKCOMMON_E_FAILED: i32 = -10003;
|
||||
/// Index out of range / entry not found.
|
||||
pub const OAKCOMMON_E_NOT_FOUND: i32 = -10004;
|
||||
/// Allocation failed.
|
||||
pub const OAKCOMMON_E_NOMEM: i32 = -10005;
|
||||
|
||||
/// Crate-internal result type.
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// Crate-internal error.
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
/// Empty handle or invalid argument.
|
||||
Invalid,
|
||||
/// Wrong state.
|
||||
State,
|
||||
/// Operation failed (context string is log-only).
|
||||
Failed(String),
|
||||
/// Not found.
|
||||
NotFound,
|
||||
/// Out of memory.
|
||||
NoMem,
|
||||
}
|
||||
|
||||
impl Error {
|
||||
/// Map to the public error code.
|
||||
pub fn code(&self) -> i32 {
|
||||
match self {
|
||||
Error::Invalid => OAKCOMMON_E_INVALID,
|
||||
Error::State => OAKCOMMON_E_STATE,
|
||||
Error::Failed(_) => OAKCOMMON_E_FAILED,
|
||||
Error::NotFound => OAKCOMMON_E_NOT_FOUND,
|
||||
Error::NoMem => OAKCOMMON_E_NOMEM,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a [`Error::Failed`] carrying a context message (log-only).
|
||||
///
|
||||
/// Convenience constructor used by the OCIO/`image` wrappers in
|
||||
/// `ocioutils.rs` / `oiioutils.rs` when an underlying library call fails.
|
||||
pub fn new(message: impl Into<String>) -> Self {
|
||||
Error::Failed(message.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ocio_rs::OcioError> for Error {
|
||||
fn from(e: ocio_rs::OcioError) -> Self {
|
||||
// The Display impl of `OcioError` always produces a non-empty message
|
||||
// (every variant carries text or a fixed phrase); it becomes the
|
||||
// log-only context of `Error::Failed`.
|
||||
Error::Failed(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn public_codes_match_header_values() {
|
||||
// Load-bearing values from include/common/error.h (module 01).
|
||||
assert_eq!(OAKCOMMON_OK, 0);
|
||||
assert_eq!(OAKCOMMON_E_INVALID, -10001);
|
||||
assert_eq!(OAKCOMMON_E_STATE, -10002);
|
||||
assert_eq!(OAKCOMMON_E_FAILED, -10003);
|
||||
assert_eq!(OAKCOMMON_E_NOT_FOUND, -10004);
|
||||
assert_eq!(OAKCOMMON_E_NOMEM, -10005);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_code_maps_each_variant() {
|
||||
assert_eq!(Error::Invalid.code(), OAKCOMMON_E_INVALID);
|
||||
assert_eq!(Error::State.code(), OAKCOMMON_E_STATE);
|
||||
assert_eq!(Error::Failed("boom".to_string()).code(), OAKCOMMON_E_FAILED);
|
||||
assert_eq!(Error::NotFound.code(), OAKCOMMON_E_NOT_FOUND);
|
||||
assert_eq!(Error::NoMem.code(), OAKCOMMON_E_NOMEM);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_codes_are_all_distinct() {
|
||||
let codes = [
|
||||
Error::Invalid.code(),
|
||||
Error::State.code(),
|
||||
Error::Failed(String::new()).code(),
|
||||
Error::NotFound.code(),
|
||||
Error::NoMem.code(),
|
||||
];
|
||||
for (i, a) in codes.iter().enumerate() {
|
||||
for b in &codes[i + 1..] {
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
// Errors are strictly negative; OK stays zero.
|
||||
assert!(*a < 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_message_is_preserved_in_debug() {
|
||||
// The context string is log-only but must survive to the log.
|
||||
let e = Error::Failed("context info".to_string());
|
||||
let dbg = format!("{e:?}");
|
||||
assert!(dbg.contains("context info"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_alias_round_trips_ok_and_err() {
|
||||
let ok: Result<i32> = Ok(7);
|
||||
assert_eq!(ok.unwrap(), 7);
|
||||
let err: Result<i32> = Err(Error::NotFound);
|
||||
assert_eq!(err.unwrap_err().code(), OAKCOMMON_E_NOT_FOUND);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_creates_failed_with_message() {
|
||||
let e = Error::new("context info");
|
||||
assert!(matches!(e, Error::Failed(_)));
|
||||
assert_eq!(e.code(), OAKCOMMON_E_FAILED);
|
||||
assert!(format!("{e:?}").contains("context info"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ocio_error_converts_to_failed() {
|
||||
let e = Error::from(ocio_rs::OcioError::InvalidInput("bad colorspace".to_string()));
|
||||
assert!(matches!(e, Error::Failed(_)));
|
||||
assert_eq!(e.code(), OAKCOMMON_E_FAILED);
|
||||
assert!(format!("{e:?}").contains("bad colorspace"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,473 @@
|
||||
// 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/>.
|
||||
|
||||
//! Stateless pixel/sample format mappings between the native formats and
|
||||
//! the opaque `FBPixelFormat` / `FBSampleFormat` constants of ffmpeg_bridge.
|
||||
//! Mirrors `src/common/src/ffmpegutils.h` and
|
||||
//! `include/common/ffmpegutils.h`. There is no handle to create or free.
|
||||
//!
|
||||
//! The bridge constants are only reachable through narrow `extern "C"`
|
||||
//! blocks into ffmpeg_bridge; native formats are plain ints matching
|
||||
//! `olive::core` enum values.
|
||||
|
||||
/// RGB channel count (flattened from `VideoParams`).
|
||||
pub const RGB_CHANNEL_COUNT: i32 = 3;
|
||||
/// RGBA channel count (flattened from `VideoParams`).
|
||||
pub const RGBA_CHANNEL_COUNT: i32 = 4;
|
||||
|
||||
//
|
||||
// Native `olive::core::PixelFormat::Format` values (see
|
||||
// `core/include/olive/core/render/pixelformat.h`). Plain ints matching the
|
||||
// C++ enum discriminants.
|
||||
//
|
||||
const PIX_FMT_INVALID: i32 = -1;
|
||||
const PIX_FMT_U8: i32 = 0;
|
||||
const PIX_FMT_U10: i32 = 1;
|
||||
const PIX_FMT_U16: i32 = 2;
|
||||
const PIX_FMT_F16: i32 = 3;
|
||||
const PIX_FMT_F32: i32 = 4;
|
||||
#[cfg(test)]
|
||||
const PIX_FMT_COUNT: i32 = 5;
|
||||
|
||||
//
|
||||
// Native `olive::core::SampleFormat::Format` values (see
|
||||
// `core/include/olive/core/render/sampleformat.h`). The `_p` (planar)
|
||||
// variants come first in the C++ enum.
|
||||
//
|
||||
const SMP_FMT_INVALID: i32 = -1;
|
||||
const SMP_FMT_U8_P: i32 = 0;
|
||||
const SMP_FMT_S16_P: i32 = 1;
|
||||
const SMP_FMT_S32_P: i32 = 2;
|
||||
const SMP_FMT_S64_P: i32 = 3;
|
||||
const SMP_FMT_F32_P: i32 = 4;
|
||||
const SMP_FMT_F64_P: i32 = 5;
|
||||
const SMP_FMT_U8: i32 = 6;
|
||||
const SMP_FMT_S16: i32 = 7;
|
||||
const SMP_FMT_S32: i32 = 8;
|
||||
const SMP_FMT_S64: i32 = 9;
|
||||
const SMP_FMT_F32: i32 = 10;
|
||||
const SMP_FMT_F64: i32 = 11;
|
||||
#[cfg(test)]
|
||||
const SMP_FMT_COUNT: i32 = 12;
|
||||
|
||||
//
|
||||
// Opaque `FBPixelFormat` constants (see
|
||||
// `ffmpeg_bridge/include/ffmpeg_bridge/ffmpeg_bridge.h`). Only the subset
|
||||
// this module's mappings produce/consume is declared; the values are copied
|
||||
// verbatim from the header.
|
||||
//
|
||||
const FB_PIX_FMT_NONE: i32 = -1;
|
||||
const FB_PIX_FMT_YU_V420_P: i32 = 0;
|
||||
const FB_PIX_FMT_RG_B24: i32 = 2;
|
||||
const FB_PIX_FMT_YU_V422_P: i32 = 4;
|
||||
const FB_PIX_FMT_YU_V444_P: i32 = 5;
|
||||
const FB_PIX_FMT_YU_V411_P: i32 = 7;
|
||||
const FB_PIX_FMT_YUV_J420_P: i32 = 12;
|
||||
const FB_PIX_FMT_YUV_J422_P: i32 = 13;
|
||||
const FB_PIX_FMT_YUV_J444_P: i32 = 14;
|
||||
const FB_PIX_FMT_RGBA: i32 = 26;
|
||||
const FB_PIX_FMT_YU_V440_P: i32 = 31;
|
||||
const FB_PIX_FMT_YUV_J440_P: i32 = 32;
|
||||
const FB_PIX_FMT_RG_B48_LE: i32 = 35;
|
||||
const FB_PIX_FMT_RGB_A64_LE: i32 = 105;
|
||||
const FB_PIX_FMT_YUV_J411_P: i32 = 138;
|
||||
const FB_PIX_FMT_RGBA_F16_LE: i32 = 207;
|
||||
const FB_PIX_FMT_RGB_F32_LE: i32 = 218;
|
||||
const FB_PIX_FMT_RGBA_F32_LE: i32 = 220;
|
||||
const FB_PIX_FMT_RGB_F16_LE: i32 = 234;
|
||||
|
||||
//
|
||||
// Opaque `FBSampleFormat` constants (same header).
|
||||
//
|
||||
const FB_SAMPLE_FMT_NONE: i32 = -1;
|
||||
const FB_SAMPLE_FMT_U8: i32 = 0;
|
||||
const FB_SAMPLE_FMT_S16: i32 = 1;
|
||||
const FB_SAMPLE_FMT_S32: i32 = 2;
|
||||
const FB_SAMPLE_FMT_FLT: i32 = 3;
|
||||
const FB_SAMPLE_FMT_DBL: i32 = 4;
|
||||
const FB_SAMPLE_FMT_U8_P: i32 = 5;
|
||||
const FB_SAMPLE_FMT_S16_P: i32 = 6;
|
||||
const FB_SAMPLE_FMT_S32_P: i32 = 7;
|
||||
const FB_SAMPLE_FMT_FLTP: i32 = 8;
|
||||
const FB_SAMPLE_FMT_DBLP: i32 = 9;
|
||||
const FB_SAMPLE_FMT_S64: i32 = 10;
|
||||
const FB_SAMPLE_FMT_S64_P: i32 = 11;
|
||||
|
||||
/// Calls the bridge's best-pixel-format search on `list` — picks the entry of
|
||||
/// a `FB_PIX_FMT_NONE`-terminated list closest to `pix_fmt`. In test builds
|
||||
/// (`cfg(test)` or feature `test-stubs`) the bridge is not linked, so a small
|
||||
/// faithful-in-spirit stub is used; the real loss-metric selection lives in
|
||||
/// ffmpeg_bridge and is only reachable in the final application build.
|
||||
///
|
||||
/// # CPP-PARITY
|
||||
/// The real `fb_find_best_pix_fmt_of_list` symbol (`ffmpeg_bridge` C ABI)
|
||||
/// has the same signature and `NONE`-terminated-list semantics as
|
||||
/// `fb_find_best_pix_fmt_of_list` in `ffmpeg_bridge/src/utils.cpp`.
|
||||
fn find_best_pix_fmt_of_list(list: &[i32; 4], pix_fmt: i32) -> i32 {
|
||||
// The bridge library is only linked into the final application, never into
|
||||
// a Rust test binary. Unit tests activate the stub via `cfg(test)`; the
|
||||
// integration tests (tests/ffi_ffmpegutils.rs) opt in with the
|
||||
// `test-stubs` cargo feature.
|
||||
#[cfg(all(not(test), not(feature = "test-stubs")))]
|
||||
extern "C" {
|
||||
fn fb_find_best_pix_fmt_of_list(
|
||||
list: *const std::ffi::c_int,
|
||||
pix_fmt: std::ffi::c_int,
|
||||
) -> std::ffi::c_int;
|
||||
}
|
||||
#[cfg(all(not(test), not(feature = "test-stubs")))]
|
||||
{
|
||||
// SAFETY: `list` is a `FB_PIX_FMT_NONE`-terminated array that outlives
|
||||
// the call; `pix_fmt` is any valid bridge pixel format.
|
||||
unsafe { fb_find_best_pix_fmt_of_list(list.as_ptr(), pix_fmt) }
|
||||
}
|
||||
#[cfg(any(test, feature = "test-stubs"))]
|
||||
{
|
||||
// Stub: exact matches are preferred, otherwise the first (most
|
||||
// desirable) candidate is returned, matching the bridge's behaviour
|
||||
// for an unknown source format. Only used by test builds.
|
||||
for &candidate in list {
|
||||
if candidate == pix_fmt {
|
||||
return candidate;
|
||||
}
|
||||
if candidate == FB_PIX_FMT_NONE {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if list[0] == FB_PIX_FMT_NONE {
|
||||
FB_PIX_FMT_NONE
|
||||
} else {
|
||||
list[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the `FB_PIX_FMT_NONE`-terminated candidate list for
|
||||
/// [`get_compatible_bridge_pixel_format`], clamped to `maximum_pix_fmt`
|
||||
/// (a native pixel format, or `PIX_FMT_INVALID` for no limit).
|
||||
///
|
||||
/// # CPP-PARITY
|
||||
/// Mirrors the `possible_pix_fmts` construction in
|
||||
/// `FFmpegUtils::get_compatible_bridge_pixel_format` (the `maximum` clamp:
|
||||
/// `u8` only allows RGBA; `f32` additionally allows RGBA-f32).
|
||||
fn compatible_bridge_pixel_format_list(maximum_pix_fmt: i32) -> [i32; 4] {
|
||||
let mut possible = [FB_PIX_FMT_NONE; 4];
|
||||
possible[0] = FB_PIX_FMT_RGBA;
|
||||
if maximum_pix_fmt == PIX_FMT_U8 {
|
||||
possible[1] = FB_PIX_FMT_NONE;
|
||||
} else {
|
||||
possible[1] = FB_PIX_FMT_RGB_A64_LE;
|
||||
if maximum_pix_fmt == PIX_FMT_F32 {
|
||||
possible[2] = FB_PIX_FMT_RGBA_F32_LE;
|
||||
possible[3] = FB_PIX_FMT_NONE;
|
||||
} else {
|
||||
possible[2] = FB_PIX_FMT_NONE;
|
||||
}
|
||||
}
|
||||
possible
|
||||
}
|
||||
|
||||
/// Bridge pixel format a frame can be converted to with minimal data loss,
|
||||
/// clamped to a maximum native precision (`maximum_pix_fmt == -1` for no
|
||||
/// limit).
|
||||
pub fn get_compatible_bridge_pixel_format(pix_fmt: i32, maximum_pix_fmt: i32) -> i32 {
|
||||
find_best_pix_fmt_of_list(&compatible_bridge_pixel_format_list(maximum_pix_fmt), pix_fmt)
|
||||
}
|
||||
|
||||
/// Native pixel format usable to convert from a native frame to a bridge
|
||||
/// frame with minimal data loss (`-1` if none).
|
||||
pub fn get_compatible_pixel_format(pix_fmt: i32) -> i32 {
|
||||
match pix_fmt {
|
||||
PIX_FMT_U8 | PIX_FMT_U10 => PIX_FMT_U8,
|
||||
PIX_FMT_U16 | PIX_FMT_F16 | PIX_FMT_F32 => PIX_FMT_U16,
|
||||
_ => PIX_FMT_INVALID,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bridge pixel format for a given native pixel format and channel count.
|
||||
pub fn get_ffmpeg_pixel_format(pix_fmt: i32, channel_count: i32) -> i32 {
|
||||
if channel_count == RGB_CHANNEL_COUNT {
|
||||
match pix_fmt {
|
||||
PIX_FMT_U8 => FB_PIX_FMT_RG_B24,
|
||||
PIX_FMT_U10 => FB_PIX_FMT_NONE,
|
||||
PIX_FMT_U16 => FB_PIX_FMT_RG_B48_LE,
|
||||
PIX_FMT_F16 => FB_PIX_FMT_RGB_F16_LE,
|
||||
PIX_FMT_F32 => FB_PIX_FMT_RGB_F32_LE,
|
||||
_ => FB_PIX_FMT_NONE,
|
||||
}
|
||||
} else if channel_count == RGBA_CHANNEL_COUNT {
|
||||
match pix_fmt {
|
||||
PIX_FMT_U8 => FB_PIX_FMT_RGBA,
|
||||
PIX_FMT_U10 => FB_PIX_FMT_NONE,
|
||||
PIX_FMT_U16 => FB_PIX_FMT_RGB_A64_LE,
|
||||
PIX_FMT_F16 => FB_PIX_FMT_RGBA_F16_LE,
|
||||
PIX_FMT_F32 => FB_PIX_FMT_RGBA_F32_LE,
|
||||
_ => FB_PIX_FMT_NONE,
|
||||
}
|
||||
} else {
|
||||
FB_PIX_FMT_NONE
|
||||
}
|
||||
}
|
||||
|
||||
/// Native sample format for a given bridge sample format (`-1` if unknown).
|
||||
pub fn get_native_sample_format(smp_fmt: i32) -> i32 {
|
||||
match smp_fmt {
|
||||
FB_SAMPLE_FMT_U8 => SMP_FMT_U8,
|
||||
FB_SAMPLE_FMT_S16 => SMP_FMT_S16,
|
||||
FB_SAMPLE_FMT_S32 => SMP_FMT_S32,
|
||||
FB_SAMPLE_FMT_S64 => SMP_FMT_S64,
|
||||
FB_SAMPLE_FMT_FLT => SMP_FMT_F32,
|
||||
FB_SAMPLE_FMT_DBL => SMP_FMT_F64,
|
||||
FB_SAMPLE_FMT_U8_P => SMP_FMT_U8_P,
|
||||
FB_SAMPLE_FMT_S16_P => SMP_FMT_S16_P,
|
||||
FB_SAMPLE_FMT_S32_P => SMP_FMT_S32_P,
|
||||
FB_SAMPLE_FMT_S64_P => SMP_FMT_S64_P,
|
||||
FB_SAMPLE_FMT_FLTP => SMP_FMT_F32_P,
|
||||
FB_SAMPLE_FMT_DBLP => SMP_FMT_F64_P,
|
||||
_ => SMP_FMT_INVALID,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bridge sample format for a given native sample format.
|
||||
pub fn get_ffmpeg_sample_format(smp_fmt: i32) -> i32 {
|
||||
match smp_fmt {
|
||||
SMP_FMT_U8 => FB_SAMPLE_FMT_U8,
|
||||
SMP_FMT_S16 => FB_SAMPLE_FMT_S16,
|
||||
SMP_FMT_S32 => FB_SAMPLE_FMT_S32,
|
||||
SMP_FMT_S64 => FB_SAMPLE_FMT_S64,
|
||||
SMP_FMT_F32 => FB_SAMPLE_FMT_FLT,
|
||||
SMP_FMT_F64 => FB_SAMPLE_FMT_DBL,
|
||||
SMP_FMT_U8_P => FB_SAMPLE_FMT_U8_P,
|
||||
SMP_FMT_S16_P => FB_SAMPLE_FMT_S16_P,
|
||||
SMP_FMT_S32_P => FB_SAMPLE_FMT_S32_P,
|
||||
SMP_FMT_S64_P => FB_SAMPLE_FMT_S64_P,
|
||||
SMP_FMT_F32_P => FB_SAMPLE_FMT_FLTP,
|
||||
SMP_FMT_F64_P => FB_SAMPLE_FMT_DBLP,
|
||||
// `invalid` and `count` both fall through to "no bridge format".
|
||||
_ => FB_SAMPLE_FMT_NONE,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a "JPEG" full-range bridge pixel format to its regular
|
||||
/// counterpart (unchanged if not JPEG).
|
||||
pub fn convert_jpeg_space_to_regular_space(pix_fmt: i32) -> i32 {
|
||||
match pix_fmt {
|
||||
FB_PIX_FMT_YUV_J420_P => FB_PIX_FMT_YU_V420_P,
|
||||
FB_PIX_FMT_YUV_J422_P => FB_PIX_FMT_YU_V422_P,
|
||||
FB_PIX_FMT_YUV_J444_P => FB_PIX_FMT_YU_V444_P,
|
||||
FB_PIX_FMT_YUV_J440_P => FB_PIX_FMT_YU_V440_P,
|
||||
FB_PIX_FMT_YUV_J411_P => FB_PIX_FMT_YU_V411_P,
|
||||
// Any other format is passed through unchanged.
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn compatible_bridge_pixel_format_no_limit_prefers_rgba_then_rgb_a64() {
|
||||
// `maximum == invalid (-1)`: candidate list is [rgba, rgb_a64_le, none].
|
||||
let list = compatible_bridge_pixel_format_list(PIX_FMT_INVALID);
|
||||
assert_eq!(list[0], FB_PIX_FMT_RGBA);
|
||||
assert_eq!(list[1], FB_PIX_FMT_RGB_A64_LE);
|
||||
assert_eq!(list[2], FB_PIX_FMT_NONE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatible_bridge_pixel_format_u8_clamp_allows_only_rgba() {
|
||||
// `maximum == u8`: candidate list is [rgba, none].
|
||||
let list = compatible_bridge_pixel_format_list(PIX_FMT_U8);
|
||||
assert_eq!(list[0], FB_PIX_FMT_RGBA);
|
||||
assert_eq!(list[1], FB_PIX_FMT_NONE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatible_bridge_pixel_format_f32_clamp_adds_rgba_f32() {
|
||||
// `maximum == f32`: candidate list is [rgba, rgb_a64_le, rgba_f32_le, none].
|
||||
let list = compatible_bridge_pixel_format_list(PIX_FMT_F32);
|
||||
assert_eq!(list[0], FB_PIX_FMT_RGBA);
|
||||
assert_eq!(list[1], FB_PIX_FMT_RGB_A64_LE);
|
||||
assert_eq!(list[2], FB_PIX_FMT_RGBA_F32_LE);
|
||||
assert_eq!(list[3], FB_PIX_FMT_NONE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatible_bridge_pixel_format_u16_clamp_matches_default() {
|
||||
// `maximum == u16` is neither u8 nor f32, so it matches the no-limit
|
||||
// candidate list.
|
||||
assert_eq!(
|
||||
compatible_bridge_pixel_format_list(PIX_FMT_U16),
|
||||
compatible_bridge_pixel_format_list(PIX_FMT_INVALID)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatible_bridge_pixel_format_picks_exact_candidate() {
|
||||
// With the test stub an exact match in the candidate list is preferred.
|
||||
assert_eq!(
|
||||
get_compatible_bridge_pixel_format(FB_PIX_FMT_RGBA, PIX_FMT_INVALID),
|
||||
FB_PIX_FMT_RGBA
|
||||
);
|
||||
assert_eq!(
|
||||
get_compatible_bridge_pixel_format(FB_PIX_FMT_RGB_A64_LE, PIX_FMT_F32),
|
||||
FB_PIX_FMT_RGB_A64_LE
|
||||
);
|
||||
assert_eq!(
|
||||
get_compatible_bridge_pixel_format(FB_PIX_FMT_RGBA_F32_LE, PIX_FMT_F32),
|
||||
FB_PIX_FMT_RGBA_F32_LE
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatible_bridge_pixel_format_unknown_falls_back_to_first() {
|
||||
// A source format that is not among the candidates falls back to the
|
||||
// first (most desirable) candidate.
|
||||
assert_eq!(
|
||||
get_compatible_bridge_pixel_format(FB_PIX_FMT_YU_V420_P, PIX_FMT_INVALID),
|
||||
FB_PIX_FMT_RGBA
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatible_pixel_format_maps_native_to_least_lossy() {
|
||||
assert_eq!(get_compatible_pixel_format(PIX_FMT_U8), PIX_FMT_U8);
|
||||
assert_eq!(get_compatible_pixel_format(PIX_FMT_U10), PIX_FMT_U8);
|
||||
assert_eq!(get_compatible_pixel_format(PIX_FMT_U16), PIX_FMT_U16);
|
||||
assert_eq!(get_compatible_pixel_format(PIX_FMT_F16), PIX_FMT_U16);
|
||||
assert_eq!(get_compatible_pixel_format(PIX_FMT_F32), PIX_FMT_U16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatible_pixel_format_invalid_and_count_map_to_invalid() {
|
||||
assert_eq!(get_compatible_pixel_format(PIX_FMT_INVALID), PIX_FMT_INVALID);
|
||||
assert_eq!(get_compatible_pixel_format(PIX_FMT_COUNT), PIX_FMT_INVALID);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ffmpeg_pixel_format_rgb_channel_layout() {
|
||||
assert_eq!(get_ffmpeg_pixel_format(PIX_FMT_U8, RGB_CHANNEL_COUNT), FB_PIX_FMT_RG_B24);
|
||||
assert_eq!(get_ffmpeg_pixel_format(PIX_FMT_U10, RGB_CHANNEL_COUNT), FB_PIX_FMT_NONE);
|
||||
assert_eq!(get_ffmpeg_pixel_format(PIX_FMT_U16, RGB_CHANNEL_COUNT), FB_PIX_FMT_RG_B48_LE);
|
||||
assert_eq!(get_ffmpeg_pixel_format(PIX_FMT_F16, RGB_CHANNEL_COUNT), FB_PIX_FMT_RGB_F16_LE);
|
||||
assert_eq!(get_ffmpeg_pixel_format(PIX_FMT_F32, RGB_CHANNEL_COUNT), FB_PIX_FMT_RGB_F32_LE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ffmpeg_pixel_format_rgba_channel_layout() {
|
||||
assert_eq!(get_ffmpeg_pixel_format(PIX_FMT_U8, RGBA_CHANNEL_COUNT), FB_PIX_FMT_RGBA);
|
||||
assert_eq!(get_ffmpeg_pixel_format(PIX_FMT_U10, RGBA_CHANNEL_COUNT), FB_PIX_FMT_NONE);
|
||||
assert_eq!(
|
||||
get_ffmpeg_pixel_format(PIX_FMT_U16, RGBA_CHANNEL_COUNT),
|
||||
FB_PIX_FMT_RGB_A64_LE
|
||||
);
|
||||
assert_eq!(
|
||||
get_ffmpeg_pixel_format(PIX_FMT_F16, RGBA_CHANNEL_COUNT),
|
||||
FB_PIX_FMT_RGBA_F16_LE
|
||||
);
|
||||
assert_eq!(
|
||||
get_ffmpeg_pixel_format(PIX_FMT_F32, RGBA_CHANNEL_COUNT),
|
||||
FB_PIX_FMT_RGBA_F32_LE
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ffmpeg_pixel_format_other_channel_layout_returns_none() {
|
||||
assert_eq!(get_ffmpeg_pixel_format(PIX_FMT_U8, 0), FB_PIX_FMT_NONE);
|
||||
assert_eq!(get_ffmpeg_pixel_format(PIX_FMT_U8, 2), FB_PIX_FMT_NONE);
|
||||
assert_eq!(get_ffmpeg_pixel_format(PIX_FMT_U8, 5), FB_PIX_FMT_NONE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ffmpeg_pixel_format_invalid_and_count_return_none() {
|
||||
assert_eq!(get_ffmpeg_pixel_format(PIX_FMT_INVALID, RGB_CHANNEL_COUNT), FB_PIX_FMT_NONE);
|
||||
assert_eq!(get_ffmpeg_pixel_format(PIX_FMT_COUNT, RGB_CHANNEL_COUNT), FB_PIX_FMT_NONE);
|
||||
assert_eq!(get_ffmpeg_pixel_format(PIX_FMT_INVALID, RGBA_CHANNEL_COUNT), FB_PIX_FMT_NONE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_sample_format_maps_bridge_to_native() {
|
||||
assert_eq!(get_native_sample_format(FB_SAMPLE_FMT_U8), SMP_FMT_U8);
|
||||
assert_eq!(get_native_sample_format(FB_SAMPLE_FMT_S16), SMP_FMT_S16);
|
||||
assert_eq!(get_native_sample_format(FB_SAMPLE_FMT_S32), SMP_FMT_S32);
|
||||
assert_eq!(get_native_sample_format(FB_SAMPLE_FMT_S64), SMP_FMT_S64);
|
||||
assert_eq!(get_native_sample_format(FB_SAMPLE_FMT_FLT), SMP_FMT_F32);
|
||||
assert_eq!(get_native_sample_format(FB_SAMPLE_FMT_DBL), SMP_FMT_F64);
|
||||
assert_eq!(get_native_sample_format(FB_SAMPLE_FMT_U8_P), SMP_FMT_U8_P);
|
||||
assert_eq!(get_native_sample_format(FB_SAMPLE_FMT_S16_P), SMP_FMT_S16_P);
|
||||
assert_eq!(get_native_sample_format(FB_SAMPLE_FMT_S32_P), SMP_FMT_S32_P);
|
||||
assert_eq!(get_native_sample_format(FB_SAMPLE_FMT_S64_P), SMP_FMT_S64_P);
|
||||
assert_eq!(get_native_sample_format(FB_SAMPLE_FMT_FLTP), SMP_FMT_F32_P);
|
||||
assert_eq!(get_native_sample_format(FB_SAMPLE_FMT_DBLP), SMP_FMT_F64_P);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_sample_format_unknown_maps_to_invalid() {
|
||||
assert_eq!(get_native_sample_format(FB_SAMPLE_FMT_NONE), SMP_FMT_INVALID);
|
||||
// A bogus code that does not match any bridge sample format.
|
||||
assert_eq!(get_native_sample_format(12345), SMP_FMT_INVALID);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ffmpeg_sample_format_maps_native_to_bridge() {
|
||||
assert_eq!(get_ffmpeg_sample_format(SMP_FMT_U8), FB_SAMPLE_FMT_U8);
|
||||
assert_eq!(get_ffmpeg_sample_format(SMP_FMT_S16), FB_SAMPLE_FMT_S16);
|
||||
assert_eq!(get_ffmpeg_sample_format(SMP_FMT_S32), FB_SAMPLE_FMT_S32);
|
||||
assert_eq!(get_ffmpeg_sample_format(SMP_FMT_S64), FB_SAMPLE_FMT_S64);
|
||||
assert_eq!(get_ffmpeg_sample_format(SMP_FMT_F32), FB_SAMPLE_FMT_FLT);
|
||||
assert_eq!(get_ffmpeg_sample_format(SMP_FMT_F64), FB_SAMPLE_FMT_DBL);
|
||||
assert_eq!(get_ffmpeg_sample_format(SMP_FMT_U8_P), FB_SAMPLE_FMT_U8_P);
|
||||
assert_eq!(get_ffmpeg_sample_format(SMP_FMT_S16_P), FB_SAMPLE_FMT_S16_P);
|
||||
assert_eq!(get_ffmpeg_sample_format(SMP_FMT_S32_P), FB_SAMPLE_FMT_S32_P);
|
||||
assert_eq!(get_ffmpeg_sample_format(SMP_FMT_S64_P), FB_SAMPLE_FMT_S64_P);
|
||||
assert_eq!(get_ffmpeg_sample_format(SMP_FMT_F32_P), FB_SAMPLE_FMT_FLTP);
|
||||
assert_eq!(get_ffmpeg_sample_format(SMP_FMT_F64_P), FB_SAMPLE_FMT_DBLP);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ffmpeg_sample_format_invalid_and_count_return_none() {
|
||||
assert_eq!(get_ffmpeg_sample_format(SMP_FMT_INVALID), FB_SAMPLE_FMT_NONE);
|
||||
assert_eq!(get_ffmpeg_sample_format(SMP_FMT_COUNT), FB_SAMPLE_FMT_NONE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_format_mappings_are_inverse() {
|
||||
for native in [SMP_FMT_U8, SMP_FMT_S16, SMP_FMT_S32, SMP_FMT_S64, SMP_FMT_F32, SMP_FMT_F64,
|
||||
SMP_FMT_U8_P, SMP_FMT_S16_P, SMP_FMT_S32_P, SMP_FMT_S64_P, SMP_FMT_F32_P,
|
||||
SMP_FMT_F64_P]
|
||||
{
|
||||
let bridge = get_ffmpeg_sample_format(native);
|
||||
assert_eq!(get_native_sample_format(bridge), native);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_space_converts_to_regular_space() {
|
||||
assert_eq!(convert_jpeg_space_to_regular_space(FB_PIX_FMT_YUV_J420_P), FB_PIX_FMT_YU_V420_P);
|
||||
assert_eq!(convert_jpeg_space_to_regular_space(FB_PIX_FMT_YUV_J422_P), FB_PIX_FMT_YU_V422_P);
|
||||
assert_eq!(convert_jpeg_space_to_regular_space(FB_PIX_FMT_YUV_J444_P), FB_PIX_FMT_YU_V444_P);
|
||||
assert_eq!(convert_jpeg_space_to_regular_space(FB_PIX_FMT_YUV_J440_P), FB_PIX_FMT_YU_V440_P);
|
||||
assert_eq!(convert_jpeg_space_to_regular_space(FB_PIX_FMT_YUV_J411_P), FB_PIX_FMT_YU_V411_P);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_space_leaves_non_jpeg_formats_unchanged() {
|
||||
assert_eq!(convert_jpeg_space_to_regular_space(FB_PIX_FMT_RGBA), FB_PIX_FMT_RGBA);
|
||||
assert_eq!(convert_jpeg_space_to_regular_space(FB_PIX_FMT_NONE), FB_PIX_FMT_NONE);
|
||||
assert_eq!(convert_jpeg_space_to_regular_space(FB_PIX_FMT_YU_V420_P), FB_PIX_FMT_YU_V420_P);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,935 @@
|
||||
// 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/>.
|
||||
|
||||
//! File/directory helpers, mirroring `src/common/src/filefunctions.h`
|
||||
//! and `include/common/filefunctions.h`. Mirrors the filefunctions C++
|
||||
//! namespace as a handle-bearing family for C ABI shape uniformity; the
|
||||
//! underlying operations are stateless.
|
||||
//!
|
||||
//! Two-stage string getters here return their result via `buf`/`buf_size`
|
||||
//! (see `crate::ffi`); the domain functions below return the owned string.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::error::Result;
|
||||
|
||||
/// FNV-1a 64-bit hash of `data`, returned as lowercase hex (`%016llx`).
|
||||
///
|
||||
/// Mirrors the anonymous `fnv1a_hex` helper in `src/common/src/filefunctions.cpp`.
|
||||
fn fnv1a_hex(data: &[u8]) -> String {
|
||||
let mut hash: u64 = 14695981039346656037;
|
||||
for &c in data {
|
||||
hash ^= u64::from(c);
|
||||
hash = hash.wrapping_mul(1099511628211);
|
||||
}
|
||||
format!("{:016x}", hash)
|
||||
}
|
||||
|
||||
/// Case-insensitive check whether `s` ends with `suffix`.
|
||||
///
|
||||
/// Mirrors `ends_with_case_insensitive` in `filefunctions.cpp`; only `A-Z`
|
||||
/// are folded to lower case (like the C++ `a >= 'A' && a <= 'Z'` test).
|
||||
fn ends_with_case_insensitive(s: &str, suffix: &str) -> bool {
|
||||
if suffix.len() > s.len() {
|
||||
return false;
|
||||
}
|
||||
let offset = s.len() - suffix.len();
|
||||
s.as_bytes()[offset..]
|
||||
.iter()
|
||||
.zip(suffix.as_bytes().iter())
|
||||
.all(|(a, b)| a.to_ascii_lowercase() == b.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
/// The filefunctions family (stateless; the handle only exists for C ABI
|
||||
/// uniformity).
|
||||
pub struct FileFunctions;
|
||||
|
||||
impl FileFunctions {
|
||||
/// Creates the filefunctions family object.
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Deterministic identifier string for a file (empty if it does not
|
||||
/// exist).
|
||||
pub fn get_unique_file_identifier(&self, filename: &str) -> Result<String> {
|
||||
// `std::path::absolute` is the std analog of `fs::absolute`: it
|
||||
// yields an absolute path without resolving symlinks or normalizing.
|
||||
let abs = match std::path::absolute(filename) {
|
||||
Ok(p) => p,
|
||||
Err(_) => return Ok(String::new()),
|
||||
};
|
||||
if !abs.exists() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
let metadata = match std::fs::metadata(&abs) {
|
||||
Ok(m) => m,
|
||||
Err(_) => return Ok(String::new()),
|
||||
};
|
||||
let mtime = match metadata.modified() {
|
||||
Ok(t) => t,
|
||||
Err(_) => return Ok(String::new()),
|
||||
};
|
||||
|
||||
// C++ appends `to_string(mtime.time_since_epoch().count())`; on
|
||||
// macOS `file_time_type` has nanosecond precision, so `.as_nanos()`
|
||||
// is the closest equivalent (used only as a cache-key salt).
|
||||
// CPP-PARITY: `time_since_epoch().count()` on a pre-epoch timestamp
|
||||
// is negative; Rust's `duration_since` returns an error which we fold
|
||||
// into the negated magnitude.
|
||||
let count: i128 = mtime
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos() as i128)
|
||||
.unwrap_or_else(|e| -(e.duration().as_nanos() as i128));
|
||||
|
||||
let mut hash_input = abs.to_string_lossy().into_owned();
|
||||
hash_input.push_str(&count.to_string());
|
||||
|
||||
Ok(fnv1a_hex(hash_input.as_bytes()))
|
||||
}
|
||||
|
||||
/// Configuration directory.
|
||||
pub fn get_configuration_location(&self) -> Result<String> {
|
||||
// Tests and tooling can redirect the configuration (and, since most
|
||||
// locations derive from it, the cache/data) root.
|
||||
if let Ok(dir) = std::env::var("OAK_CONFIG_DIR") {
|
||||
if !dir.is_empty() {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
return Ok(dir);
|
||||
}
|
||||
}
|
||||
|
||||
if Self::is_portable() {
|
||||
return Ok(Self::application_path());
|
||||
}
|
||||
|
||||
// CPP-PARITY: the C++ `#ifdef __APPLE__` picks `~/Library/Application
|
||||
// Support`; the non-Apple branch prefers `$XDG_CONFIG_HOME` then
|
||||
// `~/.config`. The empty-root fallback is the temp directory.
|
||||
#[cfg(target_os = "macos")]
|
||||
let config_root = match std::env::var("HOME") {
|
||||
Ok(h) if !h.is_empty() => {
|
||||
PathBuf::from(h).join("Library").join("Application Support")
|
||||
}
|
||||
_ => PathBuf::new(),
|
||||
};
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let config_root = match std::env::var("XDG_CONFIG_HOME") {
|
||||
Ok(x) if !x.is_empty() => PathBuf::from(x),
|
||||
_ => match std::env::var("HOME") {
|
||||
Ok(h) if !h.is_empty() => PathBuf::from(h).join(".config"),
|
||||
_ => PathBuf::new(),
|
||||
},
|
||||
};
|
||||
|
||||
let config_root = if config_root.as_os_str().is_empty() {
|
||||
std::env::temp_dir()
|
||||
} else {
|
||||
config_root
|
||||
};
|
||||
|
||||
let config_dir = config_root.join("oak");
|
||||
let _ = std::fs::create_dir_all(&config_dir);
|
||||
Ok(config_dir.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// Application path.
|
||||
pub fn get_application_path(&self) -> Result<String> {
|
||||
Ok(Self::application_path())
|
||||
}
|
||||
|
||||
/// Whether the application is running in portable mode (a `portable` file
|
||||
/// sits next to the application executable). Mirrors
|
||||
/// `FileFunctions::is_portable`.
|
||||
fn is_portable() -> bool {
|
||||
let app = Self::application_path();
|
||||
!app.is_empty() && Path::new(&app).join("portable").exists()
|
||||
}
|
||||
|
||||
/// Application path, without the `Result` wrapper (used internally).
|
||||
fn application_path() -> String {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// CPP-PARITY: C++ uses `_NSGetExecutablePath` + `weakly_canonical`;
|
||||
// `std::env::current_exe` + `canonicalize` is the portable
|
||||
// equivalent and yields the same parent directory.
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
let canonical = exe.canonicalize().unwrap_or(exe);
|
||||
if let Some(parent) = canonical.parent() {
|
||||
return parent.to_string_lossy().into_owned();
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
if let Ok(target) = std::fs::read_link("/proc/self/exe") {
|
||||
if let Some(parent) = target.parent() {
|
||||
return parent.to_string_lossy().into_owned();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: current working directory
|
||||
std::env::current_dir()
|
||||
.map(|c| c.to_string_lossy().into_owned())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Temporary file path.
|
||||
pub fn get_temp_file_path(&self) -> Result<String> {
|
||||
// CPP-PARITY: `std::env::temp_dir()` never fails the way
|
||||
// `fs::temp_directory_path(ec)` can, so the `"." / "oak-temp"`
|
||||
// fallback is unreachable here and omitted.
|
||||
let temp_path = std::env::temp_dir().join("oak");
|
||||
let _ = std::fs::create_dir_all(&temp_path);
|
||||
Ok(temp_path.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// Auto-recovery root directory.
|
||||
pub fn get_auto_recovery_root(&self) -> Result<String> {
|
||||
let config = self.get_configuration_location()?;
|
||||
Ok(PathBuf::from(config)
|
||||
.join("autorecovery")
|
||||
.to_string_lossy()
|
||||
.into_owned())
|
||||
}
|
||||
|
||||
/// Whether `source` can be copied to `dest` without overwriting.
|
||||
pub fn can_copy_directory_without_overwriting(&self, source: &str, dest: &str) -> bool {
|
||||
// CPP-PARITY: a failed `directory_iterator` (e.g. `source` missing)
|
||||
// yields an empty iteration, so the function returns `true`.
|
||||
let Ok(entries) = std::fs::read_dir(source) else {
|
||||
return true;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let dest_equivalent = PathBuf::from(dest).join(entry.file_name());
|
||||
// `entry.is_directory(ec)` failing (ec set) falls through to the
|
||||
// "else exists" branch in C++; `unwrap_or(false)` mirrors that.
|
||||
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
|
||||
if !self.can_copy_directory_without_overwriting(
|
||||
&entry.path().to_string_lossy(),
|
||||
&dest_equivalent.to_string_lossy(),
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
} else if dest_equivalent.exists() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Recursively copy a directory, optionally overwriting.
|
||||
pub fn copy_directory(&self, source: &str, dest: &str, overwrite: bool) -> Result<()> {
|
||||
if !Path::new(source).is_dir() {
|
||||
eprintln!("Failed to copy directory, source {} didn't exist", source);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if std::fs::create_dir_all(dest).is_err() {
|
||||
eprintln!("Failed to create destination directory {}", dest);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// CPP-PARITY: a failed `directory_iterator` on `source` aborts the
|
||||
// copy silently (C++ prints nothing); skipping matches that.
|
||||
let Ok(entries) = std::fs::read_dir(source) else {
|
||||
return Ok(());
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let entry_path = entry.path();
|
||||
let dest_file_path = PathBuf::from(dest).join(entry.file_name());
|
||||
|
||||
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
|
||||
// Copy dir
|
||||
self.copy_directory(
|
||||
&entry_path.to_string_lossy(),
|
||||
&dest_file_path.to_string_lossy(),
|
||||
overwrite,
|
||||
)?;
|
||||
} else {
|
||||
// Copy file
|
||||
if overwrite {
|
||||
// C++ adds owner/group/others write permission then
|
||||
// removes the destination (so read-only files can be
|
||||
// replaced). CPP-PARITY: gated to `unix` because Rust's
|
||||
// `PermissionsExt` is Unix-only; on Windows the write
|
||||
// permissions already allow removal.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if let Ok(meta) = std::fs::metadata(&dest_file_path) {
|
||||
let mut perms = meta.permissions();
|
||||
// owner_write|group_write|others_write
|
||||
perms.set_mode(perms.mode() | 0o200 | 0o020 | 0o002);
|
||||
let _ = std::fs::set_permissions(&dest_file_path, perms);
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_file(&dest_file_path);
|
||||
}
|
||||
|
||||
// CPP-PARITY: C++ `fs::copy_file` with `copy_options::none`
|
||||
// FAILS when the destination exists, but Rust's
|
||||
// `std::fs::copy` would silently overwrite it — so the
|
||||
// no-overwrite case must be guarded explicitly.
|
||||
if !overwrite && dest_file_path.exists() {
|
||||
eprintln!(
|
||||
"Failed to copy file {} to {}: File exists",
|
||||
entry_path.to_string_lossy(),
|
||||
dest_file_path.to_string_lossy()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(e) = std::fs::copy(&entry_path, &dest_file_path) {
|
||||
eprintln!(
|
||||
"Failed to copy file {} to {}: {}",
|
||||
entry_path.to_string_lossy(),
|
||||
dest_file_path.to_string_lossy(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether a directory exists, optionally creating it.
|
||||
pub fn directory_is_valid(&self, dir: &str, try_to_create: bool) -> bool {
|
||||
// Return whether the directory exists, or whether it could be created
|
||||
// if it doesn't
|
||||
if Path::new(dir).is_dir() {
|
||||
return true;
|
||||
}
|
||||
try_to_create && std::fs::create_dir_all(dir).is_ok()
|
||||
}
|
||||
|
||||
/// Filename with a (dotless) extension ensured.
|
||||
pub fn ensure_filename_extension(&self, filename: &str, extension: &str) -> Result<String> {
|
||||
let mut fn_str = filename.to_string();
|
||||
// No-op if either input is empty
|
||||
if !fn_str.is_empty() && !extension.is_empty() {
|
||||
let extension_with_dot = format!(".{}", extension);
|
||||
if !ends_with_case_insensitive(&fn_str, &extension_with_dot) {
|
||||
fn_str.push_str(&extension_with_dot);
|
||||
}
|
||||
}
|
||||
Ok(fn_str)
|
||||
}
|
||||
|
||||
/// The entire file read into a string (empty if it cannot be read).
|
||||
pub fn read_file_as_string(&self, filename: &str) -> Result<String> {
|
||||
// CPP-PARITY: the C++ returns the raw byte buffer as a `std::string`;
|
||||
// Rust `String` must be valid UTF-8, so invalid bytes are replaced
|
||||
// (lossy) rather than preserved. Project/XML files are UTF-8, so this
|
||||
// matches in practice.
|
||||
match std::fs::read(filename) {
|
||||
Ok(bytes) => Ok(String::from_utf8_lossy(&bytes).into_owned()),
|
||||
Err(_) => Ok(String::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A non-existing temporary variant of `original`.
|
||||
pub fn get_safe_temporary_filename(&self, original: &str) -> Result<String> {
|
||||
let mut counter: i32 = 0;
|
||||
|
||||
let original_path = PathBuf::from(original);
|
||||
let dir = original_path
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_default();
|
||||
let filename = original_path
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Split off the complete suffix (everything from the first dot), like
|
||||
// QFileInfo::completeSuffix()
|
||||
let mut basename = filename.clone();
|
||||
let mut complete_suffix = String::new();
|
||||
if let Some(first_dot) = filename.find('.') {
|
||||
basename = filename[..first_dot].to_string();
|
||||
complete_suffix = filename[first_dot..].to_string();
|
||||
}
|
||||
|
||||
let mut temp_abs_path;
|
||||
loop {
|
||||
temp_abs_path = dir.join(format!(
|
||||
"{}.tmp{}{}",
|
||||
basename, counter, complete_suffix
|
||||
));
|
||||
counter += 1;
|
||||
if !temp_abs_path.exists() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(temp_abs_path.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// Rename `from` to `to`, deleting `to` first if it exists.
|
||||
pub fn rename_file_allow_overwrite(&self, from: &str, to: &str) -> bool {
|
||||
if Path::new(to).exists() {
|
||||
// CPP-PARITY: C++ `fs::remove` handles both files and dirs; Rust
|
||||
// has no single "remove" that does both, and the use case here is
|
||||
// file renames, so `remove_file` is used.
|
||||
if std::fs::remove_file(to).is_err() {
|
||||
eprintln!("Couldn't remove existing file {} for overwrite", to);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// By this point, we can assume `to` either never existed or has now
|
||||
// been deleted
|
||||
if std::fs::rename(from, to).is_err() {
|
||||
eprintln!("Failed to rename file {} to {}", from, to);
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Append the platform executable suffix (".exe" on Windows).
|
||||
pub fn get_formatted_executable_for_platform(&self, unformatted: &str) -> Result<String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
Ok(format!("{}.exe", unformatted))
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
Ok(unformatted.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Location query helper reserved for the two-stage C getters; returns a
|
||||
/// path suitable for `std::fs`.
|
||||
pub(crate) fn config_location_path() -> Result<PathBuf> {
|
||||
FileFunctions::new()
|
||||
.get_configuration_location()
|
||||
.map(PathBuf::from)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
|
||||
// The configuration-location tests mutate the process-wide `OAK_CONFIG_DIR`
|
||||
// env var, which is also read by `get_auto_recovery_root` and
|
||||
// `config_location_path`. Rust runs tests in parallel, so serialize those
|
||||
// env-dependent tests with a shared lock. We use the crate-wide test lock so
|
||||
// `configstore` tests (which also mutate `OAK_CONFIG_DIR`) serialize on the
|
||||
// SAME mutex.
|
||||
fn config_lock() -> &'static Mutex<()> {
|
||||
crate::test_support::env_lock()
|
||||
}
|
||||
|
||||
fn unique_temp_dir(tag: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"oak-filefunctions-test-{}-{}-{}",
|
||||
tag,
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fnv1a_known_vectors() {
|
||||
// Offset basis as lowercase hex, and the canonical FNV-1a-64 of "a".
|
||||
assert_eq!(fnv1a_hex(b""), "cbf29ce484222325");
|
||||
assert_eq!(fnv1a_hex(b"a"), "af63dc4c8601ec8c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_extension_appends_and_is_case_insensitive() {
|
||||
let f = FileFunctions::new();
|
||||
assert_eq!(f.ensure_filename_extension("foo", "ove").unwrap(), "foo.ove");
|
||||
// Already present (case-insensitive): untouched.
|
||||
assert_eq!(f.ensure_filename_extension("foo.OVE", "ove").unwrap(), "foo.OVE");
|
||||
assert_eq!(f.ensure_filename_extension("foo.ove", "Ove").unwrap(), "foo.ove");
|
||||
// The "." in the suffix must actually be present.
|
||||
assert_eq!(f.ensure_filename_extension("fooove", "ove").unwrap(), "fooove.ove");
|
||||
// Empty inputs are no-ops.
|
||||
assert_eq!(f.ensure_filename_extension("", "ove").unwrap(), "");
|
||||
assert_eq!(f.ensure_filename_extension("foo", "").unwrap(), "foo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formatted_executable_for_platform() {
|
||||
let f = FileFunctions::new();
|
||||
let v = f.get_formatted_executable_for_platform("myapp").unwrap();
|
||||
#[cfg(target_os = "windows")]
|
||||
assert_eq!(v, "myapp.exe");
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
assert_eq!(v, "myapp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unique_identifier_existing_and_missing() {
|
||||
let f = FileFunctions::new();
|
||||
let dir = unique_temp_dir("id");
|
||||
let file = dir.join("data.txt");
|
||||
std::fs::write(&file, b"hello").unwrap();
|
||||
|
||||
let id = f
|
||||
.get_unique_file_identifier(&file.to_string_lossy())
|
||||
.unwrap();
|
||||
assert_eq!(id.len(), 16);
|
||||
assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
|
||||
// Deterministic for the same file.
|
||||
let id2 = f
|
||||
.get_unique_file_identifier(&file.to_string_lossy())
|
||||
.unwrap();
|
||||
assert_eq!(id, id2);
|
||||
|
||||
let missing = f
|
||||
.get_unique_file_identifier(&dir.join("nope.txt").to_string_lossy())
|
||||
.unwrap();
|
||||
assert_eq!(missing, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_file_as_string_roundtrip() {
|
||||
let f = FileFunctions::new();
|
||||
let dir = unique_temp_dir("read");
|
||||
let file = dir.join("x.txt");
|
||||
std::fs::write(&file, b"hello world").unwrap();
|
||||
assert_eq!(
|
||||
f.read_file_as_string(&file.to_string_lossy()).unwrap(),
|
||||
"hello world"
|
||||
);
|
||||
// Missing file -> empty string.
|
||||
assert_eq!(
|
||||
f.read_file_as_string(&dir.join("missing.txt").to_string_lossy())
|
||||
.unwrap(),
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_is_valid_create_semantics() {
|
||||
let f = FileFunctions::new();
|
||||
let parent = unique_temp_dir("dirval");
|
||||
let dir = parent.join("created");
|
||||
assert!(!dir.exists());
|
||||
assert!(f.directory_is_valid(&dir.to_string_lossy(), true));
|
||||
assert!(dir.is_dir());
|
||||
// Now it exists, so even without create it is valid.
|
||||
assert!(f.directory_is_valid(&dir.to_string_lossy(), false));
|
||||
|
||||
let missing = parent.join("never");
|
||||
assert!(!missing.exists());
|
||||
assert!(!f.directory_is_valid(&missing.to_string_lossy(), false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safe_temporary_filename_skips_existing() {
|
||||
let f = FileFunctions::new();
|
||||
let dir = unique_temp_dir("safetmp");
|
||||
let original = dir.join("video.mp4");
|
||||
// Block the first candidate so the counter must advance.
|
||||
std::fs::write(dir.join("video.tmp0.mp4"), b"x").unwrap();
|
||||
|
||||
let result = f
|
||||
.get_safe_temporary_filename(&original.to_string_lossy())
|
||||
.unwrap();
|
||||
assert!(result.ends_with(".mp4"), "suffix preserved: {}", result);
|
||||
assert!(
|
||||
result.contains(".tmp1.mp4"),
|
||||
"should skip existing .tmp0, got: {}",
|
||||
result
|
||||
);
|
||||
assert!(!Path::new(&result).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_allow_overwrite() {
|
||||
let f = FileFunctions::new();
|
||||
let dir = unique_temp_dir("rename");
|
||||
let from = dir.join("from.txt");
|
||||
let to = dir.join("to.txt");
|
||||
std::fs::write(&from, b"new").unwrap();
|
||||
std::fs::write(&to, b"old").unwrap();
|
||||
|
||||
assert!(f.rename_file_allow_overwrite(
|
||||
&from.to_string_lossy(),
|
||||
&to.to_string_lossy()
|
||||
));
|
||||
assert!(!from.exists());
|
||||
assert_eq!(std::fs::read(&to).unwrap(), b"new");
|
||||
|
||||
// Renaming a missing source fails.
|
||||
let missing = dir.join("missing.txt");
|
||||
assert!(!f.rename_file_allow_overwrite(
|
||||
&missing.to_string_lossy(),
|
||||
&dir.join("z.txt").to_string_lossy()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_directory_recursive_and_overwrite_check() {
|
||||
let f = FileFunctions::new();
|
||||
let src = unique_temp_dir("cp-src");
|
||||
let nested = src.join("sub");
|
||||
std::fs::create_dir_all(&nested).unwrap();
|
||||
std::fs::write(src.join("a.txt"), b"a").unwrap();
|
||||
std::fs::write(nested.join("b.txt"), b"b").unwrap();
|
||||
|
||||
let dest = unique_temp_dir("cp-dst");
|
||||
// Nothing in dest yet: safe to copy without overwriting.
|
||||
assert!(f.can_copy_directory_without_overwriting(
|
||||
&src.to_string_lossy(),
|
||||
&dest.to_string_lossy()
|
||||
));
|
||||
f.copy_directory(&src.to_string_lossy(), &dest.to_string_lossy(), false)
|
||||
.unwrap();
|
||||
assert!(dest.join("a.txt").exists());
|
||||
assert!(dest.join("sub").join("b.txt").exists());
|
||||
assert_eq!(std::fs::read(dest.join("a.txt")).unwrap(), b"a");
|
||||
|
||||
// Copying again would overwrite a.txt.
|
||||
assert!(!f.can_copy_directory_without_overwriting(
|
||||
&src.to_string_lossy(),
|
||||
&dest.to_string_lossy()
|
||||
));
|
||||
// With overwrite it succeeds and refreshes content.
|
||||
f.copy_directory(&src.to_string_lossy(), &dest.to_string_lossy(), true)
|
||||
.unwrap();
|
||||
assert_eq!(std::fs::read(dest.join("a.txt")).unwrap(), b"a");
|
||||
|
||||
// A missing source is a silent no-op (returns Ok).
|
||||
f.copy_directory(
|
||||
&dest.join("nope").to_string_lossy(),
|
||||
&unique_temp_dir("cp-missing").to_string_lossy(),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn application_and_temp_paths() {
|
||||
let f = FileFunctions::new();
|
||||
let app = f.get_application_path().unwrap();
|
||||
assert!(!app.is_empty());
|
||||
assert!(Path::new(&app).is_dir());
|
||||
|
||||
let temp = f.get_temp_file_path().unwrap();
|
||||
assert!(!temp.is_empty());
|
||||
assert!(Path::new(&temp).is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_location_obeys_env_override() {
|
||||
let _guard: MutexGuard<()> = config_lock().lock().unwrap();
|
||||
let f = FileFunctions::new();
|
||||
let dir = unique_temp_dir("config");
|
||||
std::env::set_var("OAK_CONFIG_DIR", &dir);
|
||||
let loc = f.get_configuration_location().unwrap();
|
||||
std::env::remove_var("OAK_CONFIG_DIR");
|
||||
assert_eq!(PathBuf::from(&loc), dir);
|
||||
assert!(dir.is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_recovery_root_derives_from_config() {
|
||||
let _guard: MutexGuard<()> = config_lock().lock().unwrap();
|
||||
let f = FileFunctions::new();
|
||||
let dir = unique_temp_dir("config2");
|
||||
std::env::set_var("OAK_CONFIG_DIR", &dir);
|
||||
let root = f.get_auto_recovery_root().unwrap();
|
||||
std::env::remove_var("OAK_CONFIG_DIR");
|
||||
assert!(root.ends_with("autorecovery"));
|
||||
assert!(PathBuf::from(&root).starts_with(&dir));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_location_path_helper() {
|
||||
let _guard: MutexGuard<()> = config_lock().lock().unwrap();
|
||||
let dir = unique_temp_dir("clp");
|
||||
std::env::set_var("OAK_CONFIG_DIR", &dir);
|
||||
let p = config_location_path().unwrap();
|
||||
std::env::remove_var("OAK_CONFIG_DIR");
|
||||
assert_eq!(p, dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ends_with_case_insensitive_matrix() {
|
||||
assert!(ends_with_case_insensitive("foo.OVE", ".ove"));
|
||||
assert!(ends_with_case_insensitive("foo.ove", ".OVE"));
|
||||
assert!(ends_with_case_insensitive("x", "x"));
|
||||
assert!(ends_with_case_insensitive("x", ""));
|
||||
// Suffix longer than the string never matches.
|
||||
assert!(!ends_with_case_insensitive("ove", ".ove"));
|
||||
assert!(!ends_with_case_insensitive("", "a"));
|
||||
// Only ASCII A-Z fold (like the C++ range test); other bytes compare
|
||||
// exactly.
|
||||
assert!(ends_with_case_insensitive("a[", "["));
|
||||
assert!(!ends_with_case_insensitive("a[", "{"));
|
||||
assert!(ends_with_case_insensitive("vidéo.MP4", ".mp4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_extension_dotfile_multi_ext_and_unicode() {
|
||||
let f = FileFunctions::new();
|
||||
// Dotfiles and multi-extension names just get the suffix appended.
|
||||
assert_eq!(f.ensure_filename_extension(".hidden", "txt").unwrap(), ".hidden.txt");
|
||||
assert_eq!(
|
||||
f.ensure_filename_extension("archive.tar", "gz").unwrap(),
|
||||
"archive.tar.gz"
|
||||
);
|
||||
assert_eq!(
|
||||
f.ensure_filename_extension("archive.tar.gz", "gz").unwrap(),
|
||||
"archive.tar.gz"
|
||||
);
|
||||
// Unicode names pass through unchanged apart from the suffix.
|
||||
assert_eq!(
|
||||
f.ensure_filename_extension("動画ファイル", "ove").unwrap(),
|
||||
"動画ファイル.ove"
|
||||
);
|
||||
// A bare "." suffix (empty extension) is a no-op per the C++.
|
||||
assert_eq!(f.ensure_filename_extension("foo.", "").unwrap(), "foo.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safe_temporary_filename_naming_variants() {
|
||||
let f = FileFunctions::new();
|
||||
let dir = unique_temp_dir("safetmp2");
|
||||
|
||||
// Counter 0 is used when nothing blocks it.
|
||||
let r0 = f
|
||||
.get_safe_temporary_filename(&dir.join("clip.mov").to_string_lossy())
|
||||
.unwrap();
|
||||
assert!(r0.ends_with("clip.tmp0.mov"), "got: {}", r0);
|
||||
|
||||
// Complete suffix = everything from the FIRST dot (QFileInfo
|
||||
// completeSuffix semantics, `filefunctions.cpp:318-326`).
|
||||
let r1 = f
|
||||
.get_safe_temporary_filename(&dir.join("a.tar.gz").to_string_lossy())
|
||||
.unwrap();
|
||||
assert!(r1.ends_with("a.tmp0.tar.gz"), "got: {}", r1);
|
||||
|
||||
// No extension at all.
|
||||
let r2 = f
|
||||
.get_safe_temporary_filename(&dir.join("README").to_string_lossy())
|
||||
.unwrap();
|
||||
assert!(r2.ends_with("README.tmp0"), "got: {}", r2);
|
||||
|
||||
// Dotfile: basename is empty, complete suffix is the whole name.
|
||||
let r3 = f
|
||||
.get_safe_temporary_filename(&dir.join(".hidden").to_string_lossy())
|
||||
.unwrap();
|
||||
assert!(r3.ends_with(".tmp0.hidden"), "got: {}", r3);
|
||||
|
||||
// No parent directory: the candidate is relative ("name.tmp0.ext").
|
||||
let r4 = f.get_safe_temporary_filename("video.mp4").unwrap();
|
||||
assert_eq!(r4, "video.tmp0.mp4");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unique_identifier_differs_by_path() {
|
||||
let f = FileFunctions::new();
|
||||
let dir = unique_temp_dir("id2");
|
||||
let a = dir.join("a.txt");
|
||||
let b = dir.join("b.txt");
|
||||
std::fs::write(&a, b"same").unwrap();
|
||||
std::fs::write(&b, b"same").unwrap();
|
||||
// The absolute path is part of the hash input
|
||||
// (`filefunctions.cpp:100-103`), so identical content in different
|
||||
// paths yields different identifiers.
|
||||
let ida = f.get_unique_file_identifier(&a.to_string_lossy()).unwrap();
|
||||
let idb = f.get_unique_file_identifier(&b.to_string_lossy()).unwrap();
|
||||
assert_ne!(ida, idb);
|
||||
|
||||
// A directory also gets an identifier (C++ only checks exists()).
|
||||
let idd = f.get_unique_file_identifier(&dir.to_string_lossy()).unwrap();
|
||||
assert_eq!(idd.len(), 16);
|
||||
|
||||
// Empty filename -> absolute() of "" is the CWD which exists; but a
|
||||
// definitely-bogus relative name yields "".
|
||||
let bogus = f.get_unique_file_identifier("definitely-not-here.xyz").unwrap();
|
||||
assert_eq!(bogus, "");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_file_as_string_binary_and_lossy() {
|
||||
let f = FileFunctions::new();
|
||||
let dir = unique_temp_dir("read2");
|
||||
// Invalid UTF-8 is replaced (lossy), not an error.
|
||||
let bad = dir.join("bad.bin");
|
||||
std::fs::write(&bad, b"a\xff\xfeb").unwrap();
|
||||
let s = f.read_file_as_string(&bad.to_string_lossy()).unwrap();
|
||||
assert_eq!(s, "a\u{FFFD}\u{FFFD}b");
|
||||
|
||||
// Empty file reads as empty, indistinguishable from a missing file.
|
||||
let empty = dir.join("empty.txt");
|
||||
std::fs::write(&empty, b"").unwrap();
|
||||
assert_eq!(f.read_file_as_string(&empty.to_string_lossy()).unwrap(), "");
|
||||
|
||||
// A directory cannot be read as a file -> empty.
|
||||
assert_eq!(f.read_file_as_string(&dir.to_string_lossy()).unwrap(), "");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_is_valid_blocked_by_file() {
|
||||
let f = FileFunctions::new();
|
||||
let parent = unique_temp_dir("dirval2");
|
||||
// A regular file blocks directory creation at the same path.
|
||||
let blocker = parent.join("blocked");
|
||||
std::fs::write(&blocker, b"x").unwrap();
|
||||
assert!(!f.directory_is_valid(&blocker.to_string_lossy(), true));
|
||||
// ...and so does a file in the MIDDLE of the path to create.
|
||||
let nested = blocker.join("child");
|
||||
assert!(!f.directory_is_valid(&nested.to_string_lossy(), true));
|
||||
// Nested creation works when nothing blocks it.
|
||||
let deep = parent.join("x").join("y").join("z");
|
||||
assert!(f.directory_is_valid(&deep.to_string_lossy(), true));
|
||||
assert!(deep.is_dir());
|
||||
|
||||
let _ = std::fs::remove_dir_all(&parent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_directory_no_overwrite_preserves_existing() {
|
||||
let f = FileFunctions::new();
|
||||
let src = unique_temp_dir("cp2-src");
|
||||
std::fs::write(src.join("a.txt"), b"new").unwrap();
|
||||
let dest = unique_temp_dir("cp2-dst");
|
||||
std::fs::write(dest.join("a.txt"), b"old").unwrap();
|
||||
|
||||
// would-overwrite detection fires on the direct conflict...
|
||||
assert!(!f.can_copy_directory_without_overwriting(
|
||||
&src.to_string_lossy(),
|
||||
&dest.to_string_lossy()
|
||||
));
|
||||
// ...and a missing source trivially reports "safe" (empty iteration,
|
||||
// `filefunctions.cpp:202`).
|
||||
assert!(f.can_copy_directory_without_overwriting(
|
||||
&src.join("nope").to_string_lossy(),
|
||||
&dest.to_string_lossy()
|
||||
));
|
||||
|
||||
// overwrite=false: the copy of the conflicting file fails (logged)
|
||||
// and the destination keeps its old content; other files still copy.
|
||||
std::fs::write(src.join("b.txt"), b"b").unwrap();
|
||||
f.copy_directory(&src.to_string_lossy(), &dest.to_string_lossy(), false)
|
||||
.unwrap();
|
||||
assert_eq!(std::fs::read(dest.join("a.txt")).unwrap(), b"old");
|
||||
assert_eq!(std::fs::read(dest.join("b.txt")).unwrap(), b"b");
|
||||
|
||||
// overwrite=true replaces the conflicting file.
|
||||
f.copy_directory(&src.to_string_lossy(), &dest.to_string_lossy(), true)
|
||||
.unwrap();
|
||||
assert_eq!(std::fs::read(dest.join("a.txt")).unwrap(), b"new");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
let _ = std::fs::remove_dir_all(&dest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_copy_detects_nested_conflict() {
|
||||
let f = FileFunctions::new();
|
||||
let src = unique_temp_dir("cp3-src");
|
||||
std::fs::create_dir_all(src.join("sub")).unwrap();
|
||||
std::fs::write(src.join("sub").join("deep.txt"), b"x").unwrap();
|
||||
let dest = unique_temp_dir("cp3-dst");
|
||||
std::fs::create_dir_all(dest.join("sub")).unwrap();
|
||||
std::fs::write(dest.join("sub").join("deep.txt"), b"y").unwrap();
|
||||
// The conflict is two levels down: recursion must find it.
|
||||
assert!(!f.can_copy_directory_without_overwriting(
|
||||
&src.to_string_lossy(),
|
||||
&dest.to_string_lossy()
|
||||
));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
let _ = std::fs::remove_dir_all(&dest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_to_fresh_destination() {
|
||||
let f = FileFunctions::new();
|
||||
let dir = unique_temp_dir("rename2");
|
||||
let from = dir.join("only.txt");
|
||||
let to = dir.join("fresh.txt");
|
||||
std::fs::write(&from, b"data").unwrap();
|
||||
assert!(f.rename_file_allow_overwrite(
|
||||
&from.to_string_lossy(),
|
||||
&to.to_string_lossy()
|
||||
));
|
||||
assert_eq!(std::fs::read(&to).unwrap(), b"data");
|
||||
|
||||
// Destination existing as a DIRECTORY cannot be removed by
|
||||
// remove_file, so the rename fails (documented CPP-PARITY
|
||||
// divergence from C++ fs::remove, which would remove an empty dir).
|
||||
let from2 = dir.join("only2.txt");
|
||||
std::fs::write(&from2, b"z").unwrap();
|
||||
let todir = dir.join("destdir");
|
||||
std::fs::create_dir(&todir).unwrap();
|
||||
assert!(!f.rename_file_allow_overwrite(
|
||||
&from2.to_string_lossy(),
|
||||
&todir.to_string_lossy()
|
||||
));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temp_file_path_under_system_temp() {
|
||||
let f = FileFunctions::new();
|
||||
let temp = f.get_temp_file_path().unwrap();
|
||||
// `<temp>/oak`, created on demand (`filefunctions.cpp:184-196`).
|
||||
assert_eq!(
|
||||
PathBuf::from(&temp),
|
||||
std::env::temp_dir().join("oak")
|
||||
);
|
||||
assert!(Path::new(&temp).is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unique_temp_dirs_are_actually_unique() {
|
||||
let a = unique_temp_dir("uniq");
|
||||
let b = unique_temp_dir("uniq");
|
||||
assert_ne!(a, b);
|
||||
let _ = std::fs::remove_dir_all(&a);
|
||||
let _ = std::fs::remove_dir_all(&b);
|
||||
}
|
||||
}
|
||||
|
||||
/// The default disk cache directory (C++ `DiskManager::
|
||||
/// get_default_disk_cache_path`): `<configuration location>/mediacache`.
|
||||
///
|
||||
/// Single-lib unification: this used to live in the oakrender crate's
|
||||
/// `bridge::common` fallback (see `docs/zh/plans/riir/single-lib.md`);
|
||||
/// oaknode and oakrender both call it directly now.
|
||||
pub fn default_disk_cache_path() -> String {
|
||||
Path::new(&FileFunctions::new().get_configuration_location().unwrap_or_default())
|
||||
.join("mediacache")
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
// 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/>.
|
||||
|
||||
//! Refcounted-handle scaffolding. Same pattern as the oakplugin crate
|
||||
//! (`src/plugin/rust/src/handle.rs`); intentionally duplicated rather
|
||||
//! than shared — each module DLL must run its own addref/release code
|
||||
//! (the function pointers in a handle always point into the DLL that
|
||||
//! created the object).
|
||||
//!
|
||||
//! Mirrors the C side (`include/common/handle.h`):
|
||||
//!
|
||||
//! ```c
|
||||
//! typedef struct OakXxx {
|
||||
//! void *ctx;
|
||||
//! void (*addref)(void *ctx);
|
||||
//! void (*release)(void *ctx);
|
||||
//! uint32_t abi_version;
|
||||
//! } OakXxx;
|
||||
//! ```
|
||||
//!
|
||||
//! Handles are passed by value; `ctx` points to a heap [`RefBox<T>`].
|
||||
//! The `addref`/`release` function pointers always point into this crate.
|
||||
|
||||
use std::panic::{catch_unwind, AssertUnwindSafe};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
/// ABI version stamped into every handle.
|
||||
pub const OAKCOMMON_ABI_VERSION: u32 = 1;
|
||||
|
||||
/// Heap box behind a handle's `ctx`.
|
||||
///
|
||||
/// `value` is deliberately the FIRST field so that `ctx` (which points at
|
||||
/// the box head) aliases the boxed value: the C-facing `ffi.rs` code
|
||||
/// frequently casts `ctx` directly to `*mut T` / `*const T`, and that cast
|
||||
/// only lands on the real value when it sits at offset 0. `refs` follows,
|
||||
/// located via field access (never a raw offset) by the addref/release
|
||||
/// thunks. `#[repr(C)]` locks the layout so those direct casts are sound.
|
||||
// CPP-PARITY: matches `include/common/handle.h` where `ctx` is an opaque
|
||||
// pointer; the C side never dereferences it, so this layout is private to
|
||||
// this crate.
|
||||
#[repr(C)]
|
||||
pub struct RefBox<T: Sized> {
|
||||
/// Boxed value (at offset 0 — see module doc).
|
||||
pub value: T,
|
||||
/// Atomic reference count.
|
||||
pub refs: AtomicU32,
|
||||
}
|
||||
|
||||
/// The shared ABI value-handle type (single-lib unification, see
|
||||
/// `docs/zh/plans/riir/single-lib.md`): one canonical
|
||||
/// `{ctx, addref, release, abi_version}` type in `oakcore-rs`, re-exported
|
||||
/// here so the crate's `ffi.rs` signatures and handle scaffolding stay
|
||||
/// source-compatible.
|
||||
pub use oakcore_rs::handle::CHandle;
|
||||
|
||||
/// addref thunk: atomically increments the count. Shared by owned and
|
||||
/// borrowed boxes — for a borrowed handle addref only extends the life of
|
||||
/// the box, not of the borrowed object.
|
||||
unsafe extern "C" fn refbox_addref<T: Send + Sized + 'static>(ctx: *mut std::ffi::c_void) {
|
||||
unsafe {
|
||||
let rb = ctx as *const RefBox<T>;
|
||||
// Caller guarantees the handle is alive (ctx non-null, not yet
|
||||
// released) for the duration of the borrow.
|
||||
(*rb).refs.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// release thunk (owned): atomically decrements; at zero the box and its
|
||||
/// value are destroyed.
|
||||
unsafe extern "C" fn refbox_release_owned<T: Send + Sized + 'static>(ctx: *mut std::ffi::c_void) {
|
||||
unsafe {
|
||||
let rb = ctx as *mut RefBox<T>;
|
||||
// AcqRel: the side that reaches zero must observe all writes made
|
||||
// before the final release (including internal state the value's
|
||||
// destructor needs).
|
||||
if (*rb).refs.fetch_sub(1, Ordering::AcqRel) == 1 {
|
||||
drop(Box::from_raw(rb));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// release thunk (borrowed, produced by [`make_borrowed`]): at zero only
|
||||
/// the box allocation is reclaimed; the value inside is forgotten — its
|
||||
/// ownership remains with the borrower.
|
||||
unsafe extern "C" fn refbox_release_borrowed<T: Send + Sized + 'static>(ctx: *mut std::ffi::c_void) {
|
||||
unsafe {
|
||||
let rb = ctx as *mut RefBox<T>;
|
||||
if (*rb).refs.fetch_sub(1, Ordering::AcqRel) == 1 {
|
||||
// Partial move: move the value out of a temporary Box, then
|
||||
// forget it so the Box drop only frees the allocation and the
|
||||
// value's destructor never runs (double-free guard).
|
||||
std::mem::forget((Box::from_raw(rb)).value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Owned handle with count 1; empty on allocation failure.
|
||||
pub fn make_owned<T: Send + Sized + 'static>(value: T) -> CHandle {
|
||||
let rb = Box::into_raw(Box::new(RefBox {
|
||||
refs: AtomicU32::new(1),
|
||||
value,
|
||||
}));
|
||||
CHandle {
|
||||
ctx: rb as *mut std::ffi::c_void,
|
||||
addref: Some(refbox_addref::<T>),
|
||||
release: Some(refbox_release_owned::<T>),
|
||||
abi_version: OAKCOMMON_ABI_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
/// Borrowed handle for an object owned elsewhere (release frees only
|
||||
/// the box).
|
||||
///
|
||||
/// The value is bit-copied into the box ("borrowing copy"); the box never
|
||||
/// runs the value's destructor — the borrower owns the original object
|
||||
/// and is responsible for destroying it.
|
||||
///
|
||||
/// # Safety
|
||||
/// Caller guarantees `ptr` outlives every derived handle and that its
|
||||
/// value is neither moved nor destroyed for the duration of the borrow.
|
||||
pub unsafe fn make_borrowed<T: Send + Sized + 'static>(ptr: *mut T) -> CHandle {
|
||||
if ptr.is_null() {
|
||||
return CHandle::null();
|
||||
}
|
||||
let rb = Box::into_raw(Box::new(RefBox {
|
||||
refs: AtomicU32::new(1),
|
||||
value: unsafe { std::ptr::read(ptr) },
|
||||
}));
|
||||
CHandle {
|
||||
ctx: rb as *mut std::ffi::c_void,
|
||||
addref: Some(refbox_addref::<T>),
|
||||
release: Some(refbox_release_borrowed::<T>),
|
||||
abi_version: OAKCOMMON_ABI_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed view into a handle; `None` for empty handles.
|
||||
///
|
||||
/// # Safety
|
||||
/// `T` must be the boxed type.
|
||||
pub unsafe fn get<T: Sized + 'static>(h: &CHandle) -> Option<&T> {
|
||||
if h.is_null() {
|
||||
return None;
|
||||
}
|
||||
unsafe { Some(&(*(h.ctx as *const RefBox<T>)).value) }
|
||||
}
|
||||
|
||||
/// Mutable typed view into a handle; `None` for empty handles.
|
||||
///
|
||||
/// # Safety
|
||||
/// `T` must be the boxed type, and the caller must not alias the returned
|
||||
/// reference with any other live reference into the same handle.
|
||||
pub unsafe fn get_mut<T: Sized + 'static>(h: &CHandle) -> Option<&mut T> {
|
||||
if h.is_null() {
|
||||
return None;
|
||||
}
|
||||
unsafe { Some(&mut (*(h.ctx as *mut RefBox<T>)).value) }
|
||||
}
|
||||
|
||||
/// Panic-catching FFI wrapper for i32-returning exports.
|
||||
pub fn guard<F: FnOnce() -> crate::error::Result<()>>(f: F) -> i32 {
|
||||
match catch_unwind(AssertUnwindSafe(f)) {
|
||||
Ok(Ok(())) => crate::error::OAKCOMMON_OK,
|
||||
Ok(Err(e)) => e.code(),
|
||||
Err(_) => crate::error::OAKCOMMON_E_FAILED,
|
||||
}
|
||||
}
|
||||
|
||||
/// Panic-catching FFI wrapper for handle-returning exports.
|
||||
pub fn guard_handle<F: FnOnce() -> crate::error::Result<CHandle>>(f: F) -> CHandle {
|
||||
match catch_unwind(AssertUnwindSafe(f)) {
|
||||
Ok(Ok(h)) => h,
|
||||
Ok(Err(_)) | Err(_) => CHandle::null(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Panic-catching FFI wrapper for void exports.
|
||||
pub fn guard_void<F: FnOnce()>(f: F) {
|
||||
let _ = catch_unwind(AssertUnwindSafe(f));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Test payload that counts how many times its destructor ran.
|
||||
struct DropCounter {
|
||||
/// Shared counter bumped by `Drop::drop`.
|
||||
drops: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl DropCounter {
|
||||
/// A new counter plus its shared tally.
|
||||
fn new() -> (Self, Arc<AtomicUsize>) {
|
||||
let drops = Arc::new(AtomicUsize::new(0));
|
||||
(
|
||||
DropCounter {
|
||||
drops: Arc::clone(&drops),
|
||||
},
|
||||
drops,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DropCounter {
|
||||
fn drop(&mut self) {
|
||||
self.drops.fetch_add(1, AtomicOrdering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the refcount behind a handle (test-only peek).
|
||||
unsafe fn refs_of<T: Send + Sized + 'static>(h: &CHandle) -> u32 {
|
||||
unsafe { (*(h.ctx as *const RefBox<T>)).refs.load(Ordering::Relaxed) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_handle_is_null_and_stamped() {
|
||||
let h = CHandle::null();
|
||||
assert!(h.is_null());
|
||||
assert!(h.ctx.is_null());
|
||||
assert!(h.addref.is_none());
|
||||
assert!(h.release.is_none());
|
||||
// The shared `null()` stamps no ABI version (single-lib
|
||||
// unification; `make_owned` stamps the crate version).
|
||||
assert_eq!(h.abi_version, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_owned_starts_at_one_ref_and_exposes_value() {
|
||||
let (value, drops) = DropCounter::new();
|
||||
let h = make_owned(value);
|
||||
assert!(!h.is_null());
|
||||
assert_eq!(h.abi_version, OAKCOMMON_ABI_VERSION);
|
||||
assert!(h.addref.is_some());
|
||||
assert!(h.release.is_some());
|
||||
unsafe {
|
||||
assert_eq!(refs_of::<DropCounter>(&h), 1);
|
||||
// get() sees the boxed value.
|
||||
let v: &DropCounter = get::<DropCounter>(&h).unwrap();
|
||||
assert_eq!(v.drops.load(AtomicOrdering::SeqCst), 0);
|
||||
(h.release.unwrap())(h.ctx);
|
||||
}
|
||||
// Releasing the last ref destroyed the box and ran the destructor.
|
||||
assert_eq!(drops.load(AtomicOrdering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owned_addref_release_balance_then_drop_at_zero() {
|
||||
let (value, drops) = DropCounter::new();
|
||||
let h = make_owned(value);
|
||||
unsafe {
|
||||
(h.addref.unwrap())(h.ctx);
|
||||
(h.addref.unwrap())(h.ctx);
|
||||
assert_eq!(refs_of::<DropCounter>(&h), 3);
|
||||
|
||||
(h.release.unwrap())(h.ctx);
|
||||
assert_eq!(refs_of::<DropCounter>(&h), 2);
|
||||
assert_eq!(drops.load(AtomicOrdering::SeqCst), 0);
|
||||
(h.release.unwrap())(h.ctx);
|
||||
assert_eq!(refs_of::<DropCounter>(&h), 1);
|
||||
assert_eq!(drops.load(AtomicOrdering::SeqCst), 0);
|
||||
(h.release.unwrap())(h.ctx);
|
||||
}
|
||||
// Final release to zero ran the destructor exactly once.
|
||||
assert_eq!(drops.load(AtomicOrdering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn borrowed_release_to_zero_does_not_run_value_destructor() {
|
||||
let (value, drops) = DropCounter::new();
|
||||
let mut value = value;
|
||||
let h = unsafe { make_borrowed(&mut value) };
|
||||
assert!(!h.is_null());
|
||||
unsafe {
|
||||
assert_eq!(refs_of::<DropCounter>(&h), 1);
|
||||
(h.addref.unwrap())(h.ctx);
|
||||
assert_eq!(refs_of::<DropCounter>(&h), 2);
|
||||
(h.release.unwrap())(h.ctx);
|
||||
assert_eq!(refs_of::<DropCounter>(&h), 1);
|
||||
// Releasing the borrowed box to zero frees only the box.
|
||||
(h.release.unwrap())(h.ctx);
|
||||
}
|
||||
// The value's destructor must NOT have run; ownership stayed here.
|
||||
assert_eq!(drops.load(AtomicOrdering::SeqCst), 0);
|
||||
drop(value);
|
||||
assert_eq!(drops.load(AtomicOrdering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn borrowed_handle_sees_borrowed_value_contents() {
|
||||
let mut data: u64 = 0xdead_beef;
|
||||
let h = unsafe { make_borrowed(&mut data) };
|
||||
unsafe {
|
||||
let v: &u64 = get::<u64>(&h).unwrap();
|
||||
assert_eq!(*v, 0xdead_beef);
|
||||
(h.release.unwrap())(h.ctx);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_borrowed_null_ptr_yields_null_handle() {
|
||||
let h = unsafe { make_borrowed::<u64>(std::ptr::null_mut()) };
|
||||
assert!(h.is_null());
|
||||
assert!(h.addref.is_none());
|
||||
assert!(h.release.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_on_null_handle_is_none() {
|
||||
let h = CHandle::null();
|
||||
assert!(unsafe { get::<u64>(&h) }.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_returns_typed_view_of_owned_box() {
|
||||
let h = make_owned(String::from("hello"));
|
||||
unsafe {
|
||||
let s: &String = get::<String>(&h).unwrap();
|
||||
assert_eq!(s, "hello");
|
||||
(h.release.unwrap())(h.ctx);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guard_maps_result_to_status_code() {
|
||||
assert_eq!(guard(|| Ok(())), crate::error::OAKCOMMON_OK);
|
||||
assert_eq!(
|
||||
guard(|| Err(crate::error::Error::Invalid)),
|
||||
crate::error::OAKCOMMON_E_INVALID
|
||||
);
|
||||
assert_eq!(
|
||||
guard(|| Err(crate::error::Error::State)),
|
||||
crate::error::OAKCOMMON_E_STATE
|
||||
);
|
||||
assert_eq!(
|
||||
guard(|| Err(crate::error::Error::Failed("x".into()))),
|
||||
crate::error::OAKCOMMON_E_FAILED
|
||||
);
|
||||
assert_eq!(
|
||||
guard(|| Err(crate::error::Error::NotFound)),
|
||||
crate::error::OAKCOMMON_E_NOT_FOUND
|
||||
);
|
||||
assert_eq!(
|
||||
guard(|| Err(crate::error::Error::NoMem)),
|
||||
crate::error::OAKCOMMON_E_NOMEM
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guard_catches_panic_as_e_failed() {
|
||||
let code = guard(|| -> crate::error::Result<()> { panic!("kaboom") });
|
||||
assert_eq!(code, crate::error::OAKCOMMON_E_FAILED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guard_handle_passes_through_success() {
|
||||
let h = guard_handle(|| Ok(make_owned(42u32)));
|
||||
assert!(!h.is_null());
|
||||
unsafe {
|
||||
assert_eq!(*get::<u32>(&h).unwrap(), 42);
|
||||
(h.release.unwrap())(h.ctx);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guard_handle_maps_err_and_panic_to_null() {
|
||||
let h = guard_handle(|| Err(crate::error::Error::NoMem));
|
||||
assert!(h.is_null());
|
||||
let h = guard_handle(|| -> crate::error::Result<CHandle> { panic!("kaboom") });
|
||||
assert!(h.is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guard_void_runs_closure_and_swallows_panic() {
|
||||
let ran = Arc::new(AtomicUsize::new(0));
|
||||
let r = Arc::clone(&ran);
|
||||
guard_void(move || {
|
||||
r.fetch_add(1, AtomicOrdering::SeqCst);
|
||||
});
|
||||
assert_eq!(ran.load(AtomicOrdering::SeqCst), 1);
|
||||
// A panicking closure must not unwind across the FFI boundary.
|
||||
guard_void(|| panic!("kaboom"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// 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/>.
|
||||
|
||||
//! # oakcommon — shared utilities (Rust). Leaf module.
|
||||
//!
|
||||
//! Implements `include/common/*.h` verbatim. See README.md.
|
||||
|
||||
#![deny(unsafe_op_in_unsafe_fn)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
pub mod colortransform;
|
||||
pub mod commandlineparser;
|
||||
pub mod cancelatom;
|
||||
pub mod configstore;
|
||||
pub mod debug;
|
||||
pub mod error;
|
||||
pub mod ffmpegutils;
|
||||
pub mod ffi;
|
||||
pub mod filefunctions;
|
||||
pub mod handle;
|
||||
pub mod miscutils;
|
||||
pub mod ocioutils;
|
||||
pub mod oiioutils;
|
||||
pub mod qtutils;
|
||||
pub mod subtitleparams;
|
||||
pub mod videoparams;
|
||||
pub mod xmlutils;
|
||||
|
||||
/// Test-only helpers shared across unit-test modules.
|
||||
///
|
||||
/// Several domain test modules (e.g. `configstore`, `filefunctions`) mutate
|
||||
/// process-global state — notably the `OAK_CONFIG_DIR` environment variable
|
||||
/// and shared temp paths — while exercising configuration-location logic.
|
||||
/// Rust runs tests in parallel, so all such tests must serialize on a single
|
||||
/// process-wide lock to avoid racing each other across module boundaries.
|
||||
#[cfg(test)]
|
||||
#[doc(hidden)]
|
||||
pub mod test_support {
|
||||
use std::sync::Mutex;
|
||||
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Process-wide lock guarding tests that mutate global config/env state.
|
||||
///
|
||||
/// Hold this for the duration of any test (or helper) that sets/removes
|
||||
/// `OAK_CONFIG_DIR` or touches the shared configuration temp path.
|
||||
pub fn env_lock() -> &'static Mutex<()> {
|
||||
&ENV_LOCK
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
// 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/>.
|
||||
|
||||
//! Miscellaneous helpers, folding together several small `include/common`
|
||||
//! headers that share no handle: `miscutils.h` (decibel/lerp),
|
||||
//! `loopmode.h`, `dropworkflowbehavior.h`, `power.h`, `current.h`. Each
|
||||
//! public header still gets its own submodule in `crate::ffi` for C ABI
|
||||
//! completeness; this file holds all their domain types.
|
||||
|
||||
use std::ffi::c_void;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crate::error::Result;
|
||||
|
||||
/// Minimum decibel value used by the editor (`-200.0` dB).
|
||||
pub const DECIBEL_MINIMUM: f64 = -200.0;
|
||||
|
||||
/// Constant `-ln(0.01)` (`lo_g100`), shared by the logarithmic slider
|
||||
/// conversions in `olive::Decibel` (`src/common/src/decibel.h`).
|
||||
// CPP-PARITY: value copied verbatim from `olive::Decibel::lo_g100`.
|
||||
const DECIBEL_LO_G100: f64 = 4.60517018599;
|
||||
|
||||
/// Convert a linear amplitude to decibels (0.0 or infinite results yield
|
||||
/// [`DECIBEL_MINIMUM`]).
|
||||
pub fn decibel_from_linear(linear: f64) -> Result<f64> {
|
||||
// CPP-PARITY: `20.0 * std::log10(linear)`, returning `minimum` when the
|
||||
// result is infinite. The C++ never fails, so this always yields Ok.
|
||||
let v = 20.0 * linear.log10();
|
||||
if v.is_infinite() {
|
||||
Ok(DECIBEL_MINIMUM)
|
||||
} else {
|
||||
Ok(v)
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert decibels to a linear amplitude (results below `1e-6` clamp to
|
||||
/// 0.0).
|
||||
pub fn decibel_to_linear(db: f64) -> Result<f64> {
|
||||
// CPP-PARITY: `std::pow(10.0, db / 20.0)`, clamping < 1e-6 to 0.
|
||||
let v = 10.0_f64.powf(db / 20.0);
|
||||
if v < 0.000001 {
|
||||
Ok(0.0)
|
||||
} else {
|
||||
Ok(v)
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a logarithmic slider position (0..1) to decibels.
|
||||
pub fn decibel_from_logarithmic(logarithmic: f64) -> Result<f64> {
|
||||
// CPP-PARITY: matches `olive::Decibel::from_logarithmic` (branch
|
||||
// thresholds and `20.0*log10(-log(1-x)/lo_g100)`).
|
||||
if logarithmic < 0.001 {
|
||||
Ok(DECIBEL_MINIMUM)
|
||||
} else if logarithmic > 0.99 {
|
||||
Ok(0.0)
|
||||
} else {
|
||||
Ok(20.0 * (-(1.0 - logarithmic).ln() / DECIBEL_LO_G100).log10())
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert decibels to a logarithmic slider position (0..1).
|
||||
pub fn decibel_to_logarithmic(db: f64) -> Result<f64> {
|
||||
// CPP-PARITY: `1 - exp(-pow(10, db/20) * lo_g100)`, short-circuiting
|
||||
// `|db| <= 1e-12` to 1.
|
||||
if db.abs() <= 1e-12 {
|
||||
Ok(1.0)
|
||||
} else {
|
||||
Ok(1.0 - (-(10.0_f64.powf(db / 20.0)) * DECIBEL_LO_G100).exp())
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a linear amplitude directly to a logarithmic position.
|
||||
pub fn decibel_linear_to_logarithmic(linear: f64) -> Result<f64> {
|
||||
// CPP-PARITY: `1 - exp(-linear * lo_g100)`.
|
||||
Ok(1.0 - (-linear * DECIBEL_LO_G100).exp())
|
||||
}
|
||||
|
||||
/// Convert a logarithmic position directly to a linear amplitude.
|
||||
pub fn decibel_logarithmic_to_linear(logarithmic: f64) -> Result<f64> {
|
||||
// CPP-PARITY: `> 0.99 -> 1`, else `-log(1-x)/lo_g100`.
|
||||
if logarithmic > 0.99 {
|
||||
Ok(1.0)
|
||||
} else {
|
||||
Ok(-(1.0 - logarithmic).ln() / DECIBEL_LO_G100)
|
||||
}
|
||||
}
|
||||
|
||||
/// Linearly interpolate between `a` and `b` using `t` (`0.0` -> `a`,
|
||||
/// `1.0` -> `b`).
|
||||
pub fn lerp(a: f64, b: f64, t: f64) -> Result<f64> {
|
||||
// CPP-PARITY: `lerp` template `(a*(1.0 - t)) + (b*t)`.
|
||||
Ok(a * (1.0 - t) + b * t)
|
||||
}
|
||||
|
||||
/// Playback loop mode (`OakLoopMode`), mirroring `olive::LoopMode`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum LoopMode {
|
||||
/// Looping disabled.
|
||||
Off = 0,
|
||||
/// Loop playback.
|
||||
Loop = 1,
|
||||
/// Clamp at the end.
|
||||
Clamp = 2,
|
||||
}
|
||||
|
||||
/// Behavior when media is dropped onto a timeline without a sequence
|
||||
/// (`OakDropWorkflowBehavior`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DropWorkflowBehavior {
|
||||
/// Ask the user every time.
|
||||
Ask = 0,
|
||||
/// Automatically create a sequence.
|
||||
Auto = 1,
|
||||
/// Never create; import manually.
|
||||
Manual = 2,
|
||||
/// Disable dropping entirely.
|
||||
Disable = 3,
|
||||
}
|
||||
|
||||
impl DropWorkflowBehavior {
|
||||
/// Whether `value` is a valid behavior.
|
||||
pub fn is_valid(value: i32) -> bool {
|
||||
// CPP-PARITY: the C++ switch matches the four enumerators.
|
||||
matches!(value, 0..=3)
|
||||
}
|
||||
|
||||
/// Printable name ("UNKNOWN" for invalid values).
|
||||
pub fn name(value: i32) -> &'static str {
|
||||
// CPP-PARITY: exact strings from the c_api `behavior_name()`.
|
||||
match value {
|
||||
0 => "ASK",
|
||||
1 => "AUTO",
|
||||
2 => "MANUAL",
|
||||
3 => "DISABLE",
|
||||
_ => "UNKNOWN",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Round `value` up to the next power of two.
|
||||
pub fn power_ceil_to_power_of_2(value: u32) -> Result<u32> {
|
||||
// CPP-PARITY: bit-blast from `olive::ceil_to_power_of_2`. Uses wrapping
|
||||
// arithmetic so the decrement of 0 wraps to `u32::MAX` (and the final
|
||||
// increment of `u32::MAX` wraps to 0), exactly like C++ unsigned wraps.
|
||||
let mut v = value.wrapping_sub(1);
|
||||
v |= v >> 1;
|
||||
v |= v >> 2;
|
||||
v |= v >> 4;
|
||||
v |= v >> 8;
|
||||
v |= v >> 16;
|
||||
Ok(v.wrapping_add(1))
|
||||
}
|
||||
|
||||
/// Round `value` down to the nearest power of two.
|
||||
pub fn power_floor_to_power_of_2(value: u32) -> Result<u32> {
|
||||
// CPP-PARITY: bit-blast from `olive::floor_to_power_of_2`.
|
||||
let mut x = value;
|
||||
x = x | (x >> 1);
|
||||
x = x | (x >> 2);
|
||||
x = x | (x >> 4);
|
||||
x = x | (x >> 8);
|
||||
x = x | (x >> 16);
|
||||
Ok(x - (x >> 1))
|
||||
}
|
||||
|
||||
/// Destructor callback for objects handed to Current slots.
|
||||
pub type DestroyFn = Option<unsafe extern "C" fn(*mut c_void)>;
|
||||
|
||||
/// One opaque slot value plus its destructor, mirroring a C++
|
||||
/// `std::shared_ptr<void>` held by `Current`.
|
||||
struct Slot {
|
||||
/// Opaque external object pointer (may be null = empty slot).
|
||||
ptr: *mut c_void,
|
||||
/// Optional destructor invoked when the slot is replaced or cleared.
|
||||
destroy: DestroyFn,
|
||||
}
|
||||
|
||||
impl Slot {
|
||||
/// An empty slot.
|
||||
fn empty() -> Self {
|
||||
Self {
|
||||
ptr: std::ptr::null_mut(),
|
||||
destroy: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `*mut c_void` is neither Send nor Sync; the singleton serialises all
|
||||
// slot access behind a `Mutex`, so promising Send+Sync for the guarded
|
||||
// `Slot` is sound.
|
||||
// CPP-PARITY: the C++ `Current` uses `std::shared_ptr` (which is
|
||||
// thread-safe) but does not itself lock; Rust guards each slot with a
|
||||
// `Mutex` for sound `Send`/`Sync`.
|
||||
unsafe impl Send for Slot {}
|
||||
unsafe impl Sync for Slot {}
|
||||
|
||||
/// The process-wide `Current` singleton (see `include/common/current.h`).
|
||||
/// `ctx` points to a statically allocated object that lives until process
|
||||
/// exit; addref/release are no-ops. Slots hold opaque external objects with
|
||||
/// optional destructors.
|
||||
pub struct Current {
|
||||
/// Video params slot.
|
||||
video_params: Mutex<Slot>,
|
||||
/// Audio params slot.
|
||||
audio_params: Mutex<Slot>,
|
||||
/// Plugin host slot.
|
||||
plugin_host: Mutex<Slot>,
|
||||
/// Plugin cache slot.
|
||||
plugin_cache: Mutex<Slot>,
|
||||
/// Whether the session is interactive.
|
||||
is_interactive: bool,
|
||||
}
|
||||
|
||||
impl Current {
|
||||
/// The process-wide singleton.
|
||||
pub fn instance() -> &'static Current {
|
||||
// CPP-PARITY: mirrors `Current::get_instance()` returning a
|
||||
// process-wide static. `OnceLock` gives the same lazy,
|
||||
// thread-safe single-instance guarantee.
|
||||
static INSTANCE: OnceLock<Current> = OnceLock::new();
|
||||
INSTANCE.get_or_init(|| Current {
|
||||
video_params: Mutex::new(Slot::empty()),
|
||||
audio_params: Mutex::new(Slot::empty()),
|
||||
plugin_host: Mutex::new(Slot::empty()),
|
||||
plugin_cache: Mutex::new(Slot::empty()),
|
||||
is_interactive: true,
|
||||
})
|
||||
}
|
||||
|
||||
/// Replace a slot's occupant, destroying the previous one if it had a
|
||||
/// destructor.
|
||||
fn set_slot(slot: &Mutex<Slot>, obj: *mut c_void, destroy: DestroyFn) -> Result<()> {
|
||||
// Recover the guard if a previous panic poisoned the mutex; the
|
||||
// slot contents remain valid.
|
||||
let mut s = slot.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let old = std::mem::replace(&mut *s, Slot { ptr: obj, destroy });
|
||||
// CPP-PARITY: the old `shared_ptr`'s refcount drops to zero when it
|
||||
// is replaced, invoking its deleter (the stored `destroy`). A
|
||||
// previously stored NULL destroy was a no-op deleter, so nothing
|
||||
// runs then either.
|
||||
if !old.ptr.is_null() {
|
||||
if let Some(d) = old.destroy {
|
||||
// Safety: the destructor was supplied by the caller of the
|
||||
// matching `set_*` and owns the pointer it is given.
|
||||
unsafe { d(old.ptr) };
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch a slot's raw occupant pointer (borrowed).
|
||||
fn get_slot(slot: &Mutex<Slot>) -> Result<*mut c_void> {
|
||||
let s = slot.lock().unwrap_or_else(|e| e.into_inner());
|
||||
Ok(s.ptr)
|
||||
}
|
||||
|
||||
/// Store a pointer in the video-params slot, taking over destruction.
|
||||
pub fn set_video_params(&self, obj: *mut c_void, destroy: DestroyFn) -> Result<()> {
|
||||
Self::set_slot(&self.video_params, obj, destroy)
|
||||
}
|
||||
|
||||
/// Store a pointer in the audio-params slot.
|
||||
pub fn set_audio_params(&self, obj: *mut c_void, destroy: DestroyFn) -> Result<()> {
|
||||
Self::set_slot(&self.audio_params, obj, destroy)
|
||||
}
|
||||
|
||||
/// Store a pointer in the plugin-host slot.
|
||||
pub fn set_plugin_host(&self, obj: *mut c_void, destroy: DestroyFn) -> Result<()> {
|
||||
Self::set_slot(&self.plugin_host, obj, destroy)
|
||||
}
|
||||
|
||||
/// Store a pointer in the plugin-cache slot.
|
||||
pub fn set_plugin_cache(&self, obj: *mut c_void, destroy: DestroyFn) -> Result<()> {
|
||||
Self::set_slot(&self.plugin_cache, obj, destroy)
|
||||
}
|
||||
|
||||
/// Fetch the video-params slot.
|
||||
pub fn get_video_params(&self) -> Result<*mut c_void> {
|
||||
Self::get_slot(&self.video_params)
|
||||
}
|
||||
|
||||
/// Fetch the audio-params slot.
|
||||
pub fn get_audio_params(&self) -> Result<*mut c_void> {
|
||||
Self::get_slot(&self.audio_params)
|
||||
}
|
||||
|
||||
/// Fetch the plugin-host slot.
|
||||
pub fn get_plugin_host(&self) -> Result<*mut c_void> {
|
||||
Self::get_slot(&self.plugin_host)
|
||||
}
|
||||
|
||||
/// Fetch the plugin-cache slot.
|
||||
pub fn get_plugin_cache(&self) -> Result<*mut c_void> {
|
||||
Self::get_slot(&self.plugin_cache)
|
||||
}
|
||||
|
||||
/// Whether the session is interactive.
|
||||
pub fn is_interactive(&self) -> Result<bool> {
|
||||
// CPP-PARITY: `Current::interactive()` is hardcoded to `true` and
|
||||
// is never mutated, so the stored flag stays true.
|
||||
Ok(self.is_interactive)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::ffi::c_void;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn decibel_from_linear_known_values() {
|
||||
assert_eq!(decibel_from_linear(1.0).unwrap(), 0.0);
|
||||
assert!((decibel_from_linear(10.0).unwrap() - 20.0).abs() < 1e-12);
|
||||
assert!((decibel_from_linear(100.0).unwrap() - 40.0).abs() < 1e-12);
|
||||
// Zero / negative-infinite log10 clamps to minimum.
|
||||
assert_eq!(decibel_from_linear(0.0).unwrap(), DECIBEL_MINIMUM);
|
||||
// Negative input yields NaN in both C++ and Rust (no clamp).
|
||||
assert!(decibel_from_linear(-1.0).unwrap().is_nan());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decibel_to_linear_known_values() {
|
||||
assert_eq!(decibel_to_linear(0.0).unwrap(), 1.0);
|
||||
assert!((decibel_to_linear(20.0).unwrap() - 10.0).abs() < 1e-12);
|
||||
// Well below -120 dB clamps to 0; exactly 1e-6 does not.
|
||||
assert_eq!(decibel_to_linear(-200.0).unwrap(), 0.0);
|
||||
assert_eq!(decibel_to_linear(-120.0).unwrap(), 0.000001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decibel_logarithmic_branch_thresholds() {
|
||||
// Very small positions clamp to minimum; >0.99 clamp to 0 dB.
|
||||
assert_eq!(decibel_from_logarithmic(0.0).unwrap(), DECIBEL_MINIMUM);
|
||||
assert_eq!(decibel_from_logarithmic(1.0).unwrap(), 0.0);
|
||||
// Mid-range is a real value.
|
||||
let db = decibel_from_logarithmic(0.5).unwrap();
|
||||
assert!(db.is_finite());
|
||||
// to_logarithmic clamps |db| <= 1e-12 to 1.0.
|
||||
assert_eq!(decibel_to_logarithmic(0.0).unwrap(), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decibel_round_trips() {
|
||||
// linear <-> logarithmic round trip (within float tolerance).
|
||||
for linear in [0.001, 0.01, 0.1, 0.5, 0.9, 0.99] {
|
||||
let log = decibel_linear_to_logarithmic(linear).unwrap();
|
||||
let back = decibel_logarithmic_to_linear(log).unwrap();
|
||||
assert!((back - linear).abs() < 1e-6, "linear {} -> {} -> {}", linear, log, back);
|
||||
}
|
||||
// db <-> logarithmic round trip. Only non-positive db are reversible:
|
||||
// a positive db pushes the logarithmic position past 0.99, which the
|
||||
// C++ `from_logarithmic` intentionally clamps back to 0 dB.
|
||||
for db in [-60.0, -30.0, -12.0, -6.0, -3.0, -1.0] {
|
||||
let log = decibel_to_logarithmic(db).unwrap();
|
||||
let back = decibel_from_logarithmic(log).unwrap();
|
||||
assert!((back - db).abs() < 1e-3, "db {} -> {} -> {}", db, log, back);
|
||||
}
|
||||
// A positive db saturates the logarithmic position > 0.99 and comes
|
||||
// back as 0 dB (faithful to the C++ clamp).
|
||||
let log6 = decibel_to_logarithmic(6.0).unwrap();
|
||||
assert!(log6 > 0.99);
|
||||
assert_eq!(decibel_from_logarithmic(log6).unwrap(), 0.0);
|
||||
// logarithmic_to_linear clamps >0.99 to 1.
|
||||
assert_eq!(decibel_logarithmic_to_linear(0.999).unwrap(), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lerp_matches_cpp() {
|
||||
assert_eq!(lerp(0.0, 10.0, 0.0).unwrap(), 0.0);
|
||||
assert_eq!(lerp(0.0, 10.0, 1.0).unwrap(), 10.0);
|
||||
assert_eq!(lerp(0.0, 10.0, 0.5).unwrap(), 5.0);
|
||||
assert_eq!(lerp(2.0, 4.0, 0.25).unwrap(), 2.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loop_mode_discriminants_match_cpp() {
|
||||
// Values are load-bearing across the C ABI (include/common/loopmode.h
|
||||
// and src/common/src/loopmode.h).
|
||||
assert_eq!(LoopMode::Off as i32, 0);
|
||||
assert_eq!(LoopMode::Loop as i32, 1);
|
||||
assert_eq!(LoopMode::Clamp as i32, 2);
|
||||
// Copy/Clone/Eq semantics of a plain enum.
|
||||
let a = LoopMode::Loop;
|
||||
let b = a;
|
||||
assert_eq!(a, b);
|
||||
assert_ne!(LoopMode::Off, LoopMode::Clamp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_workflow_behavior_discriminants_match_cpp() {
|
||||
// The config layer persists these as ints (enum OakDropWorkflowBehavior).
|
||||
assert_eq!(DropWorkflowBehavior::Ask as i32, 0);
|
||||
assert_eq!(DropWorkflowBehavior::Auto as i32, 1);
|
||||
assert_eq!(DropWorkflowBehavior::Manual as i32, 2);
|
||||
assert_eq!(DropWorkflowBehavior::Disable as i32, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_workflow_behavior() {
|
||||
for (v, expected_valid) in [(0, true), (1, true), (2, true), (3, true)] {
|
||||
assert_eq!(DropWorkflowBehavior::is_valid(v), expected_valid);
|
||||
}
|
||||
assert!(!DropWorkflowBehavior::is_valid(-1));
|
||||
assert!(!DropWorkflowBehavior::is_valid(4));
|
||||
|
||||
assert_eq!(DropWorkflowBehavior::name(0), "ASK");
|
||||
assert_eq!(DropWorkflowBehavior::name(1), "AUTO");
|
||||
assert_eq!(DropWorkflowBehavior::name(2), "MANUAL");
|
||||
assert_eq!(DropWorkflowBehavior::name(3), "DISABLE");
|
||||
assert_eq!(DropWorkflowBehavior::name(99), "UNKNOWN");
|
||||
assert_eq!(DropWorkflowBehavior::name(-5), "UNKNOWN");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn power_of_two() {
|
||||
// ceil
|
||||
assert_eq!(power_ceil_to_power_of_2(1).unwrap(), 1);
|
||||
assert_eq!(power_ceil_to_power_of_2(2).unwrap(), 2);
|
||||
assert_eq!(power_ceil_to_power_of_2(3).unwrap(), 4);
|
||||
assert_eq!(power_ceil_to_power_of_2(5).unwrap(), 8);
|
||||
assert_eq!(power_ceil_to_power_of_2(8).unwrap(), 8);
|
||||
assert_eq!(power_ceil_to_power_of_2(0).unwrap(), 0);
|
||||
// floor
|
||||
assert_eq!(power_floor_to_power_of_2(1).unwrap(), 1);
|
||||
assert_eq!(power_floor_to_power_of_2(2).unwrap(), 2);
|
||||
assert_eq!(power_floor_to_power_of_2(5).unwrap(), 4);
|
||||
assert_eq!(power_floor_to_power_of_2(9).unwrap(), 8);
|
||||
assert_eq!(power_floor_to_power_of_2(8).unwrap(), 8);
|
||||
assert_eq!(power_floor_to_power_of_2(0).unwrap(), 0);
|
||||
// large value: 0x8000_0001 saturates the bit-blast to u32::MAX, then
|
||||
// the final increment wraps to 0 (matching C++ unsigned overflow).
|
||||
assert_eq!(power_ceil_to_power_of_2(0x8000_0001).unwrap(), 0u32);
|
||||
assert_eq!(power_floor_to_power_of_2(0x8000_0000).unwrap(), 0x8000_0000);
|
||||
}
|
||||
|
||||
/// Per-process destroy counter used by the Current singleton test.
|
||||
static DESTROY_COUNT: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
/// Serialises tests that touch the process-wide `Current` singleton
|
||||
/// (cargo runs tests on threads).
|
||||
static CURRENT_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// A `DestroyFn` that bumps [`DESTROY_COUNT`].
|
||||
unsafe extern "C" fn count_destroy(_p: *mut c_void) {
|
||||
DESTROY_COUNT.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_slot_semantics() {
|
||||
let _guard = CURRENT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// Only the video-params slot is used here; the singleton is shared
|
||||
// across tests, so keep each test on its own slot to avoid races.
|
||||
let cur = Current::instance();
|
||||
assert!(cur.is_interactive().unwrap());
|
||||
|
||||
DESTROY_COUNT.store(0, Ordering::SeqCst);
|
||||
|
||||
// Empty initially.
|
||||
assert!(cur.get_video_params().unwrap().is_null());
|
||||
|
||||
// Store with a destructor.
|
||||
let p1 = 0x1 as *mut c_void;
|
||||
assert!(cur.set_video_params(p1, Some(count_destroy)).is_ok());
|
||||
assert_eq!(cur.get_video_params().unwrap(), p1);
|
||||
|
||||
// Replacing destroys the previous occupant.
|
||||
let p2 = 0x2 as *mut c_void;
|
||||
assert!(cur.set_video_params(p2, Some(count_destroy)).is_ok());
|
||||
assert_eq!(cur.get_video_params().unwrap(), p2);
|
||||
assert_eq!(DESTROY_COUNT.load(Ordering::SeqCst), 1);
|
||||
|
||||
// Storing NULL clears and destroys the prior occupant.
|
||||
assert!(cur.set_video_params(std::ptr::null_mut(), None).is_ok());
|
||||
assert!(cur.get_video_params().unwrap().is_null());
|
||||
assert_eq!(DESTROY_COUNT.load(Ordering::SeqCst), 2);
|
||||
|
||||
// Clearing when the slot is already empty does nothing.
|
||||
assert!(cur.set_video_params(std::ptr::null_mut(), None).is_ok());
|
||||
assert_eq!(DESTROY_COUNT.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_slots_independent() {
|
||||
let _guard = CURRENT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let cur = Current::instance();
|
||||
let pa = 0x10 as *mut c_void;
|
||||
let ph = 0x20 as *mut c_void;
|
||||
let pc = 0x30 as *mut c_void;
|
||||
// Each slot is independent; no cross-slot interference.
|
||||
assert!(cur.set_audio_params(pa, None).is_ok());
|
||||
assert!(cur.set_plugin_host(ph, None).is_ok());
|
||||
assert!(cur.set_plugin_cache(pc, None).is_ok());
|
||||
assert_eq!(cur.get_audio_params().unwrap(), pa);
|
||||
assert_eq!(cur.get_plugin_host().unwrap(), ph);
|
||||
assert_eq!(cur.get_plugin_cache().unwrap(), pc);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
// 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/>.
|
||||
|
||||
//! OpenColorIO utility queries, mirroring `src/common/src/ocioutils.h`
|
||||
//! and `include/common/ocioutils.h`. Also the canonical home of
|
||||
//! [`PixelFormat`], which other modules reuse (mirrors
|
||||
//! `olive::core::PixelFormat`). The object is stateless; the handle only
|
||||
//! satisfies the C API lifetime contract.
|
||||
//!
|
||||
//! Real OCIO access — config loading, color-space/role enumeration,
|
||||
//! display/view resolution and RGBA CPU transforms — is provided by
|
||||
//! [`OcioConfig`] / [`OcioProcessor`], thin wrappers over the crates.io
|
||||
//! `ocio-rs` bindings. The bit-depth mapping in
|
||||
//! [`OCIOUtils::get_ocio_bit_depth_from_pixel_format`] reads from the real
|
||||
//! `ocio_rs::BitDepth` enum rather than hard-coded constants.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use ocio_rs::TransformDirection;
|
||||
|
||||
/// Native pixel format codes, mirroring `olive::core::PixelFormat`. Values
|
||||
/// are load-bearing (they cross the C ABI as ints) and must stay in sync
|
||||
/// with `olive/core/render/pixelformat.h`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PixelFormat {
|
||||
/// Invalid/unknown format.
|
||||
Invalid = -1,
|
||||
/// 8-bit unsigned integer.
|
||||
U8 = 0,
|
||||
/// 10-bit unsigned integer.
|
||||
U10 = 1,
|
||||
/// 16-bit unsigned integer.
|
||||
U16 = 2,
|
||||
/// 16-bit float (half).
|
||||
F16 = 3,
|
||||
/// 32-bit float.
|
||||
F32 = 4,
|
||||
/// Sentinel, not a valid format.
|
||||
Count = 5,
|
||||
}
|
||||
|
||||
impl PixelFormat {
|
||||
/// One of [`PixelFormat`] for an integer code (invalid/unknown codes map
|
||||
/// to `Invalid`).
|
||||
pub fn from_code(code: i32) -> PixelFormat {
|
||||
match code {
|
||||
-1 => PixelFormat::Invalid,
|
||||
0 => PixelFormat::U8,
|
||||
1 => PixelFormat::U10,
|
||||
2 => PixelFormat::U16,
|
||||
3 => PixelFormat::F16,
|
||||
4 => PixelFormat::F32,
|
||||
5 => PixelFormat::Count,
|
||||
_ => PixelFormat::Invalid,
|
||||
}
|
||||
}
|
||||
|
||||
/// The integer code.
|
||||
pub fn code(self) -> i32 {
|
||||
self as i32
|
||||
}
|
||||
}
|
||||
|
||||
/// The OCIO utils family (stateless).
|
||||
pub struct OCIOUtils;
|
||||
|
||||
impl OCIOUtils {
|
||||
/// Creates the OCIOUtils object.
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Map a native pixel format to an OCIO bit depth code (`0` unknown,
|
||||
/// `1` uint8, `2` uint10, `3` uint12, `4` uint14, `5` uint16,
|
||||
/// `6` uint32, `7` f16, `8` f32).
|
||||
///
|
||||
/// The return type is `Result` to match the C ABI surface, but the
|
||||
/// mapping is total (never fails); invalid/out-of-range formats yield
|
||||
/// `BIT_DEPTH_UNKNOWN = 0`.
|
||||
pub fn get_ocio_bit_depth_from_pixel_format(&self, pixel_format: PixelFormat) -> Result<i32> {
|
||||
// The codes are the real `ocio_rs::BitDepth` discriminants
|
||||
// (`#[repr(i32)]`): `Uint8=1`, `Uint10=2`, `Uint16=5`, `F16=7`,
|
||||
// `F32=8`, `Unknown=0`. CPP-PARITY: matches
|
||||
// `OCIOUtils::get_ocio_bit_depth_from_pixel_format` in
|
||||
// src/common/src/ocioutils.cpp, where these are the OCIO `BitDepth`
|
||||
// constants `BIT_DEPTH_UINT8` … `BIT_DEPTH_F32`. `u10` maps to uint10
|
||||
// (not uint12) and `u16` maps to uint16 (not uint14/uint32), exactly
|
||||
// as C++.
|
||||
let depth = match pixel_format {
|
||||
PixelFormat::U8 => ocio_rs::BitDepth::Uint8,
|
||||
PixelFormat::U10 => ocio_rs::BitDepth::Uint10,
|
||||
PixelFormat::U16 => ocio_rs::BitDepth::Uint16,
|
||||
PixelFormat::F16 => ocio_rs::BitDepth::F16,
|
||||
PixelFormat::F32 => ocio_rs::BitDepth::F32,
|
||||
PixelFormat::Invalid | PixelFormat::Count => ocio_rs::BitDepth::Unknown,
|
||||
};
|
||||
Ok(depth as i32)
|
||||
}
|
||||
}
|
||||
|
||||
/// An OCIO color-config file loaded from disk (see [`OcioConfig::from_file`]) or
|
||||
/// the built-in raw config (see [`OcioConfig::raw`]).
|
||||
pub struct OcioConfig {
|
||||
inner: ocio_rs::Config,
|
||||
}
|
||||
|
||||
/// An OCIO CPU processor that transforms a single RGBA pixel.
|
||||
pub struct OcioProcessor {
|
||||
inner: ocio_rs::CPUProcessor,
|
||||
}
|
||||
|
||||
// The wrapped `ocio-rs` objects own OCIO rcptrs that are immutable for the
|
||||
// lifetime of the objects; OCIO documents its config/processor objects as
|
||||
// safe to share for read-only use, and `ocio-rs` keeps all mutable error
|
||||
// state in thread-local storage. `ocio-rs` itself marks `Processor` and
|
||||
// `CPUProcessor` `Send`; `Config` holds a raw handle, so we claim `Send` and
|
||||
// `Sync` here for both wrapper types.
|
||||
unsafe impl Send for OcioConfig {}
|
||||
unsafe impl Sync for OcioConfig {}
|
||||
unsafe impl Send for OcioProcessor {}
|
||||
unsafe impl Sync for OcioProcessor {}
|
||||
|
||||
impl OcioConfig {
|
||||
/// Loads a config from an OCIO `.ocio` file.
|
||||
pub fn from_file(path: &str) -> Result<Self> {
|
||||
let inner = ocio_rs::Config::from_file(path)?;
|
||||
Ok(OcioConfig { inner })
|
||||
}
|
||||
|
||||
/// The built-in raw (identity) config — the OCIO 2.x replacement for the
|
||||
/// removed `CreateDefault`.
|
||||
pub fn raw() -> Result<Self> {
|
||||
let inner = ocio_rs::Config::raw()?;
|
||||
Ok(OcioConfig { inner })
|
||||
}
|
||||
|
||||
/// Number of color spaces registered in the config.
|
||||
pub fn colorspace_count(&self) -> Result<i32> {
|
||||
Ok(self.inner.num_color_spaces())
|
||||
}
|
||||
|
||||
/// Name of the color space at `index` (0-based).
|
||||
pub fn colorspace_name(&self, index: i32) -> Result<String> {
|
||||
self.inner.color_space_name_by_index(index).ok_or_else(|| {
|
||||
Error::new(format!(
|
||||
"OcioConfig::colorspace_name: no color space at index {index}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Names of all color spaces in config order.
|
||||
pub fn colorspaces(&self) -> Result<Vec<String>> {
|
||||
let count = self.colorspace_count()?;
|
||||
let mut out = Vec::with_capacity(count as usize);
|
||||
for i in 0..count {
|
||||
out.push(self.colorspace_name(i)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Number of roles registered in the config.
|
||||
pub fn role_count(&self) -> Result<i32> {
|
||||
Ok(self.inner.num_roles())
|
||||
}
|
||||
|
||||
/// Name of the role at `index` (0-based).
|
||||
pub fn role_name(&self, index: i32) -> Result<String> {
|
||||
self.inner.role_name(index).ok_or_else(|| {
|
||||
Error::new(format!("OcioConfig::role_name: no role at index {index}"))
|
||||
})
|
||||
}
|
||||
|
||||
/// Names of all roles in config order.
|
||||
pub fn roles(&self) -> Result<Vec<String>> {
|
||||
let count = self.role_count()?;
|
||||
let mut out = Vec::with_capacity(count as usize);
|
||||
for i in 0..count {
|
||||
out.push(self.role_name(i)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Whether the config defines `role`.
|
||||
pub fn has_role(&self, role: &str) -> Result<bool> {
|
||||
Ok(self.inner.has_role(role))
|
||||
}
|
||||
|
||||
/// Canonical name for a color space or role. Unknown names come back
|
||||
/// unchanged (OCIO's documented behavior).
|
||||
pub fn canonical_name(&self, name: &str) -> Result<String> {
|
||||
Ok(self
|
||||
.inner
|
||||
.canonical_name(name)
|
||||
.unwrap_or_else(|| name.to_string()))
|
||||
}
|
||||
|
||||
/// The config's default display name.
|
||||
pub fn default_display(&self) -> Result<String> {
|
||||
self.inner.default_display().ok_or_else(|| {
|
||||
Error::new("OcioConfig::default_display: config defines no displays")
|
||||
})
|
||||
}
|
||||
|
||||
/// The default view for `display`.
|
||||
pub fn default_view(&self, display: &str) -> Result<String> {
|
||||
self.inner.default_view(display).ok_or_else(|| {
|
||||
Error::new(format!(
|
||||
"OcioConfig::default_view: no default view for display '{display}'"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Builds a processor transforming `src` to `dst` (either may be a role
|
||||
/// name, an alias, or a color space name).
|
||||
pub fn processor(&self, src: &str, dst: &str) -> Result<OcioProcessor> {
|
||||
let processor = self.inner.processor(src, dst)?;
|
||||
Ok(OcioProcessor {
|
||||
inner: processor.default_cpu_processor()?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Builds a processor applying `src` through the display transform for
|
||||
/// `display`/`view` in the forward direction.
|
||||
pub fn display_processor(&self, src: &str, display: &str, view: &str) -> Result<OcioProcessor> {
|
||||
let processor = self
|
||||
.inner
|
||||
.processor_display(src, display, view, TransformDirection::Forward)?;
|
||||
Ok(OcioProcessor {
|
||||
inner: processor.default_cpu_processor()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl OcioProcessor {
|
||||
/// Applies the transform to one RGBA pixel in place (0..=1 floats).
|
||||
///
|
||||
/// OCIO applies the curve to all four channels; the alpha channel is not
|
||||
/// preserved verbatim, so callers must not assume `px[3]` is unchanged.
|
||||
pub fn apply_rgba(&self, pixel: &mut [f32; 4]) -> Result<()> {
|
||||
self.inner.try_apply_rgba(pixel)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for OcioConfig {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("OcioConfig").finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for OcioProcessor {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("OcioProcessor").finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pixel_format_from_code_maps_known_codes() {
|
||||
assert_eq!(PixelFormat::from_code(-1), PixelFormat::Invalid);
|
||||
assert_eq!(PixelFormat::from_code(0), PixelFormat::U8);
|
||||
assert_eq!(PixelFormat::from_code(1), PixelFormat::U10);
|
||||
assert_eq!(PixelFormat::from_code(2), PixelFormat::U16);
|
||||
assert_eq!(PixelFormat::from_code(3), PixelFormat::F16);
|
||||
assert_eq!(PixelFormat::from_code(4), PixelFormat::F32);
|
||||
assert_eq!(PixelFormat::from_code(5), PixelFormat::Count);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_format_from_code_maps_unknown_codes_to_invalid() {
|
||||
assert_eq!(PixelFormat::from_code(-2), PixelFormat::Invalid);
|
||||
assert_eq!(PixelFormat::from_code(6), PixelFormat::Invalid);
|
||||
assert_eq!(PixelFormat::from_code(100), PixelFormat::Invalid);
|
||||
assert_eq!(PixelFormat::from_code(i32::MIN), PixelFormat::Invalid);
|
||||
assert_eq!(PixelFormat::from_code(i32::MAX), PixelFormat::Invalid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_format_code_round_trips() {
|
||||
for fmt in [
|
||||
PixelFormat::Invalid,
|
||||
PixelFormat::U8,
|
||||
PixelFormat::U10,
|
||||
PixelFormat::U16,
|
||||
PixelFormat::F16,
|
||||
PixelFormat::F32,
|
||||
PixelFormat::Count,
|
||||
] {
|
||||
assert_eq!(PixelFormat::from_code(fmt.code()), fmt);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_format_codes_have_load_bearing_values() {
|
||||
assert_eq!(PixelFormat::Invalid.code(), -1);
|
||||
assert_eq!(PixelFormat::U8.code(), 0);
|
||||
assert_eq!(PixelFormat::U10.code(), 1);
|
||||
assert_eq!(PixelFormat::U16.code(), 2);
|
||||
assert_eq!(PixelFormat::F16.code(), 3);
|
||||
assert_eq!(PixelFormat::F32.code(), 4);
|
||||
assert_eq!(PixelFormat::Count.code(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_returns_a_value() {
|
||||
let utils = OCIOUtils::new();
|
||||
// Stateless; just confirm construction works and stays usable.
|
||||
assert_eq!(
|
||||
utils.get_ocio_bit_depth_from_pixel_format(PixelFormat::U8).unwrap(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ocio_bit_depth_maps_supported_formats() {
|
||||
let utils = OCIOUtils::new();
|
||||
assert_eq!(utils.get_ocio_bit_depth_from_pixel_format(PixelFormat::U8).unwrap(), 1);
|
||||
assert_eq!(utils.get_ocio_bit_depth_from_pixel_format(PixelFormat::U10).unwrap(), 2);
|
||||
assert_eq!(utils.get_ocio_bit_depth_from_pixel_format(PixelFormat::U16).unwrap(), 5);
|
||||
assert_eq!(utils.get_ocio_bit_depth_from_pixel_format(PixelFormat::F16).unwrap(), 7);
|
||||
assert_eq!(utils.get_ocio_bit_depth_from_pixel_format(PixelFormat::F32).unwrap(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ocio_bit_depth_maps_invalid_and_count_to_unknown() {
|
||||
let utils = OCIOUtils::new();
|
||||
assert_eq!(utils.get_ocio_bit_depth_from_pixel_format(PixelFormat::Invalid).unwrap(), 0);
|
||||
assert_eq!(utils.get_ocio_bit_depth_from_pixel_format(PixelFormat::Count).unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ocio_config_raw_round_trips() {
|
||||
// A real OCIO library call through the ocio-sys bridge that needs no
|
||||
// config file and no OCIO env var: the built-in raw (identity) config
|
||||
// always defines at least the "default" role, whose canonical color
|
||||
// space is named "raw".
|
||||
let config = OcioConfig::raw().expect("raw config should load");
|
||||
assert!(config.colorspace_count().expect("count should work") > 0);
|
||||
assert!(
|
||||
config.has_role("default").expect("has_role should work"),
|
||||
"raw config should define the default role"
|
||||
);
|
||||
let canonical = config.canonical_name("default").expect("canonical should work");
|
||||
assert_eq!(canonical, "raw");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
// 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/>.
|
||||
|
||||
//! OpenImageIO utility queries, mirroring `src/common/src/oiioutils.h`
|
||||
//! and `include/common/oiioutils.h`. Reuses [`crate::ocioutils::PixelFormat`]
|
||||
//! rather than redefining the pixel format codes. The object is stateless;
|
||||
//! the handle only satisfies the C API lifetime contract.
|
||||
//!
|
||||
//! The base-type mapping is derived from the `image` crate's own color-type
|
||||
//! tables (per-channel bit depth → OIIO `TypeDesc::BASETYPE` code), with the
|
||||
//! half-float case pinned from the frozen OIIO table since `image` 0.25 has
|
||||
//! no f16 sample type. Aspect-ratio conversion uses
|
||||
//! `oakcore_rs::Rational::from_double`, the C++ `Rational::from_double`
|
||||
//! port of FFmpeg's `av_d2q` (kept as a hand-written port rather than
|
||||
//! pulling in `ffmpeg-next` — see README decision 6). 32-bit float image I/O
|
||||
//! is provided by [`F32Image`] / [`read_image_f32`] / [`write_image_f32`],
|
||||
//! built on the `image` crate.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ocioutils::PixelFormat;
|
||||
use oakcore_rs::Rational;
|
||||
use image::{ExtendedColorType, ImageBuffer, Rgb, Rgba};
|
||||
|
||||
/// OIIO base type codes, matching `OIIO::TypeDesc::BASETYPE`.
|
||||
///
|
||||
/// Values cross the C ABI as plain ints. See the doc comment on
|
||||
/// `OakOIIOUtils` in `include/common/oiioutils.h`: 0 = UNKNOWN, 1 = NONE,
|
||||
/// 2 = UINT8, 3 = INT8, 4 = UINT16, 5 = INT16, 6 = UINT32, 7 = INT32,
|
||||
/// 8 = UINT64, 9 = INT64, 10 = HALF, 11 = FLOAT, 12 = DOUBLE, 13 = STRING,
|
||||
/// 14 = PTR (OIIO >= 2.5 additionally has 15 = USTRINGHASH).
|
||||
mod basetype {
|
||||
/// Unknown / unmappable base type.
|
||||
pub(crate) const UNKNOWN: i32 = 0;
|
||||
/// 8-bit unsigned integer.
|
||||
pub(crate) const UINT8: i32 = 2;
|
||||
/// 16-bit unsigned integer.
|
||||
pub(crate) const UINT16: i32 = 4;
|
||||
/// 16-bit float (half).
|
||||
pub(crate) const HALF: i32 = 10;
|
||||
/// 32-bit float.
|
||||
pub(crate) const FLOAT: i32 = 11;
|
||||
}
|
||||
|
||||
/// The OIIO utils family (stateless).
|
||||
pub struct OIIOUtils;
|
||||
|
||||
impl OIIOUtils {
|
||||
/// Creates the OIIOUtils object.
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Map a native pixel format to an OIIO base type code. On invalid or
|
||||
/// unmappable formats returns `Ok(TypeDesc::UNKNOWN = 0)`.
|
||||
///
|
||||
/// CPP-PARITY: matches `OIIOUtils::get_oiio_base_type_from_format`. The
|
||||
/// per-channel bit depth comes from the `image` crate's own color-type
|
||||
/// tables (see [`image_color_type_for`]); the bit depth is then mapped to
|
||||
/// the OIIO base-type codes frozen in `include/common/oiioutils.h`.
|
||||
/// `u10` has no image/OIIO representation and maps to UNKNOWN;
|
||||
/// `invalid`/`count` also fall through to UNKNOWN (the C++ `break`s out
|
||||
/// of the switch and returns UNKNOWN). `f16` is a documented exception:
|
||||
/// `image` 0.25 has no half-float sample type, so HALF is pinned from the
|
||||
/// frozen OIIO table.
|
||||
pub fn get_oiio_base_type_from_format(&self, pixel_format: PixelFormat) -> Result<i32> {
|
||||
let base_type = match image_color_type_for(pixel_format) {
|
||||
Some(color_type) => match bits_per_channel(color_type) {
|
||||
8 => basetype::UINT8,
|
||||
16 => basetype::UINT16,
|
||||
32 => basetype::FLOAT,
|
||||
// Unreachable for the mapped types (L8=8, L16=16, Rgb32F=32);
|
||||
// kept as a fallback for future mappings.
|
||||
_ => basetype::UNKNOWN,
|
||||
},
|
||||
None => match pixel_format {
|
||||
// image 0.25 has no f16 sample type (see the `// TODO f16
|
||||
// types?` note in image's color.rs), so HALF is pinned from
|
||||
// the frozen OIIO base-type table (CPP-PARITY).
|
||||
PixelFormat::F16 => basetype::HALF,
|
||||
_ => basetype::UNKNOWN,
|
||||
},
|
||||
};
|
||||
Ok(base_type)
|
||||
}
|
||||
|
||||
/// Map an OIIO base type code to a native pixel format. On unknown or
|
||||
/// unmappable base types returns `Ok(PixelFormat::Invalid)`; a negative
|
||||
/// base type is an error.
|
||||
///
|
||||
/// CPP-PARITY: matches `OIIOUtils::get_format_from_oiio_basetype`. The
|
||||
/// known-but-unmappable types (INT8/INT16/INT32/UINT32/INT64/UINT64/
|
||||
/// STRING/PTR/DOUBLE/LASTBASE) print to stderr in C++ and return
|
||||
/// `invalid`; here they all fall to `Ok(PixelFormat::Invalid)`. The
|
||||
/// `base_type < 0` error mirrors the `oakcommon_oiioutils_get_format_from_oiio_basetype`
|
||||
/// c_api guard; the `>= LASTBASE` upper-bound guard is likewise a c_api
|
||||
/// concern and is not replicated in the domain function.
|
||||
pub fn get_format_from_oiio_basetype(&self, base_type: i32) -> Result<PixelFormat> {
|
||||
if base_type < 0 {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
Ok(match base_type {
|
||||
basetype::UINT8 => PixelFormat::U8,
|
||||
basetype::UINT16 => PixelFormat::U16,
|
||||
basetype::HALF => PixelFormat::F16,
|
||||
basetype::FLOAT => PixelFormat::F32,
|
||||
_ => PixelFormat::Invalid,
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert a `PixelAspectRatio` attribute value to a reduced
|
||||
/// numerator/denominator pair.
|
||||
///
|
||||
/// CPP-PARITY: `olive::core::Rational::from_double`
|
||||
/// (`core/src/util/rational.cpp:39`) via oakcore-rs
|
||||
/// [`Rational::from_double`]. NaN and `|x| > INT_MAX + 3` yield the
|
||||
/// oracle's NaN rational `(0, 0)`; `0.0` reduces to `(0, 1)`. The
|
||||
/// c_api wrapper never fails for a valid `pixel_aspect_ratio`, so
|
||||
/// this always returns `Ok`.
|
||||
pub fn get_pixel_aspect_ratio(&self, pixel_aspect_ratio: f64) -> Result<(i32, i32)> {
|
||||
let r = Rational::from_double(pixel_aspect_ratio);
|
||||
// from_double caps the reduction at i32::MAX, so the cast is lossless.
|
||||
Ok((r.numerator() as i32, r.denominator() as i32))
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a native pixel format to the `image` crate's extended color type, or
|
||||
/// `None` when the format has no image representation.
|
||||
///
|
||||
/// CPP-PARITY: the per-channel bit depth drives
|
||||
/// [`OIIOUtils::get_oiio_base_type_from_format`]; this is the "which OIIO
|
||||
/// base type would `image` emit" pivot. `u8`/`u16` map to `L8`/`L16`, `f32` to
|
||||
/// `Rgb32F` (the `image` crate's only 32-bit float RGB layout; channel
|
||||
/// count is derived separately via [`bits_per_channel`]). `u10` has no
|
||||
/// `image`/OIIO representation, `f16` has no `image` sample type (the crate
|
||||
/// has no half float; see the `// TODO f16 types?` note in its color tables),
|
||||
/// and `invalid`/`count` fall through — all `None`.
|
||||
fn image_color_type_for(pixel_format: PixelFormat) -> Option<ExtendedColorType> {
|
||||
match pixel_format {
|
||||
PixelFormat::U8 => Some(ExtendedColorType::L8),
|
||||
PixelFormat::U10 => None,
|
||||
PixelFormat::U16 => Some(ExtendedColorType::L16),
|
||||
PixelFormat::F16 => None,
|
||||
PixelFormat::F32 => Some(ExtendedColorType::Rgb32F),
|
||||
PixelFormat::Invalid | PixelFormat::Count => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-channel bit depth of an `ExtendedColorType` (derived from the crate's
|
||||
/// own tables rather than a hand-maintained list).
|
||||
fn bits_per_channel(color_type: ExtendedColorType) -> u32 {
|
||||
// `ExtendedColorType` is `#[non_exhaustive]`; the mapped types are the
|
||||
// only ones fed in, so any new variant the crate adds would surface as a
|
||||
// `0` here and fall through to UNKNOWN upstream.
|
||||
let bits_per_pixel = color_type.bits_per_pixel();
|
||||
let channel_count = color_type.channel_count();
|
||||
if channel_count == 0 {
|
||||
return 0;
|
||||
}
|
||||
bits_per_pixel as u32 / channel_count as u32
|
||||
}
|
||||
|
||||
/// A decoded image stored as 32-bit float, packed row-major with `channels`
|
||||
/// interleaved values per pixel.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct F32Image {
|
||||
/// Image width in pixels.
|
||||
pub width: i32,
|
||||
/// Image height in pixels.
|
||||
pub height: i32,
|
||||
/// Interleaved values per pixel (1 = luma, 2 = luma+alpha, 3 = RGB,
|
||||
/// 4 = RGBA).
|
||||
pub channels: i32,
|
||||
/// The interleaved float pixel data.
|
||||
pub pixels: Vec<f32>,
|
||||
}
|
||||
|
||||
impl F32Image {
|
||||
/// Total number of float values.
|
||||
pub fn len(&self) -> usize {
|
||||
self.pixels.len()
|
||||
}
|
||||
|
||||
/// Whether the image contains no pixels.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.pixels.is_empty()
|
||||
}
|
||||
|
||||
/// Access to the interleaved pixel data.
|
||||
pub fn as_slice(&self) -> &[f32] {
|
||||
&self.pixels
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads a TIFF image as 32-bit float.
|
||||
///
|
||||
/// The channel count comes from the file itself; RGBA images yield
|
||||
/// `channels == 4`, RGB `3`, gray+alpha `2`, grayscale `1`. Lower bit depths
|
||||
/// are upscaled to float. Only the TIFF format is enabled in this crate's
|
||||
/// `image` dependency; other formats fail with an error.
|
||||
pub fn read_image_f32(path: &str) -> Result<F32Image> {
|
||||
let img =
|
||||
image::open(path).map_err(|e| Error::new(format!("image::read_image_f32: {e}")))?;
|
||||
let width = img.width() as i32;
|
||||
let height = img.height() as i32;
|
||||
let channels = img.color().channel_count() as i32;
|
||||
let pixels = match channels {
|
||||
1 => img.to_luma32f().into_raw(),
|
||||
2 => img.to_luma_alpha32f().into_raw(),
|
||||
3 => img.to_rgb32f().into_raw(),
|
||||
4 => img.to_rgba32f().into_raw(),
|
||||
n => {
|
||||
return Err(Error::new(format!(
|
||||
"image::read_image_f32: unsupported channel count {n}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
Ok(F32Image {
|
||||
width,
|
||||
height,
|
||||
channels,
|
||||
pixels,
|
||||
})
|
||||
}
|
||||
|
||||
/// Writes 32-bit float pixels to `path` as a TIFF (format inferred from the
|
||||
/// `.tif`/`.tiff` extension).
|
||||
///
|
||||
/// `pixels` must hold exactly `width * height * channels` values, interleaved
|
||||
/// row-major. `channels` must be 3 (RGB) or 4 (RGBA): the TIFF encoder in the
|
||||
/// `image` crate supports 32-bit float only for those two layouts, so 1- and
|
||||
/// 2-channel writes are rejected with an error.
|
||||
pub fn write_image_f32(
|
||||
path: &str,
|
||||
width: i32,
|
||||
height: i32,
|
||||
channels: i32,
|
||||
pixels: &[f32],
|
||||
) -> Result<()> {
|
||||
if width <= 0 || height <= 0 {
|
||||
return Err(Error::new("image::write_image_f32: invalid dimensions"));
|
||||
}
|
||||
if channels != 3 && channels != 4 {
|
||||
return Err(Error::new(format!(
|
||||
"image::write_image_f32: unsupported channel count {channels} (TIFF float writes support RGB=3 or RGBA=4 only)"
|
||||
)));
|
||||
}
|
||||
let expected = (width as i64) * (height as i64) * (channels as i64);
|
||||
if expected != pixels.len() as i64 {
|
||||
return Err(Error::new(format!(
|
||||
"image::write_image_f32: pixel buffer length {} does not match {width}x{height}x{channels} = {expected}",
|
||||
pixels.len()
|
||||
)));
|
||||
}
|
||||
// Only the TIFF feature is enabled; anything else is a caller mistake.
|
||||
let format = image::ImageFormat::from_path(path)
|
||||
.map_err(|e| Error::new(format!("image::write_image_f32: {e}")))?;
|
||||
if format != image::ImageFormat::Tiff {
|
||||
return Err(Error::new(format!(
|
||||
"image::write_image_f32: unsupported image format for '{path}' (only TIFF is enabled)"
|
||||
)));
|
||||
}
|
||||
|
||||
let (w, h) = (width as u32, height as u32);
|
||||
let result = if channels == 3 {
|
||||
let buf = ImageBuffer::<Rgb<f32>, Vec<f32>>::from_raw(w, h, pixels.to_vec()).ok_or_else(
|
||||
|| {
|
||||
Error::new(format!(
|
||||
"image::write_image_f32: pixel buffer does not match {width}x{height}x{channels}"
|
||||
))
|
||||
},
|
||||
)?;
|
||||
buf.save_with_format(path, image::ImageFormat::Tiff)
|
||||
} else {
|
||||
let buf = ImageBuffer::<Rgba<f32>, Vec<f32>>::from_raw(w, h, pixels.to_vec()).ok_or_else(
|
||||
|| {
|
||||
Error::new(format!(
|
||||
"image::write_image_f32: pixel buffer does not match {width}x{height}x{channels}"
|
||||
))
|
||||
},
|
||||
)?;
|
||||
buf.save_with_format(path, image::ImageFormat::Tiff)
|
||||
};
|
||||
result.map_err(|e| Error::new(format!("image::write_image_f32: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn utils() -> OIIOUtils {
|
||||
OIIOUtils::new()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_type_from_format_mapping() {
|
||||
let u = utils();
|
||||
assert_eq!(u.get_oiio_base_type_from_format(PixelFormat::U8).unwrap(), 2); // UINT8
|
||||
assert_eq!(u.get_oiio_base_type_from_format(PixelFormat::U10).unwrap(), 0); // UNKNOWN
|
||||
assert_eq!(u.get_oiio_base_type_from_format(PixelFormat::U16).unwrap(), 4); // UINT16
|
||||
assert_eq!(u.get_oiio_base_type_from_format(PixelFormat::F16).unwrap(), 10); // HALF
|
||||
assert_eq!(u.get_oiio_base_type_from_format(PixelFormat::F32).unwrap(), 11); // FLOAT
|
||||
// Invalid / count fall through to UNKNOWN.
|
||||
assert_eq!(u.get_oiio_base_type_from_format(PixelFormat::Invalid).unwrap(), 0);
|
||||
assert_eq!(u.get_oiio_base_type_from_format(PixelFormat::Count).unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_from_oiio_basetype_mapping() {
|
||||
let u = utils();
|
||||
assert_eq!(u.get_format_from_oiio_basetype(2).unwrap(), PixelFormat::U8);
|
||||
assert_eq!(u.get_format_from_oiio_basetype(4).unwrap(), PixelFormat::U16);
|
||||
assert_eq!(u.get_format_from_oiio_basetype(10).unwrap(), PixelFormat::F16);
|
||||
assert_eq!(u.get_format_from_oiio_basetype(11).unwrap(), PixelFormat::F32);
|
||||
// Unknown / unmappable base types map to Invalid.
|
||||
assert_eq!(u.get_format_from_oiio_basetype(0).unwrap(), PixelFormat::Invalid); // UNKNOWN
|
||||
assert_eq!(u.get_format_from_oiio_basetype(1).unwrap(), PixelFormat::Invalid); // NONE
|
||||
assert_eq!(u.get_format_from_oiio_basetype(3).unwrap(), PixelFormat::Invalid); // INT8
|
||||
assert_eq!(u.get_format_from_oiio_basetype(12).unwrap(), PixelFormat::Invalid); // DOUBLE
|
||||
assert_eq!(u.get_format_from_oiio_basetype(15).unwrap(), PixelFormat::Invalid); // USTRINGHASH
|
||||
assert_eq!(u.get_format_from_oiio_basetype(99).unwrap(), PixelFormat::Invalid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_from_oiio_basetype_negative_is_error() {
|
||||
let u = utils();
|
||||
assert!(u.get_format_from_oiio_basetype(-1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_aspect_ratio_common_values() {
|
||||
let u = utils();
|
||||
// 1:1
|
||||
assert_eq!(u.get_pixel_aspect_ratio(1.0).unwrap(), (1, 1));
|
||||
// 16:9
|
||||
assert_eq!(u.get_pixel_aspect_ratio(16.0 / 9.0).unwrap(), (16, 9));
|
||||
// 4:3
|
||||
assert_eq!(u.get_pixel_aspect_ratio(4.0 / 3.0).unwrap(), (4, 3));
|
||||
// 2:1
|
||||
assert_eq!(u.get_pixel_aspect_ratio(2.0).unwrap(), (2, 1));
|
||||
// 1:2
|
||||
assert_eq!(u.get_pixel_aspect_ratio(0.5).unwrap(), (1, 2));
|
||||
// 1:1.5 = 2:3
|
||||
assert_eq!(u.get_pixel_aspect_ratio(2.0 / 3.0).unwrap(), (2, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_aspect_ratio_zero() {
|
||||
let u = utils();
|
||||
assert_eq!(u.get_pixel_aspect_ratio(0.0).unwrap(), (0, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_aspect_ratio_nan_returns_nan_rational() {
|
||||
let u = utils();
|
||||
assert_eq!(u.get_pixel_aspect_ratio(f64::NAN).unwrap(), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_aspect_ratio_out_of_range_returns_nan_rational() {
|
||||
let u = utils();
|
||||
let too_big = i32::MAX as f64 + 4.0;
|
||||
assert_eq!(u.get_pixel_aspect_ratio(too_big).unwrap(), (0, 0));
|
||||
assert_eq!(u.get_pixel_aspect_ratio(-too_big).unwrap(), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_aspect_ratio_reduction() {
|
||||
let u = utils();
|
||||
// 4.0 / 6.0 reduces to 2:3.
|
||||
assert_eq!(u.get_pixel_aspect_ratio(4.0 / 6.0).unwrap(), (2, 3));
|
||||
// 0.25 = 1:4.
|
||||
assert_eq!(u.get_pixel_aspect_ratio(0.25).unwrap(), (1, 4));
|
||||
// 1.5 = 3:2.
|
||||
assert_eq!(u.get_pixel_aspect_ratio(1.5).unwrap(), (3, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_aspect_ratio_round_trip() {
|
||||
let u = utils();
|
||||
// The recovered rational should reproduce the input within the
|
||||
// precision of a reduced fraction.
|
||||
for input in [0.5, 1.0, 1.333_333_333_333_333_3, 1.777_777_777_777_777_7, 2.0, 2.35] {
|
||||
let (n, d) = u.get_pixel_aspect_ratio(input).unwrap();
|
||||
if d == 0 {
|
||||
continue;
|
||||
}
|
||||
let recovered = n as f64 / d as f64;
|
||||
let err = (recovered - input).abs();
|
||||
assert!(err < 1e-9, "input={input} recovered={recovered} ({n}/{d}) err={err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oiioutils_new_is_stateless() {
|
||||
// The object is a stateless unit; construction must succeed and be
|
||||
// cheap to repeat.
|
||||
let _a = OIIOUtils::new();
|
||||
let _b = OIIOUtils::new();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_color_type_maps_formats() {
|
||||
assert_eq!(image_color_type_for(PixelFormat::U8), Some(ExtendedColorType::L8));
|
||||
assert_eq!(image_color_type_for(PixelFormat::U16), Some(ExtendedColorType::L16));
|
||||
assert_eq!(image_color_type_for(PixelFormat::F32), Some(ExtendedColorType::Rgb32F));
|
||||
// No image representation.
|
||||
assert_eq!(image_color_type_for(PixelFormat::U10), None);
|
||||
assert_eq!(image_color_type_for(PixelFormat::F16), None);
|
||||
assert_eq!(image_color_type_for(PixelFormat::Invalid), None);
|
||||
assert_eq!(image_color_type_for(PixelFormat::Count), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bits_per_channel_matches_crate_tables() {
|
||||
// Derived from the image crate's own bits-per-pixel / channel tables.
|
||||
assert_eq!(bits_per_channel(ExtendedColorType::L8), 8);
|
||||
assert_eq!(bits_per_channel(ExtendedColorType::L16), 16);
|
||||
assert_eq!(bits_per_channel(ExtendedColorType::Rgb32F), 32);
|
||||
assert_eq!(bits_per_channel(ExtendedColorType::Rgba32F), 32);
|
||||
// A channel-less type yields 0 (the upstream fallback to UNKNOWN).
|
||||
assert_eq!(bits_per_channel(ExtendedColorType::Unknown(0)), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_aspect_ratio_tiny_value_stays_representable() {
|
||||
// Very small magnitudes must not panic or produce a NaN rational
|
||||
// (av_d2q rescales internally).
|
||||
let u = utils();
|
||||
let (n, d) = u.get_pixel_aspect_ratio(1e-9).unwrap();
|
||||
assert_ne!(d, 0);
|
||||
let recovered = n as f64 / d as f64;
|
||||
assert!((recovered - 1e-9).abs() / 1e-9 < 1e-6, "got {recovered}");
|
||||
}
|
||||
|
||||
fn temp_tiff_path(name: &str) -> (std::path::PathBuf, String) {
|
||||
let dir = std::env::temp_dir().join("oakcommon-oiioutils");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join(name);
|
||||
let path_str = path.to_str().unwrap().to_string();
|
||||
(path, path_str)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_f32_write_read_round_trip() {
|
||||
// 2x2 RGBA float image through a temp TIFF.
|
||||
let w = 2;
|
||||
let h = 2;
|
||||
let c = 4;
|
||||
let pixels: Vec<f32> = vec![
|
||||
0.0, 0.25, 0.5, 1.0,
|
||||
0.75, 0.5, 0.25, 1.0,
|
||||
1.0, 0.0, 0.5, 0.0,
|
||||
0.125, 0.625, 0.875, 1.0,
|
||||
];
|
||||
|
||||
let (path, path_str) = temp_tiff_path("roundtrip.tif");
|
||||
write_image_f32(&path_str, w, h, c, &pixels).expect("write should succeed");
|
||||
|
||||
let img = read_image_f32(&path_str).expect("read should succeed");
|
||||
assert_eq!(img.width, w);
|
||||
assert_eq!(img.height, h);
|
||||
assert_eq!(img.channels, c);
|
||||
assert_eq!(img.pixels.len(), (w * h * c) as usize);
|
||||
assert_eq!(img.len(), img.pixels.len());
|
||||
assert!(!img.is_empty());
|
||||
assert_eq!(img.as_slice(), img.pixels.as_slice());
|
||||
|
||||
for (i, (a, b)) in img.pixels.iter().zip(pixels.iter()).enumerate() {
|
||||
let diff = (a - b).abs();
|
||||
assert!(diff < 1e-6, "pixel {i}: wrote {b}, read back {a}");
|
||||
}
|
||||
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_f32_write_rgb_round_trip() {
|
||||
// RGB (3-channel) writes are supported too; the read reports the
|
||||
// channel count from the file.
|
||||
let pixels: Vec<f32> = vec![0.1, 0.2, 0.3, 0.4, 0.5, 0.6];
|
||||
let (path, path_str) = temp_tiff_path("roundtrip_rgb.tif");
|
||||
write_image_f32(&path_str, 2, 1, 3, &pixels).expect("write should succeed");
|
||||
let img = read_image_f32(&path_str).expect("read should succeed");
|
||||
assert_eq!((img.width, img.height, img.channels), (2, 1, 3));
|
||||
for (a, b) in img.pixels.iter().zip(pixels.iter()) {
|
||||
assert!((a - b).abs() < 1e-6, "wrote {b}, read back {a}");
|
||||
}
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_f32_write_rejects_invalid_dimensions() {
|
||||
let err = write_image_f32("/unused.tif", 0, 1, 4, &[]).unwrap_err();
|
||||
assert!(matches!(err, Error::Failed(_)));
|
||||
let err = write_image_f32("/unused.tif", 1, -2, 4, &[]).unwrap_err();
|
||||
assert!(matches!(err, Error::Failed(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_f32_write_rejects_unsupported_channels() {
|
||||
let pixels = vec![0.0f32; 2];
|
||||
let err = write_image_f32("/unused.tif", 1, 1, 1, &pixels).unwrap_err();
|
||||
assert!(matches!(err, Error::Failed(_)));
|
||||
let err = write_image_f32("/unused.tif", 1, 1, 2, &pixels).unwrap_err();
|
||||
assert!(matches!(err, Error::Failed(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_f32_write_rejects_length_mismatch() {
|
||||
let err = write_image_f32("/unused.tif", 2, 2, 4, &[0.0f32; 3]).unwrap_err();
|
||||
assert!(matches!(err, Error::Failed(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_f32_write_rejects_non_tiff_extension() {
|
||||
let pixels = vec![0.0f32; 4];
|
||||
let err = write_image_f32("/unused.png", 1, 1, 4, &pixels).unwrap_err();
|
||||
assert!(matches!(err, Error::Failed(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_f32_read_missing_file_errors() {
|
||||
let err = read_image_f32("/nonexistent/oakcommon-oiioutils.tif").unwrap_err();
|
||||
assert!(matches!(err, Error::Failed(_)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
// 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/>.
|
||||
|
||||
//! Small helpers for crossing the Qt <-> C boundary, mirroring
|
||||
//! `include/common/qtutils.h`. No state and no handle.
|
||||
//!
|
||||
//! Port of `src/common/src/qtutils.{h,cpp}` and the thin wrapper in
|
||||
//! `src/common/c_api/qtutils.cpp`.
|
||||
|
||||
use std::ffi::c_void;
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "freebsd"))]
|
||||
use std::ffi::{c_char, c_int, CString};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// Convert an opaque pointer to its numeric representation.
|
||||
///
|
||||
/// A null pointer yields `0`. Never fails; the `Result` mirrors the C ABI
|
||||
/// shape (the c_api only rejects a null `out_value`).
|
||||
pub fn ptr_to_value(ptr: *mut c_void) -> Result<u64> {
|
||||
// CPP-PARITY: `reinterpret_cast<uintptr_t>(ptr)`; zero-extended to 64 bits.
|
||||
Ok(ptr as usize as u64)
|
||||
}
|
||||
|
||||
/// Convert a numeric representation back to an opaque pointer.
|
||||
///
|
||||
/// `0` yields a null pointer. Never fails; the `Result` mirrors the C ABI
|
||||
/// shape (the c_api only rejects a null `out_ptr`).
|
||||
pub fn value_to_ptr(value: u64) -> Result<*mut c_void> {
|
||||
// CPP-PARITY: `reinterpret_cast<T*>(value)`, truncating to the pointer
|
||||
// width like a 32-bit `uintptr_t` cast would.
|
||||
Ok(value as usize as *mut c_void)
|
||||
}
|
||||
|
||||
/// macOS/FreeBSD `struct timespec` mirror (`sys/types.h`).
|
||||
#[cfg(any(target_os = "macos", target_os = "freebsd"))]
|
||||
#[repr(C)]
|
||||
struct Timespec {
|
||||
tv_sec: i64,
|
||||
tv_nsec: i64,
|
||||
}
|
||||
|
||||
/// macOS/FreeBSD `struct stat` mirror (`sys/stat.h`), laid out field-for-field
|
||||
/// so the raw `stat()` FFI below reads `st_birthtimespec` / `st_ctimespec`.
|
||||
#[cfg(any(target_os = "macos", target_os = "freebsd"))]
|
||||
#[repr(C)]
|
||||
struct Stat {
|
||||
st_dev: i32,
|
||||
st_mode: u16,
|
||||
st_nlink: u16,
|
||||
st_ino: u64,
|
||||
st_uid: u32,
|
||||
st_gid: u32,
|
||||
st_rdev: i32,
|
||||
_st_pad: u32,
|
||||
st_atimespec: Timespec,
|
||||
st_mtimespec: Timespec,
|
||||
st_ctimespec: Timespec,
|
||||
st_birthtimespec: Timespec,
|
||||
st_size: i64,
|
||||
st_blocks: i64,
|
||||
st_blksize: i32,
|
||||
st_flags: u32,
|
||||
st_gen: u32,
|
||||
st_lspare: i32,
|
||||
st_qspare: [i64; 2],
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "freebsd"))]
|
||||
extern "C" {
|
||||
fn stat(path: *const c_char, buf: *mut Stat) -> c_int;
|
||||
}
|
||||
|
||||
/// File creation time as seconds since the Unix epoch.
|
||||
///
|
||||
/// On filesystems that record a birth time that value is used; otherwise the
|
||||
/// metadata change time is returned. Mirrors `olive::QtUtils::get_creation_date`
|
||||
/// plus the c_api wrapper, which turns a zero (epoch) result into
|
||||
/// `E_NOT_FOUND`.
|
||||
pub fn get_creation_date(path: &str) -> Result<i64> {
|
||||
#[cfg(any(target_os = "macos", target_os = "freebsd"))]
|
||||
{
|
||||
let cpath = CString::new(path).map_err(|_| Error::Invalid)?;
|
||||
let mut st = Stat {
|
||||
st_dev: 0,
|
||||
st_mode: 0,
|
||||
st_nlink: 0,
|
||||
st_ino: 0,
|
||||
st_uid: 0,
|
||||
st_gid: 0,
|
||||
st_rdev: 0,
|
||||
_st_pad: 0,
|
||||
st_atimespec: Timespec { tv_sec: 0, tv_nsec: 0 },
|
||||
st_mtimespec: Timespec { tv_sec: 0, tv_nsec: 0 },
|
||||
st_ctimespec: Timespec { tv_sec: 0, tv_nsec: 0 },
|
||||
st_birthtimespec: Timespec { tv_sec: 0, tv_nsec: 0 },
|
||||
st_size: 0,
|
||||
st_blocks: 0,
|
||||
st_blksize: 0,
|
||||
st_flags: 0,
|
||||
st_gen: 0,
|
||||
st_lspare: 0,
|
||||
st_qspare: [0, 0],
|
||||
};
|
||||
if unsafe { stat(cpath.as_ptr(), &mut st) } != 0 {
|
||||
return Err(Error::NotFound);
|
||||
}
|
||||
// CPP-PARITY: birth time, falling back to the metadata change time when
|
||||
// the birth time is unset (0 / -1) — qtutils.cpp on macOS/FreeBSD.
|
||||
let mut secs = st.st_birthtimespec.tv_sec;
|
||||
if secs == 0 || secs == -1 {
|
||||
secs = st.st_ctimespec.tv_sec;
|
||||
}
|
||||
// CPP-PARITY: the c_api maps a zero (epoch) result to E_NOT_FOUND.
|
||||
if secs == 0 {
|
||||
return Err(Error::NotFound);
|
||||
}
|
||||
Ok(secs)
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "freebsd")))]
|
||||
{
|
||||
let meta = std::fs::metadata(path).map_err(|_| Error::NotFound)?;
|
||||
// CPP-PARITY: `st_ctime` (metadata change time) has no std::fs
|
||||
// equivalent; fall back to creation then modification time.
|
||||
let t = meta
|
||||
.created()
|
||||
.or_else(|_| meta.modified())
|
||||
.map_err(|_| Error::NotFound)?;
|
||||
let secs = t
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
if secs == 0 {
|
||||
return Err(Error::NotFound);
|
||||
}
|
||||
Ok(secs)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::ffi::c_void;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use super::*;
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
#[test]
|
||||
fn ptr_to_value_null_is_zero() {
|
||||
assert_eq!(ptr_to_value(std::ptr::null_mut()).unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ptr_value_round_trip() {
|
||||
let value: u64 = 0x1234_5678_9abc_def0;
|
||||
let ptr = value_to_ptr(value).unwrap();
|
||||
assert_eq!(ptr_to_value(ptr).unwrap(), value);
|
||||
assert!(!ptr.is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_to_ptr_null_is_null() {
|
||||
assert!(value_to_ptr(0).unwrap().is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_to_ptr_accepts_any_u64() {
|
||||
// Mirrors `reinterpret_cast<void*>` which never rejects an input.
|
||||
assert!(value_to_ptr(u64::MAX).is_ok());
|
||||
assert!(value_to_ptr(1).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ptr_to_value_of_real_pointer_round_trips() {
|
||||
let data = 42i32;
|
||||
let raw = &data as *const i32 as *mut c_void;
|
||||
let n = ptr_to_value(raw).unwrap();
|
||||
let back = value_to_ptr(n).unwrap();
|
||||
assert_eq!(back as *const i32 as *const i32, &data as *const i32);
|
||||
}
|
||||
|
||||
// Stable temp-file helper: unique path under the system temp dir.
|
||||
fn temp_file_path(tag: &str) -> std::path::PathBuf {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
std::env::temp_dir().join(format!(
|
||||
"oakcommon_qtutils_{}_{}_{}.tmp",
|
||||
std::process::id(),
|
||||
tag,
|
||||
n
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_date_of_existing_file() {
|
||||
let p = temp_file_path("exists");
|
||||
std::fs::write(&p, b"hello").unwrap();
|
||||
let secs = get_creation_date(p.to_str().unwrap());
|
||||
// On macOS both paths read the same birth time; just assert success
|
||||
// and plausibility rather than exact equality across fs providers.
|
||||
match secs {
|
||||
Ok(s) => assert!(s > 0, "creation time should be in the past"),
|
||||
Err(e) => panic!("expected Ok, got {e:?}"),
|
||||
}
|
||||
// Cross-check against the std metadata creation time when available.
|
||||
if let Ok(meta) = std::fs::metadata(&p) {
|
||||
if let Ok(created) = meta.created() {
|
||||
let expected = created
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
assert_eq!(secs.unwrap(), expected);
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_date_matches_std_for_new_file() {
|
||||
let p = temp_file_path("match");
|
||||
std::fs::write(&p, b"data").unwrap();
|
||||
let ours = get_creation_date(p.to_str().unwrap()).unwrap();
|
||||
let expected = std::fs::metadata(&p)
|
||||
.and_then(|m| m.created())
|
||||
.map(|t| t.duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
assert!(expected > 0);
|
||||
assert_eq!(ours, expected);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_date_missing_file_is_not_found() {
|
||||
let p = temp_file_path("missing");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let res = get_creation_date(p.to_str().unwrap());
|
||||
assert!(matches!(res, Err(Error::NotFound)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_date_empty_path_is_not_found() {
|
||||
assert!(matches!(get_creation_date(""), Err(Error::NotFound)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_date_returns_result_ok_type() {
|
||||
let p = temp_file_path("typetest");
|
||||
std::fs::write(&p, b"x").unwrap();
|
||||
let res: Result<i64> = get_creation_date(p.to_str().unwrap());
|
||||
assert!(res.is_ok());
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_to_ptr_is_injective_over_u64() {
|
||||
// Distinct values that survive the pointer-width cast map back distinctly.
|
||||
let a = value_to_ptr(1).unwrap();
|
||||
let b = value_to_ptr(2).unwrap();
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user