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:
2026-08-10 20:24:25 +08:00
parent f8540e3892
commit 013a175707
4212 changed files with 8331 additions and 2274987 deletions
+98
View File
@@ -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());
}
}
+167
View File
@@ -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);
}
}
+873
View File
@@ -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
+7
View File
@@ -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);
}
+301
View File
@@ -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);
}
}
+154
View File
@@ -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
+473
View File
@@ -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);
}
}
+935
View File
@@ -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()
}
+400
View File
@@ -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"));
}
}
+63
View File
@@ -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
}
}
+514
View File
@@ -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);
}
}
+361
View File
@@ -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");
}
}
+540
View File
@@ -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(_)));
}
}
+276
View File
@@ -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