feat(rust): first green Rust crates — oakcore-rs, oakundo, oakcommon, oaktimeline, oakaudio

- oakcore-rs: Rational/TimeRange/TimeRangeList/PixelFormat/SampleFormat
  mirroring oakcore C++ semantics (91.6% coverage)
- oakundo: UndoCommand/UndoStack with vtable commands (99.6%)
- oakcommon: config/XML/VideoParams/logging etc. (89.8%, 516 tests);
  XML via quick-xml, logging via the log facade
- oaktimeline: markers/workarea/edit commands (95.4%)
- oakaudio: processor/manager/sync/waveform/levelmeter (88.4%)
- unified -MMCCCC error codes across all C ABI headers (registry in
  include/common/error.h; pass-through rule)
- olive::Variant moved node -> common (cross-module value type)
- oakcommon_videoparams_set_is_3d added to the C ABI
- M11 (oakplugin Rust rewrite) plan + notes.md freeze line for the
  inter-module C ABI wiring
This commit is contained in:
2026-08-09 04:24:28 +08:00
parent 5a564f30ca
commit 4b24aa9d67
138 changed files with 47853 additions and 48 deletions
+31
View File
@@ -0,0 +1,31 @@
# 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/>.
#
# `ocio-sys` (the FFI layer behind `ocio-rs`) does not probe the system for
# OpenColorIO on its own: with no configuration it builds a *stub* bridge
# whose calls all fail. These environment variables make it link the real
# Homebrew OpenColorIO dylib:
#
# OCIO_RS_ENABLE_REAL - opt into the real (non-stub) bridge
# OCIO_INSTALL_DIR - prefix whose include/ and lib/ hold OpenColorIO
# OCIO_RS_LINK - Homebrew ships a dylib, so link dynamically
#
# See README.md "Build & test" for the bundled alternative.
[env]
OCIO_RS_ENABLE_REAL = "1"
OCIO_INSTALL_DIR = "/opt/homebrew"
OCIO_RS_LINK = "dynamic"
+332
View File
@@ -0,0 +1,332 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bytemuck"
version = "1.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "cc"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cmake"
version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
dependencies = [
"cc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "fax"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a"
[[package]]
name = "find-msvc-tools"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de"
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"moxcms",
"num-traits",
"tiff",
]
[[package]]
name = "log"
version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "oakcommon"
version = "0.1.0"
dependencies = [
"image",
"log",
"oakcore-rs",
"ocio-rs",
"quick-xml",
]
[[package]]
name = "oakcore-rs"
version = "0.1.0"
[[package]]
name = "ocio-rs"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3492534019b59e29dba06014f907dd12824537ed4d293d4108c4bfc669de7fd"
dependencies = [
"ocio-sys",
"thiserror",
]
[[package]]
name = "ocio-sys"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e63251d72d848de5eda39d59cd6490260cf031738ebd518ea37d76b5aae614ec"
dependencies = [
"cc",
"cmake",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "pxfm"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
dependencies = [
"memchr",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tiff"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52"
dependencies = [
"fax",
"flate2",
"half",
"quick-error",
"weezl",
"zune-jpeg",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "zerocopy"
version = "0.8.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zune-core"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
+35
View File
@@ -0,0 +1,35 @@
[package]
name = "oakcommon"
version = "0.1.0"
edition = "2021"
description = "Oak Video Editor shared utilities (Rust)"
license = "GPL-3.0-or-later"
[lib]
crate-type = ["staticlib", "rlib"]
[profile.release]
panic = "unwind"
[features]
# Compile the in-crate ffmpeg_bridge mock (fb_find_best_pix_fmt_of_list stub)
# so the integration tests can link without libffmpeg_bridge. Without this
# flag, `cargo test --lib` still works (the stub is active under `cfg(test)`),
# but the C ABI wrapper tests in tests/ffi_ffmpegutils.rs need it. This
# mirrors the `test-stubs` convention of oakplugin / oaktimeline.
test-stubs = []
[dependencies]
oakcore-rs = { path = "../../oakcore-rs" }
quick-xml = "0.41.0"
log = "0.4"
# OpenColorIO bindings (crates.io `ocio-rs`, BSD-3-Clause). ocioutils.rs maps
# PixelFormat to the real `ocio_rs::BitDepth` enum and wraps
# `ocio_rs::Config`/`CPUProcessor` for config loading and RGBA transforms.
# Rationale registered in README.md.
ocio-rs = "0.2.1"
# Pure-Rust image I/O (crates.io `image`, MIT OR Apache-2.0); default features
# off, TIFF enabled — the only format current callers need. oiioutils.rs
# derives per-channel bit depths from its color-type tables and does float
# image I/O through it. See README.md.
image = { version = "0.25", default-features = false, features = ["tiff"] }
+104
View File
@@ -0,0 +1,104 @@
# oakcommon Rust crate
> Status: **implemented**. All `include/common/*.h` contracts are
> implemented in Rust and covered by unit + C ABI integration tests
> (see [Testing](#testing)).
## Scope
Replaces the C++ oakcommon module (`src/common/src`): config store,
command-line parser, XML stream reader/writer, file functions,
debug/logging, ffmpeg/OCIO/OIIO utility queries, video/subtitle
params, color transform, misc utilities. Pure leaf module — depends
only on `oakcore-rs`, `quick-xml`, `log`, `ocio-rs`, `image`, and
system libraries.
Public contract: `include/common/*.h` (18 headers) — frozen,
implemented verbatim by `src/ffi.rs`.
## Third-party crates
| Crate | Version | License | Status |
|---|---|---|---|
| `quick-xml` | 0.41 | MIT | adopted — XML reader/writer (`xmlutils.rs`) |
| `log` | 0.4 | MIT / Apache-2.0 | adopted — logging facade (`debug.rs`); the stderr sink is retained as the always-available backend |
| `ocio-rs` | 0.2.1 | BSD-3-Clause | adopted — real OpenColorIO access for `ocioutils.rs` (`OcioConfig`/`OcioProcessor`, `BitDepth` mapping). Pulled in via `ocio-sys` built with `OCIO_RS_ENABLE_REAL=1` against the Homebrew OCIO install (see `.cargo/config.toml`) |
| `image` | 0.25 | MIT / Apache-2.0 | adopted — per-channel bit-depth tables and 32-bit float TIFF I/O for `oiioutils.rs` (`image_color_type_for`/`bits_per_channel`, `F32Image`). Default features off, `tiff` only |
| `oakcore-rs` | path | GPL-3.0 | adopted — `Rational::from_double` (the C++ `Rational::from_double` port of FFmpeg's `av_d2q`) for `get_pixel_aspect_ratio`; a hand-written port kept in the leaf crate instead of pulling in `ffmpeg-next` |
| `serde_json` | — | MIT / Apache-2.0 | **evaluated, not adopted** — the ConfigStore format is INI (QSettings-style `key=value` with `[group]` sections, `%g` doubles), not JSON; switching would break C++/Rust file interop |
| `pico-args` / `clap` | — | MIT / Apache-2.0 | **evaluated, not adopted** — `commandlineparser.rs` must keep exact C++ quirks (case-insensitive names, first-match-wins, last-value-wins, argv[0] skipping, truncating getter copies, borrowed C ABI handles) that a generic parser cannot express without changing the C ABI shape |
## Architectural decisions
1. **Leaf module discipline**: no `bridge/` to other oak modules.
FFmpeg is reached through `ffmpeg_bridge`'s C ABI (narrow
`extern "C"` blocks in `ffmpegutils.rs`); OCIO and OIIO access is
pure Rust via the crates.io bindings listed in the table above
(`ocioutils.rs`, `oiioutils.rs`).
2. **`olive::Variant` disappears**: it exists in C++ only because
QVariant left a hole. Rust modules use closed enums; nothing in
common needs it. `variant.{h,cpp}` (C++) is retired when all
consumers are Rust.
3. **XML**: `XmlStreamReader/Writer` keep the C++ streaming API shape
(the C ABI is built on it), implemented over quick-xml — behavior
(attribute order, error semantics) pinned by tests against the C++
oracle.
4. **Config**: the ConfigStore is **INI-backed** (QSettings-style
`key=value` with `[group]` sections, `;`/`#` comments, `%g`
double formatting), keeping the exact C++ file format and lookup
order (user config → app defaults). It is *not* JSON — an earlier
draft described it as JSON-backed, which was wrong; that claim was
removed from this document.
5. **Logging**: `debug.rs` provides the leveled logger
(qWarning/qDebug/qCritical/qInfo replacement) with a printf-style C
ABI. Every record is written to stderr (the C++ `stderr_sink`
parity) and additionally forwarded to the `log` crate's global
logger when the host has installed one. oakcommon never installs a
global logger itself — the C ABI is loaded into hosts that set
their own, and `log::set_logger` can only be called once per
process.
6. **OCIO / OIIO / FFmpeg**: `ocioutils.rs` talks to real OpenColorIO
through the crates.io `ocio-rs` bindings (`OcioConfig`/`OcioProcessor`,
`BitDepth` enum — no hand-written constant tables); `oiioutils.rs`
derives its OIIO base-type mapping from the `image` crate's color-type
tables (HALF pinned from the frozen OIIO table — `image` has no f16
sample type) and converts aspect ratios with
`oakcore_rs::Rational::from_double` — a hand-written port of FFmpeg's
`av_d2q` matching the C++ `Rational::from_double` exactly, kept in the
leaf crate rather than adding `ffmpeg-next`/`ffmpeg-sys-next` (narrow
extern C discipline). 32-bit float image I/O (`F32Image`) is pure
`image`. All adopted crates are registered in the table above.
## Layout
```
src/
lib.rs crate doc + module map
error.rs error codes (include/common/error.h)
handle.rs refcounted-handle scaffolding
configstore.rs INI config (include/common/config.h)
commandlineparser.rs
xmlutils.rs streaming XML reader/writer
filefunctions.rs file/dir helpers
debug.rs leveled logging
ffmpegutils.rs pixfmt/samplefmt mapping (via ffmpeg_bridge C ABI)
ocioutils.rs OCIO queries
oiioutils.rs OIIO queries
videoparams.rs VideoParams plain data + queries
subtitleparams.rs SubtitleParams
colortransform.rs ColorTransform plain data
miscutils.rs misc (loop mode, drop behavior, power, current…)
ffi.rs export layer (one submodule per public header)
tests/ contract tests per module
```
## Testing
`cargo test --release --features test-stubs` runs the full suite:
unit tests, C ABI contract tests, and the integration tests (incl.
`tests/ffi_ffmpegutils.rs`). The `test-stubs` feature substitutes the
in-crate ffmpeg_bridge mock (`fb_find_best_pix_fmt_of_list` stub, see
`src/ffmpegutils.rs`) so the C ABI tests link without
libffmpeg_bridge; without the feature that symbol is imported from
`ffmpeg_bridge` at link time. This mirrors the `test-stubs`
convention of oakplugin / oaktimeline.
+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);
}
}
+922
View File
@@ -0,0 +1,922 @@
// 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);
}
}
+422
View File
@@ -0,0 +1,422 @@
// 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,
}
/// `#[repr(C)]` mirror of the public handle structs
/// (`{ctx, addref, release, abi_version}`).
#[repr(C)]
pub struct CHandle {
/// Opaque box pointer.
pub ctx: *mut std::ffi::c_void,
/// Atomic increment.
pub addref: Option<unsafe extern "C" fn(*mut std::ffi::c_void)>,
/// Atomic decrement; destroys at zero.
pub release: Option<unsafe extern "C" fn(*mut std::ffi::c_void)>,
/// ABI version.
pub abi_version: u32,
}
impl CHandle {
/// The empty handle.
pub fn null() -> Self {
Self {
ctx: std::ptr::null_mut(),
addref: None,
release: None,
abi_version: OAKCOMMON_ABI_VERSION,
}
}
/// Whether this is an empty handle (`ctx == NULL`).
pub fn is_null(&self) -> bool {
self.ctx.is_null()
}
}
/// 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());
assert_eq!(h.abi_version, OAKCOMMON_ABI_VERSION);
}
#[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"));
}
}
+62
View File
@@ -0,0 +1,62 @@
// 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 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
+153
View File
@@ -0,0 +1,153 @@
// 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/>.
//! C ABI contract tests. These assert the load-bearing constants, enum
//! discriminants, and handle layout that the C headers rely on. They do
//! NOT call any crate function, only public constants/types: these checks
//! are compile-time/constant-only and document the ABI surface that must
//! not drift from the headers.
use std::mem::{align_of, size_of};
use oakcommon::error::{
OAKCOMMON_E_FAILED, OAKCOMMON_E_INVALID, OAKCOMMON_E_NOMEM, OAKCOMMON_E_NOT_FOUND,
OAKCOMMON_E_STATE, OAKCOMMON_OK,
};
use oakcommon::ffmpegutils::{RGB_CHANNEL_COUNT, RGBA_CHANNEL_COUNT};
use oakcommon::handle::{CHandle, OAKCOMMON_ABI_VERSION};
use oakcommon::miscutils::{
DECIBEL_MINIMUM, DropWorkflowBehavior, LoopMode,
};
use oakcommon::ocioutils::PixelFormat;
use oakcommon::videoparams::{ColorRange, Interlacing, VideoType};
/// Error codes must match `include/common/error.h`.
#[test]
fn error_codes_match_header() {
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);
}
/// Handle ABI version must match `include/common/handle.h`.
#[test]
fn handle_abi_version() {
assert_eq!(OAKCOMMON_ABI_VERSION, 1);
}
/// The handle struct must be a plain `{ctx, addref, release, abi_version}`
/// `#[repr(C)]` record: 3 pointers + a u32, padded to pointer alignment.
#[test]
fn handle_layout() {
let ptr = size_of::<*const ()>();
let align = align_of::<*const ()>();
let expected = (3 * ptr + size_of::<u32>()).div_ceil(align) * align;
assert_eq!(size_of::<CHandle>(), expected);
assert_eq!(align_of::<CHandle>(), align);
}
/// Pixel-format codes must match `olive::core::PixelFormat`.
#[test]
fn pixel_format_discriminants() {
assert_eq!(PixelFormat::Invalid as i32, -1);
assert_eq!(PixelFormat::U8 as i32, 0);
assert_eq!(PixelFormat::U10 as i32, 1);
assert_eq!(PixelFormat::U16 as i32, 2);
assert_eq!(PixelFormat::F16 as i32, 3);
assert_eq!(PixelFormat::F32 as i32, 4);
assert_eq!(PixelFormat::Count as i32, 5);
}
/// Decibel minimum must match `include/common/miscutils.h`.
#[test]
fn decibel_minimum() {
assert_eq!(DECIBEL_MINIMUM, -200.0);
}
/// Channel-count constants must match `include/common/ffmpegutils.h`.
#[test]
fn channel_count_constants() {
assert_eq!(RGB_CHANNEL_COUNT, 3);
assert_eq!(RGBA_CHANNEL_COUNT, 4);
}
/// Loop-mode codes must match `include/common/loopmode.h`.
#[test]
fn loop_mode_discriminants() {
assert_eq!(LoopMode::Off as i32, 0);
assert_eq!(LoopMode::Loop as i32, 1);
assert_eq!(LoopMode::Clamp as i32, 2);
}
/// Drop-workflow behavior codes must match `include/common/dropworkflowbehavior.h`.
#[test]
fn drop_workflow_behavior_discriminants() {
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);
}
/// Interlacing codes must match `include/common/videoparams.h`.
#[test]
fn interlacing_discriminants() {
assert_eq!(Interlacing::None as i32, 0);
assert_eq!(Interlacing::TopFirst as i32, 1);
assert_eq!(Interlacing::BottomFirst as i32, 2);
}
/// Video-type codes must match `include/common/videoparams.h`.
#[test]
fn video_type_discriminants() {
assert_eq!(VideoType::Video as i32, 0);
assert_eq!(VideoType::Still as i32, 1);
assert_eq!(VideoType::ImageSequence as i32, 2);
}
/// Color-range codes must match `include/common/videoparams.h`.
#[test]
fn color_range_discriminants() {
assert_eq!(ColorRange::Limited as i32, 0);
assert_eq!(ColorRange::Full as i32, 1);
}
/// The public type names must exist and be usable at their intended ABI
/// shape (compile-time contract).
#[test]
fn public_types_exist() {
// Enums are plain C-like int enums.
let _ = PixelFormat::U8;
let _ = Interlacing::TopFirst;
let _ = VideoType::Still;
let _ = ColorRange::Full;
let _ = LoopMode::Loop;
let _ = DropWorkflowBehavior::Ask;
// The handle is a plain struct constructible without a panic.
let h = CHandle {
ctx: std::ptr::null_mut(),
addref: None,
release: None,
abi_version: 0,
};
assert!(h.ctx.is_null());
assert!(h.addref.is_none());
assert!(h.release.is_none());
assert_eq!(h.abi_version, 0);
}
+333
View File
@@ -0,0 +1,333 @@
// 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/>.
//! FFI-level integration tests for the C-ABI `colortransform` exports in
//! `oakcommon::ffi::colortransform`, asserted against the C++ oracle
//! `src/common/c_api/colortransform.cpp` and the Rust domain
//! `src/common/rust/src/colortransform.rs`.
//!
//! The crate exports the two constructors, `free`, `is_display`, and the
//! four two-stage getters; only the C++-typed `init_from_native` /
//! `get_native` adapters stay with the C++ adapter layer. The tests
//! cover:
//! - constructor success: a stamped (`OAKCOMMON_ABI_VERSION`), non-null
//! handle with live `addref`/`release` callbacks, whose boxed
//! [`ColorTransform`] carries the right fields (peeked via
//! `oakcommon::handle::get`);
//! - constructor failure: a null string argument yields a null handle
//! (guard semantics: any error or panic also collapses to a null
//! handle);
//! - the free contract (release once, nullify, no-op on null);
//! - `is_display` / `get_display` / `get_output` / `get_view` /
//! `get_look` happy paths, two-stage short-buffer behavior, and
//! empty-handle error codes;
//! - distinct handles plus the ownership contract (init yields reference
//! count 1; addref/release adjust it atomically; both free cleanly).
use std::ffi::{c_char, CString};
use std::sync::atomic::Ordering;
use oakcommon::colortransform::ColorTransform;
use oakcommon::ffi::colortransform::*;
use oakcommon::handle::{get, RefBox, CHandle, OAKCOMMON_ABI_VERSION};
/// Convert a string slice to a NUL-terminated C string for FFI inputs.
fn to_cstring(s: &str) -> CString {
CString::new(s).expect("test string must not contain NUL")
}
/// Cheap struct copy: `CHandle` is neither `Clone` nor `Copy`; rebuilding
/// from the same fields duplicates only the handle value — the box stays
/// alive as long as the original handle lives.
fn dup(h: &CHandle) -> CHandle {
CHandle {
ctx: h.ctx,
addref: h.addref,
release: h.release,
abi_version: h.abi_version,
}
}
/// Release one reference and nullify the handle. `oakcommon_colortransform_free`
/// is declared in the header but not exported by this crate, so the free
/// contract is driven through this exact replica of the crate-private
/// `free_handle` (and the C++ oracle): release once, write `CHandle::null()`.
fn free(h: &mut CHandle) {
if let Some(rel) = h.release {
unsafe {
rel(h.ctx);
}
}
*h = CHandle::null();
}
/// Pointer form of [`free`], mirroring `free_handle(*mut CHandle)`: a null
/// pointer is a no-op.
fn free_ptr(p: *mut CHandle) {
if p.is_null() {
return;
}
unsafe {
if let Some(h) = p.as_ref() {
if let Some(rel) = h.release {
rel(h.ctx);
}
}
p.write(CHandle::null());
}
}
/// Read the reference count behind an owned handle (test-only peek into
/// the crate-public `RefBox` layout; the box is guaranteed alive here).
unsafe fn refs_of(h: &CHandle) -> u32 {
unsafe { (*(h.ctx as *const RefBox<ColorTransform>)).refs.load(Ordering::Relaxed) }
}
/// A successful `init_output` yields a stamped, non-null handle whose boxed
/// transform is an output transform with the requested name; `free`
/// nullifies it.
#[test]
fn init_output_success() {
let mut h = oakcommon_colortransform_init_output(to_cstring("sRGB").as_ptr());
assert!(!h.is_null());
assert_eq!(h.abi_version, OAKCOMMON_ABI_VERSION);
assert!(h.addref.is_some());
assert!(h.release.is_some());
let t: &ColorTransform = unsafe { get::<ColorTransform>(&h) }.expect("boxed transform");
assert!(!t.is_display());
assert_eq!(t.output(), "sRGB");
assert_eq!(t.display(), "");
assert_eq!(t.view(), "");
assert_eq!(t.look(), "");
free(&mut h);
assert!(h.is_null());
}
/// A null output string yields a null handle with no callbacks.
#[test]
fn init_output_null_yields_null_handle() {
let h = oakcommon_colortransform_init_output(std::ptr::null::<c_char>());
assert!(h.is_null());
assert!(h.ctx.is_null());
assert!(h.addref.is_none());
assert!(h.release.is_none());
assert_eq!(h.abi_version, OAKCOMMON_ABI_VERSION);
}
/// An empty output name is a valid value: the transform is still an output
/// transform (C++ default-constructor parity).
#[test]
fn init_output_empty_string_succeeds() {
let mut h = oakcommon_colortransform_init_output(to_cstring("").as_ptr());
assert!(!h.is_null());
let t: &ColorTransform = unsafe { get::<ColorTransform>(&h) }.expect("boxed transform");
assert!(!t.is_display());
assert_eq!(t.output(), "");
free(&mut h);
assert!(h.is_null());
}
/// A successful `init_display` yields a stamped, non-null handle whose
/// boxed transform carries the display/view/look names.
#[test]
fn init_display_success() {
let mut h = oakcommon_colortransform_init_display(
to_cstring("DCI-P3").as_ptr(),
to_cstring("standard").as_ptr(),
to_cstring("soft").as_ptr(),
);
assert!(!h.is_null());
assert_eq!(h.abi_version, OAKCOMMON_ABI_VERSION);
assert!(h.addref.is_some());
assert!(h.release.is_some());
let t: &ColorTransform = unsafe { get::<ColorTransform>(&h) }.expect("boxed transform");
assert!(t.is_display());
assert_eq!(t.display(), "DCI-P3");
assert_eq!(t.view(), "standard");
assert_eq!(t.look(), "soft");
assert_eq!(t.output(), "");
free(&mut h);
assert!(h.is_null());
}
/// Any single null argument — and all-null — yields a null handle.
#[test]
fn init_display_null_any_arg_yields_null_handle() {
let d = to_cstring("DCI-P3");
let v = to_cstring("standard");
let l = to_cstring("soft");
let cases = [
(d.as_ptr(), v.as_ptr(), std::ptr::null::<c_char>()),
(d.as_ptr(), std::ptr::null::<c_char>(), l.as_ptr()),
(std::ptr::null::<c_char>(), v.as_ptr(), l.as_ptr()),
(
std::ptr::null::<c_char>(),
std::ptr::null::<c_char>(),
std::ptr::null::<c_char>(),
),
];
for (display, view, look) in cases {
let h = oakcommon_colortransform_init_display(display, view, look);
assert!(h.is_null());
assert!(h.ctx.is_null());
assert!(h.addref.is_none());
assert!(h.release.is_none());
assert_eq!(h.abi_version, OAKCOMMON_ABI_VERSION);
}
}
/// Empty display/view/look strings are valid; the explicit `is_display`
/// flag keeps the transform identifiable as a display transform.
#[test]
fn init_display_empty_strings_succeed() {
let mut h = oakcommon_colortransform_init_display(
to_cstring("").as_ptr(),
to_cstring("").as_ptr(),
to_cstring("").as_ptr(),
);
assert!(!h.is_null());
let t: &ColorTransform = unsafe { get::<ColorTransform>(&h) }.expect("boxed transform");
assert!(t.is_display());
assert_eq!(t.display(), "");
assert_eq!(t.view(), "");
assert_eq!(t.look(), "");
free(&mut h);
assert!(h.is_null());
}
/// The free contract: nullify, idempotent, and no-op on a null handle or a
/// null pointer.
#[test]
fn free_contract() {
// A real handle: free nullifies it and a second free is safe.
let mut h = oakcommon_colortransform_init_output(to_cstring("sRGB").as_ptr());
assert!(!h.is_null());
free(&mut h);
assert!(h.is_null());
assert!(h.ctx.is_null());
assert!(h.release.is_none());
free(&mut h);
assert!(h.is_null());
// Freeing an already-null handle is a no-op.
free(&mut CHandle::null());
// Freeing a null pointer is a no-op.
free_ptr(std::ptr::null_mut());
}
/// Two output transforms are distinct handles (different boxes); init
/// yields reference count 1, addref/release adjust it atomically, and both
/// free cleanly. `dup` copies the handle value but shares the same box.
#[test]
fn init_outputs_are_distinct_and_release_cleanly() {
let mut a = oakcommon_colortransform_init_output(to_cstring("sRGB").as_ptr());
let mut b = oakcommon_colortransform_init_output(to_cstring("Display P3").as_ptr());
assert!(!a.is_null());
assert!(!b.is_null());
assert_ne!(a.ctx, b.ctx);
// dup copies the handle value and shares the same underlying box.
let c = dup(&a);
assert_eq!(c.ctx, a.ctx);
// Ownership contract: count 1 at init, atomic addref/release.
unsafe {
assert_eq!(refs_of(&a), 1);
assert_eq!(refs_of(&b), 1);
(a.addref.unwrap())(a.ctx);
assert_eq!(refs_of(&a), 2);
(a.release.unwrap())(a.ctx);
assert_eq!(refs_of(&a), 1);
}
// Both release cleanly (single release + nullify, no double-free).
free(&mut a);
assert!(a.is_null());
free(&mut b);
assert!(b.is_null());
}
/// `is_display` distinguishes display transforms from output transforms
/// and reports OAKCOMMON_E_INVALID on an empty handle.
#[test]
fn is_display_paths() {
let mut d = oakcommon_colortransform_init_display(
to_cstring("sRGB").as_ptr(),
to_cstring("standard").as_ptr(),
to_cstring("").as_ptr(),
);
let mut o = oakcommon_colortransform_init_output(to_cstring("sRGB").as_ptr());
assert_eq!(oakcommon_colortransform_is_display(dup(&d)), 1);
assert_eq!(oakcommon_colortransform_is_display(dup(&o)), 0);
assert_eq!(
oakcommon_colortransform_is_display(CHandle::null()),
oakcommon::error::OAKCOMMON_E_INVALID
);
free(&mut d);
free(&mut o);
}
/// Two-stage getters: exact-fit returns content, short buffer leaves the
/// buffer untouched but reports the required size, empty handle yields
/// OAKCOMMON_E_INVALID.
#[test]
fn getters_two_stage_and_empty_handle() {
let mut d = oakcommon_colortransform_init_display(
to_cstring("P3").as_ptr(),
to_cstring("std").as_ptr(),
to_cstring("soft").as_ptr(),
);
let mut buf = [0i8; 32];
let need = oakcommon_colortransform_get_display(dup(&d), buf.as_mut_ptr(), 32);
assert_eq!(need, 3); // "P3" + NUL
assert_eq!(unsafe { std::ffi::CStr::from_ptr(buf.as_ptr()) }.to_str().unwrap(), "P3");
let need = oakcommon_colortransform_get_view(dup(&d), buf.as_mut_ptr(), 2);
assert_eq!(need, 4); // "std" + NUL, too small: nothing written
let need = oakcommon_colortransform_get_look(dup(&d), buf.as_mut_ptr(), 32);
assert_eq!(need, 5);
assert_eq!(unsafe { std::ffi::CStr::from_ptr(buf.as_ptr()) }.to_str().unwrap(), "soft");
let o = oakcommon_colortransform_init_output(to_cstring("sRGB").as_ptr());
let need = oakcommon_colortransform_get_output(o, buf.as_mut_ptr(), 32);
assert_eq!(need, 5);
assert_eq!(unsafe { std::ffi::CStr::from_ptr(buf.as_ptr()) }.to_str().unwrap(), "sRGB");
assert_eq!(
oakcommon_colortransform_get_display(CHandle::null(), buf.as_mut_ptr(), 32),
oakcommon::error::OAKCOMMON_E_INVALID
);
free(&mut d);
}
/// The real `oakcommon_colortransform_free` export: releases once,
/// nullifies, no-ops on a null pointer.
#[test]
fn free_export_contract() {
let mut h = oakcommon_colortransform_init_output(to_cstring("sRGB").as_ptr());
assert!(!h.is_null());
oakcommon_colortransform_free(&mut h);
assert!(h.is_null());
oakcommon_colortransform_free(&mut h); // already null: no-op
oakcommon_colortransform_free(std::ptr::null_mut()); // null pointer: no-op
}
@@ -0,0 +1,867 @@
// 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/>.
//! Integration tests for the C ABI surface of
//! `oakcommon::ffi::commandlineparser`, which implements
//! `include/common/commandlineparser.h` (oracle: `src/common/c_api/commandlineparser.cpp`).
//!
//! Every exported function gets a success-path test and a failure-path test.
//! The failure paths cover the empty (NULL-ctx) handle plus the documented
//! invalid-argument codes; the string getters are TRUNCATING (see the C++
//! `copy_setting`), so all three two-stage buffer cases are exercised.
//!
//! These tests are pure: they create their own parser/option/argument
//! handles and touch no config files, so they need no `OAK_CONFIG_DIR`
//! locking and run independently of the config tests.
use std::ffi::{c_char, CStr, CString};
use std::ptr::null_mut;
use oakcommon::error::{OAKCOMMON_E_INVALID, OAKCOMMON_OK};
use oakcommon::ffi::commandlineparser::*;
use oakcommon::handle::{CHandle, OAKCOMMON_ABI_VERSION};
/// A fresh parser handle (reference count 1, non-null ctx).
fn new_parser() -> CHandle {
let h = oakcommon_commandlineparser_init();
assert!(!h.ctx.is_null());
h
}
/// Shallow-copy a handle, mirroring C's pass-by-value semantics. The FFI
/// exports take handles by value (a `CHandle` is a plain
/// `{ctx, addref, release, abi_version}` record), but the Rust type
/// deliberately does not implement `Copy`, so each call consumes its
/// argument. `dup` copies the four scalar fields; the exports never
/// release the handles they receive, so the reference count is untouched
/// and no `addref` is needed.
fn dup(h: &CHandle) -> CHandle {
CHandle {
ctx: h.ctx,
addref: h.addref,
release: h.release,
abi_version: h.abi_version,
}
}
/// Build a NUL-terminated C string.
fn c_str(s: &str) -> CString {
CString::new(s).unwrap()
}
/// Build a `char *const *`-style array: returns the CStrings (kept alive
/// for the caller) plus their pointer array.
fn c_strings(values: &[&str]) -> (Vec<CString>, Vec<*const c_char>) {
let cs: Vec<CString> = values.iter().map(|s| c_str(s)).collect();
let ptrs = cs.iter().map(|s| s.as_ptr()).collect();
(cs, ptrs)
}
/// Register a single `takes_arg` option and return its borrowed handle.
fn register_option(parser: CHandle, name: &str) -> CHandle {
let mut out = CHandle::null();
let (_names, ptrs) = c_strings(&[name]);
let r = oakcommon_commandlineparser_add_option(
parser,
ptrs.as_ptr(),
1,
c_str("desc").as_ptr(),
1,
c_str("ARG").as_ptr(),
0,
&mut out,
);
assert_eq!(r, OAKCOMMON_OK);
assert!(!out.ctx.is_null());
out
}
/// Register a single positional argument and return its borrowed handle.
fn register_positional(parser: CHandle, name: &str) -> CHandle {
let mut out = CHandle::null();
let r = oakcommon_commandlineparser_add_positional_argument(
parser,
c_str(name).as_ptr(),
c_str("desc").as_ptr(),
1,
&mut out,
);
assert_eq!(r, OAKCOMMON_OK);
assert!(!out.ctx.is_null());
out
}
// ---------------------------------------------------------------------------
// oakcommon_commandlineparser_init
// ---------------------------------------------------------------------------
/// init returns a live handle with the standard vtable and ABI stamp, and
/// free() releases it cleanly. There is no testable failure path: the only
/// failure mode is allocation failure / panic inside `guard_handle`, both
/// of which return a NULL-ctx handle and cannot be triggered deterministically.
#[test]
fn init_returns_live_handle() {
let mut h = oakcommon_commandlineparser_init();
assert!(!h.ctx.is_null());
assert!(h.addref.is_some());
assert!(h.release.is_some());
assert_eq!(h.abi_version, OAKCOMMON_ABI_VERSION);
oakcommon_commandlineparser_free(&mut h);
assert!(h.ctx.is_null());
}
// ---------------------------------------------------------------------------
// oakcommon_commandlineparser_free
// ---------------------------------------------------------------------------
#[test]
fn parser_free_null_pointer_is_noop() {
oakcommon_commandlineparser_free(null_mut());
}
#[test]
fn parser_free_twice_is_safe_and_nulls_ctx() {
let mut h = oakcommon_commandlineparser_init();
assert!(!h.ctx.is_null());
oakcommon_commandlineparser_free(&mut h);
assert!(h.ctx.is_null());
// Second free on the same (now empty) handle must be a no-op.
oakcommon_commandlineparser_free(&mut h);
assert!(h.ctx.is_null());
}
#[test]
fn parser_free_null_handle_struct_is_noop() {
let mut h = CHandle::null();
oakcommon_commandlineparser_free(&mut h);
assert!(h.ctx.is_null());
}
// ---------------------------------------------------------------------------
// oakcommon_commandlineparser_set_app_info
// ---------------------------------------------------------------------------
#[test]
fn set_app_info_success() {
let mut p = new_parser();
let r = oakcommon_commandlineparser_set_app_info(dup(&p), c_str("myapp").as_ptr(), c_str("1.0").as_ptr());
assert_eq!(r, OAKCOMMON_OK);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn set_app_info_empty_handle_is_invalid() {
let e = CHandle::null();
let r = oakcommon_commandlineparser_set_app_info(e, c_str("n").as_ptr(), c_str("v").as_ptr());
assert_eq!(r, OAKCOMMON_E_INVALID);
}
#[test]
fn set_app_info_null_name_is_invalid() {
let mut p = new_parser();
let r = oakcommon_commandlineparser_set_app_info(dup(&p), null_mut(), c_str("v").as_ptr());
assert_eq!(r, OAKCOMMON_E_INVALID);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn set_app_info_null_version_is_invalid() {
let mut p = new_parser();
// CPP-PARITY: the C++ oracle tolerates a NULL version (treated as ""),
// but the Rust export rejects it with E_INVALID.
let r = oakcommon_commandlineparser_set_app_info(dup(&p), c_str("n").as_ptr(), null_mut());
assert_eq!(r, OAKCOMMON_E_INVALID);
oakcommon_commandlineparser_free(&mut p);
}
// ---------------------------------------------------------------------------
// oakcommon_commandlineparser_add_option
// ---------------------------------------------------------------------------
#[test]
fn add_option_success() {
let mut p = new_parser();
let mut out = CHandle::null();
let (_names, ptrs) = c_strings(&["o", "output"]);
let r = oakcommon_commandlineparser_add_option(
dup(&p),
ptrs.as_ptr(),
2,
c_str("Output file.").as_ptr(),
1,
c_str("FILE").as_ptr(),
0,
&mut out,
);
assert_eq!(r, OAKCOMMON_OK);
assert!(!out.ctx.is_null());
assert!(out.addref.is_some());
assert!(out.release.is_some());
oakcommon_commandlineoption_free(&mut out);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn add_option_empty_parser_is_invalid() {
let e = CHandle::null();
let mut out = CHandle::null();
let (_names, ptrs) = c_strings(&["o"]);
let r = oakcommon_commandlineparser_add_option(
e, ptrs.as_ptr(), 1, c_str("d").as_ptr(), 0, c_str("A").as_ptr(), 0, &mut out);
assert_eq!(r, OAKCOMMON_E_INVALID);
}
#[test]
fn add_option_null_names_is_invalid() {
let mut p = new_parser();
let mut out = CHandle::null();
let r = oakcommon_commandlineparser_add_option(
dup(&p), null_mut(), 1, c_str("d").as_ptr(), 0, c_str("A").as_ptr(), 0, &mut out);
assert_eq!(r, OAKCOMMON_E_INVALID);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn add_option_non_positive_name_count_is_invalid() {
let mut p = new_parser();
let mut out = CHandle::null();
let (_names, ptrs) = c_strings(&["o"]);
let r0 = oakcommon_commandlineparser_add_option(
dup(&p), ptrs.as_ptr(), 0, c_str("d").as_ptr(), 0, c_str("A").as_ptr(), 0, &mut out);
assert_eq!(r0, OAKCOMMON_E_INVALID);
let rn = oakcommon_commandlineparser_add_option(
dup(&p), ptrs.as_ptr(), -1, c_str("d").as_ptr(), 0, c_str("A").as_ptr(), 0, &mut out);
assert_eq!(rn, OAKCOMMON_E_INVALID);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn add_option_null_description_is_invalid() {
// CPP-PARITY: the header documents description as "may be NULL" (C++ maps
// it to ""), but the Rust export rejects a NULL description with E_INVALID.
let mut p = new_parser();
let mut out = CHandle::null();
let (_names, ptrs) = c_strings(&["o"]);
let r = oakcommon_commandlineparser_add_option(
dup(&p), ptrs.as_ptr(), 1, null_mut(), 0, c_str("A").as_ptr(), 0, &mut out);
assert_eq!(r, OAKCOMMON_E_INVALID);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn add_option_null_arg_placeholder_is_invalid() {
// CPP-PARITY: the header documents arg_placeholder as "may be NULL" (C++
// maps it to ""), but the Rust export rejects it with E_INVALID.
let mut p = new_parser();
let mut out = CHandle::null();
let (_names, ptrs) = c_strings(&["o"]);
let r = oakcommon_commandlineparser_add_option(
dup(&p), ptrs.as_ptr(), 1, c_str("d").as_ptr(), 0, null_mut(), 0, &mut out);
assert_eq!(r, OAKCOMMON_E_INVALID);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn add_option_null_out_option_is_invalid() {
// CPP-PARITY: the header says out_option "may be NULL if unused"; the Rust
// export requires it.
let mut p = new_parser();
let (_names, ptrs) = c_strings(&["o"]);
let r = oakcommon_commandlineparser_add_option(
dup(&p), ptrs.as_ptr(), 1, c_str("d").as_ptr(), 0, c_str("A").as_ptr(), 0, null_mut());
assert_eq!(r, OAKCOMMON_E_INVALID);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn add_option_non_utf8_name_is_invalid() {
let mut p = new_parser();
let mut out = CHandle::null();
// 0xFF is not valid UTF-8; the export rejects it with E_INVALID before
// the option is registered.
let bad = CString::new(vec![b'x', 0xFF]).unwrap();
let ptrs = [bad.as_ptr()];
let r = oakcommon_commandlineparser_add_option(
dup(&p), ptrs.as_ptr(), 1, c_str("d").as_ptr(), 0, c_str("A").as_ptr(), 0, &mut out);
assert_eq!(r, OAKCOMMON_E_INVALID);
// The failed registration must not leave a partial option behind: a
// subsequent valid registration still works and lands at index 0.
let mut out2 = CHandle::null();
let (_names, ptrs2) = c_strings(&["ok"]);
let r2 = oakcommon_commandlineparser_add_option(
dup(&p), ptrs2.as_ptr(), 1, c_str("d").as_ptr(), 0, c_str("A").as_ptr(), 0, &mut out2);
assert_eq!(r2, OAKCOMMON_OK);
assert!(!out2.ctx.is_null());
oakcommon_commandlineoption_free(&mut out2);
oakcommon_commandlineparser_free(&mut p);
}
// ---------------------------------------------------------------------------
// oakcommon_commandlineparser_add_positional_argument
// ---------------------------------------------------------------------------
#[test]
fn add_positional_argument_success() {
let mut p = new_parser();
let mut out = CHandle::null();
let r = oakcommon_commandlineparser_add_positional_argument(
dup(&p), c_str("input").as_ptr(), c_str("Input file").as_ptr(), 1, &mut out);
assert_eq!(r, OAKCOMMON_OK);
assert!(!out.ctx.is_null());
assert!(out.release.is_some());
oakcommon_commandlinepositionalargument_free(&mut out);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn add_positional_argument_empty_parser_is_invalid() {
let e = CHandle::null();
let mut out = CHandle::null();
let r = oakcommon_commandlineparser_add_positional_argument(
e, c_str("in").as_ptr(), c_str("d").as_ptr(), 1, &mut out);
assert_eq!(r, OAKCOMMON_E_INVALID);
}
#[test]
fn add_positional_argument_null_name_is_invalid() {
let mut p = new_parser();
let mut out = CHandle::null();
let r = oakcommon_commandlineparser_add_positional_argument(
dup(&p), null_mut(), c_str("d").as_ptr(), 1, &mut out);
assert_eq!(r, OAKCOMMON_E_INVALID);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn add_positional_argument_null_description_is_invalid() {
// CPP-PARITY: C++ maps a NULL description to ""; the Rust export rejects
// it with E_INVALID.
let mut p = new_parser();
let mut out = CHandle::null();
let r = oakcommon_commandlineparser_add_positional_argument(
dup(&p), c_str("in").as_ptr(), null_mut(), 1, &mut out);
assert_eq!(r, OAKCOMMON_E_INVALID);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn add_positional_argument_null_out_is_invalid() {
// CPP-PARITY: C++ allows a NULL out_argument; the Rust export requires it.
let mut p = new_parser();
let r = oakcommon_commandlineparser_add_positional_argument(
dup(&p), c_str("in").as_ptr(), c_str("d").as_ptr(), 1, null_mut());
assert_eq!(r, OAKCOMMON_E_INVALID);
oakcommon_commandlineparser_free(&mut p);
}
// ---------------------------------------------------------------------------
// oakcommon_commandlineparser_process
// ---------------------------------------------------------------------------
#[test]
fn process_success() {
let mut p = new_parser();
let mut opt = register_option(dup(&p), "o");
let mut pos = register_positional(dup(&p), "input");
let (_argv, argv_ptrs) = c_strings(&["prog", "-o", "out.mov", "in.mp4"]);
let r = oakcommon_commandlineparser_process(dup(&p), argv_ptrs.as_ptr(), 4);
assert_eq!(r, OAKCOMMON_OK);
oakcommon_commandlineoption_free(&mut opt);
oakcommon_commandlinepositionalargument_free(&mut pos);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn process_argc_zero_success() {
let mut p = new_parser();
// Non-null argv with argc == 0: the loop never runs, returns OK.
let (_argv, argv_ptrs) = c_strings(&["prog"]);
let r = oakcommon_commandlineparser_process(dup(&p), argv_ptrs.as_ptr(), 0);
assert_eq!(r, OAKCOMMON_OK);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn process_empty_parser_is_invalid() {
let e = CHandle::null();
let (_argv, argv_ptrs) = c_strings(&["prog"]);
let r = oakcommon_commandlineparser_process(e, argv_ptrs.as_ptr(), 1);
assert_eq!(r, OAKCOMMON_E_INVALID);
}
#[test]
fn process_null_argv_is_invalid() {
let mut p = new_parser();
let r = oakcommon_commandlineparser_process(dup(&p), null_mut(), 1);
assert_eq!(r, OAKCOMMON_E_INVALID);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn process_negative_argc_is_invalid() {
let mut p = new_parser();
let (_argv, argv_ptrs) = c_strings(&["prog"]);
let r = oakcommon_commandlineparser_process(dup(&p), argv_ptrs.as_ptr(), -1);
assert_eq!(r, OAKCOMMON_E_INVALID);
oakcommon_commandlineparser_free(&mut p);
}
// ---------------------------------------------------------------------------
// oakcommon_commandlineparser_print_help
// ---------------------------------------------------------------------------
#[test]
fn print_help_success() {
let mut p = new_parser();
let r = oakcommon_commandlineparser_print_help(dup(&p), c_str("oak").as_ptr());
assert_eq!(r, OAKCOMMON_OK);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn print_help_empty_parser_is_invalid() {
let e = CHandle::null();
let r = oakcommon_commandlineparser_print_help(e, c_str("oak").as_ptr());
assert_eq!(r, OAKCOMMON_E_INVALID);
}
#[test]
fn print_help_null_filename_is_invalid() {
let mut p = new_parser();
let r = oakcommon_commandlineparser_print_help(dup(&p), null_mut());
assert_eq!(r, OAKCOMMON_E_INVALID);
oakcommon_commandlineparser_free(&mut p);
}
// ---------------------------------------------------------------------------
// oakcommon_commandlineoption_is_set / _free
// ---------------------------------------------------------------------------
#[test]
fn option_is_set_success() {
let mut p = new_parser();
let mut opt = register_option(dup(&p), "o");
let mut is_set = true;
let r = oakcommon_commandlineoption_is_set(dup(&opt), &mut is_set);
assert_eq!(r, OAKCOMMON_OK);
// CPP-PARITY: the Rust borrowed handle is a registration-time snapshot
// (make_borrowed bit-copies), so is_set is always false on the handle;
// the C++ oracle stores a pointer to the live option and would report the
// parser's real state.
assert!(!is_set);
oakcommon_commandlineoption_free(&mut opt);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn option_is_set_empty_handle_is_invalid() {
let e = CHandle::null();
let mut is_set = true;
let r = oakcommon_commandlineoption_is_set(e, &mut is_set);
assert_eq!(r, OAKCOMMON_E_INVALID);
}
#[test]
fn option_is_set_null_out_is_invalid() {
let mut p = new_parser();
let mut opt = register_option(dup(&p), "o");
let r = oakcommon_commandlineoption_is_set(dup(&opt), null_mut());
assert_eq!(r, OAKCOMMON_E_INVALID);
oakcommon_commandlineoption_free(&mut opt);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn option_free_null_pointer_is_noop() {
oakcommon_commandlineoption_free(null_mut());
}
#[test]
fn option_free_twice_is_safe_and_nulls_ctx() {
let mut p = new_parser();
let mut opt = register_option(dup(&p), "o");
assert!(!opt.ctx.is_null());
oakcommon_commandlineoption_free(&mut opt);
assert!(opt.ctx.is_null());
// Second free on the same (now empty) handle must be a no-op.
oakcommon_commandlineoption_free(&mut opt);
assert!(opt.ctx.is_null());
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn option_free_null_handle_struct_is_noop() {
let mut h = CHandle::null();
oakcommon_commandlineoption_free(&mut h);
assert!(h.ctx.is_null());
}
// ---------------------------------------------------------------------------
// oakcommon_commandlineoption_get_setting (TRUNCATING two-stage getter)
// ---------------------------------------------------------------------------
#[test]
fn option_get_setting_size_query() {
let mut p = new_parser();
let mut opt = register_option(dup(&p), "o");
assert_eq!(
oakcommon_commandlineoption_set_setting(dup(&opt), c_str("abcdef").as_ptr()),
OAKCOMMON_OK
);
// Stage 1: null buffer, size 0 -> required size len+1, nothing written.
let r = oakcommon_commandlineoption_get_setting(dup(&opt), null_mut(), 0);
assert_eq!(r, 7); // len("abcdef") + NUL
oakcommon_commandlineoption_free(&mut opt);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn option_get_setting_short_buffer_truncates_and_returns_required() {
let mut p = new_parser();
let mut opt = register_option(dup(&p), "o");
assert_eq!(
oakcommon_commandlineoption_set_setting(dup(&opt), c_str("abcdef").as_ptr()),
OAKCOMMON_OK
);
// Stage 2: buf_size 4 < 7 -> writes "abc" + NUL, still returns 7.
let mut buf = [0xFFu8; 4];
let r = oakcommon_commandlineoption_get_setting(dup(&opt), buf.as_mut_ptr() as *mut c_char, 4);
assert_eq!(r, 7);
assert_eq!(&buf[..3], b"abc");
assert_eq!(buf[3], 0);
// buf_size 1 -> writes only the NUL terminator.
let mut tiny = [0xFFu8; 1];
let r = oakcommon_commandlineoption_get_setting(dup(&opt), tiny.as_mut_ptr() as *mut c_char, 1);
assert_eq!(r, 7);
assert_eq!(tiny[0], 0);
oakcommon_commandlineoption_free(&mut opt);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn option_get_setting_exact_fit_writes_full_string() {
let mut p = new_parser();
let mut opt = register_option(dup(&p), "o");
assert_eq!(
oakcommon_commandlineoption_set_setting(dup(&opt), c_str("abcdef").as_ptr()),
OAKCOMMON_OK
);
// Stage 3: buf_size == len+1 -> full string plus NUL, returns len+1.
let mut buf = [0xFFu8; 7];
let r = oakcommon_commandlineoption_get_setting(dup(&opt), buf.as_mut_ptr() as *mut c_char, 7);
assert_eq!(r, 7);
assert_eq!(buf[6], 0);
let s = unsafe { CStr::from_ptr(buf.as_ptr() as *const c_char) };
assert_eq!(s.to_str().unwrap(), "abcdef");
oakcommon_commandlineoption_free(&mut opt);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn option_get_setting_non_null_buf_size_zero_writes_nothing() {
let mut p = new_parser();
let mut opt = register_option(dup(&p), "o");
assert_eq!(
oakcommon_commandlineoption_set_setting(dup(&opt), c_str("abcdef").as_ptr()),
OAKCOMMON_OK
);
// A non-null buffer with size 0 is a valid size query: required returned,
// buffer untouched.
let mut buf = [0xFFu8; 8];
let r = oakcommon_commandlineoption_get_setting(dup(&opt), buf.as_mut_ptr() as *mut c_char, 0);
assert_eq!(r, 7);
assert_eq!(buf, [0xFFu8; 8]);
oakcommon_commandlineoption_free(&mut opt);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn option_get_setting_unset_returns_empty_string() {
let mut p = new_parser();
let mut opt = register_option(dup(&p), "o");
// Never set: get_setting returns the empty string (required size 1).
let mut buf = [0xFFu8; 1];
let r = oakcommon_commandlineoption_get_setting(dup(&opt), buf.as_mut_ptr() as *mut c_char, 1);
assert_eq!(r, 1);
assert_eq!(buf[0], 0);
oakcommon_commandlineoption_free(&mut opt);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn option_get_setting_empty_handle_is_invalid() {
let e = CHandle::null();
let r = oakcommon_commandlineoption_get_setting(e, null_mut(), 0);
assert_eq!(r, OAKCOMMON_E_INVALID);
}
#[test]
fn option_get_setting_negative_buf_size_is_invalid() {
let mut p = new_parser();
let mut opt = register_option(dup(&p), "o");
let mut buf = [0u8; 4];
let r = oakcommon_commandlineoption_get_setting(dup(&opt), buf.as_mut_ptr() as *mut c_char, -1);
assert_eq!(r, OAKCOMMON_E_INVALID);
let r2 = oakcommon_commandlineoption_get_setting(dup(&opt), null_mut(), -1);
assert_eq!(r2, OAKCOMMON_E_INVALID);
oakcommon_commandlineoption_free(&mut opt);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn option_get_setting_null_buf_with_size_is_invalid() {
let mut p = new_parser();
let mut opt = register_option(dup(&p), "o");
let r = oakcommon_commandlineoption_get_setting(dup(&opt), null_mut(), 1);
assert_eq!(r, OAKCOMMON_E_INVALID);
oakcommon_commandlineoption_free(&mut opt);
oakcommon_commandlineparser_free(&mut p);
}
// ---------------------------------------------------------------------------
// oakcommon_commandlineoption_set_setting
// ---------------------------------------------------------------------------
#[test]
fn option_set_setting_success() {
let mut p = new_parser();
let mut opt = register_option(dup(&p), "o");
let r = oakcommon_commandlineoption_set_setting(dup(&opt), c_str("value").as_ptr());
assert_eq!(r, OAKCOMMON_OK);
// Round-trip through the two-stage getter.
let mut buf = vec![0u8; 6];
let r = oakcommon_commandlineoption_get_setting(dup(&opt), buf.as_mut_ptr() as *mut c_char, 6);
assert_eq!(r, 6);
let s = unsafe { CStr::from_ptr(buf.as_ptr() as *const c_char) };
assert_eq!(s.to_str().unwrap(), "value");
oakcommon_commandlineoption_free(&mut opt);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn option_set_setting_empty_handle_is_invalid() {
let e = CHandle::null();
let r = oakcommon_commandlineoption_set_setting(e, c_str("v").as_ptr());
assert_eq!(r, OAKCOMMON_E_INVALID);
}
#[test]
fn option_set_setting_null_value_is_invalid() {
let mut p = new_parser();
let mut opt = register_option(dup(&p), "o");
let r = oakcommon_commandlineoption_set_setting(dup(&opt), null_mut());
assert_eq!(r, OAKCOMMON_E_INVALID);
oakcommon_commandlineoption_free(&mut opt);
oakcommon_commandlineparser_free(&mut p);
}
/// CPP-PARITY: `process()` mutates the parser-owned option; the option handle
/// returned by `add_option` is a registration-time snapshot in Rust (C++
/// holds a pointer to the live option), so the handle does not observe the
/// parse: is_set stays false and get_setting stays empty.
#[test]
fn option_handle_does_not_observe_process() {
let mut p = new_parser();
let mut opt = register_option(dup(&p), "o");
let (_argv, argv_ptrs) = c_strings(&["prog", "-o", "out.mov"]);
let r = oakcommon_commandlineparser_process(dup(&p), argv_ptrs.as_ptr(), 3);
assert_eq!(r, OAKCOMMON_OK);
// The snapshot handle still reports the empty setting.
let r = oakcommon_commandlineoption_get_setting(dup(&opt), null_mut(), 0);
assert_eq!(r, 1); // empty string
let mut is_set = true;
assert_eq!(oakcommon_commandlineoption_is_set(dup(&opt), &mut is_set), OAKCOMMON_OK);
assert!(!is_set);
oakcommon_commandlineoption_free(&mut opt);
oakcommon_commandlineparser_free(&mut p);
}
// ---------------------------------------------------------------------------
// oakcommon_commandlinepositionalargument_get_setting (TRUNCATING)
// ---------------------------------------------------------------------------
#[test]
fn positional_get_setting_size_query() {
let mut p = new_parser();
let mut pos = register_positional(dup(&p), "input");
assert_eq!(
oakcommon_commandlinepositionalargument_set_setting(dup(&pos), c_str("abcdef").as_ptr()),
OAKCOMMON_OK
);
let r = oakcommon_commandlinepositionalargument_get_setting(dup(&pos), null_mut(), 0);
assert_eq!(r, 7); // len("abcdef") + NUL
oakcommon_commandlinepositionalargument_free(&mut pos);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn positional_get_setting_short_buffer_truncates_and_returns_required() {
let mut p = new_parser();
let mut pos = register_positional(dup(&p), "input");
assert_eq!(
oakcommon_commandlinepositionalargument_set_setting(dup(&pos), c_str("abcdef").as_ptr()),
OAKCOMMON_OK
);
let mut buf = [0xFFu8; 4];
let r = oakcommon_commandlinepositionalargument_get_setting(dup(&pos), buf.as_mut_ptr() as *mut c_char, 4);
assert_eq!(r, 7);
assert_eq!(&buf[..3], b"abc");
assert_eq!(buf[3], 0);
let mut tiny = [0xFFu8; 1];
let r = oakcommon_commandlinepositionalargument_get_setting(dup(&pos), tiny.as_mut_ptr() as *mut c_char, 1);
assert_eq!(r, 7);
assert_eq!(tiny[0], 0);
oakcommon_commandlinepositionalargument_free(&mut pos);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn positional_get_setting_exact_fit_writes_full_string() {
let mut p = new_parser();
let mut pos = register_positional(dup(&p), "input");
assert_eq!(
oakcommon_commandlinepositionalargument_set_setting(dup(&pos), c_str("abcdef").as_ptr()),
OAKCOMMON_OK
);
let mut buf = [0xFFu8; 7];
let r = oakcommon_commandlinepositionalargument_get_setting(dup(&pos), buf.as_mut_ptr() as *mut c_char, 7);
assert_eq!(r, 7);
assert_eq!(buf[6], 0);
let s = unsafe { CStr::from_ptr(buf.as_ptr() as *const c_char) };
assert_eq!(s.to_str().unwrap(), "abcdef");
oakcommon_commandlinepositionalargument_free(&mut pos);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn positional_get_setting_non_null_buf_size_zero_writes_nothing() {
let mut p = new_parser();
let mut pos = register_positional(dup(&p), "input");
assert_eq!(
oakcommon_commandlinepositionalargument_set_setting(dup(&pos), c_str("abcdef").as_ptr()),
OAKCOMMON_OK
);
let mut buf = [0xFFu8; 8];
let r = oakcommon_commandlinepositionalargument_get_setting(dup(&pos), buf.as_mut_ptr() as *mut c_char, 0);
assert_eq!(r, 7);
assert_eq!(buf, [0xFFu8; 8]);
oakcommon_commandlinepositionalargument_free(&mut pos);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn positional_get_setting_unset_returns_empty_string() {
let mut p = new_parser();
let mut pos = register_positional(dup(&p), "input");
let mut buf = [0xFFu8; 1];
let r = oakcommon_commandlinepositionalargument_get_setting(dup(&pos), buf.as_mut_ptr() as *mut c_char, 1);
assert_eq!(r, 1);
assert_eq!(buf[0], 0);
oakcommon_commandlinepositionalargument_free(&mut pos);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn positional_get_setting_empty_handle_is_invalid() {
let e = CHandle::null();
let r = oakcommon_commandlinepositionalargument_get_setting(e, null_mut(), 0);
assert_eq!(r, OAKCOMMON_E_INVALID);
}
#[test]
fn positional_get_setting_negative_buf_size_is_invalid() {
let mut p = new_parser();
let mut pos = register_positional(dup(&p), "input");
let mut buf = [0u8; 4];
let r = oakcommon_commandlinepositionalargument_get_setting(dup(&pos), buf.as_mut_ptr() as *mut c_char, -1);
assert_eq!(r, OAKCOMMON_E_INVALID);
let r2 = oakcommon_commandlinepositionalargument_get_setting(dup(&pos), null_mut(), -1);
assert_eq!(r2, OAKCOMMON_E_INVALID);
oakcommon_commandlinepositionalargument_free(&mut pos);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn positional_get_setting_null_buf_with_size_is_invalid() {
let mut p = new_parser();
let mut pos = register_positional(dup(&p), "input");
let r = oakcommon_commandlinepositionalargument_get_setting(dup(&pos), null_mut(), 1);
assert_eq!(r, OAKCOMMON_E_INVALID);
oakcommon_commandlinepositionalargument_free(&mut pos);
oakcommon_commandlineparser_free(&mut p);
}
// ---------------------------------------------------------------------------
// oakcommon_commandlinepositionalargument_set_setting / _free
// ---------------------------------------------------------------------------
#[test]
fn positional_set_setting_success() {
let mut p = new_parser();
let mut pos = register_positional(dup(&p), "input");
let r = oakcommon_commandlinepositionalargument_set_setting(dup(&pos), c_str("file.mp4").as_ptr());
assert_eq!(r, OAKCOMMON_OK);
// Round-trip through the two-stage getter.
let mut buf = vec![0u8; 9];
let r = oakcommon_commandlinepositionalargument_get_setting(dup(&pos), buf.as_mut_ptr() as *mut c_char, 9);
assert_eq!(r, 9);
let s = unsafe { CStr::from_ptr(buf.as_ptr() as *const c_char) };
assert_eq!(s.to_str().unwrap(), "file.mp4");
oakcommon_commandlinepositionalargument_free(&mut pos);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn positional_set_setting_empty_handle_is_invalid() {
let e = CHandle::null();
let r = oakcommon_commandlinepositionalargument_set_setting(e, c_str("v").as_ptr());
assert_eq!(r, OAKCOMMON_E_INVALID);
}
#[test]
fn positional_set_setting_null_value_is_invalid() {
let mut p = new_parser();
let mut pos = register_positional(dup(&p), "input");
let r = oakcommon_commandlinepositionalargument_set_setting(dup(&pos), null_mut());
assert_eq!(r, OAKCOMMON_E_INVALID);
oakcommon_commandlinepositionalargument_free(&mut pos);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn positional_free_null_pointer_is_noop() {
oakcommon_commandlinepositionalargument_free(null_mut());
}
#[test]
fn positional_free_twice_is_safe_and_nulls_ctx() {
let mut p = new_parser();
let mut pos = register_positional(dup(&p), "input");
assert!(!pos.ctx.is_null());
oakcommon_commandlinepositionalargument_free(&mut pos);
assert!(pos.ctx.is_null());
// Second free on the same (now empty) handle must be a no-op.
oakcommon_commandlinepositionalargument_free(&mut pos);
assert!(pos.ctx.is_null());
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn positional_free_null_handle_struct_is_noop() {
let mut h = CHandle::null();
oakcommon_commandlinepositionalargument_free(&mut h);
assert!(h.ctx.is_null());
}
+489
View File
@@ -0,0 +1,489 @@
// 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/>.
//! FFI integration tests for the `config` submodule of the C ABI layer
//! (`oakcommon::ffi::config`, backed by `include/common/config.h`).
//!
//! Every exported symbol is exercised with both a success and a failure
//! path. The store is a process-wide singleton and the C-ABI `config`
//! functions have no handle, so the failure paths are the documented
//! behaviors: null/empty keys, invalid buffers, and absent entries.
//!
//! Tests use per-test unique keys so they stay independent of each other
//! and of the domain unit tests, and every test that mutates the singleton
//! (or the `OAK_CONFIG_DIR` env override) serializes on a local mutex. The
//! crate's `test_support::env_lock` is `#[cfg(test)]` and therefore not
//! visible to this integration-test crate, and the domain unit tests run in
//! a separate process anyway, so a local lock is sufficient.
use std::ffi::{c_char, c_void, CStr, CString};
use std::path::{Path, PathBuf};
use std::ptr::{null, null_mut};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Mutex, MutexGuard};
use oakcommon::error::{
OAKCOMMON_E_FAILED, OAKCOMMON_E_INVALID, OAKCOMMON_E_NOT_FOUND, OAKCOMMON_OK,
};
use oakcommon::ffi::config::*;
// ---- Test infrastructure --------------------------------------------------
/// Serializes tests that mutate the process-wide singleton store or the
/// `OAK_CONFIG_DIR` env override. Without this, a `reset_defaults`/`load`
/// running between another test's `set` and `get` would clear its key.
static LOCK: Mutex<()> = Mutex::new(());
fn lock() -> MutexGuard<'static, ()> {
LOCK.lock().unwrap()
}
static KEY_SEQ: AtomicUsize = AtomicUsize::new(0);
/// A per-test unique key, so parallel tests cannot collide on the shared
/// store.
fn unique_key(name: &str) -> CString {
let n = KEY_SEQ.fetch_add(1, Ordering::Relaxed);
CString::new(format!("itest_{}_{}_{}", name, std::process::id(), n)).unwrap()
}
fn cstr(s: &str) -> CString {
CString::new(s).unwrap()
}
/// Cleans up the `OAK_CONFIG_DIR` override and its temp dir even when the
/// test body panics.
struct TempConfigDir(PathBuf);
impl Drop for TempConfigDir {
fn drop(&mut self) {
std::env::remove_var("OAK_CONFIG_DIR");
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// Clears the process-global error handler on drop so a panicking test
/// cannot leak it to the rest of the suite.
struct HandlerGuard;
impl Drop for HandlerGuard {
fn drop(&mut self) {
oakcommon_config_set_error_handler(None, std::ptr::null_mut());
}
}
/// Point `OAK_CONFIG_DIR` at an isolated temp dir, run `f`, then clean up.
/// Mirrors the `with_temp_config` pattern of the domain unit tests.
fn with_temp_config<T>(f: impl FnOnce(&Path) -> T) -> T {
let _guard = lock();
let dir = std::env::temp_dir().join(format!(
"oakcommon_ffi_config_test_{}",
std::process::id()
));
let _ = std::fs::create_dir_all(&dir);
std::env::set_var("OAK_CONFIG_DIR", &dir);
let _cleanup = TempConfigDir(dir.clone());
f(&dir)
}
// ---- Error handler recording ---------------------------------------------
static HANDLER_HITS: AtomicUsize = AtomicUsize::new(0);
static HANDLER_USERDATA: AtomicUsize = AtomicUsize::new(0);
static HANDLER_TITLE: Mutex<Option<String>> = Mutex::new(None);
unsafe extern "C" fn record_handler(
title: *const c_char,
_message: *const c_char,
userdata: *mut c_void,
) {
HANDLER_HITS.fetch_add(1, Ordering::SeqCst);
HANDLER_USERDATA.store(userdata as usize, Ordering::SeqCst);
if !title.is_null() {
let s = unsafe { CStr::from_ptr(title) }.to_string_lossy().into_owned();
*HANDLER_TITLE.lock().unwrap() = Some(s);
}
}
// ---- load / save ----------------------------------------------------------
/// `oakcommon_config_load` treats a missing `config.ini` as a non-error.
#[test]
fn config_load_missing_file_is_ok() {
with_temp_config(|_| {
assert_eq!(oakcommon_config_load(), OAKCOMMON_OK);
});
}
/// `save` persists the store; `load` re-reads it (resetting to defaults
/// first), so the round trip restores the custom key.
#[test]
fn config_save_load_roundtrip() {
with_temp_config(|_| {
let key = unique_key("save_roundtrip");
oakcommon_config_set(null(), key.as_ptr(), cstr("persisted").as_ptr());
assert_eq!(oakcommon_config_save(), OAKCOMMON_OK);
assert_eq!(oakcommon_config_load(), OAKCOMMON_OK);
let mut buf = [0i8; 32];
let n = oakcommon_config_get(null(), key.as_ptr(), buf.as_mut_ptr(), 32);
assert_eq!(n, 10); // "persisted" + NUL
assert_eq!(unsafe { CStr::from_ptr(buf.as_ptr()) }.to_str().unwrap(), "persisted");
});
}
/// A save that cannot write reports through the registered error handler
/// and returns `OAKCOMMON_E_FAILED`. `OAK_CONFIG_DIR` points at a regular
/// file so `"<dir>/config.ini.tmp"` cannot be created (mirrors the domain
/// unit test).
#[test]
fn config_save_failure_reports_and_returns_failed() {
let _guard = lock();
let dir = std::env::temp_dir().join(format!(
"oakcommon_ffi_config_blocked_{}",
std::process::id()
));
let _ = std::fs::create_dir_all(&dir);
let blocker = dir.join("not_a_dir");
std::fs::write(&blocker, b"x").unwrap();
std::env::set_var("OAK_CONFIG_DIR", &blocker);
let _cleanup = TempConfigDir(dir);
HANDLER_HITS.store(0, Ordering::SeqCst);
HANDLER_USERDATA.store(0, Ordering::SeqCst);
*HANDLER_TITLE.lock().unwrap() = None;
let userdata: usize = 0xdead_beef;
let _handler_guard = HandlerGuard;
assert_eq!(
oakcommon_config_set_error_handler(Some(record_handler), userdata as *mut c_void),
OAKCOMMON_OK
);
assert_eq!(oakcommon_config_save(), OAKCOMMON_E_FAILED);
assert_eq!(HANDLER_HITS.load(Ordering::SeqCst), 1);
assert_eq!(HANDLER_USERDATA.load(Ordering::SeqCst), userdata);
assert_eq!(
HANDLER_TITLE.lock().unwrap().as_deref(),
Some("Error saving settings")
);
}
// ---- reset_defaults -------------------------------------------------------
/// `reset_defaults` drops custom keys and restores the compiled-in
/// defaults.
#[test]
fn config_reset_defaults_restores_defaults() {
let _guard = lock();
let custom = unique_key("reset_custom");
oakcommon_config_set_int(null(), custom.as_ptr(), 1);
assert_eq!(oakcommon_config_get_int(null(), custom.as_ptr(), -1), 1);
assert_eq!(oakcommon_config_reset_defaults(), OAKCOMMON_OK);
// The custom key is gone...
assert_eq!(oakcommon_config_get_int(null(), custom.as_ptr(), -1), -1);
assert_eq!(
oakcommon_config_entry_type(null(), custom.as_ptr()),
OAKCOMMON_E_NOT_FOUND
);
// ...and the compiled-in defaults are back.
assert_eq!(
oakcommon_config_get_int(null(), cstr("DefaultSequenceWidth").as_ptr(), -1),
1920
);
assert_eq!(
oakcommon_config_get_bool(null(), cstr("UseProxyMedia").as_ptr(), -1),
1
);
}
// ---- string set / get -----------------------------------------------------
/// Null keys/values are silently ignored by `oakcommon_config_set`.
#[test]
fn config_set_null_key_is_noop() {
let _guard = lock();
oakcommon_config_set(null(), null(), cstr("v").as_ptr());
oakcommon_config_set(null(), cstr("k").as_ptr(), null());
}
/// Two-stage getter, stage 1: a null buffer with size 0 returns the
/// required size (length + NUL) without writing.
#[test]
fn config_get_size_query_returns_required() {
let _guard = lock();
let key = unique_key("size_query");
let value = "hello";
oakcommon_config_set(null(), key.as_ptr(), cstr(value).as_ptr());
let required = oakcommon_config_get(null(), key.as_ptr(), null_mut(), 0);
assert_eq!(required, value.len() as i32 + 1);
}
/// Two-stage getter, short buffer: the full required size is returned but
/// the buffer is NOT touched (non-truncating, unlike `copy_setting`).
#[test]
fn config_get_short_buffer_is_non_truncating() {
let _guard = lock();
let key = unique_key("short_buf");
let value = "hello world"; // required = 12
oakcommon_config_set(null(), key.as_ptr(), cstr(value).as_ptr());
let mut buf = [0x7a as c_char; 8];
let n = oakcommon_config_get(null(), key.as_ptr(), buf.as_mut_ptr(), buf.len() as i32);
assert_eq!(n, value.len() as i32 + 1);
assert_eq!(buf, [0x7a as c_char; 8]); // sentinel untouched
}
/// Two-stage getter, exact fit: a buffer of exactly `len + 1` receives the
/// string plus NUL terminator.
#[test]
fn config_get_exact_fit_buffer() {
let _guard = lock();
let key = unique_key("exact_fit");
let value = "hello";
oakcommon_config_set(null(), key.as_ptr(), cstr(value).as_ptr());
let required = value.len() as i32 + 1;
let mut buf = vec![0i8; required as usize];
let n = oakcommon_config_get(null(), key.as_ptr(), buf.as_mut_ptr(), required);
assert_eq!(n, required);
assert_eq!(unsafe { CStr::from_ptr(buf.as_ptr()) }.to_str().unwrap(), value);
}
/// A missing key yields `OAKCOMMON_E_NOT_FOUND`.
#[test]
fn config_get_missing_key_returns_not_found() {
let _guard = lock();
let key = unique_key("missing");
let mut buf = [0i8; 16];
let n = oakcommon_config_get(null(), key.as_ptr(), buf.as_mut_ptr(), buf.len() as i32);
assert_eq!(n, OAKCOMMON_E_NOT_FOUND);
}
/// A null key yields `OAKCOMMON_E_INVALID`.
#[test]
fn config_get_null_key_returns_invalid() {
let n = oakcommon_config_get(null(), null(), null_mut(), 0);
assert_eq!(n, OAKCOMMON_E_INVALID);
}
/// An empty key is invalid (matches the C++ `is_valid_key` check, handled
/// here by the domain layer).
#[test]
fn config_get_empty_key_returns_invalid() {
let mut buf = [0i8; 8];
let n = oakcommon_config_get(null(), cstr("").as_ptr(), buf.as_mut_ptr(), 8);
assert_eq!(n, OAKCOMMON_E_INVALID);
}
/// A negative buffer size is an invalid output buffer.
#[test]
fn config_get_invalid_buffer_returns_invalid() {
let _guard = lock();
let key = unique_key("bad_buf");
oakcommon_config_set(null(), key.as_ptr(), cstr("v").as_ptr());
let n = oakcommon_config_get(null(), key.as_ptr(), null_mut(), -1);
assert_eq!(n, OAKCOMMON_E_INVALID);
}
// ---- int / int64 ----------------------------------------------------------
/// INT set/get round trip; the string getter formats the int.
#[test]
fn config_set_get_int_roundtrip() {
let _guard = lock();
let key = unique_key("int");
oakcommon_config_set_int(null(), key.as_ptr(), 42);
assert_eq!(oakcommon_config_get_int(null(), key.as_ptr(), -1), 42);
assert_eq!(oakcommon_config_entry_type(null(), key.as_ptr()), 2);
let mut buf = [0i8; 16];
let n = oakcommon_config_get(null(), key.as_ptr(), buf.as_mut_ptr(), 16);
assert_eq!(n, 3); // "42" + NUL
assert_eq!(unsafe { CStr::from_ptr(buf.as_ptr()) }.to_str().unwrap(), "42");
}
/// INT getter fallback paths: absent key, wrong type, null key.
#[test]
fn config_get_int_fallback_paths() {
let _guard = lock();
let missing = unique_key("int_missing");
let wrong = unique_key("int_wrongtype");
oakcommon_config_set(null(), wrong.as_ptr(), cstr("abc").as_ptr());
assert_eq!(oakcommon_config_get_int(null(), missing.as_ptr(), 7), 7);
assert_eq!(oakcommon_config_get_int(null(), wrong.as_ptr(), 7), 7);
assert_eq!(oakcommon_config_get_int(null(), null(), 9), 9);
}
/// Setting a string onto an existing typed entry parses it into that type;
/// an unparseable string leaves the entry unchanged (CPP-PARITY with
/// `config.cpp`).
#[test]
fn config_set_string_parses_into_typed_entry() {
let _guard = lock();
let key = unique_key("typed_parse");
oakcommon_config_set_int(null(), key.as_ptr(), 42);
oakcommon_config_set(null(), key.as_ptr(), cstr("7").as_ptr());
assert_eq!(oakcommon_config_get_int(null(), key.as_ptr(), -1), 7);
oakcommon_config_set(null(), key.as_ptr(), cstr("notanumber").as_ptr());
assert_eq!(oakcommon_config_get_int(null(), key.as_ptr(), -1), 7);
}
/// INT64 set/get round trip and fallback paths.
#[test]
fn config_set_get_int64_roundtrip() {
let _guard = lock();
let key = unique_key("int64");
let big: i64 = 3_000_000_000; // exceeds i32 range
oakcommon_config_set_int64(null(), key.as_ptr(), big);
assert_eq!(oakcommon_config_get_int64(null(), key.as_ptr(), -1), big);
let missing = unique_key("int64_missing");
assert_eq!(oakcommon_config_get_int64(null(), missing.as_ptr(), -99), -99);
assert_eq!(oakcommon_config_get_int64(null(), null(), -99), -99);
}
// ---- double ---------------------------------------------------------------
/// DOUBLE set/get round trip and fallback paths.
#[test]
fn config_set_get_double_roundtrip() {
let _guard = lock();
let key = unique_key("double");
oakcommon_config_set_double(null(), key.as_ptr(), 3.5);
assert_eq!(oakcommon_config_get_double(null(), key.as_ptr(), -1.0), 3.5);
assert_eq!(oakcommon_config_entry_type(null(), key.as_ptr()), 3);
let missing = unique_key("double_missing");
let wrong = unique_key("double_wrongtype");
oakcommon_config_set_int(null(), wrong.as_ptr(), 1);
assert_eq!(oakcommon_config_get_double(null(), missing.as_ptr(), 2.5), 2.5);
assert_eq!(oakcommon_config_get_double(null(), wrong.as_ptr(), 2.5), 2.5);
assert_eq!(oakcommon_config_get_double(null(), null(), 2.5), 2.5);
}
// ---- bool -----------------------------------------------------------------
/// BOOL set/get round trip (0/1) and string formatting as "true"/"false".
#[test]
fn config_set_get_bool_roundtrip() {
let _guard = lock();
let key = unique_key("bool");
oakcommon_config_set_bool(null(), key.as_ptr(), 1);
assert_eq!(oakcommon_config_get_bool(null(), key.as_ptr(), -1), 1);
assert_eq!(oakcommon_config_entry_type(null(), key.as_ptr()), 4);
let mut buf = [0i8; 8];
let n = oakcommon_config_get(null(), key.as_ptr(), buf.as_mut_ptr(), 8);
assert_eq!(n, 5); // "true" + NUL
assert_eq!(unsafe { CStr::from_ptr(buf.as_ptr()) }.to_str().unwrap(), "true");
oakcommon_config_set_bool(null(), key.as_ptr(), 0);
assert_eq!(oakcommon_config_get_bool(null(), key.as_ptr(), -1), 0);
let n = oakcommon_config_get(null(), key.as_ptr(), buf.as_mut_ptr(), 8);
assert_eq!(n, 6); // "false" + NUL
assert_eq!(unsafe { CStr::from_ptr(buf.as_ptr()) }.to_str().unwrap(), "false");
}
/// BOOL getter fallback paths: absent key, wrong type, null key.
#[test]
fn config_get_bool_fallback_paths() {
let _guard = lock();
let missing = unique_key("bool_missing");
let wrong = unique_key("bool_wrongtype");
oakcommon_config_set_int(null(), wrong.as_ptr(), 1);
assert_eq!(oakcommon_config_get_bool(null(), missing.as_ptr(), 1), 1);
assert_eq!(oakcommon_config_get_bool(null(), wrong.as_ptr(), 0), 0);
assert_eq!(oakcommon_config_get_bool(null(), null(), 1), 1);
}
/// Null keys are silently ignored by every typed setter.
#[test]
fn config_typed_setters_null_key_are_noop() {
let _guard = lock();
oakcommon_config_set_int(null(), null(), 1);
oakcommon_config_set_int64(null(), null(), 1);
oakcommon_config_set_bool(null(), null(), 1);
oakcommon_config_set_double(null(), null(), 1.0);
}
// ---- groups ---------------------------------------------------------------
/// A non-empty group prefixes the stored key (`group/key`), so the flat
/// lookup must not find it.
#[test]
fn config_group_prefixes_keys() {
let _guard = lock();
let key = unique_key("grouped");
let group = cstr("ffi_test_group");
oakcommon_config_set_int(group.as_ptr(), key.as_ptr(), 5);
assert_eq!(oakcommon_config_get_int(null(), key.as_ptr(), -1), -1);
assert_eq!(oakcommon_config_get_int(group.as_ptr(), key.as_ptr(), -1), 5);
assert_eq!(oakcommon_config_entry_type(group.as_ptr(), key.as_ptr()), 2);
}
// ---- entry_type -----------------------------------------------------------
/// Entry type codes: 1 = String, 2 = Int, 3 = Double, 4 = Bool.
#[test]
fn config_entry_type_codes() {
let _guard = lock();
let s = unique_key("et_str");
let i = unique_key("et_int");
let d = unique_key("et_double");
let b = unique_key("et_bool");
oakcommon_config_set(null(), s.as_ptr(), cstr("x").as_ptr());
oakcommon_config_set_int(null(), i.as_ptr(), 1);
oakcommon_config_set_double(null(), d.as_ptr(), 1.0);
oakcommon_config_set_bool(null(), b.as_ptr(), 1);
assert_eq!(oakcommon_config_entry_type(null(), s.as_ptr()), 1);
assert_eq!(oakcommon_config_entry_type(null(), i.as_ptr()), 2);
assert_eq!(oakcommon_config_entry_type(null(), d.as_ptr()), 3);
assert_eq!(oakcommon_config_entry_type(null(), b.as_ptr()), 4);
}
/// Missing, null, and empty keys for `entry_type`.
#[test]
fn config_entry_type_missing_and_invalid() {
let _guard = lock();
let missing = unique_key("et_missing");
assert_eq!(
oakcommon_config_entry_type(null(), missing.as_ptr()),
OAKCOMMON_E_NOT_FOUND
);
assert_eq!(oakcommon_config_entry_type(null(), null()), OAKCOMMON_E_INVALID);
assert_eq!(
oakcommon_config_entry_type(null(), cstr("").as_ptr()),
OAKCOMMON_E_INVALID
);
}
// ---- error handler --------------------------------------------------------
/// Registering and clearing the error handler both succeed; the handler
/// itself is exercised by `config_save_failure_reports_and_returns_failed`.
#[test]
fn config_set_error_handler_roundtrip() {
let _guard = lock();
let userdata = 0x1234 as *mut c_void;
let _handler_guard = HandlerGuard;
assert_eq!(
oakcommon_config_set_error_handler(Some(record_handler), userdata),
OAKCOMMON_OK
);
assert_eq!(
oakcommon_config_set_error_handler(None, std::ptr::null_mut()),
OAKCOMMON_OK
);
}
+175
View File
@@ -0,0 +1,175 @@
// 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/>.
//! Integration tests for the C ABI surface of `oakcommon::ffi::ffmpegutils`,
//! which implements `include/common/ffmpegutils.h` (oracle:
//! `src/common/src/ffmpegutils.cpp`).
//!
//! MUST be run with `--features test-stubs`: unlike every other ffi module,
//! `oakcommon_ffmpegutils_get_compatible_bridge_pixel_format` reaches the
//! real `fb_find_best_pix_fmt_of_list` symbol (ffmpeg_bridge C ABI) in
//! non-test builds, and the integration-test binary cannot link it. The
//! `test-stubs` feature substitutes the in-crate stub (see
//! `src/ffmpegutils.rs::find_best_pix_fmt_of_list`), mirroring the
//! oakplugin/oaktimeline convention. `cargo test --lib` needs no flag: the
//! stub is active under `cfg(test)` there.
//!
//! The pure mappings are already unit-tested in `src/ffmpegutils.rs`; these
//! tests pin the exported wrapper contract: a value written to a non-null
//! out-param with `OAKCOMMON_OK`, or `OAKCOMMON_E_INVALID` for a null
//! out-param, for every export.
#[cfg(feature = "test-stubs")]
mod ffi_ffmpegutils_tests {
use std::ptr::null_mut;
use oakcommon::error::{OAKCOMMON_E_INVALID, OAKCOMMON_OK};
use oakcommon::ffi::ffmpegutils::*;
/// Every out-param wrapper must reject a null pointer without writing.
#[test]
fn null_out_param_returns_invalid() {
let mut out: i32 = -999;
assert_eq!(
oakcommon_ffmpegutils_get_compatible_bridge_pixel_format(26, -1, null_mut()),
OAKCOMMON_E_INVALID
);
assert_eq!(oakcommon_ffmpegutils_get_compatible_pixel_format(0, null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(
oakcommon_ffmpegutils_get_ffmpeg_pixel_format(0, 4, null_mut()),
OAKCOMMON_E_INVALID
);
assert_eq!(oakcommon_ffmpegutils_get_native_sample_format(0, null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_ffmpegutils_get_ffmpeg_sample_format(6, null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(
oakcommon_ffmpegutils_convert_jpeg_space_to_regular_space(12, null_mut()),
OAKCOMMON_E_INVALID
);
// The out-param is untouched on the failure path.
assert_eq!(out, -999);
}
/// `get_compatible_bridge_pixel_format` picks an exact candidate from the
/// clamped list (values match `FB_PIX_FMT_*` / `PixelFormat`).
#[test]
fn compatible_bridge_pixel_format_exact_candidate() {
let mut out: i32 = -999;
// pix_fmt = RGBA (26), maximum = invalid (-1): no limit, RGBA is
// first in the candidate list.
assert_eq!(
oakcommon_ffmpegutils_get_compatible_bridge_pixel_format(26, -1, &mut out),
OAKCOMMON_OK
);
assert_eq!(out, 26);
// pix_fmt = RGBA_F32_LE (220), maximum = F32 (4): the f32 clamp adds
// RGBA_F32_LE, so it matches exactly.
assert_eq!(
oakcommon_ffmpegutils_get_compatible_bridge_pixel_format(220, 4, &mut out),
OAKCOMMON_OK
);
assert_eq!(out, 220);
}
/// `get_compatible_bridge_pixel_format` falls back to the first candidate
/// for an unknown source format (bridge loss-metric behaviour).
#[test]
fn compatible_bridge_pixel_format_unknown_falls_back() {
let mut out: i32 = -999;
// YUV420P (0) is not in the RGBA-oriented candidate list -> first
// candidate RGBA (26).
assert_eq!(
oakcommon_ffmpegutils_get_compatible_bridge_pixel_format(0, -1, &mut out),
OAKCOMMON_OK
);
assert_eq!(out, 26);
}
/// `get_compatible_pixel_format` maps native formats to the least-lossy
/// native format.
#[test]
fn compatible_pixel_format_maps_native() {
let mut out: i32 = -999;
assert_eq!(oakcommon_ffmpegutils_get_compatible_pixel_format(0, &mut out), OAKCOMMON_OK); // U8
assert_eq!(out, 0);
assert_eq!(oakcommon_ffmpegutils_get_compatible_pixel_format(3, &mut out), OAKCOMMON_OK); // F16
assert_eq!(out, 2); // -> U16
assert_eq!(oakcommon_ffmpegutils_get_compatible_pixel_format(-1, &mut out), OAKCOMMON_OK); // invalid
assert_eq!(out, -1);
}
/// `get_ffmpeg_pixel_format` maps native format + channel count to a
/// bridge pixel format.
#[test]
fn ffmpeg_pixel_format_maps_native_to_bridge() {
let mut out: i32 = -999;
assert_eq!(oakcommon_ffmpegutils_get_ffmpeg_pixel_format(0, 3, &mut out), OAKCOMMON_OK); // U8 RGB
assert_eq!(out, 2); // RGB24
assert_eq!(oakcommon_ffmpegutils_get_ffmpeg_pixel_format(0, 4, &mut out), OAKCOMMON_OK); // U8 RGBA
assert_eq!(out, 26); // RGBA
assert_eq!(oakcommon_ffmpegutils_get_ffmpeg_pixel_format(2, 4, &mut out), OAKCOMMON_OK); // U16 RGBA
assert_eq!(out, 105); // RGBA64LE
assert_eq!(oakcommon_ffmpegutils_get_ffmpeg_pixel_format(1, 3, &mut out), OAKCOMMON_OK); // U10 RGB
assert_eq!(out, -1); // no bridge format
}
/// `get_native_sample_format` maps bridge sample formats to native.
#[test]
fn native_sample_format_maps_bridge_to_native() {
let mut out: i32 = -999;
assert_eq!(oakcommon_ffmpegutils_get_native_sample_format(0, &mut out), OAKCOMMON_OK); // U8
assert_eq!(out, 6); // SMP_FMT_U8
assert_eq!(oakcommon_ffmpegutils_get_native_sample_format(1, &mut out), OAKCOMMON_OK); // S16
assert_eq!(out, 7);
assert_eq!(oakcommon_ffmpegutils_get_native_sample_format(8, &mut out), OAKCOMMON_OK); // FLTP
assert_eq!(out, 4); // SMP_FMT_F32_P
assert_eq!(oakcommon_ffmpegutils_get_native_sample_format(999, &mut out), OAKCOMMON_OK);
assert_eq!(out, -1); // unknown -> invalid
}
/// `get_ffmpeg_sample_format` maps native sample formats to bridge.
#[test]
fn ffmpeg_sample_format_maps_native_to_bridge() {
let mut out: i32 = -999;
assert_eq!(oakcommon_ffmpegutils_get_ffmpeg_sample_format(6, &mut out), OAKCOMMON_OK); // SMP_FMT_U8
assert_eq!(out, 0); // U8
assert_eq!(oakcommon_ffmpegutils_get_ffmpeg_sample_format(10, &mut out), OAKCOMMON_OK); // SMP_FMT_F32
assert_eq!(out, 3); // FLT
assert_eq!(oakcommon_ffmpegutils_get_ffmpeg_sample_format(-1, &mut out), OAKCOMMON_OK); // invalid
assert_eq!(out, -1);
}
/// JPEG-range bridge formats convert to their regular counterparts;
/// everything else passes through unchanged.
#[test]
fn jpeg_space_converts_to_regular_space() {
let mut out: i32 = -999;
assert_eq!(oakcommon_ffmpegutils_convert_jpeg_space_to_regular_space(12, &mut out), OAKCOMMON_OK); // YUVJ420P
assert_eq!(out, 0); // YUV420P
assert_eq!(oakcommon_ffmpegutils_convert_jpeg_space_to_regular_space(13, &mut out), OAKCOMMON_OK); // YUVJ422P
assert_eq!(out, 4); // YUV422P
assert_eq!(oakcommon_ffmpegutils_convert_jpeg_space_to_regular_space(14, &mut out), OAKCOMMON_OK); // YUVJ444P
assert_eq!(out, 5); // YUV444P
assert_eq!(oakcommon_ffmpegutils_convert_jpeg_space_to_regular_space(32, &mut out), OAKCOMMON_OK); // YUVJ440P
assert_eq!(out, 31); // YUV440P
assert_eq!(oakcommon_ffmpegutils_convert_jpeg_space_to_regular_space(138, &mut out), OAKCOMMON_OK); // YUVJ411P
assert_eq!(out, 7); // YUV411P
// Non-JPEG formats pass through unchanged.
assert_eq!(oakcommon_ffmpegutils_convert_jpeg_space_to_regular_space(26, &mut out), OAKCOMMON_OK); // RGBA
assert_eq!(out, 26);
assert_eq!(oakcommon_ffmpegutils_convert_jpeg_space_to_regular_space(-1, &mut out), OAKCOMMON_OK); // none
assert_eq!(out, -1);
}
}
+498
View File
@@ -0,0 +1,498 @@
// 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/>.
//! FFI-level integration tests for the C-ABI `misc`, `debug`, and `current`
//! exports in `oakcommon::ffi`, asserted against the C++ oracle
//! (`src/common/c_api/*.cpp`) and the Rust domain modules
//! (`src/common/rust/src/{miscutils,debug}.rs`).
//!
//! The contract under test (each point matches the implementations):
//! - decibel/lerp/power helpers write into a non-null out-param and reject a
//! null one with `E_INVALID`; domain helpers never fail, so the only
//! failure path is the null out-param;
//! - `drop_workflow_behavior_is_valid` returns 1/0 without error codes, and
//! `drop_workflow_behavior_name` is a non-truncating two-stage getter
//! ("UNKNOWN" for out-of-range codes);
//! - `debug` exports: null messages are `E_INVALID`, the level-name getter
//! is a non-truncating two-stage getter, level codes outside 0..=4 are
//! rejected by `log_set_level` with `E_INVALID`, and the get-level export
//! rejects a null out-param;
//! - `current` exports: the singleton handle is stamped and `free` is
//! idempotent, setters/getters reject a null handle (getters also reject a
//! null out-param), empty slots read back as null, and replacing an
//! occupied slot runs the previous owner's destructor exactly once.
use std::ffi::{c_char, c_void, CString};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Mutex;
use oakcommon::error::{OAKCOMMON_E_INVALID, OAKCOMMON_OK};
use oakcommon::ffi::current::*;
use oakcommon::ffi::debug::*;
use oakcommon::ffi::misc::*;
use oakcommon::handle::{CHandle, OAKCOMMON_ABI_VERSION};
/// Serialises tests that mutate the process-wide `Current` singleton (cargo
/// runs tests on threads).
static CURRENT_LOCK: Mutex<()> = Mutex::new(());
/// Serialises tests that mutate the process-wide logging level.
static DEBUG_LOCK: Mutex<()> = Mutex::new(());
/// Destroy tally for the slot-replacement test.
static DESTROY_COUNT: AtomicUsize = AtomicUsize::new(0);
/// A `DestroyFn` that bumps [`DESTROY_COUNT`].
unsafe extern "C" fn count_destroy(_p: *mut c_void) {
DESTROY_COUNT.fetch_add(1, Ordering::SeqCst);
}
/// Convert a string slice to a NUL-terminated C string for FFI inputs.
fn to_cstring(s: &str) -> CString {
CString::new(s).expect("test string must not contain NUL")
}
/// Cheap struct copy: `CHandle` is neither `Clone` nor `Copy`, but every
/// getter takes it by value. Rebuilding from the same fields duplicates
/// only the handle value — the box stays alive as long as the original
/// handle lives, and the getters never release.
fn dup(h: &CHandle) -> CHandle {
CHandle {
ctx: h.ctx,
addref: h.addref,
release: h.release,
abi_version: h.abi_version,
}
}
/// Assert two f64 values agree within `tol`.
fn assert_close(actual: f64, expected: f64, tol: f64) {
assert!(
(actual - expected).abs() <= tol,
"expected {expected} ± {tol}, got {actual}"
);
}
/// Drive a two-stage string getter against the standard `copy_string`
/// convention: a null-buffer size query, a short buffer that must stay
/// untouched (no truncation), an exact-fit copy with its NUL, and an
/// oversized copy with the tail untouched.
fn assert_two_stage_getter(getter: impl Fn(*mut c_char, i32) -> i32, expected: &str) {
let required = (expected.len() + 1) as i32;
// Size query: a null buffer returns the required size, NUL included.
assert_eq!(getter(std::ptr::null_mut(), 0), required);
// Short buffer: too small, so nothing is written to it.
let short_size = (required - 1).max(0);
let mut short = vec![0xABu8; short_size as usize];
assert_eq!(getter(short.as_mut_ptr() as *mut c_char, short_size), required);
assert!(short.iter().all(|&b| b == 0xAB), "short buffer must stay untouched");
// Exact fit: payload followed by a NUL.
let mut exact = vec![0xCDu8; required as usize];
assert_eq!(getter(exact.as_mut_ptr() as *mut c_char, required), required);
assert_eq!(&exact[..expected.len()], expected.as_bytes());
assert_eq!(exact[expected.len()], 0);
// Oversized: payload and NUL written, tail left as initialized.
let mut big = vec![0u8; (required + 8) as usize];
assert_eq!(getter(big.as_mut_ptr() as *mut c_char, required + 8), required);
assert_eq!(&big[..expected.len()], expected.as_bytes());
assert_eq!(big[expected.len()], 0);
assert!(big[(required + 1) as usize..].iter().all(|&b| b == 0));
}
// ---- debug ----
/// A null message is `E_INVALID`.
#[test]
fn debug_log_null_msg_is_invalid() {
assert_eq!(oakcommon_debug_log(0, std::ptr::null()), OAKCOMMON_E_INVALID);
}
/// Any non-null message returns `OK`, including out-of-range level codes
/// (`log_raw` never fails on the level itself).
#[test]
fn debug_log_returns_ok_for_any_level() {
let msg = to_cstring("ffi_misc debug_log probe");
assert_eq!(oakcommon_debug_log(0, msg.as_ptr()), OAKCOMMON_OK);
let raw = to_cstring("out-of-range level");
assert_eq!(oakcommon_debug_log(99, raw.as_ptr()), OAKCOMMON_OK);
}
/// Level-name getter is a non-truncating two-stage getter; out-of-range
/// codes yield "UNKNOWN".
#[test]
fn debug_level_name_two_stage() {
assert_two_stage_getter(|buf, size| oakcommon_debug_level_name(0, buf, size), "DEBUG");
assert_two_stage_getter(|buf, size| oakcommon_debug_level_name(2, buf, size), "WARNING");
assert_two_stage_getter(|buf, size| oakcommon_debug_level_name(4, buf, size), "FATAL");
assert_two_stage_getter(|buf, size| oakcommon_debug_level_name(5, buf, size), "UNKNOWN");
assert_two_stage_getter(|buf, size| oakcommon_debug_level_name(-1, buf, size), "UNKNOWN");
}
/// `log_set_level` accepts 0..=4, rejects everything else with `E_INVALID`
/// without changing the stored level, and `log_get_level` reads it back.
#[test]
fn log_set_get_level_roundtrip_and_invalid() {
let _guard = DEBUG_LOCK.lock().unwrap_or_else(|e| e.into_inner());
assert_eq!(oakcommon_log_set_level(0), OAKCOMMON_OK);
let mut level = -1;
assert_eq!(oakcommon_log_get_level(&mut level), OAKCOMMON_OK);
assert_eq!(level, 0);
assert_eq!(oakcommon_log_set_level(4), OAKCOMMON_OK);
assert_eq!(oakcommon_log_get_level(&mut level), OAKCOMMON_OK);
assert_eq!(level, 4);
assert_eq!(oakcommon_log_set_level(5), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_log_set_level(-1), OAKCOMMON_E_INVALID);
// The rejected set leaves the stored level untouched.
assert_eq!(oakcommon_log_get_level(&mut level), OAKCOMMON_OK);
assert_eq!(level, 4);
// Restore the default so other tests see a clean level.
assert_eq!(oakcommon_log_set_level(1), OAKCOMMON_OK);
assert_eq!(oakcommon_log_get_level(&mut level), OAKCOMMON_OK);
assert_eq!(level, 1);
}
/// The get-level export rejects a null out-param.
#[test]
fn log_get_level_null_out_is_invalid() {
assert_eq!(oakcommon_log_get_level(std::ptr::null_mut()), OAKCOMMON_E_INVALID);
}
// ---- misc: decibel / lerp ----
/// Linear -> decibels for exact and mid-range inputs; zero (log10 = -inf)
/// collapses to the decibel minimum, negative inputs yield NaN.
#[test]
fn decibel_from_linear_known_values() {
let mut out = -1.0;
assert_eq!(oakcommon_decibel_from_linear(1.0, &mut out), OAKCOMMON_OK);
assert_close(out, 0.0, 1e-9);
assert_eq!(oakcommon_decibel_from_linear(10.0, &mut out), OAKCOMMON_OK);
assert_close(out, 20.0, 1e-9);
assert_eq!(oakcommon_decibel_from_linear(0.5, &mut out), OAKCOMMON_OK);
assert_close(out, -6.020599913, 1e-6);
assert_eq!(oakcommon_decibel_from_linear(0.0, &mut out), OAKCOMMON_OK);
assert_eq!(out, -200.0);
assert_eq!(oakcommon_decibel_from_linear(-1.0, &mut out), OAKCOMMON_OK);
assert!(out.is_nan(), "negative linear input must produce NaN, got {out}");
}
/// Decibels -> linear for exact inputs; results below 1e-6 clamp to 0.0.
#[test]
fn decibel_to_linear_known_values() {
let mut out = -1.0;
assert_eq!(oakcommon_decibel_to_linear(0.0, &mut out), OAKCOMMON_OK);
assert_close(out, 1.0, 1e-9);
assert_eq!(oakcommon_decibel_to_linear(20.0, &mut out), OAKCOMMON_OK);
assert_close(out, 10.0, 1e-9);
assert_eq!(oakcommon_decibel_to_linear(-100.0, &mut out), OAKCOMMON_OK);
assert_close(out, 1e-5, 1e-12);
assert_eq!(oakcommon_decibel_to_linear(-200.0, &mut out), OAKCOMMON_OK);
assert_eq!(out, 0.0);
}
/// Logarithmic position -> decibels: saturates to the minimum below 0.001,
/// to 0 dB above 0.99, and follows the linearization formula in between.
#[test]
fn decibel_from_logarithmic_known_values() {
let mut out = -1.0;
assert_eq!(oakcommon_decibel_from_logarithmic(0.0, &mut out), OAKCOMMON_OK);
assert_eq!(out, -200.0);
assert_eq!(oakcommon_decibel_from_logarithmic(1.0, &mut out), OAKCOMMON_OK);
assert_eq!(out, 0.0);
assert_eq!(oakcommon_decibel_from_logarithmic(0.99, &mut out), OAKCOMMON_OK);
assert_close(out, 0.0, 1e-6);
assert_eq!(oakcommon_decibel_from_logarithmic(0.5, &mut out), OAKCOMMON_OK);
assert_close(out, -16.45, 0.05);
}
/// Decibels -> logarithmic position; `|db| <= 1e-12` snaps to 1.0.
#[test]
fn decibel_to_logarithmic_known_values() {
let mut out = -1.0;
assert_eq!(oakcommon_decibel_to_logarithmic(0.0, &mut out), OAKCOMMON_OK);
assert_eq!(out, 1.0);
assert_eq!(oakcommon_decibel_to_logarithmic(20.0, &mut out), OAKCOMMON_OK);
assert_close(out, 1.0, 1e-9);
assert_eq!(oakcommon_decibel_to_logarithmic(-120.0, &mut out), OAKCOMMON_OK);
assert_close(out, 4.605e-6, 1e-9);
}
/// Linear amplitude -> logarithmic position (`1 - exp(-linear * lo_g100)`).
#[test]
fn decibel_linear_to_logarithmic_known_values() {
let mut out = -1.0;
assert_eq!(oakcommon_decibel_linear_to_logarithmic(0.0, &mut out), OAKCOMMON_OK);
assert_eq!(out, 0.0);
assert_eq!(oakcommon_decibel_linear_to_logarithmic(1.0, &mut out), OAKCOMMON_OK);
assert_close(out, 0.99, 1e-9);
}
/// Logarithmic position -> linear amplitude; values above 0.99 snap to 1.0.
#[test]
fn decibel_logarithmic_to_linear_known_values() {
let mut out = -1.0;
assert_eq!(oakcommon_decibel_logarithmic_to_linear(0.0, &mut out), OAKCOMMON_OK);
assert_eq!(out, 0.0);
assert_eq!(oakcommon_decibel_logarithmic_to_linear(1.0, &mut out), OAKCOMMON_OK);
assert_eq!(out, 1.0);
assert_eq!(oakcommon_decibel_logarithmic_to_linear(0.99, &mut out), OAKCOMMON_OK);
assert_close(out, 1.0, 1e-6);
}
/// `lerp(a, b, t) = a*(1-t) + b*t` at the endpoints and midpoints.
#[test]
fn lerp_known_values() {
let mut out = -1.0;
assert_eq!(oakcommon_lerp(0.0, 10.0, 0.5, &mut out), OAKCOMMON_OK);
assert_close(out, 5.0, 1e-9);
assert_eq!(oakcommon_lerp(0.0, 10.0, 0.0, &mut out), OAKCOMMON_OK);
assert_close(out, 0.0, 1e-9);
assert_eq!(oakcommon_lerp(0.0, 10.0, 1.0, &mut out), OAKCOMMON_OK);
assert_close(out, 10.0, 1e-9);
assert_eq!(oakcommon_lerp(2.0, 4.0, 0.25, &mut out), OAKCOMMON_OK);
assert_close(out, 2.5, 1e-9);
}
/// Every decibel/lerp export rejects a null out-param with `E_INVALID`.
#[test]
fn decibel_lerp_reject_null_out() {
assert_eq!(oakcommon_decibel_from_linear(1.0, std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_decibel_to_linear(0.0, std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_decibel_from_logarithmic(0.5, std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_decibel_to_logarithmic(0.0, std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_decibel_linear_to_logarithmic(0.5, std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_decibel_logarithmic_to_linear(0.5, std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_lerp(0.0, 1.0, 0.5, std::ptr::null_mut()), OAKCOMMON_E_INVALID);
}
// ---- misc: drop-workflow behavior / power ----
/// Codes 0..=3 are valid; everything else returns 0.
#[test]
fn drop_workflow_behavior_is_valid() {
for value in 0..=3 {
assert_eq!(oakcommon_drop_workflow_behavior_is_valid(value), 1);
}
assert_eq!(oakcommon_drop_workflow_behavior_is_valid(4), 0);
assert_eq!(oakcommon_drop_workflow_behavior_is_valid(-1), 0);
assert_eq!(oakcommon_drop_workflow_behavior_is_valid(i32::MAX), 0);
}
/// Behavior-name getter is a non-truncating two-stage getter; out-of-range
/// codes yield "UNKNOWN".
#[test]
fn drop_workflow_behavior_name_two_stage() {
assert_two_stage_getter(|buf, size| oakcommon_drop_workflow_behavior_name(0, buf, size), "ASK");
assert_two_stage_getter(|buf, size| oakcommon_drop_workflow_behavior_name(1, buf, size), "AUTO");
assert_two_stage_getter(|buf, size| oakcommon_drop_workflow_behavior_name(2, buf, size), "MANUAL");
assert_two_stage_getter(|buf, size| oakcommon_drop_workflow_behavior_name(3, buf, size), "DISABLE");
assert_two_stage_getter(|buf, size| oakcommon_drop_workflow_behavior_name(4, buf, size), "UNKNOWN");
assert_two_stage_getter(|buf, size| oakcommon_drop_workflow_behavior_name(-1, buf, size), "UNKNOWN");
}
/// Round `value` up to a power of two (wrapping overflow -> 0); a null
/// out-param is `E_INVALID`.
#[test]
fn power_ceil_to_power_of_2() {
let mut out = 0u32;
for (input, expected) in [
(0u32, 0u32),
(1, 1),
(2, 2),
(3, 4),
(5, 8),
(9, 16),
(0x8000_0001, 0),
] {
assert_eq!(oakcommon_power_ceil_to_power_of_2(input, &mut out), OAKCOMMON_OK);
assert_eq!(out, expected, "ceil({input})");
}
assert_eq!(
oakcommon_power_ceil_to_power_of_2(7, std::ptr::null_mut()),
OAKCOMMON_E_INVALID
);
}
/// Round `value` down to a power of two; a null out-param is `E_INVALID`.
#[test]
fn power_floor_to_power_of_2() {
let mut out = 0u32;
for (input, expected) in [
(0u32, 0u32),
(1, 1),
(4, 4),
(5, 4),
(9, 8),
(0x8000_0000, 0x8000_0000),
] {
assert_eq!(oakcommon_power_floor_to_power_of_2(input, &mut out), OAKCOMMON_OK);
assert_eq!(out, expected, "floor({input})");
}
assert_eq!(
oakcommon_power_floor_to_power_of_2(7, std::ptr::null_mut()),
OAKCOMMON_E_INVALID
);
}
// ---- current ----
/// The singleton handle is stamped; `free` nullifies it, is idempotent, and
/// tolerates a null pointer or an explicit null handle.
#[test]
fn current_instance_and_free_lifecycle() {
let mut h = oakcommon_current_instance();
assert!(!h.is_null());
assert_eq!(h.abi_version, OAKCOMMON_ABI_VERSION);
assert!(h.addref.is_some());
assert!(h.release.is_some());
oakcommon_current_free(&mut h);
assert!(h.is_null());
// A second free of the now-empty handle is safe.
oakcommon_current_free(&mut h);
assert!(h.is_null());
// Freeing a null pointer is safe.
oakcommon_current_free(std::ptr::null_mut());
// Freeing an explicit null handle is safe.
let mut null_h = CHandle::null();
oakcommon_current_free(&mut null_h);
assert!(null_h.is_null());
}
/// All four slots round-trip set -> get; null handles and null out-params
/// are `E_INVALID`, and clearing a slot makes it read back as null.
#[test]
fn current_set_get_all_slots_roundtrip() {
let _guard = CURRENT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let h = oakcommon_current_instance();
let video = 0x1000usize as *mut c_void;
let audio = 0x2000usize as *mut c_void;
let host = 0x3000usize as *mut c_void;
let cache = 0x4000usize as *mut c_void;
// Empty slots read back as null before anything is stored.
let mut got: *mut c_void = std::ptr::null_mut();
assert_eq!(oakcommon_current_get_video_params(dup(&h), &mut got), OAKCOMMON_OK);
assert!(got.is_null());
assert_eq!(oakcommon_current_set_video_params(dup(&h), video, None), OAKCOMMON_OK);
assert_eq!(oakcommon_current_set_audio_params(dup(&h), audio, None), OAKCOMMON_OK);
assert_eq!(oakcommon_current_set_plugin_host(dup(&h), host, None), OAKCOMMON_OK);
assert_eq!(oakcommon_current_set_plugin_cache(dup(&h), cache, None), OAKCOMMON_OK);
assert_eq!(oakcommon_current_get_video_params(dup(&h), &mut got), OAKCOMMON_OK);
assert_eq!(got, video);
assert_eq!(oakcommon_current_get_audio_params(dup(&h), &mut got), OAKCOMMON_OK);
assert_eq!(got, audio);
assert_eq!(oakcommon_current_get_plugin_host(dup(&h), &mut got), OAKCOMMON_OK);
assert_eq!(got, host);
assert_eq!(oakcommon_current_get_plugin_cache(dup(&h), &mut got), OAKCOMMON_OK);
assert_eq!(got, cache);
// Getters reject a null out-param and a null handle.
assert_eq!(
oakcommon_current_get_video_params(dup(&h), std::ptr::null_mut()),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_current_get_video_params(CHandle::null(), &mut got),
OAKCOMMON_E_INVALID
);
// Setters reject a null handle.
assert_eq!(
oakcommon_current_set_video_params(CHandle::null(), video, None),
OAKCOMMON_E_INVALID
);
// Clear every slot so other tests see a clean singleton.
assert_eq!(oakcommon_current_set_video_params(dup(&h), std::ptr::null_mut(), None), OAKCOMMON_OK);
assert_eq!(oakcommon_current_set_audio_params(dup(&h), std::ptr::null_mut(), None), OAKCOMMON_OK);
assert_eq!(oakcommon_current_set_plugin_host(dup(&h), std::ptr::null_mut(), None), OAKCOMMON_OK);
assert_eq!(oakcommon_current_set_plugin_cache(dup(&h), std::ptr::null_mut(), None), OAKCOMMON_OK);
assert_eq!(oakcommon_current_get_video_params(dup(&h), &mut got), OAKCOMMON_OK);
assert!(got.is_null());
}
/// Replacing an occupied slot runs the previous owner's destructor exactly
/// once; clearing a slot whose occupant has no destructor runs nothing.
#[test]
fn current_set_destroys_replaced_pointer() {
let _guard = CURRENT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let h = oakcommon_current_instance();
let base = DESTROY_COUNT.load(Ordering::SeqCst);
assert_eq!(
oakcommon_current_set_video_params(dup(&h), 0xAAAAusize as *mut c_void, Some(count_destroy)),
OAKCOMMON_OK
);
assert_eq!(DESTROY_COUNT.load(Ordering::SeqCst), base);
// Replacing the slot invokes the stored destructor exactly once.
assert_eq!(
oakcommon_current_set_video_params(dup(&h), 0xBBBBusize as *mut c_void, None),
OAKCOMMON_OK
);
assert_eq!(DESTROY_COUNT.load(Ordering::SeqCst), base + 1);
let mut got: *mut c_void = std::ptr::null_mut();
assert_eq!(oakcommon_current_get_video_params(dup(&h), &mut got), OAKCOMMON_OK);
assert_eq!(got, 0xBBBBusize as *mut c_void);
// Clearing a slot with no destructor invokes nothing.
assert_eq!(
oakcommon_current_set_video_params(dup(&h), std::ptr::null_mut(), None),
OAKCOMMON_OK
);
assert_eq!(DESTROY_COUNT.load(Ordering::SeqCst), base + 1);
assert_eq!(oakcommon_current_get_video_params(dup(&h), &mut got), OAKCOMMON_OK);
assert!(got.is_null());
}
/// `is_interactive` writes 1; null handle/out-param are `E_INVALID`.
#[test]
fn current_is_interactive_writes_one() {
let _guard = CURRENT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let h = oakcommon_current_instance();
let mut out = 0i32;
assert_eq!(oakcommon_current_is_interactive(dup(&h), &mut out), OAKCOMMON_OK);
assert_eq!(out, 1);
assert_eq!(
oakcommon_current_is_interactive(dup(&h), std::ptr::null_mut()),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_current_is_interactive(CHandle::null(), &mut out),
OAKCOMMON_E_INVALID
);
}
+539
View File
@@ -0,0 +1,539 @@
// 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/>.
//! FFI-level integration tests for the C-ABI `subtitleparams` exports in
//! `oakcommon::ffi::subtitleparams`, asserted against the C++ oracle
//! `src/common/c_api/subtitleparams.cpp` and the Rust domain
//! `src/common/rust/src/subtitleparams.rs`.
//!
//! The contract under test (each point matches the C++ oracle):
//! - exports take a `CHandle` by value and never release it, so callers
//! keep the handle alive and `free` it exactly once;
//! - `free` nullifies the handle, is idempotent, and tolerates a null
//! pointer;
//! - two-stage string getters return the required size (NUL included) and
//! only copy when the buffer is large enough — they never truncate;
//! - getters reject a null handle or null out-param with `E_INVALID`,
//! setters reject a null handle, and string inputs reject null pointers;
//! - an out-of-range subtitle index yields `E_NOT_FOUND`; malformed XML
//! yields `E_FAILED`; the empty set is invalid with `count` 0 and
//! `duration` 0/1.
use std::ffi::{c_char, CString};
use oakcommon::error::{
OAKCOMMON_E_FAILED, OAKCOMMON_E_INVALID, OAKCOMMON_E_NOT_FOUND, OAKCOMMON_OK,
};
use oakcommon::ffi::subtitleparams::*;
use oakcommon::handle::{CHandle, OAKCOMMON_ABI_VERSION};
/// Create an empty subtitle parameter set.
fn make() -> CHandle {
oakcommon_subtitleparams_init()
}
/// A populated set: stream index 2, disabled, two subtitles.
fn make_populated() -> CHandle {
let h = make();
assert_eq!(
oakcommon_subtitleparams_add_subtitle(dup(&h), 0, 1, 25, 1, to_cstring("hello").as_ptr()),
OAKCOMMON_OK
);
assert_eq!(
oakcommon_subtitleparams_add_subtitle(dup(&h), 25, 1, 50, 1, to_cstring("world").as_ptr()),
OAKCOMMON_OK
);
assert_eq!(oakcommon_subtitleparams_set_stream_index(dup(&h), 2), OAKCOMMON_OK);
assert_eq!(oakcommon_subtitleparams_set_enabled(dup(&h), 0), OAKCOMMON_OK);
h
}
/// Convert a string slice to a NUL-terminated C string for FFI inputs.
fn to_cstring(s: &str) -> CString {
CString::new(s).expect("test string must not contain NUL")
}
/// Cheap struct copy: `CHandle` is neither `Clone` nor `Copy`, but every
/// getter takes it by value. Rebuilding from the same fields duplicates
/// only the handle value — the box stays alive as long as the original
/// handle lives, and the getters never release.
fn dup(h: &CHandle) -> CHandle {
CHandle {
ctx: h.ctx,
addref: h.addref,
release: h.release,
abi_version: h.abi_version,
}
}
/// Drive a two-stage string getter against the C++ `copy_string`
/// convention: a null-buffer size query, a short buffer that must stay
/// untouched (no truncation), an exact-fit copy with its NUL, and an
/// oversized copy with the tail untouched.
fn assert_two_stage_getter(getter: impl Fn(*mut c_char, i32) -> i32, expected: &str) {
let required = (expected.len() + 1) as i32;
// Size query: a null buffer returns the required size, NUL included.
assert_eq!(getter(std::ptr::null_mut(), 0), required);
// Short buffer: too small, so nothing is written to it.
let short_size = (required - 1).max(0);
let mut short = vec![0xABu8; short_size as usize];
assert_eq!(getter(short.as_mut_ptr() as *mut c_char, short_size), required);
assert!(short.iter().all(|&b| b == 0xAB), "short buffer must stay untouched");
// Exact fit: payload followed by a NUL.
let mut exact = vec![0xCDu8; required as usize];
assert_eq!(getter(exact.as_mut_ptr() as *mut c_char, required), required);
assert_eq!(&exact[..expected.len()], expected.as_bytes());
assert_eq!(exact[expected.len()], 0);
// Oversized: payload and NUL written, tail left as initialized.
let mut big = vec![0u8; (required + 8) as usize];
assert_eq!(getter(big.as_mut_ptr() as *mut c_char, required + 8), required);
assert_eq!(&big[..expected.len()], expected.as_bytes());
assert_eq!(big[expected.len()], 0);
assert!(big[(required + 1) as usize..].iter().all(|&b| b == 0));
}
// ---- Handle lifecycle ----
/// `init` yields a stamped, non-empty handle; `free` nullifies it, is
/// idempotent, and tolerates a null pointer.
#[test]
fn init_free_lifecycle() {
let h = oakcommon_subtitleparams_init();
assert!(!h.is_null());
assert_eq!(h.abi_version, OAKCOMMON_ABI_VERSION);
assert!(h.addref.is_some());
assert!(h.release.is_some());
let mut hf = make();
assert!(!hf.is_null());
oakcommon_subtitleparams_free(&mut hf);
assert!(hf.is_null());
// A second free of the now-empty handle is safe.
oakcommon_subtitleparams_free(&mut hf);
assert!(hf.is_null());
// Freeing a null pointer is safe.
oakcommon_subtitleparams_free(std::ptr::null_mut());
// Freeing a handle struct that is already null (by value) is safe too.
let mut empty = CHandle::null();
oakcommon_subtitleparams_free(&mut empty);
assert!(empty.is_null());
}
// ---- Core field round-trips ----
/// Stream index defaults to 0 and round-trips; a null handle or null
/// out-param is `E_INVALID`.
#[test]
fn stream_index_roundtrip() {
let h = make();
let mut si = -1i32;
assert_eq!(oakcommon_subtitleparams_get_stream_index(dup(&h), &mut si), OAKCOMMON_OK);
assert_eq!(si, 0);
assert_eq!(oakcommon_subtitleparams_set_stream_index(dup(&h), 3), OAKCOMMON_OK);
assert_eq!(oakcommon_subtitleparams_get_stream_index(dup(&h), &mut si), OAKCOMMON_OK);
assert_eq!(si, 3);
assert_eq!(oakcommon_subtitleparams_set_stream_index(CHandle::null(), 1), OAKCOMMON_E_INVALID);
assert_eq!(
oakcommon_subtitleparams_get_stream_index(CHandle::null(), &mut si),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_subtitleparams_get_stream_index(dup(&h), std::ptr::null_mut()),
OAKCOMMON_E_INVALID
);
}
/// `enabled` defaults to 1 (CPP-PARITY: the C++ constructor sets
/// `enabled_ = true`) and round-trips; any non-zero setter value enables.
#[test]
fn enabled_roundtrip() {
let h = make();
let mut en = -1i32;
assert_eq!(oakcommon_subtitleparams_get_enabled(dup(&h), &mut en), OAKCOMMON_OK);
assert_eq!(en, 1);
assert_eq!(oakcommon_subtitleparams_set_enabled(dup(&h), 0), OAKCOMMON_OK);
assert_eq!(oakcommon_subtitleparams_get_enabled(dup(&h), &mut en), OAKCOMMON_OK);
assert_eq!(en, 0);
// A non-zero code (even 5) enables the stream.
assert_eq!(oakcommon_subtitleparams_set_enabled(dup(&h), 5), OAKCOMMON_OK);
assert_eq!(oakcommon_subtitleparams_get_enabled(dup(&h), &mut en), OAKCOMMON_OK);
assert_eq!(en, 1);
assert_eq!(oakcommon_subtitleparams_set_enabled(CHandle::null(), 1), OAKCOMMON_E_INVALID);
assert_eq!(
oakcommon_subtitleparams_get_enabled(CHandle::null(), &mut en),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_subtitleparams_get_enabled(dup(&h), std::ptr::null_mut()),
OAKCOMMON_E_INVALID
);
}
// ---- Empty-set behavior ----
/// An empty set is invalid, has count 0, and duration 0/1; null handle or
/// null out-param is `E_INVALID`.
#[test]
fn empty_set_defaults() {
let h = make();
let mut v = -1i32;
let mut c = -1i32;
let mut n = -1i32;
let mut d = -1i32;
assert_eq!(oakcommon_subtitleparams_is_valid(dup(&h), &mut v), OAKCOMMON_OK);
assert_eq!(v, 0);
assert_eq!(oakcommon_subtitleparams_count(dup(&h), &mut c), OAKCOMMON_OK);
assert_eq!(c, 0);
assert_eq!(oakcommon_subtitleparams_duration(dup(&h), &mut n, &mut d), OAKCOMMON_OK);
assert_eq!((n, d), (0, 1));
assert_eq!(oakcommon_subtitleparams_is_valid(CHandle::null(), &mut v), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_subtitleparams_count(CHandle::null(), &mut c), OAKCOMMON_E_INVALID);
assert_eq!(
oakcommon_subtitleparams_duration(CHandle::null(), &mut n, &mut d),
OAKCOMMON_E_INVALID
);
assert_eq!(oakcommon_subtitleparams_is_valid(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_subtitleparams_count(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(
oakcommon_subtitleparams_duration(dup(&h), std::ptr::null_mut(), &mut d),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_subtitleparams_duration(dup(&h), &mut n, std::ptr::null_mut()),
OAKCOMMON_E_INVALID
);
}
// ---- Subtitles ----
/// `add_subtitle` appends entries; `is_valid`/`count`/`duration`/
/// `get_subtitle` reflect the populated set. Rationals are reduced
/// (CPP-PARITY with the C++ `Rational` constructor).
#[test]
fn add_subtitle_and_query() {
let h = make_populated();
let mut v = -1i32;
let mut c = -1i32;
let mut n = -1i32;
let mut d = -1i32;
assert_eq!(oakcommon_subtitleparams_is_valid(dup(&h), &mut v), OAKCOMMON_OK);
assert_eq!(v, 1);
assert_eq!(oakcommon_subtitleparams_count(dup(&h), &mut c), OAKCOMMON_OK);
assert_eq!(c, 2);
assert_eq!(oakcommon_subtitleparams_duration(dup(&h), &mut n, &mut d), OAKCOMMON_OK);
assert_eq!((n, d), (50, 1));
assert_eq!(oakcommon_subtitleparams_get_subtitle(dup(&h), 0, &mut n, &mut d, &mut v, &mut c), OAKCOMMON_OK);
assert_eq!((n, d), (0, 1));
assert_eq!((v, c), (25, 1));
assert_eq!(oakcommon_subtitleparams_get_subtitle(dup(&h), 1, &mut n, &mut d, &mut v, &mut c), OAKCOMMON_OK);
assert_eq!((n, d), (25, 1));
assert_eq!((v, c), (50, 1));
// Reduction: Rational(2,4) -> 1/2, Rational(9,3) -> 3/1.
assert_eq!(
oakcommon_subtitleparams_add_subtitle(dup(&h), 2, 4, 9, 3, to_cstring("t").as_ptr()),
OAKCOMMON_OK
);
assert_eq!(oakcommon_subtitleparams_get_subtitle(dup(&h), 2, &mut n, &mut d, &mut v, &mut c), OAKCOMMON_OK);
assert_eq!((n, d), (1, 2));
assert_eq!((v, c), (3, 1));
}
/// `add_subtitle` rejects a null handle or null text; an empty text string
/// is a valid subtitle.
#[test]
fn add_subtitle_failures() {
let h = make();
assert_eq!(
oakcommon_subtitleparams_add_subtitle(CHandle::null(), 0, 1, 1, 1, to_cstring("x").as_ptr()),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_subtitleparams_add_subtitle(dup(&h), 0, 1, 1, 1, std::ptr::null()),
OAKCOMMON_E_INVALID
);
// Empty text is fine and counts as a subtitle.
assert_eq!(
oakcommon_subtitleparams_add_subtitle(dup(&h), 0, 1, 1, 1, to_cstring("").as_ptr()),
OAKCOMMON_OK
);
let mut c = -1i32;
assert_eq!(oakcommon_subtitleparams_count(dup(&h), &mut c), OAKCOMMON_OK);
assert_eq!(c, 1);
}
/// `get_subtitle` on an out-of-range index is `E_NOT_FOUND`; a null handle
/// or any null out-param is `E_INVALID`.
#[test]
fn get_subtitle_out_of_range() {
let h = make_populated();
let mut n = -1i32;
let mut d = -1i32;
let mut v = -1i32;
let mut c = -1i32;
assert_eq!(oakcommon_subtitleparams_get_subtitle(dup(&h), 2, &mut n, &mut d, &mut v, &mut c), OAKCOMMON_E_NOT_FOUND);
assert_eq!(oakcommon_subtitleparams_get_subtitle(dup(&h), -1, &mut n, &mut d, &mut v, &mut c), OAKCOMMON_E_NOT_FOUND);
// On an empty set even index 0 is out of range.
let e = make();
assert_eq!(oakcommon_subtitleparams_get_subtitle(dup(&e), 0, &mut n, &mut d, &mut v, &mut c), OAKCOMMON_E_NOT_FOUND);
assert_eq!(
oakcommon_subtitleparams_get_subtitle(CHandle::null(), 0, &mut n, &mut d, &mut v, &mut c),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_subtitleparams_get_subtitle(dup(&h), 0, std::ptr::null_mut(), &mut d, &mut v, &mut c),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_subtitleparams_get_subtitle(dup(&h), 0, &mut n, std::ptr::null_mut(), &mut v, &mut c),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_subtitleparams_get_subtitle(dup(&h), 0, &mut n, &mut d, std::ptr::null_mut(), &mut c),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_subtitleparams_get_subtitle(dup(&h), 0, &mut n, &mut d, &mut v, std::ptr::null_mut()),
OAKCOMMON_E_INVALID
);
}
/// `clear` removes every subtitle (count 0, invalid, duration 0/1) and
/// rejects a null handle.
#[test]
fn clear() {
let h = make_populated();
assert_eq!(oakcommon_subtitleparams_clear(dup(&h)), OAKCOMMON_OK);
let mut v = -1i32;
let mut c = -1i32;
let mut n = -1i32;
let mut d = -1i32;
assert_eq!(oakcommon_subtitleparams_count(dup(&h), &mut c), OAKCOMMON_OK);
assert_eq!(c, 0);
assert_eq!(oakcommon_subtitleparams_is_valid(dup(&h), &mut v), OAKCOMMON_OK);
assert_eq!(v, 0);
assert_eq!(oakcommon_subtitleparams_duration(dup(&h), &mut n, &mut d), OAKCOMMON_OK);
assert_eq!((n, d), (0, 1));
// Clearing again is a no-op success.
assert_eq!(oakcommon_subtitleparams_clear(dup(&h)), OAKCOMMON_OK);
assert_eq!(oakcommon_subtitleparams_clear(CHandle::null()), OAKCOMMON_E_INVALID);
}
// ---- String getters ----
/// `get_subtitle_text` is a two-stage string getter; an out-of-range index
/// is `E_NOT_FOUND`, and a null handle or invalid out-buffer is
/// `E_INVALID`.
#[test]
fn get_subtitle_text_two_stage() {
let h = make_populated();
assert_two_stage_getter(
|buf, size| oakcommon_subtitleparams_get_subtitle_text(dup(&h), 0, buf, size),
"hello",
);
assert_two_stage_getter(
|buf, size| oakcommon_subtitleparams_get_subtitle_text(dup(&h), 1, buf, size),
"world",
);
// Out-of-range index is E_NOT_FOUND (even on a size query).
assert_eq!(
oakcommon_subtitleparams_get_subtitle_text(dup(&h), 2, std::ptr::null_mut(), 0),
OAKCOMMON_E_NOT_FOUND
);
assert_eq!(
oakcommon_subtitleparams_get_subtitle_text(dup(&h), -1, std::ptr::null_mut(), 0),
OAKCOMMON_E_NOT_FOUND
);
assert_eq!(
oakcommon_subtitleparams_get_subtitle_text(CHandle::null(), 0, std::ptr::null_mut(), 0),
OAKCOMMON_E_INVALID
);
// A null buffer with a positive size is an invalid string out-param.
assert_eq!(
oakcommon_subtitleparams_get_subtitle_text(dup(&h), 0, std::ptr::null_mut(), 5),
OAKCOMMON_E_INVALID
);
// A negative size is invalid too.
assert_eq!(
oakcommon_subtitleparams_get_subtitle_text(dup(&h), 0, std::ptr::null_mut(), -1),
OAKCOMMON_E_INVALID
);
}
/// `generate_ass_header` is a static two-stage string getter (no handle).
/// The payload matches the C++ header verbatim (CRLF line endings).
#[test]
fn generate_ass_header_two_stage() {
let expected = "[Script Info]\r\n\
; Script generated by Oak\r\n\
ScriptType: v4.00+\r\n\
PlayResX: 384\r\n\
PlayResY: 288\r\n\
ScaledBorderAndShadow: yes\r\n\
\r\n\
[V4+ Styles]\r\n\
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, \
BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, \
BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\r\n\
Style: Default,Arial,16,&HFFFFFF,&HFFFFFF,&H000000,&H000000,0,0,0,0,100,100,0,0,1,1,0,2,10,10,10,0\r\n\
\r\n\
[Events]\r\n\
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\r\n";
assert_two_stage_getter(
|buf, size| oakcommon_subtitleparams_generate_ass_header(buf, size),
expected,
);
// An invalid out-buffer (null with a positive size) is E_INVALID.
assert_eq!(
oakcommon_subtitleparams_generate_ass_header(std::ptr::null_mut(), 5),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_subtitleparams_generate_ass_header(std::ptr::null_mut(), -1),
OAKCOMMON_E_INVALID
);
}
// ---- XML ----
/// `load_xml` parses a fragment and applies stream index, enabled, and
/// subtitles; malformed input or a missing root is `E_FAILED`; a null
/// handle or null xml is `E_INVALID`.
#[test]
fn load_xml() {
let h = make();
let xml = "<subtitleparams><streamindex>7</streamindex><enabled>0</enabled>\
<subtitles><subtitle in=\"0/1\" out=\"25/1\">hello</subtitle>\
<subtitle in=\"25/1\" out=\"50/1\">world</subtitle></subtitles></subtitleparams>";
assert_eq!(oakcommon_subtitleparams_load_xml(dup(&h), to_cstring(xml).as_ptr()), OAKCOMMON_OK);
let mut si = -1i32;
let mut en = -1i32;
let mut c = -1i32;
assert_eq!(oakcommon_subtitleparams_get_stream_index(dup(&h), &mut si), OAKCOMMON_OK);
assert_eq!(si, 7);
assert_eq!(oakcommon_subtitleparams_get_enabled(dup(&h), &mut en), OAKCOMMON_OK);
assert_eq!(en, 0);
assert_eq!(oakcommon_subtitleparams_count(dup(&h), &mut c), OAKCOMMON_OK);
assert_eq!(c, 2);
assert_eq!(oakcommon_subtitleparams_get_subtitle_text(dup(&h), 0, std::ptr::null_mut(), 0), 6);
assert_two_stage_getter(
|buf, size| oakcommon_subtitleparams_get_subtitle_text(dup(&h), 1, buf, size),
"world",
);
// Malformed / missing-root fragments fail with E_FAILED.
assert_eq!(
oakcommon_subtitleparams_load_xml(dup(&h), to_cstring("<subtitleparams><streamindex>").as_ptr()),
OAKCOMMON_E_FAILED
);
assert_eq!(
oakcommon_subtitleparams_load_xml(dup(&h), to_cstring("not xml").as_ptr()),
OAKCOMMON_E_FAILED
);
assert_eq!(
oakcommon_subtitleparams_load_xml(dup(&h), to_cstring("").as_ptr()),
OAKCOMMON_E_FAILED
);
assert_eq!(
oakcommon_subtitleparams_load_xml(CHandle::null(), to_cstring(xml).as_ptr()),
OAKCOMMON_E_INVALID
);
assert_eq!(oakcommon_subtitleparams_load_xml(dup(&h), std::ptr::null()), OAKCOMMON_E_INVALID);
}
/// `save_xml` is a two-stage string getter; the output matches the C++
/// `XmlStreamWriter` format exactly (no indentation, escaped text).
#[test]
fn save_xml_two_stage() {
// The empty set serializes to streamindex 0 / enabled 1 / no subtitles.
let e = make();
assert_two_stage_getter(
|buf, size| oakcommon_subtitleparams_save_xml(dup(&e), buf, size),
"<subtitleparams><streamindex>0</streamindex><enabled>1</enabled><subtitles></subtitles></subtitleparams>",
);
// The populated set round-trips through load_xml.
let h = make_populated();
let expected = "<subtitleparams><streamindex>2</streamindex><enabled>0</enabled>\
<subtitles><subtitle in=\"0/1\" out=\"25/1\">hello</subtitle>\
<subtitle in=\"25/1\" out=\"50/1\">world</subtitle></subtitles></subtitleparams>";
assert_two_stage_getter(
|buf, size| oakcommon_subtitleparams_save_xml(dup(&h), buf, size),
expected,
);
// A loaded fragment round-trips byte-for-byte.
let xml = "<subtitleparams><streamindex>9</streamindex><enabled>1</enabled>\
<subtitles><subtitle in=\"3/2\" out=\"5/1\">hi</subtitle></subtitles></subtitleparams>";
assert_eq!(oakcommon_subtitleparams_load_xml(dup(&h), to_cstring(xml).as_ptr()), OAKCOMMON_OK);
assert_two_stage_getter(
|buf, size| oakcommon_subtitleparams_save_xml(dup(&h), buf, size),
xml,
);
assert_eq!(
oakcommon_subtitleparams_save_xml(CHandle::null(), std::ptr::null_mut(), 0),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_subtitleparams_save_xml(dup(&h), std::ptr::null_mut(), 5),
OAKCOMMON_E_INVALID
);
// The saved XML parses back to the same data via the C API.
let mut si = -1i32;
let mut en = -1i32;
let mut c = -1i32;
let mut n = -1i32;
let mut d = -1i32;
assert_eq!(oakcommon_subtitleparams_get_stream_index(dup(&h), &mut si), OAKCOMMON_OK);
assert_eq!(si, 9);
assert_eq!(oakcommon_subtitleparams_get_enabled(dup(&h), &mut en), OAKCOMMON_OK);
assert_eq!(en, 1);
assert_eq!(oakcommon_subtitleparams_count(dup(&h), &mut c), OAKCOMMON_OK);
assert_eq!(c, 1);
assert_eq!(oakcommon_subtitleparams_get_subtitle(dup(&h), 0, &mut n, &mut d, &mut si, &mut en), OAKCOMMON_OK);
assert_eq!((n, d), (3, 2));
assert_eq!((si, en), (5, 1));
}
+775
View File
@@ -0,0 +1,775 @@
// 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/>.
//! FFI-level integration tests for the C-ABI `videoparams` exports in
//! `oakcommon::ffi::videoparams`, asserted against the C++ oracle
//! `src/common/c_api/videoparams.cpp` and the Rust domain
//! `src/common/rust/src/videoparams.rs`.
//!
//! The contract under test (each point matches the C++ oracle):
//! - exports take a `CHandle` by value and never release it, so callers
//! keep the handle alive and `free` it exactly once;
//! - `free` nullifies the handle, is idempotent, and tolerates a null
//! pointer;
//! - two-stage string getters return the required size (NUL included) and
//! only copy when the buffer is large enough — they never truncate;
//! - getters reject a null handle or null out-param with `E_INVALID`,
//! setters reject a null handle, and string inputs reject null pointers;
//! - out-of-range enum-like setter codes clamp to a documented fallback
//! (CPP-PARITY with the C++ `static_cast` semantics).
use std::ffi::{c_char, CStr, CString};
use oakcommon::error::{OAKCOMMON_E_FAILED, OAKCOMMON_E_INVALID, OAKCOMMON_OK};
use oakcommon::ffi::videoparams::*;
use oakcommon::handle::{CHandle, OAKCOMMON_ABI_VERSION};
/// `init_basic` arguments: 1920x1080 U8 RGBA, square pixels, progressive,
/// divider 1.
fn make() -> CHandle {
oakcommon_videoparams_init_basic(1920, 1080, 0, 4, 1, 1, 0, 1)
}
/// `init_with_time_base` arguments: 1920x1080 U8 RGBA, time base 1001/30000,
/// square pixels, progressive, divider 1.
fn make_tb() -> CHandle {
oakcommon_videoparams_init_with_time_base(1920, 1080, 1001, 30000, 0, 4, 1, 1, 0, 1)
}
/// Convert a string slice to a NUL-terminated C string for FFI inputs.
fn to_cstring(s: &str) -> CString {
CString::new(s).expect("test string must not contain NUL")
}
/// Cheap struct copy: `CHandle` is neither `Clone` nor `Copy`, but every
/// getter takes it by value. Rebuilding from the same fields duplicates
/// only the handle value — the box stays alive as long as the original
/// handle lives, and the getters never release.
fn dup(h: &CHandle) -> CHandle {
CHandle {
ctx: h.ctx,
addref: h.addref,
release: h.release,
abi_version: h.abi_version,
}
}
/// Drive a two-stage string getter against the C++ `copy_string`
/// convention: a null-buffer size query, a short buffer that must stay
/// untouched (no truncation), an exact-fit copy with its NUL, and an
/// oversized copy with the tail untouched.
fn assert_two_stage_getter(getter: impl Fn(*mut c_char, i32) -> i32, expected: &str) {
let required = (expected.len() + 1) as i32;
// Size query: a null buffer returns the required size, NUL included.
assert_eq!(getter(std::ptr::null_mut(), 0), required);
// Short buffer: too small, so nothing is written to it.
let short_size = (required - 1).max(0);
let mut short = vec![0xABu8; short_size as usize];
assert_eq!(getter(short.as_mut_ptr() as *mut c_char, short_size), required);
assert!(short.iter().all(|&b| b == 0xAB), "short buffer must stay untouched");
// Exact fit: payload followed by a NUL.
let mut exact = vec![0xCDu8; required as usize];
assert_eq!(getter(exact.as_mut_ptr() as *mut c_char, required), required);
assert_eq!(&exact[..expected.len()], expected.as_bytes());
assert_eq!(exact[expected.len()], 0);
// Oversized: payload and NUL written, tail left as initialized.
let mut big = vec![0u8; (required + 8) as usize];
assert_eq!(getter(big.as_mut_ptr() as *mut c_char, required + 8), required);
assert_eq!(&big[..expected.len()], expected.as_bytes());
assert_eq!(big[expected.len()], 0);
assert!(big[(required + 1) as usize..].iter().all(|&b| b == 0));
}
// ---- Handle lifecycle ----
/// All three constructors yield a stamped, non-empty handle; `free`
/// nullifies it, is idempotent, and tolerates a null pointer.
#[test]
fn init_free_lifecycle() {
let h = oakcommon_videoparams_init();
assert!(!h.is_null());
assert_eq!(h.abi_version, OAKCOMMON_ABI_VERSION);
assert!(h.addref.is_some());
assert!(h.release.is_some());
let hb = make();
assert!(!hb.is_null());
assert_eq!(hb.abi_version, OAKCOMMON_ABI_VERSION);
let ht = make_tb();
assert!(!ht.is_null());
assert_eq!(ht.abi_version, OAKCOMMON_ABI_VERSION);
let mut hf = oakcommon_videoparams_init_basic(1, 1, 4, 4, 1, 1, 0, 1);
assert!(!hf.is_null());
oakcommon_videoparams_free(&mut hf);
assert!(hf.is_null());
// A second free of the now-empty handle is safe.
oakcommon_videoparams_free(&mut hf);
assert!(hf.is_null());
// Freeing a null pointer is safe.
oakcommon_videoparams_free(std::ptr::null_mut());
}
// ---- Core field round-trips ----
/// Width, height, and depth round-trip; a null handle or null out-param is
/// `E_INVALID`.
#[test]
fn width_height_depth_roundtrip() {
let h = make();
let mut w = -1i32;
let mut ht = -1i32;
let mut d = -1i32;
assert_eq!(oakcommon_videoparams_get_width(dup(&h), &mut w), OAKCOMMON_OK);
assert_eq!(w, 1920);
assert_eq!(oakcommon_videoparams_get_height(dup(&h), &mut ht), OAKCOMMON_OK);
assert_eq!(ht, 1080);
assert_eq!(oakcommon_videoparams_get_depth(dup(&h), &mut d), OAKCOMMON_OK);
assert_eq!(d, 1);
assert_eq!(oakcommon_videoparams_set_width(dup(&h), 640), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_set_height(dup(&h), 480), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_set_depth(dup(&h), 2), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_width(dup(&h), &mut w), OAKCOMMON_OK);
assert_eq!(w, 640);
assert_eq!(oakcommon_videoparams_get_height(dup(&h), &mut ht), OAKCOMMON_OK);
assert_eq!(ht, 480);
assert_eq!(oakcommon_videoparams_get_depth(dup(&h), &mut d), OAKCOMMON_OK);
assert_eq!(d, 2);
assert_eq!(oakcommon_videoparams_set_width(CHandle::null(), 1), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_width(CHandle::null(), &mut w), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_width(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_height(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_depth(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
}
/// `is_3d` is derived from depth (CPP-PARITY: C++ has no `set_is_3d`;
/// the getter reads `depth > 1`).
#[test]
fn is_3d_roundtrip() {
let h = make();
let mut is3d = -1i32;
let mut d = -1i32;
assert_eq!(oakcommon_videoparams_get_is_3d(dup(&h), &mut is3d), OAKCOMMON_OK);
assert_eq!(is3d, 0);
assert_eq!(oakcommon_videoparams_set_depth(dup(&h), 2), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_is_3d(dup(&h), &mut is3d), OAKCOMMON_OK);
assert_eq!(is3d, 1);
assert_eq!(oakcommon_videoparams_get_depth(dup(&h), &mut d), OAKCOMMON_OK);
assert_eq!(d, 2);
assert_eq!(oakcommon_videoparams_set_depth(dup(&h), 1), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_is_3d(dup(&h), &mut is3d), OAKCOMMON_OK);
assert_eq!(is3d, 0);
assert_eq!(oakcommon_videoparams_get_depth(dup(&h), &mut d), OAKCOMMON_OK);
assert_eq!(d, 1);
assert_eq!(oakcommon_videoparams_set_depth(CHandle::null(), 1), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_is_3d(CHandle::null(), &mut is3d), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_is_3d(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
}
/// Time base and frame rate round-trip independently: `set_time_base` does
/// not touch the frame rate and `set_frame_rate` does not touch the time
/// base (CPP-PARITY with the C++ setters). The frame rate expressed as a
/// time base is always the flipped frame rate.
#[test]
fn time_base_frame_rate_roundtrip() {
let h = make_tb();
let mut n = -1i32;
let mut d = -1i32;
// Derived from the constructor: frame rate = flipped time base.
assert_eq!(oakcommon_videoparams_get_time_base(dup(&h), &mut n, &mut d), OAKCOMMON_OK);
assert_eq!((n, d), (1001, 30000));
assert_eq!(oakcommon_videoparams_get_frame_rate(dup(&h), &mut n, &mut d), OAKCOMMON_OK);
assert_eq!((n, d), (30000, 1001));
assert_eq!(oakcommon_videoparams_frame_rate_as_time_base(dup(&h), &mut n, &mut d), OAKCOMMON_OK);
assert_eq!((n, d), (1001, 30000));
assert_eq!(oakcommon_videoparams_set_time_base(dup(&h), 1, 50), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_time_base(dup(&h), &mut n, &mut d), OAKCOMMON_OK);
assert_eq!((n, d), (1, 50));
// The frame rate is untouched by `set_time_base`.
assert_eq!(oakcommon_videoparams_get_frame_rate(dup(&h), &mut n, &mut d), OAKCOMMON_OK);
assert_eq!((n, d), (30000, 1001));
assert_eq!(oakcommon_videoparams_set_frame_rate(dup(&h), 60, 1), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_frame_rate(dup(&h), &mut n, &mut d), OAKCOMMON_OK);
assert_eq!((n, d), (60, 1));
// The time base is untouched by `set_frame_rate`.
assert_eq!(oakcommon_videoparams_get_time_base(dup(&h), &mut n, &mut d), OAKCOMMON_OK);
assert_eq!((n, d), (1, 50));
assert_eq!(oakcommon_videoparams_frame_rate_as_time_base(dup(&h), &mut n, &mut d), OAKCOMMON_OK);
assert_eq!((n, d), (1, 60));
assert_eq!(oakcommon_videoparams_set_time_base(CHandle::null(), 1, 50), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_set_frame_rate(CHandle::null(), 60, 1), OAKCOMMON_E_INVALID);
for getter in [
|buf1, buf2| oakcommon_videoparams_get_time_base(CHandle::null(), buf1, buf2),
|buf1, buf2| oakcommon_videoparams_get_frame_rate(CHandle::null(), buf1, buf2),
|buf1, buf2| oakcommon_videoparams_frame_rate_as_time_base(CHandle::null(), buf1, buf2),
] {
assert_eq!(getter(&mut n, &mut d), OAKCOMMON_E_INVALID);
}
// A null out-param is `E_INVALID` even with a valid handle.
assert_eq!(
oakcommon_videoparams_get_time_base(dup(&h), std::ptr::null_mut(), &mut d),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_videoparams_get_frame_rate(dup(&h), &mut n, std::ptr::null_mut()),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_videoparams_frame_rate_as_time_base(dup(&h), std::ptr::null_mut(), std::ptr::null_mut()),
OAKCOMMON_E_INVALID
);
}
/// Pixel aspect ratio, format, and channel count round-trip. The default
/// handle has no format (code -1) and zero channels; an out-of-range format
/// code maps back to `Invalid` (code -1, CPP-PARITY with the C++ enum
/// `static_cast`).
#[test]
fn par_format_channel_roundtrip() {
let h = oakcommon_videoparams_init();
let mut n = -1i32;
let mut d = -1i32;
let mut f = -1i32;
let mut c = -1i32;
assert_eq!(oakcommon_videoparams_get_pixel_aspect_ratio(dup(&h), &mut n, &mut d), OAKCOMMON_OK);
assert_eq!((n, d), (1, 1));
assert_eq!(oakcommon_videoparams_get_format(dup(&h), &mut f), OAKCOMMON_OK);
assert_eq!(f, -1);
assert_eq!(oakcommon_videoparams_get_channel_count(dup(&h), &mut c), OAKCOMMON_OK);
assert_eq!(c, 0);
assert_eq!(oakcommon_videoparams_set_pixel_aspect_ratio(dup(&h), 16, 9), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_set_format(dup(&h), 0), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_set_channel_count(dup(&h), 2), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_pixel_aspect_ratio(dup(&h), &mut n, &mut d), OAKCOMMON_OK);
assert_eq!((n, d), (16, 9));
assert_eq!(oakcommon_videoparams_get_format(dup(&h), &mut f), OAKCOMMON_OK);
assert_eq!(f, 0);
assert_eq!(oakcommon_videoparams_get_channel_count(dup(&h), &mut c), OAKCOMMON_OK);
assert_eq!(c, 2);
// A null (0/5) pixel aspect ratio falls back to square pixels.
assert_eq!(oakcommon_videoparams_set_pixel_aspect_ratio(dup(&h), 0, 5), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_pixel_aspect_ratio(dup(&h), &mut n, &mut d), OAKCOMMON_OK);
assert_eq!((n, d), (1, 1));
// Out-of-range format codes map to `Invalid`, read back as -1.
assert_eq!(oakcommon_videoparams_set_format(dup(&h), 999), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_format(dup(&h), &mut f), OAKCOMMON_OK);
assert_eq!(f, -1);
assert_eq!(oakcommon_videoparams_set_pixel_aspect_ratio(CHandle::null(), 1, 1), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_set_format(CHandle::null(), 0), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_set_channel_count(CHandle::null(), 2), OAKCOMMON_E_INVALID);
assert_eq!(
oakcommon_videoparams_get_pixel_aspect_ratio(CHandle::null(), &mut n, &mut d),
OAKCOMMON_E_INVALID
);
assert_eq!(oakcommon_videoparams_get_format(CHandle::null(), &mut f), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_channel_count(CHandle::null(), &mut c), OAKCOMMON_E_INVALID);
assert_eq!(
oakcommon_videoparams_get_pixel_aspect_ratio(dup(&h), std::ptr::null_mut(), &mut d),
OAKCOMMON_E_INVALID
);
assert_eq!(oakcommon_videoparams_get_format(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_channel_count(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
}
/// Interlacing, divider, and enabled round-trip. Interlacing codes clamp:
/// 1 -> TopFirst, 2 -> BottomFirst, anything else -> None (0).
#[test]
fn interlacing_divider_enabled_roundtrip() {
let h = make();
let mut il = -1i32;
let mut dv = -1i32;
let mut en = -1i32;
assert_eq!(oakcommon_videoparams_get_interlacing(dup(&h), &mut il), OAKCOMMON_OK);
assert_eq!(il, 0);
assert_eq!(oakcommon_videoparams_get_divider(dup(&h), &mut dv), OAKCOMMON_OK);
assert_eq!(dv, 1);
assert_eq!(oakcommon_videoparams_get_enabled(dup(&h), &mut en), OAKCOMMON_OK);
assert_eq!(en, 1);
assert_eq!(oakcommon_videoparams_set_interlacing(dup(&h), 1), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_interlacing(dup(&h), &mut il), OAKCOMMON_OK);
assert_eq!(il, 1);
assert_eq!(oakcommon_videoparams_set_interlacing(dup(&h), 2), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_interlacing(dup(&h), &mut il), OAKCOMMON_OK);
assert_eq!(il, 2);
assert_eq!(oakcommon_videoparams_set_interlacing(dup(&h), 3), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_interlacing(dup(&h), &mut il), OAKCOMMON_OK);
assert_eq!(il, 0);
assert_eq!(oakcommon_videoparams_set_divider(dup(&h), 4), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_divider(dup(&h), &mut dv), OAKCOMMON_OK);
assert_eq!(dv, 4);
assert_eq!(oakcommon_videoparams_set_enabled(dup(&h), 0), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_enabled(dup(&h), &mut en), OAKCOMMON_OK);
assert_eq!(en, 0);
assert_eq!(oakcommon_videoparams_set_interlacing(CHandle::null(), 1), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_set_divider(CHandle::null(), 2), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_set_enabled(CHandle::null(), 1), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_interlacing(CHandle::null(), &mut il), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_divider(CHandle::null(), &mut dv), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_enabled(CHandle::null(), &mut en), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_interlacing(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_divider(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_enabled(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
}
/// Video type, x/y offset, stream index, start time, and duration
/// round-trip. Video-type codes clamp: 1 -> Still, 2 -> ImageSequence,
/// anything else -> Video (0).
#[test]
fn video_type_position_stream_roundtrip() {
let h = make();
let mut vt = -1i32;
let mut x = -1f32;
let mut y = -1f32;
let mut si = -1i32;
let mut st = -1i64;
let mut du = -1i64;
assert_eq!(oakcommon_videoparams_get_video_type(dup(&h), &mut vt), OAKCOMMON_OK);
assert_eq!(vt, 0);
assert_eq!(oakcommon_videoparams_get_x(dup(&h), &mut x), OAKCOMMON_OK);
assert_eq!(x, 0.0);
assert_eq!(oakcommon_videoparams_get_y(dup(&h), &mut y), OAKCOMMON_OK);
assert_eq!(y, 0.0);
assert_eq!(oakcommon_videoparams_get_stream_index(dup(&h), &mut si), OAKCOMMON_OK);
assert_eq!(si, 0);
assert_eq!(oakcommon_videoparams_get_start_time(dup(&h), &mut st), OAKCOMMON_OK);
assert_eq!(st, 0);
assert_eq!(oakcommon_videoparams_get_duration(dup(&h), &mut du), OAKCOMMON_OK);
assert_eq!(du, 0);
assert_eq!(oakcommon_videoparams_set_video_type(dup(&h), 1), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_video_type(dup(&h), &mut vt), OAKCOMMON_OK);
assert_eq!(vt, 1);
assert_eq!(oakcommon_videoparams_set_video_type(dup(&h), 2), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_video_type(dup(&h), &mut vt), OAKCOMMON_OK);
assert_eq!(vt, 2);
assert_eq!(oakcommon_videoparams_set_video_type(dup(&h), 9), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_video_type(dup(&h), &mut vt), OAKCOMMON_OK);
assert_eq!(vt, 0);
assert_eq!(oakcommon_videoparams_set_x(dup(&h), 1.5), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_set_y(dup(&h), -2.5), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_set_stream_index(dup(&h), 3), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_set_start_time(dup(&h), 12345i64), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_set_duration(dup(&h), 67890i64), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_x(dup(&h), &mut x), OAKCOMMON_OK);
assert_eq!(x, 1.5);
assert_eq!(oakcommon_videoparams_get_y(dup(&h), &mut y), OAKCOMMON_OK);
assert_eq!(y, -2.5);
assert_eq!(oakcommon_videoparams_get_stream_index(dup(&h), &mut si), OAKCOMMON_OK);
assert_eq!(si, 3);
assert_eq!(oakcommon_videoparams_get_start_time(dup(&h), &mut st), OAKCOMMON_OK);
assert_eq!(st, 12345);
assert_eq!(oakcommon_videoparams_get_duration(dup(&h), &mut du), OAKCOMMON_OK);
assert_eq!(du, 67890);
assert_eq!(oakcommon_videoparams_set_video_type(CHandle::null(), 1), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_set_x(CHandle::null(), 1.0), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_set_y(CHandle::null(), 1.0), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_set_stream_index(CHandle::null(), 1), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_set_start_time(CHandle::null(), 1), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_set_duration(CHandle::null(), 1), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_video_type(CHandle::null(), &mut vt), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_x(CHandle::null(), &mut x), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_y(CHandle::null(), &mut y), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_stream_index(CHandle::null(), &mut si), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_start_time(CHandle::null(), &mut st), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_duration(CHandle::null(), &mut du), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_video_type(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_x(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_y(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_stream_index(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_start_time(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_duration(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
}
/// Premultiplied alpha, color range, primaries, and transfer round-trip.
/// Color-range codes clamp: 1 -> Full, anything else -> Limited (0).
#[test]
fn color_fields_roundtrip() {
let h = make();
let mut pa = -1i32;
let mut cr = -1i32;
let mut cp = -1i32;
let mut ct = -1i32;
assert_eq!(oakcommon_videoparams_get_premultiplied_alpha(dup(&h), &mut pa), OAKCOMMON_OK);
assert_eq!(pa, 0);
assert_eq!(oakcommon_videoparams_get_color_range(dup(&h), &mut cr), OAKCOMMON_OK);
assert_eq!(cr, 0);
assert_eq!(oakcommon_videoparams_get_color_primaries(dup(&h), &mut cp), OAKCOMMON_OK);
assert_eq!(cp, 0);
assert_eq!(oakcommon_videoparams_get_color_transfer(dup(&h), &mut ct), OAKCOMMON_OK);
assert_eq!(ct, 0);
assert_eq!(oakcommon_videoparams_set_premultiplied_alpha(dup(&h), 1), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_set_color_range(dup(&h), 1), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_set_color_primaries(dup(&h), 2), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_set_color_transfer(dup(&h), 3), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_premultiplied_alpha(dup(&h), &mut pa), OAKCOMMON_OK);
assert_eq!(pa, 1);
assert_eq!(oakcommon_videoparams_get_color_range(dup(&h), &mut cr), OAKCOMMON_OK);
assert_eq!(cr, 1);
assert_eq!(oakcommon_videoparams_get_color_primaries(dup(&h), &mut cp), OAKCOMMON_OK);
assert_eq!(cp, 2);
assert_eq!(oakcommon_videoparams_get_color_transfer(dup(&h), &mut ct), OAKCOMMON_OK);
assert_eq!(ct, 3);
// Out-of-range color range clamps to Limited.
assert_eq!(oakcommon_videoparams_set_color_range(dup(&h), 5), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_color_range(dup(&h), &mut cr), OAKCOMMON_OK);
assert_eq!(cr, 0);
assert_eq!(oakcommon_videoparams_set_premultiplied_alpha(CHandle::null(), 1), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_set_color_range(CHandle::null(), 1), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_set_color_primaries(CHandle::null(), 1), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_set_color_transfer(CHandle::null(), 1), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_premultiplied_alpha(CHandle::null(), &mut pa), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_color_range(CHandle::null(), &mut cr), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_color_primaries(CHandle::null(), &mut cp), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_color_transfer(CHandle::null(), &mut ct), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_premultiplied_alpha(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_color_range(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_color_primaries(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_color_transfer(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
}
// ---- String fields ----
/// `colorspace` round-trips through a two-stage getter; the empty string is
/// a valid value (required size 1). Null handle or null string is
/// `E_INVALID`.
#[test]
fn colorspace_two_stage() {
let h = make();
assert_eq!(oakcommon_videoparams_set_colorspace(dup(&h), to_cstring("sRGB").as_ptr()), OAKCOMMON_OK);
assert_two_stage_getter(|buf, size| oakcommon_videoparams_get_colorspace(dup(&h), buf, size), "sRGB");
// The empty colorspace is a valid value.
assert_eq!(oakcommon_videoparams_set_colorspace(dup(&h), to_cstring("").as_ptr()), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_colorspace(dup(&h), std::ptr::null_mut(), 0), 1);
assert_eq!(oakcommon_videoparams_set_colorspace(CHandle::null(), to_cstring("sRGB").as_ptr()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_set_colorspace(dup(&h), std::ptr::null()), OAKCOMMON_E_INVALID);
assert_eq!(
oakcommon_videoparams_get_colorspace(CHandle::null(), std::ptr::null_mut(), 0),
OAKCOMMON_E_INVALID
);
}
// ---- Derived values ----
/// Square-pixel width, effective dimensions/depth, validity, bytes per
/// channel/pixel, and buffer size, computed from the stored fields.
#[test]
fn derived_dimensions_and_buffer() {
let h = make();
let mut v = -1i32;
let mut w = -1i32;
let mut ht = -1i32;
let mut d = -1i32;
assert_eq!(oakcommon_videoparams_get_square_pixel_width(dup(&h), &mut v), OAKCOMMON_OK);
assert_eq!(v, 1920);
assert_eq!(oakcommon_videoparams_get_effective_width(dup(&h), &mut v), OAKCOMMON_OK);
assert_eq!(v, 1920);
assert_eq!(oakcommon_videoparams_get_effective_height(dup(&h), &mut v), OAKCOMMON_OK);
assert_eq!(v, 1080);
assert_eq!(oakcommon_videoparams_get_effective_depth(dup(&h), &mut v), OAKCOMMON_OK);
assert_eq!(v, 1);
assert_eq!(oakcommon_videoparams_get_is_valid(dup(&h), &mut v), OAKCOMMON_OK);
assert_eq!(v, 1);
assert_eq!(oakcommon_videoparams_get_bytes_per_channel(dup(&h), &mut v), OAKCOMMON_OK);
assert_eq!(v, 1);
assert_eq!(oakcommon_videoparams_get_bytes_per_pixel(dup(&h), &mut v), OAKCOMMON_OK);
assert_eq!(v, 4);
assert_eq!(oakcommon_videoparams_get_buffer_size(dup(&h), &mut v), OAKCOMMON_OK);
assert_eq!(v, 1920 * 1080 * 4);
// Square-pixel width follows the pixel aspect ratio (lround(1920*16/9)
// == 3413) and the effective width follows the divider.
assert_eq!(oakcommon_videoparams_set_pixel_aspect_ratio(dup(&h), 16, 9), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_square_pixel_width(dup(&h), &mut v), OAKCOMMON_OK);
assert_eq!(v, 3413);
assert_eq!(oakcommon_videoparams_set_divider(dup(&h), 2), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_effective_width(dup(&h), &mut v), OAKCOMMON_OK);
assert_eq!(v, 960);
assert_eq!(oakcommon_videoparams_get_effective_height(dup(&h), &mut v), OAKCOMMON_OK);
assert_eq!(v, 540);
// An invalid format makes the parameter set invalid.
assert_eq!(oakcommon_videoparams_set_format(dup(&h), 999), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_is_valid(dup(&h), &mut v), OAKCOMMON_OK);
assert_eq!(v, 0);
assert_eq!(oakcommon_videoparams_get_square_pixel_width(CHandle::null(), &mut v), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_effective_width(CHandle::null(), &mut v), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_effective_height(CHandle::null(), &mut v), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_effective_depth(CHandle::null(), &mut v), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_is_valid(CHandle::null(), &mut v), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_bytes_per_channel(CHandle::null(), &mut v), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_bytes_per_pixel(CHandle::null(), &mut v), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_buffer_size(CHandle::null(), &mut v), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_square_pixel_width(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_effective_width(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_effective_height(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_effective_depth(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_is_valid(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_bytes_per_channel(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_bytes_per_pixel(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_buffer_size(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
// Clean up the width/height out-params used above to keep clippy quiet.
assert_eq!(w, -1);
assert_eq!(ht, -1);
assert_eq!(d, -1);
}
/// `get_time_in_timebase_units` converts a time into time-base units plus
/// the start time. With no time base set it writes `i64::MIN`
/// (CPP-PARITY: C++ returns `INT64_MIN` / `AV_NOPTS_VALUE`) but still
/// succeeds.
#[test]
fn time_in_timebase_units() {
let h = make_tb();
let mut ts = -1i64;
// Time base 1001/30000; 1001/30000 seconds == 1 unit.
assert_eq!(oakcommon_videoparams_get_time_in_timebase_units(dup(&h), 1001, 30000, &mut ts), OAKCOMMON_OK);
assert_eq!(ts, 1);
assert_eq!(oakcommon_videoparams_set_start_time(dup(&h), 5i64), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_time_in_timebase_units(dup(&h), 1001, 30000, &mut ts), OAKCOMMON_OK);
assert_eq!(ts, 6);
// A time base of 1001/30000 (29.97 fps): 1 second == 30 units.
assert_eq!(oakcommon_videoparams_get_time_in_timebase_units(dup(&h), 1, 1, &mut ts), OAKCOMMON_OK);
assert_eq!(ts, 35);
// `init_basic` has no time base: i64::MIN is written, call succeeds.
let nb = make();
assert_eq!(oakcommon_videoparams_get_time_in_timebase_units(dup(&nb), 1, 1, &mut ts), OAKCOMMON_OK);
assert_eq!(ts, i64::MIN);
assert_eq!(
oakcommon_videoparams_get_time_in_timebase_units(CHandle::null(), 1, 1, &mut ts),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_videoparams_get_time_in_timebase_units(dup(&h), 1, 1, std::ptr::null_mut()),
OAKCOMMON_E_INVALID
);
}
/// `equals` compares the nine parameter-set fields; the same fields yield 1
/// and any difference yields 0. A null handle, null `other`, or null
/// out-param is `E_INVALID`.
#[test]
fn equals() {
let a = make();
let b = make();
let mut eq = -1i32;
assert_eq!(oakcommon_videoparams_equals(dup(&a), dup(&b), &mut eq), OAKCOMMON_OK);
assert_eq!(eq, 1);
assert_eq!(oakcommon_videoparams_set_width(dup(&b), 640), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_equals(dup(&a), dup(&b), &mut eq), OAKCOMMON_OK);
assert_eq!(eq, 0);
assert_eq!(
oakcommon_videoparams_equals(CHandle::null(), dup(&b), &mut eq),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_videoparams_equals(dup(&a), CHandle::null(), &mut eq),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_videoparams_equals(dup(&a), dup(&b), std::ptr::null_mut()),
OAKCOMMON_E_INVALID
);
}
// ---- XML ----
/// `load_xml` parses a fragment and applies the fields; malformed input is
/// `E_FAILED`. `save_xml` is a two-stage getter whose output starts with
/// the `<videoparams>` root.
#[test]
fn load_save_xml() {
let h = make();
// Round-trip through XML.
let xml = "<videoparams><width>640</width><height>480</height></videoparams>";
assert_eq!(oakcommon_videoparams_load_xml(dup(&h), to_cstring(xml).as_ptr()), OAKCOMMON_OK);
let mut w = -1i32;
let mut ht = -1i32;
assert_eq!(oakcommon_videoparams_get_width(dup(&h), &mut w), OAKCOMMON_OK);
assert_eq!(w, 640);
assert_eq!(oakcommon_videoparams_get_height(dup(&h), &mut ht), OAKCOMMON_OK);
assert_eq!(ht, 480);
// save_xml is a two-stage getter (CPP-PARITY: copy_string semantics).
let required = oakcommon_videoparams_save_xml(dup(&h), std::ptr::null_mut(), 0);
assert!(required > 0);
let mut out = vec![0u8; required as usize];
assert_eq!(
oakcommon_videoparams_save_xml(dup(&h), out.as_mut_ptr() as *mut c_char, required),
required
);
let s = unsafe { CStr::from_ptr(out.as_ptr() as *const c_char) }.to_str().unwrap();
assert!(s.starts_with("<videoparams>"));
assert!(s.contains("<width>640</width>"));
// Malformed fragments fail with E_FAILED.
assert_eq!(oakcommon_videoparams_load_xml(dup(&h), to_cstring("<width>").as_ptr()), OAKCOMMON_E_FAILED);
assert_eq!(oakcommon_videoparams_load_xml(dup(&h), to_cstring("not xml").as_ptr()), OAKCOMMON_E_FAILED);
assert_eq!(oakcommon_videoparams_load_xml(dup(&h), to_cstring("").as_ptr()), OAKCOMMON_E_FAILED);
assert_eq!(
oakcommon_videoparams_load_xml(CHandle::null(), to_cstring(xml).as_ptr()),
OAKCOMMON_E_INVALID
);
assert_eq!(oakcommon_videoparams_load_xml(dup(&h), std::ptr::null()), OAKCOMMON_E_INVALID);
assert_eq!(
oakcommon_videoparams_save_xml(CHandle::null(), std::ptr::null_mut(), 0),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_videoparams_save_xml(dup(&h), std::ptr::null_mut(), 5),
OAKCOMMON_E_INVALID
);
}
// ---- Static helpers ----
/// Pure static arithmetic helpers. These take no handle and have no failure
/// path by design (out-of-range formats map to `Invalid`, which yields 0
/// bytes).
#[test]
fn static_arithmetic() {
// bytes per channel: U8=1, U10=0, U16/F16=2, F32=4, Invalid/Count=0.
assert_eq!(oakcommon_videoparams_get_bytes_per_channel_for_format(0), 1);
assert_eq!(oakcommon_videoparams_get_bytes_per_channel_for_format(1), 0);
assert_eq!(oakcommon_videoparams_get_bytes_per_channel_for_format(2), 2);
assert_eq!(oakcommon_videoparams_get_bytes_per_channel_for_format(4), 4);
assert_eq!(oakcommon_videoparams_get_bytes_per_channel_for_format(999), 0);
assert_eq!(oakcommon_videoparams_static_get_bytes_per_channel(0), 1);
assert_eq!(oakcommon_videoparams_static_get_bytes_per_channel(4), 4);
// bytes per pixel: U10 is packed 4 bytes only for 4 channels.
assert_eq!(oakcommon_videoparams_get_bytes_per_pixel_for_format(0, 4), 4);
assert_eq!(oakcommon_videoparams_get_bytes_per_pixel_for_format(1, 4), 4);
assert_eq!(oakcommon_videoparams_get_bytes_per_pixel_for_format(1, 3), 0);
assert_eq!(oakcommon_videoparams_get_bytes_per_pixel_for_format(2, 3), 6);
assert_eq!(oakcommon_videoparams_get_bytes_per_pixel_for_format(4, 4), 16);
assert_eq!(oakcommon_videoparams_get_bytes_per_pixel_for_format(999, 4), 0);
assert_eq!(oakcommon_videoparams_static_get_bytes_per_pixel(0, 4), 4);
assert_eq!(oakcommon_videoparams_static_get_bytes_per_pixel(1, 4), 4);
// buffer size = w * h * bpp (U8 RGBA).
assert_eq!(oakcommon_videoparams_calculate_buffer_size(1920, 1080, 0, 4), 1920 * 1080 * 4);
// float formats are F16/F32 only.
assert_eq!(oakcommon_videoparams_format_is_float(3), 1);
assert_eq!(oakcommon_videoparams_format_is_float(4), 1);
assert_eq!(oakcommon_videoparams_format_is_float(0), 0);
assert_eq!(oakcommon_videoparams_format_is_float(999), 0);
// auto divider: 1920x1080 -> 2 (of 8, 12, 16 ... capped).
assert_eq!(oakcommon_videoparams_generate_auto_divider(100, 100), 1);
assert_eq!(oakcommon_videoparams_generate_auto_divider(1920, 1080), 2);
assert_eq!(oakcommon_videoparams_generate_auto_divider(3840, 2160), 3);
// scaled dimension is floor(dim/divider).
assert_eq!(oakcommon_videoparams_get_scaled_dimension(1920, 2), 960);
assert_eq!(oakcommon_videoparams_get_scaled_dimension(1080, 2), 540);
// divider for a target resolution.
assert_eq!(
oakcommon_videoparams_get_divider_for_target_resolution(3840, 2160, 1920, 1080),
2
);
assert_eq!(
oakcommon_videoparams_get_divider_for_target_resolution(1920, 1080, 1920, 1080),
1
);
assert_eq!(
oakcommon_videoparams_get_divider_for_target_resolution(1921, 1081, 959, 540),
3
);
}
/// Static two-stage string getters: divider names, format names, and
/// frame-rate strings. An invalid output buffer is `E_INVALID`.
#[test]
fn static_string_getters() {
assert_two_stage_getter(|buf, size| oakcommon_videoparams_get_name_for_divider(1, buf, size), "Full");
assert_two_stage_getter(|buf, size| oakcommon_videoparams_get_name_for_divider(2, buf, size), "1/2");
assert_two_stage_getter(|buf, size| oakcommon_videoparams_get_name_for_divider(8, buf, size), "1/8");
assert_two_stage_getter(|buf, size| oakcommon_videoparams_get_format_name(0, buf, size), "8-bit");
assert_two_stage_getter(|buf, size| oakcommon_videoparams_get_format_name(1, buf, size), "10-bit Packed");
assert_two_stage_getter(|buf, size| oakcommon_videoparams_get_format_name(2, buf, size), "16-bit Integer");
assert_two_stage_getter(|buf, size| oakcommon_videoparams_get_format_name(3, buf, size), "Half-Float (16-bit)");
assert_two_stage_getter(|buf, size| oakcommon_videoparams_get_format_name(4, buf, size), "Full-Float (32-bit)");
assert_two_stage_getter(|buf, size| oakcommon_videoparams_get_format_name(5, buf, size), "Unknown (0x5)");
assert_two_stage_getter(|buf, size| oakcommon_videoparams_get_format_name(-1, buf, size), "Unknown (0xFFFFFFFF)");
assert_two_stage_getter(
|buf, size| oakcommon_videoparams_frame_rate_to_string(24000, 1001, buf, size),
"23.976 FPS",
);
assert_two_stage_getter(
|buf, size| oakcommon_videoparams_frame_rate_to_string(24, 1, buf, size),
"24 FPS",
);
assert_two_stage_getter(
|buf, size| oakcommon_videoparams_frame_rate_to_string(0, 0, buf, size),
"nan FPS",
);
// An invalid output buffer (null with a positive size) is E_INVALID.
assert_eq!(oakcommon_videoparams_get_name_for_divider(1, std::ptr::null_mut(), 5), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_format_name(0, std::ptr::null_mut(), 5), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_frame_rate_to_string(24, 1, std::ptr::null_mut(), 5), OAKCOMMON_E_INVALID);
}
+666
View File
@@ -0,0 +1,666 @@
// 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/>.
//! FFI-level integration tests for the C-ABI XML streaming exports in
//! `oakcommon::ffi::xmlutils`, asserted against the C++ oracle
//! `src/common/c_api/xmlutils.cpp`.
//!
//! `ffi::error` and `ffi::error_abi` are documented placeholder modules
//! (`include/common/error.h` exposes no functions; the `OAKCOMMON_OK` /
//! `OAKCOMMON_E_*` constants live in `crate::error`), so the only callable
//! surface here is `xmlutils`. The error constants its exports return are
//! pinned in `error_constants_returned_by_xmlutils_match_header`; the full
//! set is already asserted in `contract.rs`.
//!
//! The contract under test (each point matches the C++ oracle):
//! - exports take a `CHandle` by value and never release it;
//! - two-stage string getters return the required size (NUL included) and
//! only copy when the buffer is large enough — they never truncate;
//! - out-of-range attribute indexes report `OAKCOMMON_E_NOT_FOUND`;
//! - a malformed document still yields a usable handle whose `has_error`
//! flag reads back 1 and whose navigation reports no start elements.
use std::ffi::{c_char, CString};
use oakcommon::error::{OAKCOMMON_E_INVALID, OAKCOMMON_E_NOT_FOUND, OAKCOMMON_OK};
use oakcommon::ffi::xmlutils::*;
use oakcommon::handle::{CHandle, OAKCOMMON_ABI_VERSION};
/// Shared sample document: two attributes on the root plus a nested text
/// element. The writer test sequence reproduces this exact document.
const DOC: &str = r#"<root a="1" b="two"><child>text here</child></root>"#;
/// Convert a string slice to a NUL-terminated C string for FFI inputs.
fn to_cstring(s: &str) -> CString {
CString::new(s).expect("test string must not contain NUL")
}
/// Cheap struct copy: `CHandle` is neither `Clone` nor `Copy`, but every
/// getter takes it by value. Rebuilding from the same fields duplicates
/// only the handle value — the box stays alive as long as the original
/// handle lives, and the getters never release.
fn dup(h: &CHandle) -> CHandle {
CHandle {
ctx: h.ctx,
addref: h.addref,
release: h.release,
abi_version: h.abi_version,
}
}
/// Drive a two-stage string getter against the C++ `copy_string`
/// convention: a null-buffer size query, a short buffer that must stay
/// untouched (no truncation), an exact-fit copy with its NUL, and an
/// oversized copy with the tail untouched.
fn assert_two_stage_getter(getter: impl Fn(*mut c_char, i32) -> i32, expected: &str) {
let required = (expected.len() + 1) as i32;
// Size query: a null buffer returns the required size, NUL included.
assert_eq!(getter(std::ptr::null_mut(), 0), required);
// Short buffer: too small, so nothing is written to it.
let short_size = (required - 1).max(0);
let mut short = vec![0xABu8; short_size as usize];
assert_eq!(getter(short.as_mut_ptr() as *mut c_char, short_size), required);
assert!(short.iter().all(|&b| b == 0xAB), "short buffer must stay untouched");
// Exact fit: payload followed by a NUL.
let mut exact = vec![0xCDu8; required as usize];
assert_eq!(getter(exact.as_mut_ptr() as *mut c_char, required), required);
assert_eq!(&exact[..expected.len()], expected.as_bytes());
assert_eq!(exact[expected.len()], 0);
// Oversized: payload and NUL written, tail left as initialized.
let mut big = vec![0u8; (required + 8) as usize];
assert_eq!(getter(big.as_mut_ptr() as *mut c_char, required + 8), required);
assert_eq!(&big[..expected.len()], expected.as_bytes());
assert_eq!(big[expected.len()], 0);
assert!(big[(required + 1) as usize..].iter().all(|&b| b == 0));
}
// ---- Reader: handle lifecycle ----
/// `init` with a null data pointer yields an empty handle.
#[test]
fn init_returns_null_handle_for_null_data() {
let h = oakcommon_xml_reader_init(std::ptr::null());
assert!(h.is_null());
assert!(h.ctx.is_null());
assert!(h.addref.is_none());
assert!(h.release.is_none());
}
/// `init` over valid data yields a stamped, non-empty handle.
#[test]
fn init_creates_stamped_handle() {
let h = oakcommon_xml_reader_init(to_cstring(DOC).as_ptr());
assert!(!h.is_null());
assert_eq!(h.abi_version, OAKCOMMON_ABI_VERSION);
assert!(h.addref.is_some());
assert!(h.release.is_some());
}
/// `free` nullifies the handle, is idempotent, and tolerates a null
/// pointer.
#[test]
fn free_nullifies_and_is_idempotent() {
let mut h = oakcommon_xml_reader_init(to_cstring(DOC).as_ptr());
assert!(!h.is_null());
oakcommon_xml_reader_free(&mut h);
assert!(h.is_null());
// A second free of the now-empty handle is safe.
oakcommon_xml_reader_free(&mut h);
assert!(h.is_null());
// Freeing a null pointer is safe.
oakcommon_xml_reader_free(std::ptr::null_mut());
}
// ---- Reader: navigation ----
/// `read_next_start_element` writes 1 while a start element is found and
/// 0 once the document is exhausted (CPP-PARITY: an end element and the
/// end of the document both report 0, not an error).
#[test]
fn read_next_start_element_reports_found() {
let r = oakcommon_xml_reader_init(to_cstring(DOC).as_ptr());
let mut found = -1i32;
assert_eq!(
oakcommon_xml_reader_read_next_start_element(dup(&r), &mut found),
OAKCOMMON_OK
);
assert_eq!(found, 1); // root
assert_eq!(
oakcommon_xml_reader_read_next_start_element(dup(&r), &mut found),
OAKCOMMON_OK
);
assert_eq!(found, 1); // child
assert_eq!(
oakcommon_xml_reader_read_next_start_element(dup(&r), &mut found),
OAKCOMMON_OK
);
assert_eq!(found, 0); // child's end element
assert_eq!(
oakcommon_xml_reader_read_next_start_element(dup(&r), &mut found),
OAKCOMMON_OK
);
assert_eq!(found, 0); // root's end element
}
/// A null reader handle or a null `found` out-param is `E_INVALID`.
#[test]
fn read_next_start_element_rejects_null_args() {
let r = oakcommon_xml_reader_init(to_cstring(DOC).as_ptr());
let mut found = 0i32;
assert_eq!(
oakcommon_xml_reader_read_next_start_element(CHandle::null(), &mut found),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_xml_reader_read_next_start_element(dup(&r), std::ptr::null_mut()),
OAKCOMMON_E_INVALID
);
}
// ---- Reader: two-stage string getters ----
/// `name` is a two-stage getter over the current element's name; a null
/// handle is `E_INVALID`.
#[test]
fn name_two_stage_getter() {
let r = oakcommon_xml_reader_init(to_cstring(DOC).as_ptr());
let mut found = 0i32;
assert_eq!(
oakcommon_xml_reader_read_next_start_element(dup(&r), &mut found),
OAKCOMMON_OK
);
assert_eq!(found, 1);
assert_two_stage_getter(|buf, size| oakcommon_xml_reader_name(dup(&r), buf, size), "root");
assert_eq!(
oakcommon_xml_reader_name(CHandle::null(), std::ptr::null_mut(), 0),
OAKCOMMON_E_INVALID
);
}
/// Before any token is read the name is the empty string (size 1).
#[test]
fn name_is_empty_before_any_read() {
let r = oakcommon_xml_reader_init(to_cstring(DOC).as_ptr());
assert_eq!(oakcommon_xml_reader_name(dup(&r), std::ptr::null_mut(), 0), 1);
}
/// `read_element_text` is a two-stage getter and caches its result, so a
/// second read returns the same text even though the stream was consumed
/// by the first (CPP-PARITY with the C++ `XmlReaderState::cached_text`).
#[test]
fn read_element_text_two_stage_with_cache() {
let r = oakcommon_xml_reader_init(to_cstring(DOC).as_ptr());
let mut found = 0i32;
assert_eq!(
oakcommon_xml_reader_read_next_start_element(dup(&r), &mut found),
OAKCOMMON_OK
);
assert_eq!(
oakcommon_xml_reader_read_next_start_element(dup(&r), &mut found),
OAKCOMMON_OK
);
assert_eq!(found, 1);
assert_two_stage_getter(
|buf, size| oakcommon_xml_reader_read_element_text(dup(&r), buf, size),
"text here",
);
assert_two_stage_getter(
|buf, size| oakcommon_xml_reader_read_element_text(dup(&r), buf, size),
"text here",
);
}
/// When not on a start element the text is empty (CPP-PARITY: the C++
/// reader returns an empty string).
#[test]
fn read_element_text_not_on_start_element_is_empty() {
let r = oakcommon_xml_reader_init(to_cstring(DOC).as_ptr());
let mut found = 0i32;
assert_eq!(
oakcommon_xml_reader_read_next_start_element(dup(&r), &mut found),
OAKCOMMON_OK
);
assert_eq!(
oakcommon_xml_reader_read_next_start_element(dup(&r), &mut found),
OAKCOMMON_OK
);
assert_eq!(
oakcommon_xml_reader_read_next_start_element(dup(&r), &mut found),
OAKCOMMON_OK
);
assert_eq!(found, 0);
assert_eq!(
oakcommon_xml_reader_read_element_text(dup(&r), std::ptr::null_mut(), 0),
1
);
assert_eq!(
oakcommon_xml_reader_read_element_text(CHandle::null(), std::ptr::null_mut(), 0),
OAKCOMMON_E_INVALID
);
}
/// `skip_current_element` consumes the current element and its subtree; a
/// null handle is `E_INVALID`.
#[test]
fn skip_current_element_skips_subtree() {
let r = oakcommon_xml_reader_init(to_cstring(DOC).as_ptr());
let mut found = 0i32;
assert_eq!(
oakcommon_xml_reader_read_next_start_element(dup(&r), &mut found),
OAKCOMMON_OK
);
assert_eq!(found, 1);
assert_eq!(oakcommon_xml_reader_skip_current_element(dup(&r)), OAKCOMMON_OK);
assert_eq!(
oakcommon_xml_reader_read_next_start_element(dup(&r), &mut found),
OAKCOMMON_OK
);
assert_eq!(found, 0);
assert_eq!(
oakcommon_xml_reader_skip_current_element(CHandle::null()),
OAKCOMMON_E_INVALID
);
}
// ---- Reader: attributes ----
/// `attribute_count` reports 0 before any token and the real count on a
/// start element; a null handle or a null count out-param is `E_INVALID`.
#[test]
fn attribute_count_reports_attributes() {
let r = oakcommon_xml_reader_init(to_cstring(DOC).as_ptr());
let mut count = -1i32;
assert_eq!(
oakcommon_xml_reader_attribute_count(dup(&r), &mut count),
OAKCOMMON_OK
);
assert_eq!(count, 0);
let mut found = 0i32;
assert_eq!(
oakcommon_xml_reader_read_next_start_element(dup(&r), &mut found),
OAKCOMMON_OK
);
assert_eq!(found, 1);
assert_eq!(
oakcommon_xml_reader_attribute_count(dup(&r), &mut count),
OAKCOMMON_OK
);
assert_eq!(count, 2);
assert_eq!(
oakcommon_xml_reader_attribute_count(CHandle::null(), &mut count),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_xml_reader_attribute_count(dup(&r), std::ptr::null_mut()),
OAKCOMMON_E_INVALID
);
}
/// `attribute_name` / `attribute_value` are two-stage getters over the
/// attributes of the current start element, in document order.
#[test]
fn attribute_name_and_value_two_stage() {
let r = oakcommon_xml_reader_init(to_cstring(DOC).as_ptr());
let mut found = 0i32;
assert_eq!(
oakcommon_xml_reader_read_next_start_element(dup(&r), &mut found),
OAKCOMMON_OK
);
assert_eq!(found, 1);
assert_two_stage_getter(
|buf, size| oakcommon_xml_reader_attribute_name(dup(&r), 0, buf, size),
"a",
);
assert_two_stage_getter(
|buf, size| oakcommon_xml_reader_attribute_value(dup(&r), 0, buf, size),
"1",
);
assert_two_stage_getter(
|buf, size| oakcommon_xml_reader_attribute_name(dup(&r), 1, buf, size),
"b",
);
assert_two_stage_getter(
|buf, size| oakcommon_xml_reader_attribute_value(dup(&r), 1, buf, size),
"two",
);
}
/// An out-of-range attribute index is `E_NOT_FOUND` (CPP-PARITY), while a
/// null handle is `E_INVALID`.
#[test]
fn attribute_out_of_range_is_not_found() {
let r = oakcommon_xml_reader_init(to_cstring(DOC).as_ptr());
let mut found = 0i32;
assert_eq!(
oakcommon_xml_reader_read_next_start_element(dup(&r), &mut found),
OAKCOMMON_OK
);
assert_eq!(found, 1);
assert_eq!(
oakcommon_xml_reader_attribute_name(dup(&r), 2, std::ptr::null_mut(), 0),
OAKCOMMON_E_NOT_FOUND
);
assert_eq!(
oakcommon_xml_reader_attribute_name(dup(&r), -1, std::ptr::null_mut(), 0),
OAKCOMMON_E_NOT_FOUND
);
assert_eq!(
oakcommon_xml_reader_attribute_value(dup(&r), 2, std::ptr::null_mut(), 0),
OAKCOMMON_E_NOT_FOUND
);
assert_eq!(
oakcommon_xml_reader_attribute_name(CHandle::null(), 0, std::ptr::null_mut(), 0),
OAKCOMMON_E_INVALID
);
}
// ---- Reader: error reporting ----
/// `has_error` is 0 for a well-formed document and 1 for a malformed one;
/// a malformed document still navigates safely and reports no start
/// elements (CPP-PARITY: parse failures surface lazily through
/// `has_error`, never at init time).
#[test]
fn has_error_flags_malformed_documents() {
let mut err = -1i32;
let r = oakcommon_xml_reader_init(to_cstring(DOC).as_ptr());
assert_eq!(oakcommon_xml_reader_has_error(dup(&r), &mut err), OAKCOMMON_OK);
assert_eq!(err, 0);
let bad = oakcommon_xml_reader_init(to_cstring("<a></b>").as_ptr());
assert!(!bad.is_null());
assert_eq!(oakcommon_xml_reader_has_error(dup(&bad), &mut err), OAKCOMMON_OK);
assert_eq!(err, 1);
let mut found = -1i32;
assert_eq!(
oakcommon_xml_reader_read_next_start_element(dup(&bad), &mut found),
OAKCOMMON_OK
);
assert_eq!(found, 0);
let empty = oakcommon_xml_reader_init(to_cstring("").as_ptr());
assert!(!empty.is_null());
assert_eq!(oakcommon_xml_reader_has_error(dup(&empty), &mut err), OAKCOMMON_OK);
assert_eq!(err, 1);
assert_eq!(
oakcommon_xml_reader_has_error(CHandle::null(), &mut err),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_xml_reader_has_error(dup(&r), std::ptr::null_mut()),
OAKCOMMON_E_INVALID
);
}
// ---- Writer: handle lifecycle ----
/// `writer_init` yields a stamped handle; `free` nullifies it, is
/// idempotent, and tolerates a null pointer.
#[test]
fn writer_init_free_and_idempotent() {
let mut w = oakcommon_xml_writer_init();
assert!(!w.is_null());
assert_eq!(w.abi_version, OAKCOMMON_ABI_VERSION);
assert!(w.addref.is_some());
assert!(w.release.is_some());
oakcommon_xml_writer_free(&mut w);
assert!(w.is_null());
oakcommon_xml_writer_free(&mut w);
assert!(w.is_null());
oakcommon_xml_writer_free(std::ptr::null_mut());
}
// ---- Writer: operations ----
/// Every writer export rejects a null handle with `E_INVALID`, and the
/// string-taking exports reject null strings.
#[test]
fn writer_rejects_null_arguments() {
let w = oakcommon_xml_writer_init();
assert_eq!(
oakcommon_xml_writer_write_start_element(CHandle::null(), to_cstring("a").as_ptr()),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_xml_writer_write_attribute(
CHandle::null(),
to_cstring("a").as_ptr(),
to_cstring("b").as_ptr(),
),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_xml_writer_write_characters(CHandle::null(), to_cstring("x").as_ptr()),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_xml_writer_write_text_element(
CHandle::null(),
to_cstring("a").as_ptr(),
to_cstring("b").as_ptr(),
),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_xml_writer_write_end_element(CHandle::null()),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_xml_writer_write_end_document(CHandle::null()),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_xml_writer_output(CHandle::null(), std::ptr::null_mut(), 0),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_xml_writer_write_start_element(dup(&w), std::ptr::null()),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_xml_writer_write_attribute(dup(&w), std::ptr::null(), to_cstring("b").as_ptr()),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_xml_writer_write_attribute(dup(&w), to_cstring("a").as_ptr(), std::ptr::null()),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_xml_writer_write_characters(dup(&w), std::ptr::null()),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_xml_writer_write_text_element(dup(&w), std::ptr::null(), to_cstring("b").as_ptr()),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_xml_writer_write_text_element(dup(&w), to_cstring("a").as_ptr(), std::ptr::null()),
OAKCOMMON_E_INVALID
);
}
/// The writer sequence builds the exact sample document, and `output` is a
/// two-stage getter over it.
#[test]
fn writer_builds_document_and_output_two_stage() {
let w = oakcommon_xml_writer_init();
assert_eq!(
oakcommon_xml_writer_write_start_element(dup(&w), to_cstring("root").as_ptr()),
OAKCOMMON_OK
);
assert_eq!(
oakcommon_xml_writer_write_attribute(dup(&w), to_cstring("a").as_ptr(), to_cstring("1").as_ptr()),
OAKCOMMON_OK
);
assert_eq!(
oakcommon_xml_writer_write_attribute(dup(&w), to_cstring("b").as_ptr(), to_cstring("two").as_ptr()),
OAKCOMMON_OK
);
assert_eq!(
oakcommon_xml_writer_write_text_element(
dup(&w),
to_cstring("child").as_ptr(),
to_cstring("text here").as_ptr(),
),
OAKCOMMON_OK
);
assert_eq!(oakcommon_xml_writer_write_end_element(dup(&w)), OAKCOMMON_OK);
assert_eq!(oakcommon_xml_writer_write_end_document(dup(&w)), OAKCOMMON_OK);
assert_two_stage_getter(|buf, size| oakcommon_xml_writer_output(dup(&w), buf, size), DOC);
}
/// The writer's output round-trips through the reader.
#[test]
fn writer_output_round_trips_through_reader() {
let w = oakcommon_xml_writer_init();
assert_eq!(
oakcommon_xml_writer_write_start_element(dup(&w), to_cstring("root").as_ptr()),
OAKCOMMON_OK
);
assert_eq!(
oakcommon_xml_writer_write_attribute(dup(&w), to_cstring("a").as_ptr(), to_cstring("1").as_ptr()),
OAKCOMMON_OK
);
assert_eq!(
oakcommon_xml_writer_write_attribute(dup(&w), to_cstring("b").as_ptr(), to_cstring("two").as_ptr()),
OAKCOMMON_OK
);
assert_eq!(
oakcommon_xml_writer_write_text_element(
dup(&w),
to_cstring("child").as_ptr(),
to_cstring("text here").as_ptr(),
),
OAKCOMMON_OK
);
assert_eq!(oakcommon_xml_writer_write_end_element(dup(&w)), OAKCOMMON_OK);
let mut out = vec![0u8; 64];
let needed = oakcommon_xml_writer_output(dup(&w), out.as_mut_ptr() as *mut c_char, 64);
assert_eq!(needed, (DOC.len() + 1) as i32);
assert_eq!(&out[..DOC.len()], DOC.as_bytes());
assert_eq!(out[DOC.len()], 0);
let r = oakcommon_xml_reader_init(out.as_ptr() as *const c_char);
assert!(!r.is_null());
let mut found = 0i32;
assert_eq!(
oakcommon_xml_reader_read_next_start_element(dup(&r), &mut found),
OAKCOMMON_OK
);
assert_eq!(found, 1);
assert_two_stage_getter(|buf, size| oakcommon_xml_reader_name(dup(&r), buf, size), "root");
assert_eq!(
oakcommon_xml_reader_read_next_start_element(dup(&r), &mut found),
OAKCOMMON_OK
);
assert_eq!(found, 1);
assert_two_stage_getter(
|buf, size| oakcommon_xml_reader_read_element_text(dup(&r), buf, size),
"text here",
);
assert_eq!(
oakcommon_xml_reader_read_next_start_element(dup(&r), &mut found),
OAKCOMMON_OK
);
assert_eq!(found, 0);
}
/// Documented no-op writer operations (attribute without an open start
/// tag, end element on an empty stack, end document with nothing open)
/// still return `OK`.
#[test]
fn writer_noop_operations_return_ok() {
let w = oakcommon_xml_writer_init();
assert_eq!(
oakcommon_xml_writer_write_attribute(dup(&w), to_cstring("a").as_ptr(), to_cstring("b").as_ptr()),
OAKCOMMON_OK
);
assert_eq!(oakcommon_xml_writer_write_end_element(dup(&w)), OAKCOMMON_OK);
assert_eq!(oakcommon_xml_writer_write_end_document(dup(&w)), OAKCOMMON_OK);
assert_eq!(
oakcommon_xml_writer_write_characters(dup(&w), to_cstring("x").as_ptr()),
OAKCOMMON_OK
);
assert_two_stage_getter(|buf, size| oakcommon_xml_writer_output(dup(&w), buf, size), "x");
}
/// An empty element with attributes serializes as `<a k="v"/>`.
#[test]
fn writer_self_closing_empty_element() {
let w = oakcommon_xml_writer_init();
assert_eq!(
oakcommon_xml_writer_write_start_element(dup(&w), to_cstring("a").as_ptr()),
OAKCOMMON_OK
);
assert_eq!(
oakcommon_xml_writer_write_attribute(dup(&w), to_cstring("k").as_ptr(), to_cstring("v").as_ptr()),
OAKCOMMON_OK
);
assert_eq!(oakcommon_xml_writer_write_end_element(dup(&w)), OAKCOMMON_OK);
assert_two_stage_getter(|buf, size| oakcommon_xml_writer_output(dup(&w), buf, size), r#"<a k="v"/>"#);
}
/// Text and attribute values are escaped for the five predefined XML
/// entities.
#[test]
fn writer_escapes_text_and_attributes() {
let w = oakcommon_xml_writer_init();
assert_eq!(
oakcommon_xml_writer_write_text_element(
dup(&w),
to_cstring("e").as_ptr(),
to_cstring("hi & bye <there>").as_ptr(),
),
OAKCOMMON_OK
);
assert_eq!(
oakcommon_xml_writer_write_start_element(dup(&w), to_cstring("a").as_ptr()),
OAKCOMMON_OK
);
assert_eq!(
oakcommon_xml_writer_write_attribute(
dup(&w),
to_cstring("q").as_ptr(),
to_cstring("x\"y&z").as_ptr(),
),
OAKCOMMON_OK
);
assert_eq!(oakcommon_xml_writer_write_end_element(dup(&w)), OAKCOMMON_OK);
assert_two_stage_getter(
|buf, size| oakcommon_xml_writer_output(dup(&w), buf, size),
r#"<e>hi &amp; bye &lt;there&gt;</e><a q="x&quot;y&amp;z"/>"#,
);
}
// ---- error / error_abi ----
/// The constants the xmlutils exports return are defined in `crate::error`
/// and match `include/common/error.h` (the `ffi::error` / `ffi::error_abi`
/// modules only point here). The full set is pinned in `contract.rs`.
#[test]
fn error_constants_returned_by_xmlutils_match_header() {
assert_eq!(OAKCOMMON_OK, 0);
assert_eq!(OAKCOMMON_E_INVALID, -10001);
assert_eq!(OAKCOMMON_E_NOT_FOUND, -10004);
}
+168
View File
@@ -0,0 +1,168 @@
// 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/>.
//! Real-library smoke tests for the OCIO/image-backed utilities.
//!
//! These exercise the real OpenColorIO library through `ocio-rs`/`ocio-sys`
//! (compiled with `OCIO_RS_ENABLE_REAL=1`, see `.cargo/config.toml`). Each
//! OCIO test loads the project's own config at
//! `engine/render/ocioconf/config.ocio` and sets the `OCIO` environment
//! variable first (the library reads it at config-load time). The image tests
//! round-trip a small float TIFF through a temp file via the `image` crate.
use oakcommon::error::Error;
use oakcommon::ocioutils::OcioConfig;
use oakcommon::oiioutils::{read_image_f32, write_image_f32};
/// Path to the project's OCIO config, relative to this crate's manifest dir.
fn config_path() -> String {
let manifest = env!("CARGO_MANIFEST_DIR");
// src/common/rust -> repository root
format!("{}/../../../engine/render/ocioconf/config.ocio", manifest)
}
/// The `OCIO` env var is consumed by the library at config-load time; setting
/// it here keeps the test hermetic regardless of the host environment.
fn set_ocio_env() -> String {
let path = config_path();
std::env::set_var("OCIO", &path);
path
}
#[test]
fn ocio_load_and_list_colorspaces() {
let path = set_ocio_env();
let config = OcioConfig::from_file(&path).expect("config.ocio should load");
let count = config.colorspace_count().expect("count should work");
assert!(count > 0, "config should define at least one color space");
let colorspaces = config.colorspaces().expect("listing should work");
assert_eq!(colorspaces.len(), count as usize);
assert!(colorspaces.contains(&"Linear".to_string()));
assert!(colorspaces.contains(&"sRGB OETF".to_string()));
eprintln!("color spaces ({}): {:?}", colorspaces.len(), colorspaces);
}
#[test]
fn ocio_roles_and_canonical_names() {
let path = set_ocio_env();
let config = OcioConfig::from_file(&path).unwrap();
let roles = config.roles().unwrap();
assert!(!roles.is_empty(), "config should define roles");
eprintln!("roles: {:?}", roles);
assert!(config.has_role("scene_linear").unwrap(), "scene_linear role should exist");
assert!(config.has_role("default").unwrap());
assert!(!config.has_role("no_such_role").unwrap());
// scene_linear maps to the Linear color space.
assert_eq!(config.canonical_name("scene_linear").unwrap(), "Linear");
// Role/name equivalence: role name resolves to its canonical color space.
assert_eq!(config.canonical_name("Linear").unwrap(), "Linear");
}
#[test]
fn ocio_displays_and_views() {
let path = set_ocio_env();
let config = OcioConfig::from_file(&path).unwrap();
let display = config.default_display().unwrap();
assert_eq!(display, "sRGB");
let view = config.default_view(&display).unwrap();
assert_eq!(view, "sRGB OETF");
}
#[test]
fn ocio_processor_apply_rgba() {
let path = set_ocio_env();
let config = OcioConfig::from_file(&path).unwrap();
// Linear -> sRGB OETF: a mid-gray 0.18 (a common linear display-referred
// midpoint) should map well above itself and stay finite.
let processor = config.processor("Linear", "sRGB OETF").unwrap();
let mut px = [0.18f32, 0.18f32, 0.18f32, 1.0f32];
processor.apply_rgba(&mut px).unwrap();
assert!(px[0] > 0.18f32, "sRGB OETF should lift 0.18 linear, got {}", px[0]);
assert!(px[0] < 1.0f32 + 1e-6, "sRGB OETF output should be <= 1.0, got {}", px[0]);
assert!(px.iter().all(|v| v.is_finite()));
// Display-referred path: scene_linear -> default sRGB view.
let processor = config
.display_processor("scene_linear", "sRGB", "sRGB OETF")
.unwrap();
let mut px = [0.18f32, 0.18f32, 0.18f32, 1.0f32];
processor.apply_rgba(&mut px).unwrap();
assert!(px.iter().all(|v| v.is_finite()));
assert!(px[0] > 0.18f32, "display processor should also lift 0.18, got {}", px[0]);
}
#[test]
fn ocio_error_paths() {
let path = set_ocio_env();
// Nonexistent config file.
let err = OcioConfig::from_file("/nonexistent/oakcommon-real-ocio.ocio").unwrap_err();
eprintln!("nonexistent config error: {err:?}");
assert!(matches!(err, Error::Failed(_)));
let config = OcioConfig::from_file(&path).unwrap();
// Unknown destination color space.
let err = config.processor("Linear", "No Such Color Space").unwrap_err();
eprintln!("unknown colorspace error: {err:?}");
assert!(matches!(err, Error::Failed(_)));
// Unknown display/view.
let err = config.display_processor("Linear", "No Such Display", "No View").unwrap_err();
eprintln!("unknown display error: {err:?}");
assert!(matches!(err, Error::Failed(_)));
}
#[test]
fn image_f32_round_trip() {
let dir = std::env::temp_dir().join("oakcommon-real-ocio");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("roundtrip.tif");
let path_str = path.to_str().unwrap().to_string();
// 2x2 RGBA float image.
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,
];
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);
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();
}
+2
View File
@@ -32,6 +32,8 @@ add_library(oakcommon SHARED
loopmode.h
subtitleparams.cpp
subtitleparams.h
variant.cpp
variant.h
videoparams.cpp
videoparams.h
ffmpegutils.cpp
+314
View File
@@ -0,0 +1,314 @@
/***
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/>.
***/
#include "variant.h"
#include <cctype>
#include <cstdio>
#include <cstdlib>
namespace olive
{
/**
* @brief Format a double like QString::number(d) ('g', 6 significant digits).
*/
static std::string format_double(double d)
{
char buf[64];
std::snprintf(buf, sizeof(buf), "%g", d);
return std::string(buf);
}
double Variant::to_double(bool *ok) const
{
if (is_numeric_kind()) {
if (ok) {
*ok = true;
}
return numeric_as_double();
}
if (kind_ == k_string) {
char *end = nullptr;
double d = std::strtod(string_.c_str(), &end);
bool valid = end != string_.c_str();
if (ok) {
*ok = valid;
}
return valid ? d : 0.0;
}
if (ok) {
*ok = false;
}
return 0.0;
}
float Variant::to_float(bool *ok) const
{
return float(to_double(ok));
}
int64_t Variant::to_long_long(bool *ok) const
{
if (is_numeric_kind()) {
if (ok) {
*ok = true;
}
return numeric_as_int64();
}
if (kind_ == k_string) {
char *end = nullptr;
long long i = std::strtoll(string_.c_str(), &end, 10);
bool valid = end != string_.c_str();
if (ok) {
*ok = valid;
}
return valid ? int64_t(i) : 0;
}
if (ok) {
*ok = false;
}
return 0;
}
uint64_t Variant::to_u_long_long(bool *ok) const
{
if (is_numeric_kind()) {
if (ok) {
*ok = true;
}
return numeric_as_uint64();
}
if (kind_ == k_string) {
char *end = nullptr;
unsigned long long i = std::strtoull(string_.c_str(), &end, 10);
bool valid = end != string_.c_str();
if (ok) {
*ok = valid;
}
return valid ? uint64_t(i) : 0;
}
if (ok) {
*ok = false;
}
return 0;
}
int Variant::to_int(bool *ok) const
{
return int(to_long_long(ok));
}
unsigned int Variant::to_uint(bool *ok) const
{
return (unsigned int)(to_u_long_long(ok));
}
bool Variant::to_bool() const
{
if (is_numeric_kind()) {
return numeric_as_double() != 0.0;
}
if (kind_ == k_string) {
if (string_ == "true" || string_ == "TRUE") {
return true;
}
if (string_ == "false" || string_ == "FALSE" || string_.empty()) {
return false;
}
// QVariant falls back to numeric interpretation
bool ok = false;
double d = to_double(&ok);
return ok && d != 0.0;
}
return false;
}
std::string Variant::to_string() const
{
switch (kind_) {
case k_null:
return std::string();
case k_bool:
return bool_ ? std::string("true") : std::string("false");
case k_int:
return std::to_string(int_);
case k_uint:
return std::to_string(uint_);
case k_double:
return format_double(double_);
case k_string:
return string_;
case k_byte_array:
return std::string(byte_array_.begin(), byte_array_.end());
case k_string_list:
case k_custom:
return std::string();
}
return std::string();
}
StringList Variant::to_string_list() const
{
if (kind_ == k_string_list) {
return string_list_;
}
if (kind_ == k_string) {
// QString -> QStringList conversion wraps the string in a list
return StringList{ string_ };
}
return StringList();
}
ByteArray Variant::to_byte_array() const
{
if (kind_ == k_byte_array) {
return byte_array_;
}
if (kind_ == k_string) {
return ByteArray(string_.begin(), string_.end());
}
return ByteArray();
}
bool Variant::operator==(const Variant &rhs) const
{
if (kind_ == k_null || rhs.kind_ == k_null) {
return kind_ == rhs.kind_;
}
if (is_numeric_kind() && rhs.is_numeric_kind()) {
// QVariant compares numeric types numerically across kinds
if (kind_ == k_double || rhs.kind_ == k_double) {
return numeric_as_double() == rhs.numeric_as_double();
}
if (kind_ == k_uint || rhs.kind_ == k_uint) {
return numeric_as_uint64() == rhs.numeric_as_uint64();
}
return numeric_as_int64() == rhs.numeric_as_int64();
}
if (kind_ != rhs.kind_) {
return false;
}
switch (kind_) {
case k_string:
return string_ == rhs.string_;
case k_string_list:
return string_list_ == rhs.string_list_;
case k_byte_array:
return byte_array_ == rhs.byte_array_;
case k_custom:
return custom_->equals(rhs.custom_.get());
default:
return false;
}
}
/**
* @brief QByteArray::toBase64() equivalent.
*/
std::string byte_array_to_base64(const ByteArray &data)
{
static const char table[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string out;
out.reserve((data.size() + 2) / 3 * 4);
for (size_t i = 0; i < data.size(); i += 3) {
uint32_t chunk = uint8_t(data[i]) << 16;
if (i + 1 < data.size()) {
chunk |= uint8_t(data[i + 1]) << 8;
}
if (i + 2 < data.size()) {
chunk |= uint8_t(data[i + 2]);
}
out += table[(chunk >> 18) & 0x3F];
out += table[(chunk >> 12) & 0x3F];
out += (i + 1 < data.size()) ? table[(chunk >> 6) & 0x3F] : '=';
out += (i + 2 < data.size()) ? table[chunk & 0x3F] : '=';
}
return out;
}
/**
* @brief QByteArray::fromBase64() equivalent.
*/
ByteArray byte_array_from_base64(const std::string &text)
{
auto decode_char = [](char c) -> int {
if (c >= 'A' && c <= 'Z') {
return c - 'A';
}
if (c >= 'a' && c <= 'z') {
return c - 'a' + 26;
}
if (c >= '0' && c <= '9') {
return c - '0' + 52;
}
if (c == '+') {
return 62;
}
if (c == '/') {
return 63;
}
return -1;
};
ByteArray out;
int accumulator = 0;
int bits = 0;
for (char c : text) {
if (c == '=' || std::isspace((unsigned char)(c))) {
continue;
}
int v = decode_char(c);
if (v < 0) {
continue;
}
accumulator = (accumulator << 6) | v;
bits += 6;
if (bits >= 8) {
bits -= 8;
out.push_back(char((accumulator >> bits) & 0xFF));
}
}
return out;
}
}
+546
View File
@@ -0,0 +1,546 @@
/***
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/>.
***/
#ifndef OAK_NODEVARIANT_H
#define OAK_NODEVARIANT_H
#include <cstdint>
#include <memory>
#include <string>
#include <typeinfo>
#include <type_traits>
#include <utility>
#include <vector>
namespace olive
{
/**
* @brief De-Qt replacement for QStringList.
*/
using StringList = std::vector<std::string>;
/**
* @brief De-Qt replacement for QByteArray.
*/
using ByteArray = std::vector<char>;
/**
* @brief De-Qt replacement for QVariant.
*
* A discriminated value container covering the data set actually carried
* through the node module:
*
* - null (default-constructed)
* - bool
* - signed integer (all signed integer types are stored as int64_t)
* - unsigned integer (stored as uint64_t)
* - floating point (float and double are both stored as double)
* - string (std::string, replaces QString)
* - string list (StringList, replaces QStringList)
* - byte array (ByteArray, replaces QByteArray)
* - any other copy-constructible type, held type-erased (replaces
* Q_DECLARE_METATYPE custom types such as Color, Vector2D, TexturePtr,
* NodeValueArray, etc.)
*
* The API mirrors the QVariant subset used by the node module, renamed to
* snake_case: value<T>(), from_value(), can_convert<T>(), is_null(),
* to_double(), to_float(), to_int(), to_uint(), to_long_long(),
* to_u_long_long(), to_bool(), to_string(), to_std_string(),
* to_string_list(), to_byte_array().
*
* Conversion semantics follow QVariant for the stored kinds: numeric kinds
* convert freely between each other, strings parse to numbers (failure
* yields 0), and numbers format to strings (double uses QString::number's
* default 'g' format with 6 significant digits). Custom (type-erased)
* values only convert to their exact C++ type.
*
* Equality follows QVariant: two numeric values compare numerically across
* kinds, strings/byte-arrays/lists compare by content, custom values
* compare with the type's operator== (types without one compare unequal),
* and two nulls compare equal.
*/
class Variant {
public:
/**
* @brief Construct a null variant.
*/
Variant() = default;
Variant(const Variant &other)
: kind_(other.kind_)
, int_(other.int_)
, uint_(other.uint_)
, double_(other.double_)
, bool_(other.bool_)
, string_(other.string_)
, string_list_(other.string_list_)
, byte_array_(other.byte_array_)
, custom_(other.custom_ ? other.custom_->clone() : nullptr)
{
}
Variant(Variant &&) = default;
Variant &operator=(const Variant &other)
{
if (this != &other) {
kind_ = other.kind_;
int_ = other.int_;
uint_ = other.uint_;
double_ = other.double_;
bool_ = other.bool_;
string_ = other.string_;
string_list_ = other.string_list_;
byte_array_ = other.byte_array_;
custom_.reset(other.custom_ ? other.custom_->clone() : nullptr);
}
return *this;
}
Variant &operator=(Variant &&) = default;
Variant(const char *s)
: kind_(k_string)
, string_(s)
{
}
/**
* @brief String literal overload (beats the generic template ctor).
*/
template <size_t N>
Variant(const char (&s)[N])
: kind_(k_string)
, string_(s)
{
}
Variant(const std::string &s)
: kind_(k_string)
, string_(s)
{
}
Variant(const StringList &l)
: kind_(k_string_list)
, string_list_(l)
{
}
Variant(const ByteArray &b)
: kind_(k_byte_array)
, byte_array_(b)
{
}
Variant(bool b)
: kind_(k_bool)
, bool_(b)
{
}
/**
* @brief Construct from any other type.
*
* Arithmetic types are stored in the corresponding numeric kind
* (mirroring QVariant's int/uint/double storage); everything else is
* stored type-erased, mirroring QVariant::fromValue() of a custom
* metatype.
*/
template <typename T,
typename std::enable_if<
!std::is_same<typename std::decay<T>::type, Variant>::value &&
!std::is_same<typename std::decay<T>::type, bool>::value &&
!std::is_same<typename std::decay<T>::type, std::string>::value &&
!std::is_same<typename std::decay<T>::type, StringList>::value &&
!std::is_same<typename std::decay<T>::type, ByteArray>::value,
int>::type = 0>
Variant(const T &v)
{
set_value(v);
}
/**
* @brief QVariant::fromValue() equivalent.
*/
template <typename T> static Variant from_value(const T &v)
{
return Variant(v);
}
bool is_null() const
{
return kind_ == k_null;
}
/**
* @brief Extract the value as type T.
*
* For the built-in kinds this performs QVariant-style conversion; for
* custom types it returns the stored value if the type matches exactly,
* or a default-constructed T otherwise (QVariant behavior).
*/
template <typename T> T value() const
{
return value_impl(static_cast<T *>(nullptr));
}
/**
* @brief QVariant::canConvert<T>() equivalent.
*/
template <typename T> bool can_convert() const
{
return can_convert_impl(static_cast<T *>(nullptr));
}
double to_double(bool *ok = nullptr) const;
float to_float(bool *ok = nullptr) const;
int to_int(bool *ok = nullptr) const;
unsigned int to_uint(bool *ok = nullptr) const;
int64_t to_long_long(bool *ok = nullptr) const;
uint64_t to_u_long_long(bool *ok = nullptr) const;
bool to_bool() const;
/**
* @brief QVariant::toString() equivalent (QString::number formatting for
* doubles: 'g' with 6 significant digits).
*/
std::string to_string() const;
/**
* @brief QByteArray-like conversion of string data.
*/
std::string to_std_string() const
{
return to_string();
}
StringList to_string_list() const;
ByteArray to_byte_array() const;
bool operator==(const Variant &rhs) const;
bool operator!=(const Variant &rhs) const
{
return !(*this == rhs);
}
private:
enum Kind {
k_null,
k_bool,
k_int,
k_uint,
k_double,
k_string,
k_string_list,
k_byte_array,
k_custom
};
struct CustomHolderBase {
virtual ~CustomHolderBase() = default;
virtual CustomHolderBase *clone() const = 0;
virtual const std::type_info &type() const = 0;
virtual const void *data() const = 0;
virtual bool equals(const CustomHolderBase *rhs) const = 0;
};
template <typename T, typename = void> struct HasEquality : std::false_type {
};
template <typename T>
struct HasEquality<T,
typename std::enable_if< std::is_convertible<
decltype(std::declval<const T &>() == std::declval<const T &>()),
bool>::value>::type> : std::true_type {
};
template <typename T> bool custom_equals(const T &a, const T &b, std::true_type) const
{
return a == b;
}
template <typename T> bool custom_equals(const T &, const T &, std::false_type) const
{
return false;
}
template <typename T> struct CustomHolder : CustomHolderBase {
explicit CustomHolder(const T &v)
: value_(v)
{
}
CustomHolderBase *clone() const override
{
return new CustomHolder<T>(value_);
}
const std::type_info &type() const override
{
return typeid(T);
}
const void *data() const override
{
return &value_;
}
bool equals(const CustomHolderBase *rhs) const override
{
if (rhs->type() != typeid(T)) {
return false;
}
const T &other = *static_cast<const T *>(rhs->data());
Variant dummy;
return dummy.custom_equals(value_, other, HasEquality<T>());
}
T value_;
};
template <typename T>
typename std::enable_if<std::is_integral<T>::value && std::is_signed<T>::value &&
!std::is_same<T, bool>::value,
void>::type
set_value(const T &v)
{
kind_ = k_int;
int_ = int64_t(v);
}
template <typename T>
typename std::enable_if<std::is_integral<T>::value && std::is_unsigned<T>::value &&
!std::is_same<T, bool>::value,
void>::type
set_value(const T &v)
{
kind_ = k_uint;
uint_ = uint64_t(v);
}
template <typename T>
typename std::enable_if<std::is_floating_point<T>::value, void>::type
set_value(const T &v)
{
kind_ = k_double;
double_ = double(v);
}
template <typename T>
typename std::enable_if<
!std::is_arithmetic<T>::value && !std::is_same<T, bool>::value &&
!std::is_same<T, std::string>::value && !std::is_same<T, StringList>::value &&
!std::is_same<T, ByteArray>::value,
void>::type
set_value(const T &v)
{
kind_ = k_custom;
custom_.reset(new CustomHolder<T>(v));
}
bool is_numeric_kind() const
{
return kind_ == k_bool || kind_ == k_int || kind_ == k_uint ||
kind_ == k_double;
}
double numeric_as_double() const
{
switch (kind_) {
case k_bool:
return bool_ ? 1.0 : 0.0;
case k_int:
return double(int_);
case k_uint:
return double(uint_);
case k_double:
return double_;
default:
return 0.0;
}
}
int64_t numeric_as_int64() const
{
switch (kind_) {
case k_bool:
return bool_ ? 1 : 0;
case k_int:
return int_;
case k_uint:
return int64_t(uint_);
case k_double:
return int64_t(double_);
default:
return 0;
}
}
uint64_t numeric_as_uint64() const
{
switch (kind_) {
case k_bool:
return bool_ ? 1 : 0;
case k_int:
return uint64_t(int_);
case k_uint:
return uint_;
case k_double:
return uint64_t(double_);
default:
return 0;
}
}
// value<T>() dispatchers
template <typename T>
typename std::enable_if<std::is_same<T, std::string>::value, T>::type
value_impl(T *) const
{
return to_string();
}
template <typename T>
typename std::enable_if<std::is_same<T, StringList>::value, T>::type
value_impl(T *) const
{
return to_string_list();
}
template <typename T>
typename std::enable_if<std::is_same<T, ByteArray>::value, T>::type
value_impl(T *) const
{
return to_byte_array();
}
template <typename T>
typename std::enable_if<std::is_same<T, bool>::value, T>::type
value_impl(T *) const
{
return to_bool();
}
template <typename T>
typename std::enable_if<std::is_floating_point<T>::value, T>::type
value_impl(T *) const
{
return T(to_double());
}
template <typename T>
typename std::enable_if<std::is_integral<T>::value && std::is_signed<T>::value &&
!std::is_same<T, bool>::value,
T>::type
value_impl(T *) const
{
return T(to_long_long());
}
template <typename T>
typename std::enable_if<std::is_integral<T>::value && std::is_unsigned<T>::value &&
!std::is_same<T, bool>::value,
T>::type
value_impl(T *) const
{
return T(to_u_long_long());
}
template <typename T>
typename std::enable_if<
!std::is_arithmetic<T>::value && !std::is_same<T, std::string>::value &&
!std::is_same<T, StringList>::value && !std::is_same<T, ByteArray>::value,
T>::type
value_impl(T *) const
{
if (kind_ == k_custom && custom_->type() == typeid(T)) {
return *static_cast<const T *>(custom_->data());
}
return T();
}
// can_convert<T>() dispatchers
template <typename T>
typename std::enable_if<std::is_same<T, std::string>::value, bool>::type
can_convert_impl(T *) const
{
return is_numeric_kind() || kind_ == k_string || kind_ == k_byte_array;
}
template <typename T>
typename std::enable_if<std::is_same<T, StringList>::value, bool>::type
can_convert_impl(T *) const
{
return kind_ == k_string_list || kind_ == k_string;
}
template <typename T>
typename std::enable_if<std::is_same<T, ByteArray>::value, bool>::type
can_convert_impl(T *) const
{
return kind_ == k_byte_array || kind_ == k_string;
}
template <typename T>
typename std::enable_if<std::is_arithmetic<T>::value, bool>::type
can_convert_impl(T *) const
{
return is_numeric_kind() || kind_ == k_string;
}
template <typename T>
typename std::enable_if<
!std::is_arithmetic<T>::value && !std::is_same<T, std::string>::value &&
!std::is_same<T, StringList>::value && !std::is_same<T, ByteArray>::value,
bool>::type
can_convert_impl(T *) const
{
return kind_ == k_custom && custom_->type() == typeid(T);
}
Kind kind_ = k_null;
int64_t int_ = 0;
uint64_t uint_ = 0;
double double_ = 0.0;
bool bool_ = false;
std::string string_;
StringList string_list_;
ByteArray byte_array_;
std::unique_ptr<CustomHolderBase> custom_;
};
/**
* @brief QByteArray::toBase64() equivalent.
*/
std::string byte_array_to_base64(const ByteArray &data);
/**
* @brief QByteArray::fromBase64() equivalent.
*/
ByteArray byte_array_from_base64(const std::string &text);
}
#endif // OAK_NODEVARIANT_H