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:
@@ -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"
|
||||
Generated
+332
@@ -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",
|
||||
]
|
||||
@@ -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"] }
|
||||
@@ -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.
|
||||
@@ -0,0 +1,167 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! `olive::ColorTransform` — a color transform description. Mirrors
|
||||
//! `src/common/src/colortransform.h` and `include/common/colortransform.h`.
|
||||
//!
|
||||
//! A transform is either an output-colorspace transform or a
|
||||
//! display/view/look transform. The C++ object is passed through the C ABI
|
||||
//! as a refcounted handle ([`OakColorTransform`] in `crate::ffi`); this
|
||||
//! module owns the plain-data description behind the handle.
|
||||
//!
|
||||
//! The C++-only functions `oakcommon_colortransform_init_from_native` /
|
||||
//! `get_native` take or return `olive::ColorTransform` and cannot be
|
||||
//! expressed from Rust; they are served by the C++ adapter layer, not here.
|
||||
|
||||
/// `olive::ColorTransform` — the plain-data description behind the handle.
|
||||
pub struct ColorTransform {
|
||||
/// Output colorspace name (empty when this is a display transform).
|
||||
output: String,
|
||||
/// Display name (empty when this is an output-colorspace transform).
|
||||
display: String,
|
||||
/// Whether this is a display/view/look transform.
|
||||
// CPP-PARITY: the C++ class tracks this with an explicit `is_display_`
|
||||
// member (`src/common/src/colortransform.h`). We keep it as a separate
|
||||
// bool as well so an empty display name on a display transform is still
|
||||
// recognized as a display transform.
|
||||
is_display: bool,
|
||||
/// View name (display transforms only).
|
||||
view: String,
|
||||
/// Look name (display transforms only).
|
||||
look: String,
|
||||
}
|
||||
|
||||
impl ColorTransform {
|
||||
/// New output-colorspace transform.
|
||||
pub fn new_output(output: &str) -> Self {
|
||||
Self {
|
||||
output: output.to_string(),
|
||||
display: String::new(),
|
||||
is_display: false,
|
||||
view: String::new(),
|
||||
look: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// New display/view/look transform.
|
||||
pub fn new_display(display: &str, view: &str, look: &str) -> Self {
|
||||
// CPP-PARITY: the C++ display constructor stores the display name in
|
||||
// the shared `output_` member and sets `is_display_ = true`; here the
|
||||
// display name lives in its own `display` field (the skeleton's public
|
||||
// `display()` / `output()` accessors read the respective field).
|
||||
Self {
|
||||
output: String::new(),
|
||||
display: display.to_string(),
|
||||
is_display: true,
|
||||
view: view.to_string(),
|
||||
look: look.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this is a display/view/look transform.
|
||||
pub fn is_display(&self) -> bool {
|
||||
self.is_display
|
||||
}
|
||||
|
||||
/// Display name (empty if this is an output transform).
|
||||
pub fn display(&self) -> &str {
|
||||
&self.display
|
||||
}
|
||||
|
||||
/// Output colorspace name (empty if this is a display transform).
|
||||
pub fn output(&self) -> &str {
|
||||
&self.output
|
||||
}
|
||||
|
||||
/// View name.
|
||||
pub fn view(&self) -> &str {
|
||||
&self.view
|
||||
}
|
||||
|
||||
/// Look name.
|
||||
pub fn look(&self) -> &str {
|
||||
&self.look
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ColorTransform;
|
||||
|
||||
#[test]
|
||||
fn output_transform_defaults() {
|
||||
let t = ColorTransform::new_output("sRGB");
|
||||
assert!(!t.is_display());
|
||||
assert_eq!(t.output(), "sRGB");
|
||||
assert_eq!(t.display(), "");
|
||||
assert_eq!(t.view(), "");
|
||||
assert_eq!(t.look(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_transform_defaults() {
|
||||
let t = ColorTransform::new_display("DCI-P3", "standard", "soft");
|
||||
assert!(t.is_display());
|
||||
assert_eq!(t.display(), "DCI-P3");
|
||||
assert_eq!(t.view(), "standard");
|
||||
assert_eq!(t.look(), "soft");
|
||||
assert_eq!(t.output(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_transform_empty_strings() {
|
||||
// Mirrors the C++ default constructor: an output transform built from
|
||||
// an empty string still has `is_display == false`.
|
||||
let t = ColorTransform::new_output("");
|
||||
assert!(!t.is_display());
|
||||
assert_eq!(t.output(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_transform_recognized_even_with_empty_display_name() {
|
||||
// The explicit is_display flag (vs. deriving from a non-empty display
|
||||
// name) keeps an empty-named display transform identifiable.
|
||||
let t = ColorTransform::new_display("", "view", "");
|
||||
assert!(t.is_display());
|
||||
assert_eq!(t.display(), "");
|
||||
assert_eq!(t.view(), "view");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_return_borrowed_slices() {
|
||||
let t = ColorTransform::new_display("Display", "View", "Look");
|
||||
let d: &str = t.display();
|
||||
let v: &str = t.view();
|
||||
let l: &str = t.look();
|
||||
let o: &str = t.output();
|
||||
assert_eq!(d, "Display");
|
||||
assert_eq!(v, "View");
|
||||
assert_eq!(l, "Look");
|
||||
assert_eq!(o, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c_api_string_sizes() {
|
||||
// The c_api string getters return the required size including the NUL
|
||||
// (value.size() + 1) and, being NON-TRUNCATING, only copy when the
|
||||
// buffer is large enough. Verify the values the domain layer exposes
|
||||
// line up with those sizes.
|
||||
let t = ColorTransform::new_display("RGB", "ACES", "none");
|
||||
assert_eq!(t.display().len() + 1, 4);
|
||||
assert_eq!(t.view().len() + 1, 5);
|
||||
assert_eq!(t.look().len() + 1, 5);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,873 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Command-line option parser, mirroring
|
||||
//! `src/common/src/commandlineparser.h` and
|
||||
//! `include/common/commandlineparser.h`.
|
||||
//!
|
||||
//! A parser owns a list of registered options and positional arguments;
|
||||
//! option/argument handles returned at registration time are owned by the
|
||||
//! parser and stay valid until it is destroyed.
|
||||
|
||||
use std::ffi::CString;
|
||||
use std::io::{self, Write};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// `olive::CommandLineParser` — owns registered options and positional
|
||||
/// arguments.
|
||||
pub struct CommandLineParser {
|
||||
/// Application name shown by `print_help`.
|
||||
app_name: String,
|
||||
/// Application version shown by `print_help`.
|
||||
app_version: String,
|
||||
/// Registered named options.
|
||||
///
|
||||
/// Boxed so that the option objects keep a stable address when the
|
||||
/// vector grows; option handles borrowed by the C ABI point at the
|
||||
/// boxed value. // CPP-PARITY: the C++ code heap-allocates each
|
||||
/// `Option` (`new Option()`); `Vec<Box<..>>` reproduces that stability
|
||||
/// against reallocation.
|
||||
options: Vec<Box<CommandLineOption>>,
|
||||
/// Registered positional arguments (see `options` for the boxing note).
|
||||
positionals: Vec<Box<CommandLinePositionalArgument>>,
|
||||
}
|
||||
|
||||
impl CommandLineParser {
|
||||
/// New empty parser.
|
||||
pub fn new() -> Self {
|
||||
// CPP-PARITY: `app_name_` defaults to "oak" in the C++ header
|
||||
// (`std::string app_name_ = "oak"`), `app_version_` to "".
|
||||
Self {
|
||||
app_name: "oak".to_string(),
|
||||
app_version: String::new(),
|
||||
options: Vec::new(),
|
||||
positionals: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the application name/version shown by `print_help`.
|
||||
pub fn set_app_info(&mut self, name: &str, version: &str) {
|
||||
self.app_name = name.to_string();
|
||||
self.app_version = version.to_string();
|
||||
}
|
||||
|
||||
/// Register an option with one or more name strings.
|
||||
///
|
||||
/// Returns the option, which remains owned by the parser.
|
||||
pub fn add_option(
|
||||
&mut self,
|
||||
names: &[CString],
|
||||
description: &str,
|
||||
takes_arg: bool,
|
||||
arg_placeholder: &str,
|
||||
hidden: bool,
|
||||
) -> Result<()> {
|
||||
let mut strings = Vec::with_capacity(names.len());
|
||||
for n in names {
|
||||
// CPP-PARITY: C++ stores raw byte strings and accepts any bytes;
|
||||
// Rust `String` is UTF-8, so a non-UTF-8 option name is rejected
|
||||
// here with E_INVALID. The C ABI `add_option` already rejects
|
||||
// null/empty names before reaching this point.
|
||||
strings.push(n.to_str().map_err(|_| Error::Invalid)?.to_string());
|
||||
}
|
||||
|
||||
self.options.push(Box::new(CommandLineOption {
|
||||
names: strings,
|
||||
description: description.to_string(),
|
||||
takes_arg,
|
||||
arg_placeholder: arg_placeholder.to_string(),
|
||||
hidden,
|
||||
is_set: false,
|
||||
setting: None,
|
||||
}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Register a positional argument.
|
||||
pub fn add_positional_argument(
|
||||
&mut self,
|
||||
name: &str,
|
||||
description: &str,
|
||||
required: bool,
|
||||
) -> Result<()> {
|
||||
self.positionals.push(Box::new(CommandLinePositionalArgument {
|
||||
name: name.to_string(),
|
||||
description: description.to_string(),
|
||||
required,
|
||||
setting: None,
|
||||
}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse an argv-style argument list (argv[0] is the program name and
|
||||
/// is skipped).
|
||||
pub fn process(&mut self, argv: &[CString]) -> Result<()> {
|
||||
let mut positional_index = 0usize;
|
||||
let mut i = 1usize; // CPP-PARITY: argv[0] is the program name, skipped.
|
||||
|
||||
while i < argv.len() {
|
||||
let arg_str = match argv[i].to_str() {
|
||||
Ok(s) => s.to_string(),
|
||||
Err(_) => {
|
||||
// CPP-PARITY: C++ compares raw bytes; a non-UTF-8 arg
|
||||
// cannot match a known option/positional here, so treat
|
||||
// it as an unknown parameter (best effort for the log).
|
||||
eprintln!("Unknown parameter: {}", argv[i].to_string_lossy());
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if !arg_str.is_empty() && arg_str.starts_with('-') {
|
||||
// Must be an option. Skip past the first dash.
|
||||
let arg_basename = &arg_str[1..];
|
||||
|
||||
let mut matched_known = false;
|
||||
let mut consume_next = false;
|
||||
|
||||
// CPP-PARITY: the C++ `goto found_flag` is a labelled break
|
||||
// out of the double loop; the match is case-insensitive.
|
||||
'find: for opt in self.options.iter_mut() {
|
||||
for name in &opt.names {
|
||||
if name.eq_ignore_ascii_case(arg_basename) {
|
||||
opt.is_set = true;
|
||||
if opt.takes_arg && i + 1 < argv.len() {
|
||||
// CPP-PARITY: the argument value is stored as
|
||||
// a byte string in C++; Rust only keeps it when
|
||||
// it is valid UTF-8, otherwise it is left unset.
|
||||
opt.setting = argv[i + 1].to_str().ok().map(str::to_owned);
|
||||
consume_next = true;
|
||||
}
|
||||
matched_known = true;
|
||||
break 'find;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !matched_known {
|
||||
eprintln!("Unknown parameter: {}", arg_str);
|
||||
}
|
||||
// CPP-PARITY: when a `takes_arg` option consumes the following
|
||||
// argument, C++ advances `i` twice (the inner `i++` plus the
|
||||
// loop increment); otherwise once.
|
||||
i += if consume_next { 2 } else { 1 };
|
||||
} else {
|
||||
// Must be a positional argument.
|
||||
if positional_index < self.positionals.len() {
|
||||
self.positionals[positional_index].setting = Some(arg_str);
|
||||
positional_index += 1;
|
||||
} else {
|
||||
eprintln!("Unknown parameter: {}", arg_str);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Print usage/help text to stdout.
|
||||
pub fn print_help(&self, filename: &str) -> Result<()> {
|
||||
let stdout = io::stdout();
|
||||
let mut out = stdout.lock();
|
||||
self.write_help(&mut out, filename)
|
||||
}
|
||||
|
||||
/// Render the usage/help text into `out`, mirroring
|
||||
/// `CommandLineParser::print_help`. Split out so tests can capture the
|
||||
/// output in a buffer.
|
||||
fn write_help<W: Write>(&self, out: &mut W, filename: &str) -> Result<()> {
|
||||
let r = (|| -> io::Result<()> {
|
||||
// CPP-PARITY: `printf("%s %s\n")` always emits the separating
|
||||
// space, so "oak " + version + "\n" when the version is empty.
|
||||
writeln!(out, "{} {}", self.app_name, self.app_version)?;
|
||||
writeln!(out, "Copyright (C) 2018-2022 Oak Video Editor Team")?;
|
||||
|
||||
// Build the "[name] [name] ..." positional list.
|
||||
let mut positional_args = String::new();
|
||||
for (i, p) in self.positionals.iter().enumerate() {
|
||||
if i > 0 {
|
||||
positional_args.push(' ');
|
||||
}
|
||||
positional_args.push('[');
|
||||
positional_args.push_str(&p.name);
|
||||
positional_args.push(']');
|
||||
}
|
||||
|
||||
// CPP-PARITY: on POSIX the basename is everything after the last
|
||||
// '/'; without a slash the whole string is used.
|
||||
let basename = match filename.rfind('/') {
|
||||
Some(pos) => &filename[pos + 1..],
|
||||
None => filename,
|
||||
};
|
||||
|
||||
writeln!(out, "Usage: {} [options] {}\n", basename, positional_args)?;
|
||||
|
||||
for opt in &self.options {
|
||||
if opt.hidden {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut all_args = String::new();
|
||||
for (i, name) in opt.names.iter().enumerate() {
|
||||
if i > 0 {
|
||||
all_args.push_str(", ");
|
||||
}
|
||||
all_args.push('-');
|
||||
all_args.push_str(name);
|
||||
}
|
||||
|
||||
if opt.arg_placeholder.is_empty() {
|
||||
writeln!(out, " {}", all_args)?;
|
||||
} else {
|
||||
writeln!(out, " {} <{}>", all_args, opt.arg_placeholder)?;
|
||||
}
|
||||
writeln!(out, " {}\n", opt.description)?;
|
||||
}
|
||||
|
||||
writeln!(out)?;
|
||||
Ok(())
|
||||
})();
|
||||
r.map_err(|e| Error::Failed(e.to_string()))
|
||||
}
|
||||
|
||||
// The six accessors below are `pub(crate)` so the C ABI layer
|
||||
// (`crate::ffi::commandlineparser`) can hand out stable borrowed handles
|
||||
// to the boxed options/arguments; they are currently unused until that
|
||||
// layer is implemented.
|
||||
#[allow(dead_code)]
|
||||
/// Number of registered options. `pub(crate)`: used by the C ABI layer
|
||||
/// to look up the option just appended by [`Self::add_option`].
|
||||
pub(crate) fn option_count(&self) -> usize {
|
||||
self.options.len()
|
||||
}
|
||||
|
||||
/// Borrow a registered option by index. `pub(crate)`: hands the C ABI
|
||||
/// layer a stable pointer to the boxed option for a borrowed handle.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn option(&self, index: usize) -> Option<&CommandLineOption> {
|
||||
self.options.get(index).map(|b| b.as_ref())
|
||||
}
|
||||
|
||||
/// Mutably borrow a registered option by index.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn option_mut(&mut self, index: usize) -> Option<&mut CommandLineOption> {
|
||||
self.options.get_mut(index).map(|b| b.as_mut())
|
||||
}
|
||||
|
||||
/// Number of registered positional arguments.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn positional_count(&self) -> usize {
|
||||
self.positionals.len()
|
||||
}
|
||||
|
||||
/// Borrow a registered positional argument by index.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn positional(&self, index: usize) -> Option<&CommandLinePositionalArgument> {
|
||||
self.positionals.get(index).map(|b| b.as_ref())
|
||||
}
|
||||
|
||||
/// Mutably borrow a registered positional argument by index.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn positional_mut(
|
||||
&mut self,
|
||||
index: usize,
|
||||
) -> Option<&mut CommandLinePositionalArgument> {
|
||||
self.positionals.get_mut(index).map(|b| b.as_mut())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CommandLineParser {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// `olive::CommandLineOption` — a registered named option.
|
||||
pub struct CommandLineOption {
|
||||
/// Option name strings (without leading dash).
|
||||
names: Vec<String>,
|
||||
/// Help text.
|
||||
description: String,
|
||||
/// Whether the option consumes the following argument.
|
||||
takes_arg: bool,
|
||||
/// Placeholder shown in help when `takes_arg`.
|
||||
arg_placeholder: String,
|
||||
/// Whether to omit from help output.
|
||||
hidden: bool,
|
||||
/// Whether the option was present on the command line.
|
||||
is_set: bool,
|
||||
/// The argument value (present only when set).
|
||||
setting: Option<String>,
|
||||
}
|
||||
|
||||
impl CommandLineOption {
|
||||
/// Whether the option was present on the command line.
|
||||
pub fn is_set(&self) -> bool {
|
||||
self.is_set
|
||||
}
|
||||
|
||||
/// The option's argument value, if one was set.
|
||||
pub fn get_setting(&self) -> Result<&str> {
|
||||
match &self.setting {
|
||||
Some(v) => Ok(v.as_str()),
|
||||
// CPP-PARITY: C++ `get_setting()` returns the (possibly empty)
|
||||
// stored string and never fails; the "was it supplied" question
|
||||
// is answered by [`Self::is_set`]. Rust mirrors this by returning
|
||||
// an empty string when nothing was stored.
|
||||
None => Ok(""),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the option's argument value.
|
||||
pub fn set_setting(&mut self, value: &str) -> Result<()> {
|
||||
self.setting = Some(value.to_string());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// `olive::CommandLinePositionalArgument` — a registered positional
|
||||
/// argument.
|
||||
pub struct CommandLinePositionalArgument {
|
||||
/// Argument name.
|
||||
name: String,
|
||||
/// Help text.
|
||||
#[allow(dead_code)] // CPP-PARITY: stored but never read in C++ print_help/process.
|
||||
description: String,
|
||||
/// Whether the argument is required.
|
||||
#[allow(dead_code)] // CPP-PARITY: stored but never consumed in the C++ source.
|
||||
required: bool,
|
||||
/// The argument value (present only when parsed).
|
||||
setting: Option<String>,
|
||||
}
|
||||
|
||||
impl CommandLinePositionalArgument {
|
||||
/// The argument's value, if one was parsed.
|
||||
pub fn get_setting(&self) -> Result<&str> {
|
||||
match &self.setting {
|
||||
Some(v) => Ok(v.as_str()),
|
||||
None => Ok(""),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the argument's value.
|
||||
pub fn set_setting(&mut self, value: &str) -> Result<()> {
|
||||
self.setting = Some(value.to_string());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cstr(s: &str) -> CString {
|
||||
CString::new(s).unwrap()
|
||||
}
|
||||
|
||||
/// `new()` reproduces the C++ defaults: app name "oak", empty version,
|
||||
/// no registered options/arguments.
|
||||
#[test]
|
||||
fn defaults() {
|
||||
let p = CommandLineParser::new();
|
||||
assert_eq!(p.app_name, "oak");
|
||||
assert_eq!(p.app_version, "");
|
||||
assert_eq!(p.option_count(), 0);
|
||||
assert_eq!(p.positional_count(), 0);
|
||||
}
|
||||
|
||||
/// `set_app_info` overwrites the name and version.
|
||||
#[test]
|
||||
fn set_app_info() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.set_app_info("myapp", "2.5");
|
||||
assert_eq!(p.app_name, "myapp");
|
||||
assert_eq!(p.app_version, "2.5");
|
||||
}
|
||||
|
||||
/// Adding options/arguments increments the counts and they are
|
||||
/// retrievable by index.
|
||||
#[test]
|
||||
fn add_registers() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_option(&[cstr("h"), cstr("help")], "show help", false, "", false)
|
||||
.unwrap();
|
||||
p.add_option(&[cstr("o")], "output", true, "FILE", false).unwrap();
|
||||
p.add_option(&[cstr("secret")], "hidden opt", false, "", true)
|
||||
.unwrap();
|
||||
p.add_positional_argument("input", "input file", true).unwrap();
|
||||
p.add_positional_argument("output", "output file", false).unwrap();
|
||||
|
||||
assert_eq!(p.option_count(), 3);
|
||||
assert_eq!(p.positional_count(), 2);
|
||||
|
||||
let opt = p.option(0).unwrap();
|
||||
assert_eq!(opt.names, vec!["h".to_string(), "help".to_string()]);
|
||||
assert!(!opt.takes_arg);
|
||||
assert!(!opt.hidden);
|
||||
assert!(!opt.is_set());
|
||||
assert_eq!(opt.get_setting().unwrap(), "");
|
||||
|
||||
let opt = p.option(1).unwrap();
|
||||
assert!(opt.takes_arg);
|
||||
assert_eq!(opt.arg_placeholder, "FILE");
|
||||
|
||||
let opt = p.option(2).unwrap();
|
||||
assert!(opt.hidden);
|
||||
|
||||
let pos = p.positional(0).unwrap();
|
||||
assert_eq!(pos.name, "input");
|
||||
assert!(pos.required);
|
||||
assert_eq!(pos.get_setting().unwrap(), "");
|
||||
}
|
||||
|
||||
/// A non-UTF-8 option name is rejected with E_INVALID.
|
||||
#[test]
|
||||
fn add_option_rejects_non_utf8_name() {
|
||||
let mut p = CommandLineParser::new();
|
||||
let bad = CString::new(vec![b'x', 0xFF, 0xFE]).unwrap();
|
||||
assert!(matches!(
|
||||
p.add_option(&[bad], "", false, "", false),
|
||||
Err(Error::Invalid)
|
||||
));
|
||||
assert_eq!(p.option_count(), 0);
|
||||
}
|
||||
|
||||
/// get/set_setting round-trips on an option.
|
||||
#[test]
|
||||
fn option_setting_roundtrip() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_option(&[cstr("o")], "", true, "F", false).unwrap();
|
||||
let mut opt = p.option_mut(0).unwrap();
|
||||
assert_eq!(opt.get_setting().unwrap(), "");
|
||||
opt.set_setting("value").unwrap();
|
||||
assert_eq!(opt.get_setting().unwrap(), "value");
|
||||
}
|
||||
|
||||
/// get/set_setting round-trips on a positional argument.
|
||||
#[test]
|
||||
fn positional_setting_roundtrip() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_positional_argument("in", "", true).unwrap();
|
||||
let mut pos = p.positional_mut(0).unwrap();
|
||||
assert_eq!(pos.get_setting().unwrap(), "");
|
||||
pos.set_setting("file.mp4").unwrap();
|
||||
assert_eq!(pos.get_setting().unwrap(), "file.mp4");
|
||||
}
|
||||
|
||||
/// process(): simple flags, case-insensitive matching, value-consuming
|
||||
/// options, and positional assignment.
|
||||
#[test]
|
||||
fn process_flags_and_values() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_option(&[cstr("h"), cstr("help")], "", false, "", false)
|
||||
.unwrap();
|
||||
p.add_option(&[cstr("o")], "", true, "FILE", false).unwrap();
|
||||
p.add_option(&[cstr("V")], "", false, "", false).unwrap();
|
||||
p.add_positional_argument("input", "", true).unwrap();
|
||||
|
||||
let argv = [
|
||||
cstr("prog"),
|
||||
cstr("-help"),
|
||||
cstr("-o"),
|
||||
cstr("out.mov"),
|
||||
cstr("-v"),
|
||||
cstr("in.mp4"),
|
||||
];
|
||||
p.process(&argv).unwrap();
|
||||
|
||||
assert!(p.option(0).unwrap().is_set());
|
||||
assert_eq!(p.option(0).unwrap().get_setting().unwrap(), "");
|
||||
|
||||
// `-o` consumed `out.mov` as its argument.
|
||||
assert!(p.option(1).unwrap().is_set());
|
||||
assert_eq!(p.option(1).unwrap().get_setting().unwrap(), "out.mov");
|
||||
|
||||
// `-v` matched `-V` case-insensitively.
|
||||
assert!(p.option(2).unwrap().is_set());
|
||||
|
||||
// `in.mp4` filled the first positional.
|
||||
assert_eq!(p.positional(0).unwrap().get_setting().unwrap(), "in.mp4");
|
||||
}
|
||||
|
||||
/// process(): argv[0] is skipped; a leading empty string before the
|
||||
/// first arg does not disturb parsing.
|
||||
#[test]
|
||||
fn process_skips_program_name() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_option(&[cstr("x")], "", false, "", false).unwrap();
|
||||
let argv = [cstr("prog"), cstr("-x")];
|
||||
p.process(&argv).unwrap();
|
||||
assert!(p.option(0).unwrap().is_set());
|
||||
|
||||
// A bare program name with no args is fine.
|
||||
let argv = [cstr("prog")];
|
||||
p.process(&argv).unwrap();
|
||||
}
|
||||
|
||||
/// process(): a `takes_arg` option at the end of argv consumes nothing
|
||||
/// and stays set without a value.
|
||||
#[test]
|
||||
fn process_takes_arg_at_end() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_option(&[cstr("o")], "", true, "FILE", false).unwrap();
|
||||
let argv = [cstr("prog"), cstr("-o")];
|
||||
p.process(&argv).unwrap();
|
||||
assert!(p.option(0).unwrap().is_set());
|
||||
assert_eq!(p.option(0).unwrap().get_setting().unwrap(), "");
|
||||
}
|
||||
|
||||
/// process(): C++ `process` only ever sets state, never resets it, so a
|
||||
/// second call that does not mention an option leaves its previous
|
||||
/// `is_set`/setting intact.
|
||||
#[test]
|
||||
fn process_accumulates_state() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_option(&[cstr("o")], "", true, "F", false).unwrap();
|
||||
p.process(&[cstr("p"), cstr("-o"), cstr("a")]).unwrap();
|
||||
assert!(p.option(0).unwrap().is_set());
|
||||
assert_eq!(p.option(0).unwrap().get_setting().unwrap(), "a");
|
||||
|
||||
// CPP-PARITY: no reset between calls.
|
||||
p.process(&[cstr("p")]).unwrap();
|
||||
assert!(p.option(0).unwrap().is_set());
|
||||
assert_eq!(p.option(0).unwrap().get_setting().unwrap(), "a");
|
||||
}
|
||||
|
||||
/// process(): only a single leading dash is stripped, so `--o` is a
|
||||
/// distinct (unknown) argument rather than matching option `o`.
|
||||
#[test]
|
||||
fn process_strips_single_dash() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_option(&[cstr("o")], "", true, "F", false).unwrap();
|
||||
p.process(&[cstr("p"), cstr("--o"), cstr("v")]).unwrap();
|
||||
assert!(!p.option(0).unwrap().is_set());
|
||||
// `--o` was unknown, so `v` fell through to be ignored (no
|
||||
// positionals registered).
|
||||
assert_eq!(p.option(0).unwrap().get_setting().unwrap(), "");
|
||||
}
|
||||
|
||||
/// print_help() renders the exact text the C++ produces.
|
||||
#[test]
|
||||
fn print_help_exact() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.set_app_info("oak", "1.2.3");
|
||||
p.add_option(&[cstr("h"), cstr("help")], "Show this help message.", false, "", false)
|
||||
.unwrap();
|
||||
p.add_option(&[cstr("o")], "Output file.", true, "FILE", true)
|
||||
.unwrap(); // hidden, omitted
|
||||
p.add_option(&[cstr("t")], "Time.", true, "SEC", false).unwrap();
|
||||
p.add_positional_argument("input", "Input file", true).unwrap();
|
||||
|
||||
let mut buf = Vec::new();
|
||||
p.write_help(&mut buf, "/usr/local/bin/oak").unwrap();
|
||||
let text = String::from_utf8(buf).unwrap();
|
||||
|
||||
// CPP-PARITY: C++ emits " %s\n\n" for the last option and then
|
||||
// a final "\n", so the text ends with three newlines after "Time.".
|
||||
let expected = "\
|
||||
oak 1.2.3
|
||||
Copyright (C) 2018-2022 Oak Video Editor Team
|
||||
Usage: oak [options] [input]
|
||||
|
||||
-h, -help
|
||||
Show this help message.
|
||||
|
||||
-t <SEC>
|
||||
Time.
|
||||
|
||||
|
||||
";
|
||||
assert_eq!(text, expected);
|
||||
}
|
||||
|
||||
/// print_help() with a bare filename (no slash) uses it as-is; the
|
||||
/// default empty version yields a trailing-space header line.
|
||||
#[test]
|
||||
fn print_help_bare_filename_and_default_version() {
|
||||
let p = CommandLineParser::new();
|
||||
let mut buf = Vec::new();
|
||||
p.write_help(&mut buf, "oak").unwrap();
|
||||
let text = String::from_utf8(buf).unwrap();
|
||||
assert!(text.starts_with("oak \n"), "header was {:?}", &text[..12]);
|
||||
assert!(text.contains("Usage: oak [options] \n"));
|
||||
}
|
||||
|
||||
/// An option/argument handle stays valid (stable address) while more
|
||||
/// options are registered, because options are boxed.
|
||||
#[test]
|
||||
fn option_address_is_stable() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_option(&[cstr("a")], "", false, "", false).unwrap();
|
||||
let first = p.option(0).unwrap() as *const CommandLineOption;
|
||||
|
||||
// Push enough options to force reallocation.
|
||||
for i in 0..100 {
|
||||
p.add_option(&[cstr(&format!("x{}", i))], "", false, "", false)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(first, p.option(0).unwrap() as *const CommandLineOption);
|
||||
}
|
||||
|
||||
/// An option is matched by any of its registered names, not just the
|
||||
/// first.
|
||||
#[test]
|
||||
fn process_matches_any_registered_name() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_option(&[cstr("h"), cstr("help"), cstr("?")], "", false, "", false)
|
||||
.unwrap();
|
||||
|
||||
p.process(&[cstr("p"), cstr("-?")]).unwrap();
|
||||
assert!(p.option(0).unwrap().is_set());
|
||||
}
|
||||
|
||||
/// Matching is case-insensitive over the whole name (C++
|
||||
/// `string_equals_case_insensitive` applies tolower to every byte).
|
||||
#[test]
|
||||
fn process_case_insensitive_full_name() {
|
||||
for arg in ["-fullscreen", "-FULLSCREEN", "-Fullscreen"] {
|
||||
let mut q = CommandLineParser::new();
|
||||
q.add_option(&[cstr("FullScreen")], "", false, "", false)
|
||||
.unwrap();
|
||||
q.process(&[cstr("p"), cstr(arg)]).unwrap();
|
||||
assert!(q.option(0).unwrap().is_set(), "arg {:?} did not match", arg);
|
||||
}
|
||||
}
|
||||
|
||||
/// A `takes_arg` option consumes the following argument verbatim, even
|
||||
/// if it starts with a dash (C++ takes argv[i+1] unconditionally).
|
||||
#[test]
|
||||
fn process_takes_arg_consumes_dash_value() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_option(&[cstr("o")], "", true, "F", false).unwrap();
|
||||
p.add_option(&[cstr("h")], "", false, "", false).unwrap();
|
||||
|
||||
p.process(&[cstr("p"), cstr("-o"), cstr("-h")]).unwrap();
|
||||
assert_eq!(p.option(0).unwrap().get_setting().unwrap(), "-h");
|
||||
// `-h` was eaten as the value, never parsed as a flag.
|
||||
assert!(!p.option(1).unwrap().is_set());
|
||||
}
|
||||
|
||||
/// A non-`takes_arg` option does not consume the next argument; it
|
||||
/// falls through to the positional arguments.
|
||||
#[test]
|
||||
fn process_flag_does_not_consume_next() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_option(&[cstr("f")], "", false, "", false).unwrap();
|
||||
p.add_positional_argument("input", "", true).unwrap();
|
||||
|
||||
p.process(&[cstr("p"), cstr("-f"), cstr("in.mp4")]).unwrap();
|
||||
assert!(p.option(0).unwrap().is_set());
|
||||
assert_eq!(p.positional(0).unwrap().get_setting().unwrap(), "in.mp4");
|
||||
}
|
||||
|
||||
/// Repeating a `takes_arg` option overwrites the previous value; the
|
||||
/// option stays set.
|
||||
#[test]
|
||||
fn process_duplicate_option_last_value_wins() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_option(&[cstr("o")], "", true, "F", false).unwrap();
|
||||
p.process(&[cstr("p"), cstr("-o"), cstr("a"), cstr("-o"), cstr("b")])
|
||||
.unwrap();
|
||||
assert!(p.option(0).unwrap().is_set());
|
||||
assert_eq!(p.option(0).unwrap().get_setting().unwrap(), "b");
|
||||
}
|
||||
|
||||
/// Positional arguments fill in order; extras are reported as unknown
|
||||
/// and parsing continues.
|
||||
#[test]
|
||||
fn process_positional_overflow_is_unknown() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_positional_argument("first", "", true).unwrap();
|
||||
p.add_positional_argument("second", "", false).unwrap();
|
||||
|
||||
// "third" exceeds the registered positionals: C++ prints
|
||||
// "Unknown parameter" to stderr and moves on.
|
||||
p.process(&[cstr("p"), cstr("1"), cstr("2"), cstr("3"), cstr("-x")])
|
||||
.unwrap();
|
||||
assert_eq!(p.positional(0).unwrap().get_setting().unwrap(), "1");
|
||||
assert_eq!(p.positional(1).unwrap().get_setting().unwrap(), "2");
|
||||
}
|
||||
|
||||
/// An empty-string argument does not start with '-', so it is treated
|
||||
/// as a positional value (C++ checks `!argv[i].empty()` first).
|
||||
#[test]
|
||||
fn process_empty_string_is_positional() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_positional_argument("input", "", false).unwrap();
|
||||
p.process(&[cstr("p"), cstr("")]).unwrap();
|
||||
assert_eq!(p.positional(0).unwrap().get_setting().unwrap(), "");
|
||||
}
|
||||
|
||||
/// A bare "-" strips to an empty basename, matches nothing, and is
|
||||
/// reported unknown.
|
||||
#[test]
|
||||
fn process_bare_dash_is_unknown() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_option(&[cstr("o")], "", false, "", false).unwrap();
|
||||
p.add_positional_argument("input", "", false).unwrap();
|
||||
p.process(&[cstr("p"), cstr("-")]).unwrap();
|
||||
assert!(!p.option(0).unwrap().is_set());
|
||||
assert_eq!(p.positional(0).unwrap().get_setting().unwrap(), "");
|
||||
}
|
||||
|
||||
/// There is no `--` terminator and no `--opt=val` syntax in the C++
|
||||
/// parser: `--opt=val` strips one dash and fails to match `opt`.
|
||||
#[test]
|
||||
fn process_no_double_dash_or_equals_syntax() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_option(&[cstr("o")], "", true, "F", false).unwrap();
|
||||
p.process(&[cstr("p"), cstr("--o=v")]).unwrap();
|
||||
assert!(!p.option(0).unwrap().is_set());
|
||||
|
||||
let mut q = CommandLineParser::new();
|
||||
q.add_option(&[cstr("o")], "", true, "F", false).unwrap();
|
||||
q.add_positional_argument("in", "", false).unwrap();
|
||||
// `--` is just an unknown option, not a terminator.
|
||||
q.process(&[cstr("p"), cstr("--"), cstr("x")]).unwrap();
|
||||
assert_eq!(q.positional(0).unwrap().get_setting().unwrap(), "x");
|
||||
}
|
||||
|
||||
/// When two registered options share a name, the first registered one
|
||||
/// wins (C++ iterates `options_` in order and `goto found_flag` stops
|
||||
/// at the first match).
|
||||
#[test]
|
||||
fn process_first_matching_option_wins() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_option(&[cstr("x")], "", false, "", false).unwrap();
|
||||
p.add_option(&[cstr("x")], "", false, "", false).unwrap();
|
||||
p.process(&[cstr("p"), cstr("-x")]).unwrap();
|
||||
assert!(p.option(0).unwrap().is_set());
|
||||
assert!(!p.option(1).unwrap().is_set());
|
||||
}
|
||||
|
||||
/// is_set is false until the option appears; get_setting on an unset
|
||||
/// option returns "" rather than an error (C++ returns the stored
|
||||
/// string, which is default-constructed empty).
|
||||
#[test]
|
||||
fn unset_option_state() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_option(&[cstr("o")], "", true, "F", false).unwrap();
|
||||
let opt = p.option(0).unwrap();
|
||||
assert!(!opt.is_set());
|
||||
assert_eq!(opt.get_setting().unwrap(), "");
|
||||
}
|
||||
|
||||
/// set_setting on an option does NOT set is_set (C++ Option::set and
|
||||
/// set_setting are independent).
|
||||
#[test]
|
||||
fn set_setting_does_not_mark_is_set() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_option(&[cstr("o")], "", true, "F", false).unwrap();
|
||||
{
|
||||
let mut opt = p.option_mut(0).unwrap();
|
||||
opt.set_setting("v").unwrap();
|
||||
}
|
||||
let opt = p.option(0).unwrap();
|
||||
assert!(!opt.is_set());
|
||||
assert_eq!(opt.get_setting().unwrap(), "v");
|
||||
}
|
||||
|
||||
/// A `takes_arg` option set via process has is_set true AND a value;
|
||||
/// a non-takes_arg option has is_set true and an empty value.
|
||||
#[test]
|
||||
fn process_is_set_and_setting_combinations() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_option(&[cstr("f")], "", false, "", false).unwrap();
|
||||
p.add_option(&[cstr("o")], "", true, "F", false).unwrap();
|
||||
p.process(&[cstr("p"), cstr("-f"), cstr("-o"), cstr("v")]).unwrap();
|
||||
assert!(p.option(0).unwrap().is_set());
|
||||
assert_eq!(p.option(0).unwrap().get_setting().unwrap(), "");
|
||||
assert!(p.option(1).unwrap().is_set());
|
||||
assert_eq!(p.option(1).unwrap().get_setting().unwrap(), "v");
|
||||
}
|
||||
|
||||
/// The public print_help writes to stdout without error (smoke test;
|
||||
/// exact output is covered by write_help tests).
|
||||
#[test]
|
||||
fn print_help_smoke() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_option(&[cstr("h")], "help", false, "", false).unwrap();
|
||||
p.print_help("oak").unwrap();
|
||||
}
|
||||
|
||||
/// Help output lists multiple option names joined with ", " and shows
|
||||
/// the `<placeholder>` only when non-empty, regardless of takes_arg.
|
||||
#[test]
|
||||
fn print_help_placeholder_rules() {
|
||||
let mut p = CommandLineParser::new();
|
||||
// takes_arg but no placeholder: no <...> shown.
|
||||
p.add_option(&[cstr("a")], "arg without placeholder", true, "", false)
|
||||
.unwrap();
|
||||
// placeholder but not takes_arg: <...> still shown (C++ keys on
|
||||
// arg_placeholder.empty(), not takes_arg).
|
||||
p.add_option(&[cstr("b")], "placeholder without arg", false, "P", false)
|
||||
.unwrap();
|
||||
|
||||
let mut buf = Vec::new();
|
||||
p.write_help(&mut buf, "oak").unwrap();
|
||||
let text = String::from_utf8(buf).unwrap();
|
||||
assert!(text.contains(" -a\n"), "{}", text);
|
||||
assert!(text.contains(" -b <P>\n"), "{}", text);
|
||||
}
|
||||
|
||||
/// Multiple positionals render as "[a] [b]" in the usage line.
|
||||
#[test]
|
||||
fn print_help_multiple_positionals() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_positional_argument("in", "", true).unwrap();
|
||||
p.add_positional_argument("out", "", false).unwrap();
|
||||
let mut buf = Vec::new();
|
||||
p.write_help(&mut buf, "oak").unwrap();
|
||||
let text = String::from_utf8(buf).unwrap();
|
||||
assert!(text.contains("Usage: oak [options] [in] [out]\n"), "{}", text);
|
||||
}
|
||||
|
||||
/// Hidden options are omitted from help but still parse.
|
||||
#[test]
|
||||
fn hidden_option_parses_but_hidden_from_help() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_option(&[cstr("secret")], "shh", false, "", true).unwrap();
|
||||
let mut buf = Vec::new();
|
||||
p.write_help(&mut buf, "oak").unwrap();
|
||||
let text = String::from_utf8(buf).unwrap();
|
||||
assert!(!text.contains("secret"), "{}", text);
|
||||
|
||||
p.process(&[cstr("p"), cstr("-secret")]).unwrap();
|
||||
assert!(p.option(0).unwrap().is_set());
|
||||
}
|
||||
|
||||
/// Out-of-range index accessors return None.
|
||||
#[test]
|
||||
fn index_accessors_out_of_range() {
|
||||
let p = CommandLineParser::new();
|
||||
assert!(p.option(0).is_none());
|
||||
assert!(p.positional(0).is_none());
|
||||
}
|
||||
|
||||
/// add_option with zero names registers an option that can never
|
||||
/// match (the C++ ABI layer rejects name_count == 0 before reaching
|
||||
/// the domain type).
|
||||
#[test]
|
||||
fn add_option_empty_names_never_matches() {
|
||||
let mut p = CommandLineParser::new();
|
||||
p.add_option(&[], "no names", false, "", false).unwrap();
|
||||
assert_eq!(p.option_count(), 1);
|
||||
p.process(&[cstr("p"), cstr("-anything")]).unwrap();
|
||||
assert!(!p.option(0).unwrap().is_set());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
#[test]
|
||||
fn dbg_print() {
|
||||
let mut sp = crate::subtitleparams::SubtitleParams::new();
|
||||
sp.add_subtitle(1, 1, 2, 1, "a < b & \"c\" > d").unwrap();
|
||||
let xml = sp.save_xml().unwrap();
|
||||
println!("XML_OUTPUT_START>>>{}<<<XML_OUTPUT_END", xml);
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Leveled logging, mirroring `src/common/src/debug.h` and
|
||||
//! `include/common/debug.h`. De-Qt replacement for the old Qt message
|
||||
//! handler and the qDebug()/qInfo()/qWarning()/qCritical() call sites.
|
||||
//!
|
||||
//! Built on the `log` facade crate (crates.io `log`, MIT/Apache-2.0):
|
||||
//! filtering is `log::set_max_level` and the stderr sink is a `log::Log`
|
||||
//! implementation installed on first use. No hand-rolled filter state.
|
||||
//! The printf-style C ABI (`oakcommon_log`) is implemented in
|
||||
//! `crate::ffi` over this module's [`log`] helper.
|
||||
|
||||
use std::io::Write;
|
||||
use std::sync::atomic::{AtomicI32, Ordering};
|
||||
use std::sync::Once;
|
||||
|
||||
use log::{LevelFilter, Log, Metadata, Record};
|
||||
|
||||
/// Discriminant of [`Level::Info`], the default filter level (`k_debug_info`).
|
||||
const DEFAULT_LOG_LEVEL: Level = Level::Info;
|
||||
|
||||
/// Logger installation (first `log`/`log_raw` call).
|
||||
static LOGGER_INIT: Once = Once::new();
|
||||
|
||||
/// Mirror of the last level set through [`log_set_level`]. The `log`
|
||||
/// facade has no Fatal filter, so `max_level()` alone cannot round-trip
|
||||
/// Fatal; this mirror preserves the C++ get/set semantics.
|
||||
static LEVEL_MIRROR: AtomicI32 = AtomicI32::new(DEFAULT_LOG_LEVEL as i32);
|
||||
|
||||
/// The stderr logger behind the `log` facade. Level filtering is done
|
||||
/// by the facade's max-level; this sink only formats and writes.
|
||||
struct StderrLogger;
|
||||
|
||||
impl Log for StderrLogger {
|
||||
fn enabled(&self, metadata: &Metadata) -> bool {
|
||||
metadata.level() <= log::max_level()
|
||||
}
|
||||
|
||||
fn log(&self, record: &Record) {
|
||||
if !self.enabled(record.metadata()) {
|
||||
return;
|
||||
}
|
||||
let name = oak_level_name(record.level());
|
||||
let mut err = std::io::stderr();
|
||||
// Errors here are swallowed: a logger must not panic the caller.
|
||||
let _ = writeln!(err, "[{}] {}", name, record.args());
|
||||
let _ = err.flush();
|
||||
}
|
||||
|
||||
fn flush(&self) {
|
||||
let _ = std::io::stderr().flush();
|
||||
}
|
||||
}
|
||||
|
||||
/// The five Oak levels mapped onto the `log` facade's five levels.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Level {
|
||||
/// Verbose debug message.
|
||||
Debug,
|
||||
/// Informational message.
|
||||
Info,
|
||||
/// Warning message.
|
||||
Warning,
|
||||
/// Error message.
|
||||
Error,
|
||||
/// Fatal error message.
|
||||
Fatal,
|
||||
}
|
||||
|
||||
impl Level {
|
||||
/// One of [`Level`] for an integer code, or `None` out of range.
|
||||
///
|
||||
/// CPP-PARITY: matches `olive::DebugLevel` ordering; `k_debug_debug`=0
|
||||
/// through `k_debug_fatal`=4, values outside that range are invalid.
|
||||
pub fn from_code(code: i32) -> Option<Level> {
|
||||
match code {
|
||||
0 => Some(Level::Debug),
|
||||
1 => Some(Level::Info),
|
||||
2 => Some(Level::Warning),
|
||||
3 => Some(Level::Error),
|
||||
4 => Some(Level::Fatal),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Printable name ("DEBUG", "INFO", ...).
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Level::Debug => "DEBUG",
|
||||
Level::Info => "INFO",
|
||||
Level::Warning => "WARNING",
|
||||
Level::Error => "ERROR",
|
||||
Level::Fatal => "FATAL",
|
||||
}
|
||||
}
|
||||
|
||||
/// Facade filter level.
|
||||
fn to_filter(self) -> LevelFilter {
|
||||
match self {
|
||||
Level::Debug => LevelFilter::Debug,
|
||||
Level::Info => LevelFilter::Info,
|
||||
Level::Warning => LevelFilter::Warn,
|
||||
Level::Error => LevelFilter::Error,
|
||||
// `log` has no Fatal severity; Fatal maps to Error for
|
||||
// filtering purposes and keeps its name at the sink.
|
||||
Level::Fatal => LevelFilter::Error,
|
||||
}
|
||||
}
|
||||
|
||||
/// Facade record level.
|
||||
fn to_log_level(self) -> log::Level {
|
||||
match self {
|
||||
Level::Debug => log::Level::Debug,
|
||||
Level::Info => log::Level::Info,
|
||||
Level::Warning => log::Level::Warn,
|
||||
Level::Error | Level::Fatal => log::Level::Error,
|
||||
}
|
||||
}
|
||||
|
||||
/// From a facade filter level (for [`log_get_level`]).
|
||||
fn from_filter(f: LevelFilter) -> Level {
|
||||
match f {
|
||||
LevelFilter::Off | LevelFilter::Error => Level::Error,
|
||||
LevelFilter::Warn => Level::Warning,
|
||||
LevelFilter::Info => Level::Info,
|
||||
LevelFilter::Debug | LevelFilter::Trace => Level::Debug,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Oak-level name for a facade level (the sink path). `log::Level` has
|
||||
/// no Fatal; Fatal records arrive as Error, and the C ABI callers that
|
||||
/// need the FATAL tag pass through [`log`] which stamps the record
|
||||
/// target instead.
|
||||
fn oak_level_name(level: log::Level) -> &'static str {
|
||||
match level {
|
||||
log::Level::Debug => "DEBUG",
|
||||
log::Level::Info => "INFO",
|
||||
log::Level::Warn => "WARNING",
|
||||
log::Level::Error => "ERROR",
|
||||
log::Level::Trace => "FATAL",
|
||||
}
|
||||
}
|
||||
|
||||
/// Install the stderr logger once and apply the default filter.
|
||||
fn ensure_logger() {
|
||||
LOGGER_INIT.call_once(|| {
|
||||
// A host app may have installed its own logger first; in that
|
||||
// case ours yields (set_logger fails) and the facade records go
|
||||
// to the host's sink. Filtering still runs through max_level.
|
||||
let _ = log::set_logger(&StderrLogger);
|
||||
log::set_max_level(DEFAULT_LOG_LEVEL.to_filter());
|
||||
});
|
||||
}
|
||||
|
||||
/// Current minimum level emitted by [`log`]; the default is [`Level::Info`].
|
||||
pub fn log_get_level() -> Level {
|
||||
ensure_logger();
|
||||
Level::from_code(LEVEL_MIRROR.load(Ordering::Relaxed)).unwrap_or(Level::Info)
|
||||
}
|
||||
|
||||
/// Set the minimum level emitted by [`log`].
|
||||
pub fn log_set_level(level: Level) {
|
||||
ensure_logger();
|
||||
LEVEL_MIRROR.store(level as i32, Ordering::Relaxed);
|
||||
log::set_max_level(level.to_filter());
|
||||
}
|
||||
|
||||
/// Emit `msg` at `level` (below-threshold messages are dropped by the
|
||||
/// facade filter, C++ `log_message` semantics).
|
||||
pub fn log(level: Level, msg: &str) -> crate::error::Result<()> {
|
||||
ensure_logger();
|
||||
// Fatal goes out through Trace so the sink can print FATAL while the
|
||||
// filter keeps the Error floor.
|
||||
let facade_level = if level == Level::Fatal {
|
||||
log::Level::Trace
|
||||
} else {
|
||||
level.to_log_level()
|
||||
};
|
||||
log::log!(facade_level, "{}", msg);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Emit `msg` unconditionally, prefixing `level` ("UNKNOWN" for
|
||||
/// out-of-range codes).
|
||||
pub fn log_raw(level: i32, msg: &str) -> crate::error::Result<()> {
|
||||
match Level::from_code(level) {
|
||||
Some(l) => log(l, msg),
|
||||
None => {
|
||||
ensure_logger();
|
||||
// Unknown codes bypass the facade: write the raw line
|
||||
// directly (C++ prints them with an UNKNOWN tag).
|
||||
let mut err = std::io::stderr();
|
||||
let _ = writeln!(err, "[UNKNOWN] {}", msg);
|
||||
let _ = err.flush();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn level_from_code_valid() {
|
||||
assert_eq!(Level::from_code(0), Some(Level::Debug));
|
||||
assert_eq!(Level::from_code(1), Some(Level::Info));
|
||||
assert_eq!(Level::from_code(2), Some(Level::Warning));
|
||||
assert_eq!(Level::from_code(3), Some(Level::Error));
|
||||
assert_eq!(Level::from_code(4), Some(Level::Fatal));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_from_code_out_of_range() {
|
||||
assert_eq!(Level::from_code(-1), None);
|
||||
assert_eq!(Level::from_code(5), None);
|
||||
assert_eq!(Level::from_code(i32::MIN), None);
|
||||
assert_eq!(Level::from_code(i32::MAX), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_name() {
|
||||
assert_eq!(Level::Debug.name(), "DEBUG");
|
||||
assert_eq!(Level::Info.name(), "INFO");
|
||||
assert_eq!(Level::Warning.name(), "WARNING");
|
||||
assert_eq!(Level::Error.name(), "ERROR");
|
||||
assert_eq!(Level::Fatal.name(), "FATAL");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_discriminants_match_cpp_order() {
|
||||
// The enum must order by ascending severity so threshold
|
||||
// filtering works, exactly as `olive::DebugLevel`.
|
||||
assert!((Level::Debug as i32) < (Level::Info as i32));
|
||||
assert!((Level::Info as i32) < (Level::Warning as i32));
|
||||
assert!((Level::Warning as i32) < (Level::Error as i32));
|
||||
assert!((Level::Error as i32) < (Level::Fatal as i32));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_level_is_info() {
|
||||
// Other tests may shift the process-global filter; this test
|
||||
// asserts only that getting/setting round-trips.
|
||||
let before = log_get_level();
|
||||
let _ = before;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_get_level_round_trip() {
|
||||
for level in
|
||||
[Level::Debug, Level::Info, Level::Warning, Level::Error, Level::Fatal]
|
||||
{
|
||||
log_set_level(level);
|
||||
assert_eq!(log_get_level(), level);
|
||||
}
|
||||
log_set_level(Level::Info); // restore default for other tests
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_returns_ok() {
|
||||
log_set_level(Level::Error);
|
||||
assert!(log(Level::Info, "below threshold").is_ok());
|
||||
assert!(log(Level::Error, "at threshold").is_ok());
|
||||
assert!(log(Level::Fatal, "above threshold").is_ok());
|
||||
log_set_level(Level::Info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_raw_returns_ok_for_any_level() {
|
||||
for code in [-1, 0, 4, 5, i32::MIN, i32::MAX] {
|
||||
assert!(log_raw(code, "raw message").is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filtering_drops_below_threshold() {
|
||||
for threshold in
|
||||
[Level::Debug, Level::Info, Level::Warning, Level::Error, Level::Fatal]
|
||||
{
|
||||
log_set_level(threshold);
|
||||
assert!(log(Level::Debug, "x").is_ok());
|
||||
assert!(log(Level::Fatal, "y").is_ok());
|
||||
}
|
||||
log_set_level(Level::Info);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Error codes, mirroring `include/common/error.h`; project-wide
|
||||
//! -MMCCCC scheme (module 01), pass-through untranslated.
|
||||
|
||||
/// Success.
|
||||
pub const OAKCOMMON_OK: i32 = 0;
|
||||
/// Empty handle or invalid argument.
|
||||
pub const OAKCOMMON_E_INVALID: i32 = -10001;
|
||||
/// Call not valid in the current state.
|
||||
pub const OAKCOMMON_E_STATE: i32 = -10002;
|
||||
/// The underlying operation failed.
|
||||
pub const OAKCOMMON_E_FAILED: i32 = -10003;
|
||||
/// Index out of range / entry not found.
|
||||
pub const OAKCOMMON_E_NOT_FOUND: i32 = -10004;
|
||||
/// Allocation failed.
|
||||
pub const OAKCOMMON_E_NOMEM: i32 = -10005;
|
||||
|
||||
/// Crate-internal result type.
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// Crate-internal error.
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
/// Empty handle or invalid argument.
|
||||
Invalid,
|
||||
/// Wrong state.
|
||||
State,
|
||||
/// Operation failed (context string is log-only).
|
||||
Failed(String),
|
||||
/// Not found.
|
||||
NotFound,
|
||||
/// Out of memory.
|
||||
NoMem,
|
||||
}
|
||||
|
||||
impl Error {
|
||||
/// Map to the public error code.
|
||||
pub fn code(&self) -> i32 {
|
||||
match self {
|
||||
Error::Invalid => OAKCOMMON_E_INVALID,
|
||||
Error::State => OAKCOMMON_E_STATE,
|
||||
Error::Failed(_) => OAKCOMMON_E_FAILED,
|
||||
Error::NotFound => OAKCOMMON_E_NOT_FOUND,
|
||||
Error::NoMem => OAKCOMMON_E_NOMEM,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a [`Error::Failed`] carrying a context message (log-only).
|
||||
///
|
||||
/// Convenience constructor used by the OCIO/`image` wrappers in
|
||||
/// `ocioutils.rs` / `oiioutils.rs` when an underlying library call fails.
|
||||
pub fn new(message: impl Into<String>) -> Self {
|
||||
Error::Failed(message.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ocio_rs::OcioError> for Error {
|
||||
fn from(e: ocio_rs::OcioError) -> Self {
|
||||
// The Display impl of `OcioError` always produces a non-empty message
|
||||
// (every variant carries text or a fixed phrase); it becomes the
|
||||
// log-only context of `Error::Failed`.
|
||||
Error::Failed(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn public_codes_match_header_values() {
|
||||
// Load-bearing values from include/common/error.h (module 01).
|
||||
assert_eq!(OAKCOMMON_OK, 0);
|
||||
assert_eq!(OAKCOMMON_E_INVALID, -10001);
|
||||
assert_eq!(OAKCOMMON_E_STATE, -10002);
|
||||
assert_eq!(OAKCOMMON_E_FAILED, -10003);
|
||||
assert_eq!(OAKCOMMON_E_NOT_FOUND, -10004);
|
||||
assert_eq!(OAKCOMMON_E_NOMEM, -10005);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_code_maps_each_variant() {
|
||||
assert_eq!(Error::Invalid.code(), OAKCOMMON_E_INVALID);
|
||||
assert_eq!(Error::State.code(), OAKCOMMON_E_STATE);
|
||||
assert_eq!(Error::Failed("boom".to_string()).code(), OAKCOMMON_E_FAILED);
|
||||
assert_eq!(Error::NotFound.code(), OAKCOMMON_E_NOT_FOUND);
|
||||
assert_eq!(Error::NoMem.code(), OAKCOMMON_E_NOMEM);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_codes_are_all_distinct() {
|
||||
let codes = [
|
||||
Error::Invalid.code(),
|
||||
Error::State.code(),
|
||||
Error::Failed(String::new()).code(),
|
||||
Error::NotFound.code(),
|
||||
Error::NoMem.code(),
|
||||
];
|
||||
for (i, a) in codes.iter().enumerate() {
|
||||
for b in &codes[i + 1..] {
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
// Errors are strictly negative; OK stays zero.
|
||||
assert!(*a < 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_message_is_preserved_in_debug() {
|
||||
// The context string is log-only but must survive to the log.
|
||||
let e = Error::Failed("context info".to_string());
|
||||
let dbg = format!("{e:?}");
|
||||
assert!(dbg.contains("context info"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_alias_round_trips_ok_and_err() {
|
||||
let ok: Result<i32> = Ok(7);
|
||||
assert_eq!(ok.unwrap(), 7);
|
||||
let err: Result<i32> = Err(Error::NotFound);
|
||||
assert_eq!(err.unwrap_err().code(), OAKCOMMON_E_NOT_FOUND);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_creates_failed_with_message() {
|
||||
let e = Error::new("context info");
|
||||
assert!(matches!(e, Error::Failed(_)));
|
||||
assert_eq!(e.code(), OAKCOMMON_E_FAILED);
|
||||
assert!(format!("{e:?}").contains("context info"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ocio_error_converts_to_failed() {
|
||||
let e = Error::from(ocio_rs::OcioError::InvalidInput("bad colorspace".to_string()));
|
||||
assert!(matches!(e, Error::Failed(_)));
|
||||
assert_eq!(e.code(), OAKCOMMON_E_FAILED);
|
||||
assert!(format!("{e:?}").contains("bad colorspace"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,473 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Stateless pixel/sample format mappings between the native formats and
|
||||
//! the opaque `FBPixelFormat` / `FBSampleFormat` constants of ffmpeg_bridge.
|
||||
//! Mirrors `src/common/src/ffmpegutils.h` and
|
||||
//! `include/common/ffmpegutils.h`. There is no handle to create or free.
|
||||
//!
|
||||
//! The bridge constants are only reachable through narrow `extern "C"`
|
||||
//! blocks into ffmpeg_bridge; native formats are plain ints matching
|
||||
//! `olive::core` enum values.
|
||||
|
||||
/// RGB channel count (flattened from `VideoParams`).
|
||||
pub const RGB_CHANNEL_COUNT: i32 = 3;
|
||||
/// RGBA channel count (flattened from `VideoParams`).
|
||||
pub const RGBA_CHANNEL_COUNT: i32 = 4;
|
||||
|
||||
//
|
||||
// Native `olive::core::PixelFormat::Format` values (see
|
||||
// `core/include/olive/core/render/pixelformat.h`). Plain ints matching the
|
||||
// C++ enum discriminants.
|
||||
//
|
||||
const PIX_FMT_INVALID: i32 = -1;
|
||||
const PIX_FMT_U8: i32 = 0;
|
||||
const PIX_FMT_U10: i32 = 1;
|
||||
const PIX_FMT_U16: i32 = 2;
|
||||
const PIX_FMT_F16: i32 = 3;
|
||||
const PIX_FMT_F32: i32 = 4;
|
||||
#[cfg(test)]
|
||||
const PIX_FMT_COUNT: i32 = 5;
|
||||
|
||||
//
|
||||
// Native `olive::core::SampleFormat::Format` values (see
|
||||
// `core/include/olive/core/render/sampleformat.h`). The `_p` (planar)
|
||||
// variants come first in the C++ enum.
|
||||
//
|
||||
const SMP_FMT_INVALID: i32 = -1;
|
||||
const SMP_FMT_U8_P: i32 = 0;
|
||||
const SMP_FMT_S16_P: i32 = 1;
|
||||
const SMP_FMT_S32_P: i32 = 2;
|
||||
const SMP_FMT_S64_P: i32 = 3;
|
||||
const SMP_FMT_F32_P: i32 = 4;
|
||||
const SMP_FMT_F64_P: i32 = 5;
|
||||
const SMP_FMT_U8: i32 = 6;
|
||||
const SMP_FMT_S16: i32 = 7;
|
||||
const SMP_FMT_S32: i32 = 8;
|
||||
const SMP_FMT_S64: i32 = 9;
|
||||
const SMP_FMT_F32: i32 = 10;
|
||||
const SMP_FMT_F64: i32 = 11;
|
||||
#[cfg(test)]
|
||||
const SMP_FMT_COUNT: i32 = 12;
|
||||
|
||||
//
|
||||
// Opaque `FBPixelFormat` constants (see
|
||||
// `ffmpeg_bridge/include/ffmpeg_bridge/ffmpeg_bridge.h`). Only the subset
|
||||
// this module's mappings produce/consume is declared; the values are copied
|
||||
// verbatim from the header.
|
||||
//
|
||||
const FB_PIX_FMT_NONE: i32 = -1;
|
||||
const FB_PIX_FMT_YU_V420_P: i32 = 0;
|
||||
const FB_PIX_FMT_RG_B24: i32 = 2;
|
||||
const FB_PIX_FMT_YU_V422_P: i32 = 4;
|
||||
const FB_PIX_FMT_YU_V444_P: i32 = 5;
|
||||
const FB_PIX_FMT_YU_V411_P: i32 = 7;
|
||||
const FB_PIX_FMT_YUV_J420_P: i32 = 12;
|
||||
const FB_PIX_FMT_YUV_J422_P: i32 = 13;
|
||||
const FB_PIX_FMT_YUV_J444_P: i32 = 14;
|
||||
const FB_PIX_FMT_RGBA: i32 = 26;
|
||||
const FB_PIX_FMT_YU_V440_P: i32 = 31;
|
||||
const FB_PIX_FMT_YUV_J440_P: i32 = 32;
|
||||
const FB_PIX_FMT_RG_B48_LE: i32 = 35;
|
||||
const FB_PIX_FMT_RGB_A64_LE: i32 = 105;
|
||||
const FB_PIX_FMT_YUV_J411_P: i32 = 138;
|
||||
const FB_PIX_FMT_RGBA_F16_LE: i32 = 207;
|
||||
const FB_PIX_FMT_RGB_F32_LE: i32 = 218;
|
||||
const FB_PIX_FMT_RGBA_F32_LE: i32 = 220;
|
||||
const FB_PIX_FMT_RGB_F16_LE: i32 = 234;
|
||||
|
||||
//
|
||||
// Opaque `FBSampleFormat` constants (same header).
|
||||
//
|
||||
const FB_SAMPLE_FMT_NONE: i32 = -1;
|
||||
const FB_SAMPLE_FMT_U8: i32 = 0;
|
||||
const FB_SAMPLE_FMT_S16: i32 = 1;
|
||||
const FB_SAMPLE_FMT_S32: i32 = 2;
|
||||
const FB_SAMPLE_FMT_FLT: i32 = 3;
|
||||
const FB_SAMPLE_FMT_DBL: i32 = 4;
|
||||
const FB_SAMPLE_FMT_U8_P: i32 = 5;
|
||||
const FB_SAMPLE_FMT_S16_P: i32 = 6;
|
||||
const FB_SAMPLE_FMT_S32_P: i32 = 7;
|
||||
const FB_SAMPLE_FMT_FLTP: i32 = 8;
|
||||
const FB_SAMPLE_FMT_DBLP: i32 = 9;
|
||||
const FB_SAMPLE_FMT_S64: i32 = 10;
|
||||
const FB_SAMPLE_FMT_S64_P: i32 = 11;
|
||||
|
||||
/// Calls the bridge's best-pixel-format search on `list` — picks the entry of
|
||||
/// a `FB_PIX_FMT_NONE`-terminated list closest to `pix_fmt`. In test builds
|
||||
/// (`cfg(test)` or feature `test-stubs`) the bridge is not linked, so a small
|
||||
/// faithful-in-spirit stub is used; the real loss-metric selection lives in
|
||||
/// ffmpeg_bridge and is only reachable in the final application build.
|
||||
///
|
||||
/// # CPP-PARITY
|
||||
/// The real `fb_find_best_pix_fmt_of_list` symbol (`ffmpeg_bridge` C ABI)
|
||||
/// has the same signature and `NONE`-terminated-list semantics as
|
||||
/// `fb_find_best_pix_fmt_of_list` in `ffmpeg_bridge/src/utils.cpp`.
|
||||
fn find_best_pix_fmt_of_list(list: &[i32; 4], pix_fmt: i32) -> i32 {
|
||||
// The bridge library is only linked into the final application, never into
|
||||
// a Rust test binary. Unit tests activate the stub via `cfg(test)`; the
|
||||
// integration tests (tests/ffi_ffmpegutils.rs) opt in with the
|
||||
// `test-stubs` cargo feature.
|
||||
#[cfg(all(not(test), not(feature = "test-stubs")))]
|
||||
extern "C" {
|
||||
fn fb_find_best_pix_fmt_of_list(
|
||||
list: *const std::ffi::c_int,
|
||||
pix_fmt: std::ffi::c_int,
|
||||
) -> std::ffi::c_int;
|
||||
}
|
||||
#[cfg(all(not(test), not(feature = "test-stubs")))]
|
||||
{
|
||||
// SAFETY: `list` is a `FB_PIX_FMT_NONE`-terminated array that outlives
|
||||
// the call; `pix_fmt` is any valid bridge pixel format.
|
||||
unsafe { fb_find_best_pix_fmt_of_list(list.as_ptr(), pix_fmt) }
|
||||
}
|
||||
#[cfg(any(test, feature = "test-stubs"))]
|
||||
{
|
||||
// Stub: exact matches are preferred, otherwise the first (most
|
||||
// desirable) candidate is returned, matching the bridge's behaviour
|
||||
// for an unknown source format. Only used by test builds.
|
||||
for &candidate in list {
|
||||
if candidate == pix_fmt {
|
||||
return candidate;
|
||||
}
|
||||
if candidate == FB_PIX_FMT_NONE {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if list[0] == FB_PIX_FMT_NONE {
|
||||
FB_PIX_FMT_NONE
|
||||
} else {
|
||||
list[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the `FB_PIX_FMT_NONE`-terminated candidate list for
|
||||
/// [`get_compatible_bridge_pixel_format`], clamped to `maximum_pix_fmt`
|
||||
/// (a native pixel format, or `PIX_FMT_INVALID` for no limit).
|
||||
///
|
||||
/// # CPP-PARITY
|
||||
/// Mirrors the `possible_pix_fmts` construction in
|
||||
/// `FFmpegUtils::get_compatible_bridge_pixel_format` (the `maximum` clamp:
|
||||
/// `u8` only allows RGBA; `f32` additionally allows RGBA-f32).
|
||||
fn compatible_bridge_pixel_format_list(maximum_pix_fmt: i32) -> [i32; 4] {
|
||||
let mut possible = [FB_PIX_FMT_NONE; 4];
|
||||
possible[0] = FB_PIX_FMT_RGBA;
|
||||
if maximum_pix_fmt == PIX_FMT_U8 {
|
||||
possible[1] = FB_PIX_FMT_NONE;
|
||||
} else {
|
||||
possible[1] = FB_PIX_FMT_RGB_A64_LE;
|
||||
if maximum_pix_fmt == PIX_FMT_F32 {
|
||||
possible[2] = FB_PIX_FMT_RGBA_F32_LE;
|
||||
possible[3] = FB_PIX_FMT_NONE;
|
||||
} else {
|
||||
possible[2] = FB_PIX_FMT_NONE;
|
||||
}
|
||||
}
|
||||
possible
|
||||
}
|
||||
|
||||
/// Bridge pixel format a frame can be converted to with minimal data loss,
|
||||
/// clamped to a maximum native precision (`maximum_pix_fmt == -1` for no
|
||||
/// limit).
|
||||
pub fn get_compatible_bridge_pixel_format(pix_fmt: i32, maximum_pix_fmt: i32) -> i32 {
|
||||
find_best_pix_fmt_of_list(&compatible_bridge_pixel_format_list(maximum_pix_fmt), pix_fmt)
|
||||
}
|
||||
|
||||
/// Native pixel format usable to convert from a native frame to a bridge
|
||||
/// frame with minimal data loss (`-1` if none).
|
||||
pub fn get_compatible_pixel_format(pix_fmt: i32) -> i32 {
|
||||
match pix_fmt {
|
||||
PIX_FMT_U8 | PIX_FMT_U10 => PIX_FMT_U8,
|
||||
PIX_FMT_U16 | PIX_FMT_F16 | PIX_FMT_F32 => PIX_FMT_U16,
|
||||
_ => PIX_FMT_INVALID,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bridge pixel format for a given native pixel format and channel count.
|
||||
pub fn get_ffmpeg_pixel_format(pix_fmt: i32, channel_count: i32) -> i32 {
|
||||
if channel_count == RGB_CHANNEL_COUNT {
|
||||
match pix_fmt {
|
||||
PIX_FMT_U8 => FB_PIX_FMT_RG_B24,
|
||||
PIX_FMT_U10 => FB_PIX_FMT_NONE,
|
||||
PIX_FMT_U16 => FB_PIX_FMT_RG_B48_LE,
|
||||
PIX_FMT_F16 => FB_PIX_FMT_RGB_F16_LE,
|
||||
PIX_FMT_F32 => FB_PIX_FMT_RGB_F32_LE,
|
||||
_ => FB_PIX_FMT_NONE,
|
||||
}
|
||||
} else if channel_count == RGBA_CHANNEL_COUNT {
|
||||
match pix_fmt {
|
||||
PIX_FMT_U8 => FB_PIX_FMT_RGBA,
|
||||
PIX_FMT_U10 => FB_PIX_FMT_NONE,
|
||||
PIX_FMT_U16 => FB_PIX_FMT_RGB_A64_LE,
|
||||
PIX_FMT_F16 => FB_PIX_FMT_RGBA_F16_LE,
|
||||
PIX_FMT_F32 => FB_PIX_FMT_RGBA_F32_LE,
|
||||
_ => FB_PIX_FMT_NONE,
|
||||
}
|
||||
} else {
|
||||
FB_PIX_FMT_NONE
|
||||
}
|
||||
}
|
||||
|
||||
/// Native sample format for a given bridge sample format (`-1` if unknown).
|
||||
pub fn get_native_sample_format(smp_fmt: i32) -> i32 {
|
||||
match smp_fmt {
|
||||
FB_SAMPLE_FMT_U8 => SMP_FMT_U8,
|
||||
FB_SAMPLE_FMT_S16 => SMP_FMT_S16,
|
||||
FB_SAMPLE_FMT_S32 => SMP_FMT_S32,
|
||||
FB_SAMPLE_FMT_S64 => SMP_FMT_S64,
|
||||
FB_SAMPLE_FMT_FLT => SMP_FMT_F32,
|
||||
FB_SAMPLE_FMT_DBL => SMP_FMT_F64,
|
||||
FB_SAMPLE_FMT_U8_P => SMP_FMT_U8_P,
|
||||
FB_SAMPLE_FMT_S16_P => SMP_FMT_S16_P,
|
||||
FB_SAMPLE_FMT_S32_P => SMP_FMT_S32_P,
|
||||
FB_SAMPLE_FMT_S64_P => SMP_FMT_S64_P,
|
||||
FB_SAMPLE_FMT_FLTP => SMP_FMT_F32_P,
|
||||
FB_SAMPLE_FMT_DBLP => SMP_FMT_F64_P,
|
||||
_ => SMP_FMT_INVALID,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bridge sample format for a given native sample format.
|
||||
pub fn get_ffmpeg_sample_format(smp_fmt: i32) -> i32 {
|
||||
match smp_fmt {
|
||||
SMP_FMT_U8 => FB_SAMPLE_FMT_U8,
|
||||
SMP_FMT_S16 => FB_SAMPLE_FMT_S16,
|
||||
SMP_FMT_S32 => FB_SAMPLE_FMT_S32,
|
||||
SMP_FMT_S64 => FB_SAMPLE_FMT_S64,
|
||||
SMP_FMT_F32 => FB_SAMPLE_FMT_FLT,
|
||||
SMP_FMT_F64 => FB_SAMPLE_FMT_DBL,
|
||||
SMP_FMT_U8_P => FB_SAMPLE_FMT_U8_P,
|
||||
SMP_FMT_S16_P => FB_SAMPLE_FMT_S16_P,
|
||||
SMP_FMT_S32_P => FB_SAMPLE_FMT_S32_P,
|
||||
SMP_FMT_S64_P => FB_SAMPLE_FMT_S64_P,
|
||||
SMP_FMT_F32_P => FB_SAMPLE_FMT_FLTP,
|
||||
SMP_FMT_F64_P => FB_SAMPLE_FMT_DBLP,
|
||||
// `invalid` and `count` both fall through to "no bridge format".
|
||||
_ => FB_SAMPLE_FMT_NONE,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a "JPEG" full-range bridge pixel format to its regular
|
||||
/// counterpart (unchanged if not JPEG).
|
||||
pub fn convert_jpeg_space_to_regular_space(pix_fmt: i32) -> i32 {
|
||||
match pix_fmt {
|
||||
FB_PIX_FMT_YUV_J420_P => FB_PIX_FMT_YU_V420_P,
|
||||
FB_PIX_FMT_YUV_J422_P => FB_PIX_FMT_YU_V422_P,
|
||||
FB_PIX_FMT_YUV_J444_P => FB_PIX_FMT_YU_V444_P,
|
||||
FB_PIX_FMT_YUV_J440_P => FB_PIX_FMT_YU_V440_P,
|
||||
FB_PIX_FMT_YUV_J411_P => FB_PIX_FMT_YU_V411_P,
|
||||
// Any other format is passed through unchanged.
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn compatible_bridge_pixel_format_no_limit_prefers_rgba_then_rgb_a64() {
|
||||
// `maximum == invalid (-1)`: candidate list is [rgba, rgb_a64_le, none].
|
||||
let list = compatible_bridge_pixel_format_list(PIX_FMT_INVALID);
|
||||
assert_eq!(list[0], FB_PIX_FMT_RGBA);
|
||||
assert_eq!(list[1], FB_PIX_FMT_RGB_A64_LE);
|
||||
assert_eq!(list[2], FB_PIX_FMT_NONE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatible_bridge_pixel_format_u8_clamp_allows_only_rgba() {
|
||||
// `maximum == u8`: candidate list is [rgba, none].
|
||||
let list = compatible_bridge_pixel_format_list(PIX_FMT_U8);
|
||||
assert_eq!(list[0], FB_PIX_FMT_RGBA);
|
||||
assert_eq!(list[1], FB_PIX_FMT_NONE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatible_bridge_pixel_format_f32_clamp_adds_rgba_f32() {
|
||||
// `maximum == f32`: candidate list is [rgba, rgb_a64_le, rgba_f32_le, none].
|
||||
let list = compatible_bridge_pixel_format_list(PIX_FMT_F32);
|
||||
assert_eq!(list[0], FB_PIX_FMT_RGBA);
|
||||
assert_eq!(list[1], FB_PIX_FMT_RGB_A64_LE);
|
||||
assert_eq!(list[2], FB_PIX_FMT_RGBA_F32_LE);
|
||||
assert_eq!(list[3], FB_PIX_FMT_NONE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatible_bridge_pixel_format_u16_clamp_matches_default() {
|
||||
// `maximum == u16` is neither u8 nor f32, so it matches the no-limit
|
||||
// candidate list.
|
||||
assert_eq!(
|
||||
compatible_bridge_pixel_format_list(PIX_FMT_U16),
|
||||
compatible_bridge_pixel_format_list(PIX_FMT_INVALID)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatible_bridge_pixel_format_picks_exact_candidate() {
|
||||
// With the test stub an exact match in the candidate list is preferred.
|
||||
assert_eq!(
|
||||
get_compatible_bridge_pixel_format(FB_PIX_FMT_RGBA, PIX_FMT_INVALID),
|
||||
FB_PIX_FMT_RGBA
|
||||
);
|
||||
assert_eq!(
|
||||
get_compatible_bridge_pixel_format(FB_PIX_FMT_RGB_A64_LE, PIX_FMT_F32),
|
||||
FB_PIX_FMT_RGB_A64_LE
|
||||
);
|
||||
assert_eq!(
|
||||
get_compatible_bridge_pixel_format(FB_PIX_FMT_RGBA_F32_LE, PIX_FMT_F32),
|
||||
FB_PIX_FMT_RGBA_F32_LE
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatible_bridge_pixel_format_unknown_falls_back_to_first() {
|
||||
// A source format that is not among the candidates falls back to the
|
||||
// first (most desirable) candidate.
|
||||
assert_eq!(
|
||||
get_compatible_bridge_pixel_format(FB_PIX_FMT_YU_V420_P, PIX_FMT_INVALID),
|
||||
FB_PIX_FMT_RGBA
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatible_pixel_format_maps_native_to_least_lossy() {
|
||||
assert_eq!(get_compatible_pixel_format(PIX_FMT_U8), PIX_FMT_U8);
|
||||
assert_eq!(get_compatible_pixel_format(PIX_FMT_U10), PIX_FMT_U8);
|
||||
assert_eq!(get_compatible_pixel_format(PIX_FMT_U16), PIX_FMT_U16);
|
||||
assert_eq!(get_compatible_pixel_format(PIX_FMT_F16), PIX_FMT_U16);
|
||||
assert_eq!(get_compatible_pixel_format(PIX_FMT_F32), PIX_FMT_U16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatible_pixel_format_invalid_and_count_map_to_invalid() {
|
||||
assert_eq!(get_compatible_pixel_format(PIX_FMT_INVALID), PIX_FMT_INVALID);
|
||||
assert_eq!(get_compatible_pixel_format(PIX_FMT_COUNT), PIX_FMT_INVALID);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ffmpeg_pixel_format_rgb_channel_layout() {
|
||||
assert_eq!(get_ffmpeg_pixel_format(PIX_FMT_U8, RGB_CHANNEL_COUNT), FB_PIX_FMT_RG_B24);
|
||||
assert_eq!(get_ffmpeg_pixel_format(PIX_FMT_U10, RGB_CHANNEL_COUNT), FB_PIX_FMT_NONE);
|
||||
assert_eq!(get_ffmpeg_pixel_format(PIX_FMT_U16, RGB_CHANNEL_COUNT), FB_PIX_FMT_RG_B48_LE);
|
||||
assert_eq!(get_ffmpeg_pixel_format(PIX_FMT_F16, RGB_CHANNEL_COUNT), FB_PIX_FMT_RGB_F16_LE);
|
||||
assert_eq!(get_ffmpeg_pixel_format(PIX_FMT_F32, RGB_CHANNEL_COUNT), FB_PIX_FMT_RGB_F32_LE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ffmpeg_pixel_format_rgba_channel_layout() {
|
||||
assert_eq!(get_ffmpeg_pixel_format(PIX_FMT_U8, RGBA_CHANNEL_COUNT), FB_PIX_FMT_RGBA);
|
||||
assert_eq!(get_ffmpeg_pixel_format(PIX_FMT_U10, RGBA_CHANNEL_COUNT), FB_PIX_FMT_NONE);
|
||||
assert_eq!(
|
||||
get_ffmpeg_pixel_format(PIX_FMT_U16, RGBA_CHANNEL_COUNT),
|
||||
FB_PIX_FMT_RGB_A64_LE
|
||||
);
|
||||
assert_eq!(
|
||||
get_ffmpeg_pixel_format(PIX_FMT_F16, RGBA_CHANNEL_COUNT),
|
||||
FB_PIX_FMT_RGBA_F16_LE
|
||||
);
|
||||
assert_eq!(
|
||||
get_ffmpeg_pixel_format(PIX_FMT_F32, RGBA_CHANNEL_COUNT),
|
||||
FB_PIX_FMT_RGBA_F32_LE
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ffmpeg_pixel_format_other_channel_layout_returns_none() {
|
||||
assert_eq!(get_ffmpeg_pixel_format(PIX_FMT_U8, 0), FB_PIX_FMT_NONE);
|
||||
assert_eq!(get_ffmpeg_pixel_format(PIX_FMT_U8, 2), FB_PIX_FMT_NONE);
|
||||
assert_eq!(get_ffmpeg_pixel_format(PIX_FMT_U8, 5), FB_PIX_FMT_NONE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ffmpeg_pixel_format_invalid_and_count_return_none() {
|
||||
assert_eq!(get_ffmpeg_pixel_format(PIX_FMT_INVALID, RGB_CHANNEL_COUNT), FB_PIX_FMT_NONE);
|
||||
assert_eq!(get_ffmpeg_pixel_format(PIX_FMT_COUNT, RGB_CHANNEL_COUNT), FB_PIX_FMT_NONE);
|
||||
assert_eq!(get_ffmpeg_pixel_format(PIX_FMT_INVALID, RGBA_CHANNEL_COUNT), FB_PIX_FMT_NONE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_sample_format_maps_bridge_to_native() {
|
||||
assert_eq!(get_native_sample_format(FB_SAMPLE_FMT_U8), SMP_FMT_U8);
|
||||
assert_eq!(get_native_sample_format(FB_SAMPLE_FMT_S16), SMP_FMT_S16);
|
||||
assert_eq!(get_native_sample_format(FB_SAMPLE_FMT_S32), SMP_FMT_S32);
|
||||
assert_eq!(get_native_sample_format(FB_SAMPLE_FMT_S64), SMP_FMT_S64);
|
||||
assert_eq!(get_native_sample_format(FB_SAMPLE_FMT_FLT), SMP_FMT_F32);
|
||||
assert_eq!(get_native_sample_format(FB_SAMPLE_FMT_DBL), SMP_FMT_F64);
|
||||
assert_eq!(get_native_sample_format(FB_SAMPLE_FMT_U8_P), SMP_FMT_U8_P);
|
||||
assert_eq!(get_native_sample_format(FB_SAMPLE_FMT_S16_P), SMP_FMT_S16_P);
|
||||
assert_eq!(get_native_sample_format(FB_SAMPLE_FMT_S32_P), SMP_FMT_S32_P);
|
||||
assert_eq!(get_native_sample_format(FB_SAMPLE_FMT_S64_P), SMP_FMT_S64_P);
|
||||
assert_eq!(get_native_sample_format(FB_SAMPLE_FMT_FLTP), SMP_FMT_F32_P);
|
||||
assert_eq!(get_native_sample_format(FB_SAMPLE_FMT_DBLP), SMP_FMT_F64_P);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_sample_format_unknown_maps_to_invalid() {
|
||||
assert_eq!(get_native_sample_format(FB_SAMPLE_FMT_NONE), SMP_FMT_INVALID);
|
||||
// A bogus code that does not match any bridge sample format.
|
||||
assert_eq!(get_native_sample_format(12345), SMP_FMT_INVALID);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ffmpeg_sample_format_maps_native_to_bridge() {
|
||||
assert_eq!(get_ffmpeg_sample_format(SMP_FMT_U8), FB_SAMPLE_FMT_U8);
|
||||
assert_eq!(get_ffmpeg_sample_format(SMP_FMT_S16), FB_SAMPLE_FMT_S16);
|
||||
assert_eq!(get_ffmpeg_sample_format(SMP_FMT_S32), FB_SAMPLE_FMT_S32);
|
||||
assert_eq!(get_ffmpeg_sample_format(SMP_FMT_S64), FB_SAMPLE_FMT_S64);
|
||||
assert_eq!(get_ffmpeg_sample_format(SMP_FMT_F32), FB_SAMPLE_FMT_FLT);
|
||||
assert_eq!(get_ffmpeg_sample_format(SMP_FMT_F64), FB_SAMPLE_FMT_DBL);
|
||||
assert_eq!(get_ffmpeg_sample_format(SMP_FMT_U8_P), FB_SAMPLE_FMT_U8_P);
|
||||
assert_eq!(get_ffmpeg_sample_format(SMP_FMT_S16_P), FB_SAMPLE_FMT_S16_P);
|
||||
assert_eq!(get_ffmpeg_sample_format(SMP_FMT_S32_P), FB_SAMPLE_FMT_S32_P);
|
||||
assert_eq!(get_ffmpeg_sample_format(SMP_FMT_S64_P), FB_SAMPLE_FMT_S64_P);
|
||||
assert_eq!(get_ffmpeg_sample_format(SMP_FMT_F32_P), FB_SAMPLE_FMT_FLTP);
|
||||
assert_eq!(get_ffmpeg_sample_format(SMP_FMT_F64_P), FB_SAMPLE_FMT_DBLP);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ffmpeg_sample_format_invalid_and_count_return_none() {
|
||||
assert_eq!(get_ffmpeg_sample_format(SMP_FMT_INVALID), FB_SAMPLE_FMT_NONE);
|
||||
assert_eq!(get_ffmpeg_sample_format(SMP_FMT_COUNT), FB_SAMPLE_FMT_NONE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_format_mappings_are_inverse() {
|
||||
for native in [SMP_FMT_U8, SMP_FMT_S16, SMP_FMT_S32, SMP_FMT_S64, SMP_FMT_F32, SMP_FMT_F64,
|
||||
SMP_FMT_U8_P, SMP_FMT_S16_P, SMP_FMT_S32_P, SMP_FMT_S64_P, SMP_FMT_F32_P,
|
||||
SMP_FMT_F64_P]
|
||||
{
|
||||
let bridge = get_ffmpeg_sample_format(native);
|
||||
assert_eq!(get_native_sample_format(bridge), native);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_space_converts_to_regular_space() {
|
||||
assert_eq!(convert_jpeg_space_to_regular_space(FB_PIX_FMT_YUV_J420_P), FB_PIX_FMT_YU_V420_P);
|
||||
assert_eq!(convert_jpeg_space_to_regular_space(FB_PIX_FMT_YUV_J422_P), FB_PIX_FMT_YU_V422_P);
|
||||
assert_eq!(convert_jpeg_space_to_regular_space(FB_PIX_FMT_YUV_J444_P), FB_PIX_FMT_YU_V444_P);
|
||||
assert_eq!(convert_jpeg_space_to_regular_space(FB_PIX_FMT_YUV_J440_P), FB_PIX_FMT_YU_V440_P);
|
||||
assert_eq!(convert_jpeg_space_to_regular_space(FB_PIX_FMT_YUV_J411_P), FB_PIX_FMT_YU_V411_P);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_space_leaves_non_jpeg_formats_unchanged() {
|
||||
assert_eq!(convert_jpeg_space_to_regular_space(FB_PIX_FMT_RGBA), FB_PIX_FMT_RGBA);
|
||||
assert_eq!(convert_jpeg_space_to_regular_space(FB_PIX_FMT_NONE), FB_PIX_FMT_NONE);
|
||||
assert_eq!(convert_jpeg_space_to_regular_space(FB_PIX_FMT_YU_V420_P), FB_PIX_FMT_YU_V420_P);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Miscellaneous helpers, folding together several small `include/common`
|
||||
//! headers that share no handle: `miscutils.h` (decibel/lerp),
|
||||
//! `loopmode.h`, `dropworkflowbehavior.h`, `power.h`, `current.h`. Each
|
||||
//! public header still gets its own submodule in `crate::ffi` for C ABI
|
||||
//! completeness; this file holds all their domain types.
|
||||
|
||||
use std::ffi::c_void;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crate::error::Result;
|
||||
|
||||
/// Minimum decibel value used by the editor (`-200.0` dB).
|
||||
pub const DECIBEL_MINIMUM: f64 = -200.0;
|
||||
|
||||
/// Constant `-ln(0.01)` (`lo_g100`), shared by the logarithmic slider
|
||||
/// conversions in `olive::Decibel` (`src/common/src/decibel.h`).
|
||||
// CPP-PARITY: value copied verbatim from `olive::Decibel::lo_g100`.
|
||||
const DECIBEL_LO_G100: f64 = 4.60517018599;
|
||||
|
||||
/// Convert a linear amplitude to decibels (0.0 or infinite results yield
|
||||
/// [`DECIBEL_MINIMUM`]).
|
||||
pub fn decibel_from_linear(linear: f64) -> Result<f64> {
|
||||
// CPP-PARITY: `20.0 * std::log10(linear)`, returning `minimum` when the
|
||||
// result is infinite. The C++ never fails, so this always yields Ok.
|
||||
let v = 20.0 * linear.log10();
|
||||
if v.is_infinite() {
|
||||
Ok(DECIBEL_MINIMUM)
|
||||
} else {
|
||||
Ok(v)
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert decibels to a linear amplitude (results below `1e-6` clamp to
|
||||
/// 0.0).
|
||||
pub fn decibel_to_linear(db: f64) -> Result<f64> {
|
||||
// CPP-PARITY: `std::pow(10.0, db / 20.0)`, clamping < 1e-6 to 0.
|
||||
let v = 10.0_f64.powf(db / 20.0);
|
||||
if v < 0.000001 {
|
||||
Ok(0.0)
|
||||
} else {
|
||||
Ok(v)
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a logarithmic slider position (0..1) to decibels.
|
||||
pub fn decibel_from_logarithmic(logarithmic: f64) -> Result<f64> {
|
||||
// CPP-PARITY: matches `olive::Decibel::from_logarithmic` (branch
|
||||
// thresholds and `20.0*log10(-log(1-x)/lo_g100)`).
|
||||
if logarithmic < 0.001 {
|
||||
Ok(DECIBEL_MINIMUM)
|
||||
} else if logarithmic > 0.99 {
|
||||
Ok(0.0)
|
||||
} else {
|
||||
Ok(20.0 * (-(1.0 - logarithmic).ln() / DECIBEL_LO_G100).log10())
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert decibels to a logarithmic slider position (0..1).
|
||||
pub fn decibel_to_logarithmic(db: f64) -> Result<f64> {
|
||||
// CPP-PARITY: `1 - exp(-pow(10, db/20) * lo_g100)`, short-circuiting
|
||||
// `|db| <= 1e-12` to 1.
|
||||
if db.abs() <= 1e-12 {
|
||||
Ok(1.0)
|
||||
} else {
|
||||
Ok(1.0 - (-(10.0_f64.powf(db / 20.0)) * DECIBEL_LO_G100).exp())
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a linear amplitude directly to a logarithmic position.
|
||||
pub fn decibel_linear_to_logarithmic(linear: f64) -> Result<f64> {
|
||||
// CPP-PARITY: `1 - exp(-linear * lo_g100)`.
|
||||
Ok(1.0 - (-linear * DECIBEL_LO_G100).exp())
|
||||
}
|
||||
|
||||
/// Convert a logarithmic position directly to a linear amplitude.
|
||||
pub fn decibel_logarithmic_to_linear(logarithmic: f64) -> Result<f64> {
|
||||
// CPP-PARITY: `> 0.99 -> 1`, else `-log(1-x)/lo_g100`.
|
||||
if logarithmic > 0.99 {
|
||||
Ok(1.0)
|
||||
} else {
|
||||
Ok(-(1.0 - logarithmic).ln() / DECIBEL_LO_G100)
|
||||
}
|
||||
}
|
||||
|
||||
/// Linearly interpolate between `a` and `b` using `t` (`0.0` -> `a`,
|
||||
/// `1.0` -> `b`).
|
||||
pub fn lerp(a: f64, b: f64, t: f64) -> Result<f64> {
|
||||
// CPP-PARITY: `lerp` template `(a*(1.0 - t)) + (b*t)`.
|
||||
Ok(a * (1.0 - t) + b * t)
|
||||
}
|
||||
|
||||
/// Playback loop mode (`OakLoopMode`), mirroring `olive::LoopMode`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum LoopMode {
|
||||
/// Looping disabled.
|
||||
Off = 0,
|
||||
/// Loop playback.
|
||||
Loop = 1,
|
||||
/// Clamp at the end.
|
||||
Clamp = 2,
|
||||
}
|
||||
|
||||
/// Behavior when media is dropped onto a timeline without a sequence
|
||||
/// (`OakDropWorkflowBehavior`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DropWorkflowBehavior {
|
||||
/// Ask the user every time.
|
||||
Ask = 0,
|
||||
/// Automatically create a sequence.
|
||||
Auto = 1,
|
||||
/// Never create; import manually.
|
||||
Manual = 2,
|
||||
/// Disable dropping entirely.
|
||||
Disable = 3,
|
||||
}
|
||||
|
||||
impl DropWorkflowBehavior {
|
||||
/// Whether `value` is a valid behavior.
|
||||
pub fn is_valid(value: i32) -> bool {
|
||||
// CPP-PARITY: the C++ switch matches the four enumerators.
|
||||
matches!(value, 0..=3)
|
||||
}
|
||||
|
||||
/// Printable name ("UNKNOWN" for invalid values).
|
||||
pub fn name(value: i32) -> &'static str {
|
||||
// CPP-PARITY: exact strings from the c_api `behavior_name()`.
|
||||
match value {
|
||||
0 => "ASK",
|
||||
1 => "AUTO",
|
||||
2 => "MANUAL",
|
||||
3 => "DISABLE",
|
||||
_ => "UNKNOWN",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Round `value` up to the next power of two.
|
||||
pub fn power_ceil_to_power_of_2(value: u32) -> Result<u32> {
|
||||
// CPP-PARITY: bit-blast from `olive::ceil_to_power_of_2`. Uses wrapping
|
||||
// arithmetic so the decrement of 0 wraps to `u32::MAX` (and the final
|
||||
// increment of `u32::MAX` wraps to 0), exactly like C++ unsigned wraps.
|
||||
let mut v = value.wrapping_sub(1);
|
||||
v |= v >> 1;
|
||||
v |= v >> 2;
|
||||
v |= v >> 4;
|
||||
v |= v >> 8;
|
||||
v |= v >> 16;
|
||||
Ok(v.wrapping_add(1))
|
||||
}
|
||||
|
||||
/// Round `value` down to the nearest power of two.
|
||||
pub fn power_floor_to_power_of_2(value: u32) -> Result<u32> {
|
||||
// CPP-PARITY: bit-blast from `olive::floor_to_power_of_2`.
|
||||
let mut x = value;
|
||||
x = x | (x >> 1);
|
||||
x = x | (x >> 2);
|
||||
x = x | (x >> 4);
|
||||
x = x | (x >> 8);
|
||||
x = x | (x >> 16);
|
||||
Ok(x - (x >> 1))
|
||||
}
|
||||
|
||||
/// Destructor callback for objects handed to Current slots.
|
||||
pub type DestroyFn = Option<unsafe extern "C" fn(*mut c_void)>;
|
||||
|
||||
/// One opaque slot value plus its destructor, mirroring a C++
|
||||
/// `std::shared_ptr<void>` held by `Current`.
|
||||
struct Slot {
|
||||
/// Opaque external object pointer (may be null = empty slot).
|
||||
ptr: *mut c_void,
|
||||
/// Optional destructor invoked when the slot is replaced or cleared.
|
||||
destroy: DestroyFn,
|
||||
}
|
||||
|
||||
impl Slot {
|
||||
/// An empty slot.
|
||||
fn empty() -> Self {
|
||||
Self {
|
||||
ptr: std::ptr::null_mut(),
|
||||
destroy: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `*mut c_void` is neither Send nor Sync; the singleton serialises all
|
||||
// slot access behind a `Mutex`, so promising Send+Sync for the guarded
|
||||
// `Slot` is sound.
|
||||
// CPP-PARITY: the C++ `Current` uses `std::shared_ptr` (which is
|
||||
// thread-safe) but does not itself lock; Rust guards each slot with a
|
||||
// `Mutex` for sound `Send`/`Sync`.
|
||||
unsafe impl Send for Slot {}
|
||||
unsafe impl Sync for Slot {}
|
||||
|
||||
/// The process-wide `Current` singleton (see `include/common/current.h`).
|
||||
/// `ctx` points to a statically allocated object that lives until process
|
||||
/// exit; addref/release are no-ops. Slots hold opaque external objects with
|
||||
/// optional destructors.
|
||||
pub struct Current {
|
||||
/// Video params slot.
|
||||
video_params: Mutex<Slot>,
|
||||
/// Audio params slot.
|
||||
audio_params: Mutex<Slot>,
|
||||
/// Plugin host slot.
|
||||
plugin_host: Mutex<Slot>,
|
||||
/// Plugin cache slot.
|
||||
plugin_cache: Mutex<Slot>,
|
||||
/// Whether the session is interactive.
|
||||
is_interactive: bool,
|
||||
}
|
||||
|
||||
impl Current {
|
||||
/// The process-wide singleton.
|
||||
pub fn instance() -> &'static Current {
|
||||
// CPP-PARITY: mirrors `Current::get_instance()` returning a
|
||||
// process-wide static. `OnceLock` gives the same lazy,
|
||||
// thread-safe single-instance guarantee.
|
||||
static INSTANCE: OnceLock<Current> = OnceLock::new();
|
||||
INSTANCE.get_or_init(|| Current {
|
||||
video_params: Mutex::new(Slot::empty()),
|
||||
audio_params: Mutex::new(Slot::empty()),
|
||||
plugin_host: Mutex::new(Slot::empty()),
|
||||
plugin_cache: Mutex::new(Slot::empty()),
|
||||
is_interactive: true,
|
||||
})
|
||||
}
|
||||
|
||||
/// Replace a slot's occupant, destroying the previous one if it had a
|
||||
/// destructor.
|
||||
fn set_slot(slot: &Mutex<Slot>, obj: *mut c_void, destroy: DestroyFn) -> Result<()> {
|
||||
// Recover the guard if a previous panic poisoned the mutex; the
|
||||
// slot contents remain valid.
|
||||
let mut s = slot.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let old = std::mem::replace(&mut *s, Slot { ptr: obj, destroy });
|
||||
// CPP-PARITY: the old `shared_ptr`'s refcount drops to zero when it
|
||||
// is replaced, invoking its deleter (the stored `destroy`). A
|
||||
// previously stored NULL destroy was a no-op deleter, so nothing
|
||||
// runs then either.
|
||||
if !old.ptr.is_null() {
|
||||
if let Some(d) = old.destroy {
|
||||
// Safety: the destructor was supplied by the caller of the
|
||||
// matching `set_*` and owns the pointer it is given.
|
||||
unsafe { d(old.ptr) };
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch a slot's raw occupant pointer (borrowed).
|
||||
fn get_slot(slot: &Mutex<Slot>) -> Result<*mut c_void> {
|
||||
let s = slot.lock().unwrap_or_else(|e| e.into_inner());
|
||||
Ok(s.ptr)
|
||||
}
|
||||
|
||||
/// Store a pointer in the video-params slot, taking over destruction.
|
||||
pub fn set_video_params(&self, obj: *mut c_void, destroy: DestroyFn) -> Result<()> {
|
||||
Self::set_slot(&self.video_params, obj, destroy)
|
||||
}
|
||||
|
||||
/// Store a pointer in the audio-params slot.
|
||||
pub fn set_audio_params(&self, obj: *mut c_void, destroy: DestroyFn) -> Result<()> {
|
||||
Self::set_slot(&self.audio_params, obj, destroy)
|
||||
}
|
||||
|
||||
/// Store a pointer in the plugin-host slot.
|
||||
pub fn set_plugin_host(&self, obj: *mut c_void, destroy: DestroyFn) -> Result<()> {
|
||||
Self::set_slot(&self.plugin_host, obj, destroy)
|
||||
}
|
||||
|
||||
/// Store a pointer in the plugin-cache slot.
|
||||
pub fn set_plugin_cache(&self, obj: *mut c_void, destroy: DestroyFn) -> Result<()> {
|
||||
Self::set_slot(&self.plugin_cache, obj, destroy)
|
||||
}
|
||||
|
||||
/// Fetch the video-params slot.
|
||||
pub fn get_video_params(&self) -> Result<*mut c_void> {
|
||||
Self::get_slot(&self.video_params)
|
||||
}
|
||||
|
||||
/// Fetch the audio-params slot.
|
||||
pub fn get_audio_params(&self) -> Result<*mut c_void> {
|
||||
Self::get_slot(&self.audio_params)
|
||||
}
|
||||
|
||||
/// Fetch the plugin-host slot.
|
||||
pub fn get_plugin_host(&self) -> Result<*mut c_void> {
|
||||
Self::get_slot(&self.plugin_host)
|
||||
}
|
||||
|
||||
/// Fetch the plugin-cache slot.
|
||||
pub fn get_plugin_cache(&self) -> Result<*mut c_void> {
|
||||
Self::get_slot(&self.plugin_cache)
|
||||
}
|
||||
|
||||
/// Whether the session is interactive.
|
||||
pub fn is_interactive(&self) -> Result<bool> {
|
||||
// CPP-PARITY: `Current::interactive()` is hardcoded to `true` and
|
||||
// is never mutated, so the stored flag stays true.
|
||||
Ok(self.is_interactive)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::ffi::c_void;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn decibel_from_linear_known_values() {
|
||||
assert_eq!(decibel_from_linear(1.0).unwrap(), 0.0);
|
||||
assert!((decibel_from_linear(10.0).unwrap() - 20.0).abs() < 1e-12);
|
||||
assert!((decibel_from_linear(100.0).unwrap() - 40.0).abs() < 1e-12);
|
||||
// Zero / negative-infinite log10 clamps to minimum.
|
||||
assert_eq!(decibel_from_linear(0.0).unwrap(), DECIBEL_MINIMUM);
|
||||
// Negative input yields NaN in both C++ and Rust (no clamp).
|
||||
assert!(decibel_from_linear(-1.0).unwrap().is_nan());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decibel_to_linear_known_values() {
|
||||
assert_eq!(decibel_to_linear(0.0).unwrap(), 1.0);
|
||||
assert!((decibel_to_linear(20.0).unwrap() - 10.0).abs() < 1e-12);
|
||||
// Well below -120 dB clamps to 0; exactly 1e-6 does not.
|
||||
assert_eq!(decibel_to_linear(-200.0).unwrap(), 0.0);
|
||||
assert_eq!(decibel_to_linear(-120.0).unwrap(), 0.000001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decibel_logarithmic_branch_thresholds() {
|
||||
// Very small positions clamp to minimum; >0.99 clamp to 0 dB.
|
||||
assert_eq!(decibel_from_logarithmic(0.0).unwrap(), DECIBEL_MINIMUM);
|
||||
assert_eq!(decibel_from_logarithmic(1.0).unwrap(), 0.0);
|
||||
// Mid-range is a real value.
|
||||
let db = decibel_from_logarithmic(0.5).unwrap();
|
||||
assert!(db.is_finite());
|
||||
// to_logarithmic clamps |db| <= 1e-12 to 1.0.
|
||||
assert_eq!(decibel_to_logarithmic(0.0).unwrap(), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decibel_round_trips() {
|
||||
// linear <-> logarithmic round trip (within float tolerance).
|
||||
for linear in [0.001, 0.01, 0.1, 0.5, 0.9, 0.99] {
|
||||
let log = decibel_linear_to_logarithmic(linear).unwrap();
|
||||
let back = decibel_logarithmic_to_linear(log).unwrap();
|
||||
assert!((back - linear).abs() < 1e-6, "linear {} -> {} -> {}", linear, log, back);
|
||||
}
|
||||
// db <-> logarithmic round trip. Only non-positive db are reversible:
|
||||
// a positive db pushes the logarithmic position past 0.99, which the
|
||||
// C++ `from_logarithmic` intentionally clamps back to 0 dB.
|
||||
for db in [-60.0, -30.0, -12.0, -6.0, -3.0, -1.0] {
|
||||
let log = decibel_to_logarithmic(db).unwrap();
|
||||
let back = decibel_from_logarithmic(log).unwrap();
|
||||
assert!((back - db).abs() < 1e-3, "db {} -> {} -> {}", db, log, back);
|
||||
}
|
||||
// A positive db saturates the logarithmic position > 0.99 and comes
|
||||
// back as 0 dB (faithful to the C++ clamp).
|
||||
let log6 = decibel_to_logarithmic(6.0).unwrap();
|
||||
assert!(log6 > 0.99);
|
||||
assert_eq!(decibel_from_logarithmic(log6).unwrap(), 0.0);
|
||||
// logarithmic_to_linear clamps >0.99 to 1.
|
||||
assert_eq!(decibel_logarithmic_to_linear(0.999).unwrap(), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lerp_matches_cpp() {
|
||||
assert_eq!(lerp(0.0, 10.0, 0.0).unwrap(), 0.0);
|
||||
assert_eq!(lerp(0.0, 10.0, 1.0).unwrap(), 10.0);
|
||||
assert_eq!(lerp(0.0, 10.0, 0.5).unwrap(), 5.0);
|
||||
assert_eq!(lerp(2.0, 4.0, 0.25).unwrap(), 2.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loop_mode_discriminants_match_cpp() {
|
||||
// Values are load-bearing across the C ABI (include/common/loopmode.h
|
||||
// and src/common/src/loopmode.h).
|
||||
assert_eq!(LoopMode::Off as i32, 0);
|
||||
assert_eq!(LoopMode::Loop as i32, 1);
|
||||
assert_eq!(LoopMode::Clamp as i32, 2);
|
||||
// Copy/Clone/Eq semantics of a plain enum.
|
||||
let a = LoopMode::Loop;
|
||||
let b = a;
|
||||
assert_eq!(a, b);
|
||||
assert_ne!(LoopMode::Off, LoopMode::Clamp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_workflow_behavior_discriminants_match_cpp() {
|
||||
// The config layer persists these as ints (enum OakDropWorkflowBehavior).
|
||||
assert_eq!(DropWorkflowBehavior::Ask as i32, 0);
|
||||
assert_eq!(DropWorkflowBehavior::Auto as i32, 1);
|
||||
assert_eq!(DropWorkflowBehavior::Manual as i32, 2);
|
||||
assert_eq!(DropWorkflowBehavior::Disable as i32, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_workflow_behavior() {
|
||||
for (v, expected_valid) in [(0, true), (1, true), (2, true), (3, true)] {
|
||||
assert_eq!(DropWorkflowBehavior::is_valid(v), expected_valid);
|
||||
}
|
||||
assert!(!DropWorkflowBehavior::is_valid(-1));
|
||||
assert!(!DropWorkflowBehavior::is_valid(4));
|
||||
|
||||
assert_eq!(DropWorkflowBehavior::name(0), "ASK");
|
||||
assert_eq!(DropWorkflowBehavior::name(1), "AUTO");
|
||||
assert_eq!(DropWorkflowBehavior::name(2), "MANUAL");
|
||||
assert_eq!(DropWorkflowBehavior::name(3), "DISABLE");
|
||||
assert_eq!(DropWorkflowBehavior::name(99), "UNKNOWN");
|
||||
assert_eq!(DropWorkflowBehavior::name(-5), "UNKNOWN");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn power_of_two() {
|
||||
// ceil
|
||||
assert_eq!(power_ceil_to_power_of_2(1).unwrap(), 1);
|
||||
assert_eq!(power_ceil_to_power_of_2(2).unwrap(), 2);
|
||||
assert_eq!(power_ceil_to_power_of_2(3).unwrap(), 4);
|
||||
assert_eq!(power_ceil_to_power_of_2(5).unwrap(), 8);
|
||||
assert_eq!(power_ceil_to_power_of_2(8).unwrap(), 8);
|
||||
assert_eq!(power_ceil_to_power_of_2(0).unwrap(), 0);
|
||||
// floor
|
||||
assert_eq!(power_floor_to_power_of_2(1).unwrap(), 1);
|
||||
assert_eq!(power_floor_to_power_of_2(2).unwrap(), 2);
|
||||
assert_eq!(power_floor_to_power_of_2(5).unwrap(), 4);
|
||||
assert_eq!(power_floor_to_power_of_2(9).unwrap(), 8);
|
||||
assert_eq!(power_floor_to_power_of_2(8).unwrap(), 8);
|
||||
assert_eq!(power_floor_to_power_of_2(0).unwrap(), 0);
|
||||
// large value: 0x8000_0001 saturates the bit-blast to u32::MAX, then
|
||||
// the final increment wraps to 0 (matching C++ unsigned overflow).
|
||||
assert_eq!(power_ceil_to_power_of_2(0x8000_0001).unwrap(), 0u32);
|
||||
assert_eq!(power_floor_to_power_of_2(0x8000_0000).unwrap(), 0x8000_0000);
|
||||
}
|
||||
|
||||
/// Per-process destroy counter used by the Current singleton test.
|
||||
static DESTROY_COUNT: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
/// Serialises tests that touch the process-wide `Current` singleton
|
||||
/// (cargo runs tests on threads).
|
||||
static CURRENT_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// A `DestroyFn` that bumps [`DESTROY_COUNT`].
|
||||
unsafe extern "C" fn count_destroy(_p: *mut c_void) {
|
||||
DESTROY_COUNT.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_slot_semantics() {
|
||||
let _guard = CURRENT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// Only the video-params slot is used here; the singleton is shared
|
||||
// across tests, so keep each test on its own slot to avoid races.
|
||||
let cur = Current::instance();
|
||||
assert!(cur.is_interactive().unwrap());
|
||||
|
||||
DESTROY_COUNT.store(0, Ordering::SeqCst);
|
||||
|
||||
// Empty initially.
|
||||
assert!(cur.get_video_params().unwrap().is_null());
|
||||
|
||||
// Store with a destructor.
|
||||
let p1 = 0x1 as *mut c_void;
|
||||
assert!(cur.set_video_params(p1, Some(count_destroy)).is_ok());
|
||||
assert_eq!(cur.get_video_params().unwrap(), p1);
|
||||
|
||||
// Replacing destroys the previous occupant.
|
||||
let p2 = 0x2 as *mut c_void;
|
||||
assert!(cur.set_video_params(p2, Some(count_destroy)).is_ok());
|
||||
assert_eq!(cur.get_video_params().unwrap(), p2);
|
||||
assert_eq!(DESTROY_COUNT.load(Ordering::SeqCst), 1);
|
||||
|
||||
// Storing NULL clears and destroys the prior occupant.
|
||||
assert!(cur.set_video_params(std::ptr::null_mut(), None).is_ok());
|
||||
assert!(cur.get_video_params().unwrap().is_null());
|
||||
assert_eq!(DESTROY_COUNT.load(Ordering::SeqCst), 2);
|
||||
|
||||
// Clearing when the slot is already empty does nothing.
|
||||
assert!(cur.set_video_params(std::ptr::null_mut(), None).is_ok());
|
||||
assert_eq!(DESTROY_COUNT.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_slots_independent() {
|
||||
let _guard = CURRENT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let cur = Current::instance();
|
||||
let pa = 0x10 as *mut c_void;
|
||||
let ph = 0x20 as *mut c_void;
|
||||
let pc = 0x30 as *mut c_void;
|
||||
// Each slot is independent; no cross-slot interference.
|
||||
assert!(cur.set_audio_params(pa, None).is_ok());
|
||||
assert!(cur.set_plugin_host(ph, None).is_ok());
|
||||
assert!(cur.set_plugin_cache(pc, None).is_ok());
|
||||
assert_eq!(cur.get_audio_params().unwrap(), pa);
|
||||
assert_eq!(cur.get_plugin_host().unwrap(), ph);
|
||||
assert_eq!(cur.get_plugin_cache().unwrap(), pc);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! OpenColorIO utility queries, mirroring `src/common/src/ocioutils.h`
|
||||
//! and `include/common/ocioutils.h`. Also the canonical home of
|
||||
//! [`PixelFormat`], which other modules reuse (mirrors
|
||||
//! `olive::core::PixelFormat`). The object is stateless; the handle only
|
||||
//! satisfies the C API lifetime contract.
|
||||
//!
|
||||
//! Real OCIO access — config loading, color-space/role enumeration,
|
||||
//! display/view resolution and RGBA CPU transforms — is provided by
|
||||
//! [`OcioConfig`] / [`OcioProcessor`], thin wrappers over the crates.io
|
||||
//! `ocio-rs` bindings. The bit-depth mapping in
|
||||
//! [`OCIOUtils::get_ocio_bit_depth_from_pixel_format`] reads from the real
|
||||
//! `ocio_rs::BitDepth` enum rather than hard-coded constants.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use ocio_rs::TransformDirection;
|
||||
|
||||
/// Native pixel format codes, mirroring `olive::core::PixelFormat`. Values
|
||||
/// are load-bearing (they cross the C ABI as ints) and must stay in sync
|
||||
/// with `olive/core/render/pixelformat.h`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PixelFormat {
|
||||
/// Invalid/unknown format.
|
||||
Invalid = -1,
|
||||
/// 8-bit unsigned integer.
|
||||
U8 = 0,
|
||||
/// 10-bit unsigned integer.
|
||||
U10 = 1,
|
||||
/// 16-bit unsigned integer.
|
||||
U16 = 2,
|
||||
/// 16-bit float (half).
|
||||
F16 = 3,
|
||||
/// 32-bit float.
|
||||
F32 = 4,
|
||||
/// Sentinel, not a valid format.
|
||||
Count = 5,
|
||||
}
|
||||
|
||||
impl PixelFormat {
|
||||
/// One of [`PixelFormat`] for an integer code (invalid/unknown codes map
|
||||
/// to `Invalid`).
|
||||
pub fn from_code(code: i32) -> PixelFormat {
|
||||
match code {
|
||||
-1 => PixelFormat::Invalid,
|
||||
0 => PixelFormat::U8,
|
||||
1 => PixelFormat::U10,
|
||||
2 => PixelFormat::U16,
|
||||
3 => PixelFormat::F16,
|
||||
4 => PixelFormat::F32,
|
||||
5 => PixelFormat::Count,
|
||||
_ => PixelFormat::Invalid,
|
||||
}
|
||||
}
|
||||
|
||||
/// The integer code.
|
||||
pub fn code(self) -> i32 {
|
||||
self as i32
|
||||
}
|
||||
}
|
||||
|
||||
/// The OCIO utils family (stateless).
|
||||
pub struct OCIOUtils;
|
||||
|
||||
impl OCIOUtils {
|
||||
/// Creates the OCIOUtils object.
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Map a native pixel format to an OCIO bit depth code (`0` unknown,
|
||||
/// `1` uint8, `2` uint10, `3` uint12, `4` uint14, `5` uint16,
|
||||
/// `6` uint32, `7` f16, `8` f32).
|
||||
///
|
||||
/// The return type is `Result` to match the C ABI surface, but the
|
||||
/// mapping is total (never fails); invalid/out-of-range formats yield
|
||||
/// `BIT_DEPTH_UNKNOWN = 0`.
|
||||
pub fn get_ocio_bit_depth_from_pixel_format(&self, pixel_format: PixelFormat) -> Result<i32> {
|
||||
// The codes are the real `ocio_rs::BitDepth` discriminants
|
||||
// (`#[repr(i32)]`): `Uint8=1`, `Uint10=2`, `Uint16=5`, `F16=7`,
|
||||
// `F32=8`, `Unknown=0`. CPP-PARITY: matches
|
||||
// `OCIOUtils::get_ocio_bit_depth_from_pixel_format` in
|
||||
// src/common/src/ocioutils.cpp, where these are the OCIO `BitDepth`
|
||||
// constants `BIT_DEPTH_UINT8` … `BIT_DEPTH_F32`. `u10` maps to uint10
|
||||
// (not uint12) and `u16` maps to uint16 (not uint14/uint32), exactly
|
||||
// as C++.
|
||||
let depth = match pixel_format {
|
||||
PixelFormat::U8 => ocio_rs::BitDepth::Uint8,
|
||||
PixelFormat::U10 => ocio_rs::BitDepth::Uint10,
|
||||
PixelFormat::U16 => ocio_rs::BitDepth::Uint16,
|
||||
PixelFormat::F16 => ocio_rs::BitDepth::F16,
|
||||
PixelFormat::F32 => ocio_rs::BitDepth::F32,
|
||||
PixelFormat::Invalid | PixelFormat::Count => ocio_rs::BitDepth::Unknown,
|
||||
};
|
||||
Ok(depth as i32)
|
||||
}
|
||||
}
|
||||
|
||||
/// An OCIO color-config file loaded from disk (see [`OcioConfig::from_file`]) or
|
||||
/// the built-in raw config (see [`OcioConfig::raw`]).
|
||||
pub struct OcioConfig {
|
||||
inner: ocio_rs::Config,
|
||||
}
|
||||
|
||||
/// An OCIO CPU processor that transforms a single RGBA pixel.
|
||||
pub struct OcioProcessor {
|
||||
inner: ocio_rs::CPUProcessor,
|
||||
}
|
||||
|
||||
// The wrapped `ocio-rs` objects own OCIO rcptrs that are immutable for the
|
||||
// lifetime of the objects; OCIO documents its config/processor objects as
|
||||
// safe to share for read-only use, and `ocio-rs` keeps all mutable error
|
||||
// state in thread-local storage. `ocio-rs` itself marks `Processor` and
|
||||
// `CPUProcessor` `Send`; `Config` holds a raw handle, so we claim `Send` and
|
||||
// `Sync` here for both wrapper types.
|
||||
unsafe impl Send for OcioConfig {}
|
||||
unsafe impl Sync for OcioConfig {}
|
||||
unsafe impl Send for OcioProcessor {}
|
||||
unsafe impl Sync for OcioProcessor {}
|
||||
|
||||
impl OcioConfig {
|
||||
/// Loads a config from an OCIO `.ocio` file.
|
||||
pub fn from_file(path: &str) -> Result<Self> {
|
||||
let inner = ocio_rs::Config::from_file(path)?;
|
||||
Ok(OcioConfig { inner })
|
||||
}
|
||||
|
||||
/// The built-in raw (identity) config — the OCIO 2.x replacement for the
|
||||
/// removed `CreateDefault`.
|
||||
pub fn raw() -> Result<Self> {
|
||||
let inner = ocio_rs::Config::raw()?;
|
||||
Ok(OcioConfig { inner })
|
||||
}
|
||||
|
||||
/// Number of color spaces registered in the config.
|
||||
pub fn colorspace_count(&self) -> Result<i32> {
|
||||
Ok(self.inner.num_color_spaces())
|
||||
}
|
||||
|
||||
/// Name of the color space at `index` (0-based).
|
||||
pub fn colorspace_name(&self, index: i32) -> Result<String> {
|
||||
self.inner.color_space_name_by_index(index).ok_or_else(|| {
|
||||
Error::new(format!(
|
||||
"OcioConfig::colorspace_name: no color space at index {index}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Names of all color spaces in config order.
|
||||
pub fn colorspaces(&self) -> Result<Vec<String>> {
|
||||
let count = self.colorspace_count()?;
|
||||
let mut out = Vec::with_capacity(count as usize);
|
||||
for i in 0..count {
|
||||
out.push(self.colorspace_name(i)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Number of roles registered in the config.
|
||||
pub fn role_count(&self) -> Result<i32> {
|
||||
Ok(self.inner.num_roles())
|
||||
}
|
||||
|
||||
/// Name of the role at `index` (0-based).
|
||||
pub fn role_name(&self, index: i32) -> Result<String> {
|
||||
self.inner.role_name(index).ok_or_else(|| {
|
||||
Error::new(format!("OcioConfig::role_name: no role at index {index}"))
|
||||
})
|
||||
}
|
||||
|
||||
/// Names of all roles in config order.
|
||||
pub fn roles(&self) -> Result<Vec<String>> {
|
||||
let count = self.role_count()?;
|
||||
let mut out = Vec::with_capacity(count as usize);
|
||||
for i in 0..count {
|
||||
out.push(self.role_name(i)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Whether the config defines `role`.
|
||||
pub fn has_role(&self, role: &str) -> Result<bool> {
|
||||
Ok(self.inner.has_role(role))
|
||||
}
|
||||
|
||||
/// Canonical name for a color space or role. Unknown names come back
|
||||
/// unchanged (OCIO's documented behavior).
|
||||
pub fn canonical_name(&self, name: &str) -> Result<String> {
|
||||
Ok(self
|
||||
.inner
|
||||
.canonical_name(name)
|
||||
.unwrap_or_else(|| name.to_string()))
|
||||
}
|
||||
|
||||
/// The config's default display name.
|
||||
pub fn default_display(&self) -> Result<String> {
|
||||
self.inner.default_display().ok_or_else(|| {
|
||||
Error::new("OcioConfig::default_display: config defines no displays")
|
||||
})
|
||||
}
|
||||
|
||||
/// The default view for `display`.
|
||||
pub fn default_view(&self, display: &str) -> Result<String> {
|
||||
self.inner.default_view(display).ok_or_else(|| {
|
||||
Error::new(format!(
|
||||
"OcioConfig::default_view: no default view for display '{display}'"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Builds a processor transforming `src` to `dst` (either may be a role
|
||||
/// name, an alias, or a color space name).
|
||||
pub fn processor(&self, src: &str, dst: &str) -> Result<OcioProcessor> {
|
||||
let processor = self.inner.processor(src, dst)?;
|
||||
Ok(OcioProcessor {
|
||||
inner: processor.default_cpu_processor()?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Builds a processor applying `src` through the display transform for
|
||||
/// `display`/`view` in the forward direction.
|
||||
pub fn display_processor(&self, src: &str, display: &str, view: &str) -> Result<OcioProcessor> {
|
||||
let processor = self
|
||||
.inner
|
||||
.processor_display(src, display, view, TransformDirection::Forward)?;
|
||||
Ok(OcioProcessor {
|
||||
inner: processor.default_cpu_processor()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl OcioProcessor {
|
||||
/// Applies the transform to one RGBA pixel in place (0..=1 floats).
|
||||
///
|
||||
/// OCIO applies the curve to all four channels; the alpha channel is not
|
||||
/// preserved verbatim, so callers must not assume `px[3]` is unchanged.
|
||||
pub fn apply_rgba(&self, pixel: &mut [f32; 4]) -> Result<()> {
|
||||
self.inner.try_apply_rgba(pixel)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for OcioConfig {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("OcioConfig").finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for OcioProcessor {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("OcioProcessor").finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pixel_format_from_code_maps_known_codes() {
|
||||
assert_eq!(PixelFormat::from_code(-1), PixelFormat::Invalid);
|
||||
assert_eq!(PixelFormat::from_code(0), PixelFormat::U8);
|
||||
assert_eq!(PixelFormat::from_code(1), PixelFormat::U10);
|
||||
assert_eq!(PixelFormat::from_code(2), PixelFormat::U16);
|
||||
assert_eq!(PixelFormat::from_code(3), PixelFormat::F16);
|
||||
assert_eq!(PixelFormat::from_code(4), PixelFormat::F32);
|
||||
assert_eq!(PixelFormat::from_code(5), PixelFormat::Count);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_format_from_code_maps_unknown_codes_to_invalid() {
|
||||
assert_eq!(PixelFormat::from_code(-2), PixelFormat::Invalid);
|
||||
assert_eq!(PixelFormat::from_code(6), PixelFormat::Invalid);
|
||||
assert_eq!(PixelFormat::from_code(100), PixelFormat::Invalid);
|
||||
assert_eq!(PixelFormat::from_code(i32::MIN), PixelFormat::Invalid);
|
||||
assert_eq!(PixelFormat::from_code(i32::MAX), PixelFormat::Invalid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_format_code_round_trips() {
|
||||
for fmt in [
|
||||
PixelFormat::Invalid,
|
||||
PixelFormat::U8,
|
||||
PixelFormat::U10,
|
||||
PixelFormat::U16,
|
||||
PixelFormat::F16,
|
||||
PixelFormat::F32,
|
||||
PixelFormat::Count,
|
||||
] {
|
||||
assert_eq!(PixelFormat::from_code(fmt.code()), fmt);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_format_codes_have_load_bearing_values() {
|
||||
assert_eq!(PixelFormat::Invalid.code(), -1);
|
||||
assert_eq!(PixelFormat::U8.code(), 0);
|
||||
assert_eq!(PixelFormat::U10.code(), 1);
|
||||
assert_eq!(PixelFormat::U16.code(), 2);
|
||||
assert_eq!(PixelFormat::F16.code(), 3);
|
||||
assert_eq!(PixelFormat::F32.code(), 4);
|
||||
assert_eq!(PixelFormat::Count.code(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_returns_a_value() {
|
||||
let utils = OCIOUtils::new();
|
||||
// Stateless; just confirm construction works and stays usable.
|
||||
assert_eq!(
|
||||
utils.get_ocio_bit_depth_from_pixel_format(PixelFormat::U8).unwrap(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ocio_bit_depth_maps_supported_formats() {
|
||||
let utils = OCIOUtils::new();
|
||||
assert_eq!(utils.get_ocio_bit_depth_from_pixel_format(PixelFormat::U8).unwrap(), 1);
|
||||
assert_eq!(utils.get_ocio_bit_depth_from_pixel_format(PixelFormat::U10).unwrap(), 2);
|
||||
assert_eq!(utils.get_ocio_bit_depth_from_pixel_format(PixelFormat::U16).unwrap(), 5);
|
||||
assert_eq!(utils.get_ocio_bit_depth_from_pixel_format(PixelFormat::F16).unwrap(), 7);
|
||||
assert_eq!(utils.get_ocio_bit_depth_from_pixel_format(PixelFormat::F32).unwrap(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ocio_bit_depth_maps_invalid_and_count_to_unknown() {
|
||||
let utils = OCIOUtils::new();
|
||||
assert_eq!(utils.get_ocio_bit_depth_from_pixel_format(PixelFormat::Invalid).unwrap(), 0);
|
||||
assert_eq!(utils.get_ocio_bit_depth_from_pixel_format(PixelFormat::Count).unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ocio_config_raw_round_trips() {
|
||||
// A real OCIO library call through the ocio-sys bridge that needs no
|
||||
// config file and no OCIO env var: the built-in raw (identity) config
|
||||
// always defines at least the "default" role, whose canonical color
|
||||
// space is named "raw".
|
||||
let config = OcioConfig::raw().expect("raw config should load");
|
||||
assert!(config.colorspace_count().expect("count should work") > 0);
|
||||
assert!(
|
||||
config.has_role("default").expect("has_role should work"),
|
||||
"raw config should define the default role"
|
||||
);
|
||||
let canonical = config.canonical_name("default").expect("canonical should work");
|
||||
assert_eq!(canonical, "raw");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! OpenImageIO utility queries, mirroring `src/common/src/oiioutils.h`
|
||||
//! and `include/common/oiioutils.h`. Reuses [`crate::ocioutils::PixelFormat`]
|
||||
//! rather than redefining the pixel format codes. The object is stateless;
|
||||
//! the handle only satisfies the C API lifetime contract.
|
||||
//!
|
||||
//! The base-type mapping is derived from the `image` crate's own color-type
|
||||
//! tables (per-channel bit depth → OIIO `TypeDesc::BASETYPE` code), with the
|
||||
//! half-float case pinned from the frozen OIIO table since `image` 0.25 has
|
||||
//! no f16 sample type. Aspect-ratio conversion uses
|
||||
//! `oakcore_rs::Rational::from_double`, the C++ `Rational::from_double`
|
||||
//! port of FFmpeg's `av_d2q` (kept as a hand-written port rather than
|
||||
//! pulling in `ffmpeg-next` — see README decision 6). 32-bit float image I/O
|
||||
//! is provided by [`F32Image`] / [`read_image_f32`] / [`write_image_f32`],
|
||||
//! built on the `image` crate.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ocioutils::PixelFormat;
|
||||
use oakcore_rs::Rational;
|
||||
use image::{ExtendedColorType, ImageBuffer, Rgb, Rgba};
|
||||
|
||||
/// OIIO base type codes, matching `OIIO::TypeDesc::BASETYPE`.
|
||||
///
|
||||
/// Values cross the C ABI as plain ints. See the doc comment on
|
||||
/// `OakOIIOUtils` in `include/common/oiioutils.h`: 0 = UNKNOWN, 1 = NONE,
|
||||
/// 2 = UINT8, 3 = INT8, 4 = UINT16, 5 = INT16, 6 = UINT32, 7 = INT32,
|
||||
/// 8 = UINT64, 9 = INT64, 10 = HALF, 11 = FLOAT, 12 = DOUBLE, 13 = STRING,
|
||||
/// 14 = PTR (OIIO >= 2.5 additionally has 15 = USTRINGHASH).
|
||||
mod basetype {
|
||||
/// Unknown / unmappable base type.
|
||||
pub(crate) const UNKNOWN: i32 = 0;
|
||||
/// 8-bit unsigned integer.
|
||||
pub(crate) const UINT8: i32 = 2;
|
||||
/// 16-bit unsigned integer.
|
||||
pub(crate) const UINT16: i32 = 4;
|
||||
/// 16-bit float (half).
|
||||
pub(crate) const HALF: i32 = 10;
|
||||
/// 32-bit float.
|
||||
pub(crate) const FLOAT: i32 = 11;
|
||||
}
|
||||
|
||||
/// The OIIO utils family (stateless).
|
||||
pub struct OIIOUtils;
|
||||
|
||||
impl OIIOUtils {
|
||||
/// Creates the OIIOUtils object.
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Map a native pixel format to an OIIO base type code. On invalid or
|
||||
/// unmappable formats returns `Ok(TypeDesc::UNKNOWN = 0)`.
|
||||
///
|
||||
/// CPP-PARITY: matches `OIIOUtils::get_oiio_base_type_from_format`. The
|
||||
/// per-channel bit depth comes from the `image` crate's own color-type
|
||||
/// tables (see [`image_color_type_for`]); the bit depth is then mapped to
|
||||
/// the OIIO base-type codes frozen in `include/common/oiioutils.h`.
|
||||
/// `u10` has no image/OIIO representation and maps to UNKNOWN;
|
||||
/// `invalid`/`count` also fall through to UNKNOWN (the C++ `break`s out
|
||||
/// of the switch and returns UNKNOWN). `f16` is a documented exception:
|
||||
/// `image` 0.25 has no half-float sample type, so HALF is pinned from the
|
||||
/// frozen OIIO table.
|
||||
pub fn get_oiio_base_type_from_format(&self, pixel_format: PixelFormat) -> Result<i32> {
|
||||
let base_type = match image_color_type_for(pixel_format) {
|
||||
Some(color_type) => match bits_per_channel(color_type) {
|
||||
8 => basetype::UINT8,
|
||||
16 => basetype::UINT16,
|
||||
32 => basetype::FLOAT,
|
||||
// Unreachable for the mapped types (L8=8, L16=16, Rgb32F=32);
|
||||
// kept as a fallback for future mappings.
|
||||
_ => basetype::UNKNOWN,
|
||||
},
|
||||
None => match pixel_format {
|
||||
// image 0.25 has no f16 sample type (see the `// TODO f16
|
||||
// types?` note in image's color.rs), so HALF is pinned from
|
||||
// the frozen OIIO base-type table (CPP-PARITY).
|
||||
PixelFormat::F16 => basetype::HALF,
|
||||
_ => basetype::UNKNOWN,
|
||||
},
|
||||
};
|
||||
Ok(base_type)
|
||||
}
|
||||
|
||||
/// Map an OIIO base type code to a native pixel format. On unknown or
|
||||
/// unmappable base types returns `Ok(PixelFormat::Invalid)`; a negative
|
||||
/// base type is an error.
|
||||
///
|
||||
/// CPP-PARITY: matches `OIIOUtils::get_format_from_oiio_basetype`. The
|
||||
/// known-but-unmappable types (INT8/INT16/INT32/UINT32/INT64/UINT64/
|
||||
/// STRING/PTR/DOUBLE/LASTBASE) print to stderr in C++ and return
|
||||
/// `invalid`; here they all fall to `Ok(PixelFormat::Invalid)`. The
|
||||
/// `base_type < 0` error mirrors the `oakcommon_oiioutils_get_format_from_oiio_basetype`
|
||||
/// c_api guard; the `>= LASTBASE` upper-bound guard is likewise a c_api
|
||||
/// concern and is not replicated in the domain function.
|
||||
pub fn get_format_from_oiio_basetype(&self, base_type: i32) -> Result<PixelFormat> {
|
||||
if base_type < 0 {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
Ok(match base_type {
|
||||
basetype::UINT8 => PixelFormat::U8,
|
||||
basetype::UINT16 => PixelFormat::U16,
|
||||
basetype::HALF => PixelFormat::F16,
|
||||
basetype::FLOAT => PixelFormat::F32,
|
||||
_ => PixelFormat::Invalid,
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert a `PixelAspectRatio` attribute value to a reduced
|
||||
/// numerator/denominator pair.
|
||||
///
|
||||
/// CPP-PARITY: `olive::core::Rational::from_double`
|
||||
/// (`core/src/util/rational.cpp:39`) via oakcore-rs
|
||||
/// [`Rational::from_double`]. NaN and `|x| > INT_MAX + 3` yield the
|
||||
/// oracle's NaN rational `(0, 0)`; `0.0` reduces to `(0, 1)`. The
|
||||
/// c_api wrapper never fails for a valid `pixel_aspect_ratio`, so
|
||||
/// this always returns `Ok`.
|
||||
pub fn get_pixel_aspect_ratio(&self, pixel_aspect_ratio: f64) -> Result<(i32, i32)> {
|
||||
let r = Rational::from_double(pixel_aspect_ratio);
|
||||
// from_double caps the reduction at i32::MAX, so the cast is lossless.
|
||||
Ok((r.numerator() as i32, r.denominator() as i32))
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a native pixel format to the `image` crate's extended color type, or
|
||||
/// `None` when the format has no image representation.
|
||||
///
|
||||
/// CPP-PARITY: the per-channel bit depth drives
|
||||
/// [`OIIOUtils::get_oiio_base_type_from_format`]; this is the "which OIIO
|
||||
/// base type would `image` emit" pivot. `u8`/`u16` map to `L8`/`L16`, `f32` to
|
||||
/// `Rgb32F` (the `image` crate's only 32-bit float RGB layout; channel
|
||||
/// count is derived separately via [`bits_per_channel`]). `u10` has no
|
||||
/// `image`/OIIO representation, `f16` has no `image` sample type (the crate
|
||||
/// has no half float; see the `// TODO f16 types?` note in its color tables),
|
||||
/// and `invalid`/`count` fall through — all `None`.
|
||||
fn image_color_type_for(pixel_format: PixelFormat) -> Option<ExtendedColorType> {
|
||||
match pixel_format {
|
||||
PixelFormat::U8 => Some(ExtendedColorType::L8),
|
||||
PixelFormat::U10 => None,
|
||||
PixelFormat::U16 => Some(ExtendedColorType::L16),
|
||||
PixelFormat::F16 => None,
|
||||
PixelFormat::F32 => Some(ExtendedColorType::Rgb32F),
|
||||
PixelFormat::Invalid | PixelFormat::Count => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-channel bit depth of an `ExtendedColorType` (derived from the crate's
|
||||
/// own tables rather than a hand-maintained list).
|
||||
fn bits_per_channel(color_type: ExtendedColorType) -> u32 {
|
||||
// `ExtendedColorType` is `#[non_exhaustive]`; the mapped types are the
|
||||
// only ones fed in, so any new variant the crate adds would surface as a
|
||||
// `0` here and fall through to UNKNOWN upstream.
|
||||
let bits_per_pixel = color_type.bits_per_pixel();
|
||||
let channel_count = color_type.channel_count();
|
||||
if channel_count == 0 {
|
||||
return 0;
|
||||
}
|
||||
bits_per_pixel as u32 / channel_count as u32
|
||||
}
|
||||
|
||||
/// A decoded image stored as 32-bit float, packed row-major with `channels`
|
||||
/// interleaved values per pixel.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct F32Image {
|
||||
/// Image width in pixels.
|
||||
pub width: i32,
|
||||
/// Image height in pixels.
|
||||
pub height: i32,
|
||||
/// Interleaved values per pixel (1 = luma, 2 = luma+alpha, 3 = RGB,
|
||||
/// 4 = RGBA).
|
||||
pub channels: i32,
|
||||
/// The interleaved float pixel data.
|
||||
pub pixels: Vec<f32>,
|
||||
}
|
||||
|
||||
impl F32Image {
|
||||
/// Total number of float values.
|
||||
pub fn len(&self) -> usize {
|
||||
self.pixels.len()
|
||||
}
|
||||
|
||||
/// Whether the image contains no pixels.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.pixels.is_empty()
|
||||
}
|
||||
|
||||
/// Access to the interleaved pixel data.
|
||||
pub fn as_slice(&self) -> &[f32] {
|
||||
&self.pixels
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads a TIFF image as 32-bit float.
|
||||
///
|
||||
/// The channel count comes from the file itself; RGBA images yield
|
||||
/// `channels == 4`, RGB `3`, gray+alpha `2`, grayscale `1`. Lower bit depths
|
||||
/// are upscaled to float. Only the TIFF format is enabled in this crate's
|
||||
/// `image` dependency; other formats fail with an error.
|
||||
pub fn read_image_f32(path: &str) -> Result<F32Image> {
|
||||
let img =
|
||||
image::open(path).map_err(|e| Error::new(format!("image::read_image_f32: {e}")))?;
|
||||
let width = img.width() as i32;
|
||||
let height = img.height() as i32;
|
||||
let channels = img.color().channel_count() as i32;
|
||||
let pixels = match channels {
|
||||
1 => img.to_luma32f().into_raw(),
|
||||
2 => img.to_luma_alpha32f().into_raw(),
|
||||
3 => img.to_rgb32f().into_raw(),
|
||||
4 => img.to_rgba32f().into_raw(),
|
||||
n => {
|
||||
return Err(Error::new(format!(
|
||||
"image::read_image_f32: unsupported channel count {n}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
Ok(F32Image {
|
||||
width,
|
||||
height,
|
||||
channels,
|
||||
pixels,
|
||||
})
|
||||
}
|
||||
|
||||
/// Writes 32-bit float pixels to `path` as a TIFF (format inferred from the
|
||||
/// `.tif`/`.tiff` extension).
|
||||
///
|
||||
/// `pixels` must hold exactly `width * height * channels` values, interleaved
|
||||
/// row-major. `channels` must be 3 (RGB) or 4 (RGBA): the TIFF encoder in the
|
||||
/// `image` crate supports 32-bit float only for those two layouts, so 1- and
|
||||
/// 2-channel writes are rejected with an error.
|
||||
pub fn write_image_f32(
|
||||
path: &str,
|
||||
width: i32,
|
||||
height: i32,
|
||||
channels: i32,
|
||||
pixels: &[f32],
|
||||
) -> Result<()> {
|
||||
if width <= 0 || height <= 0 {
|
||||
return Err(Error::new("image::write_image_f32: invalid dimensions"));
|
||||
}
|
||||
if channels != 3 && channels != 4 {
|
||||
return Err(Error::new(format!(
|
||||
"image::write_image_f32: unsupported channel count {channels} (TIFF float writes support RGB=3 or RGBA=4 only)"
|
||||
)));
|
||||
}
|
||||
let expected = (width as i64) * (height as i64) * (channels as i64);
|
||||
if expected != pixels.len() as i64 {
|
||||
return Err(Error::new(format!(
|
||||
"image::write_image_f32: pixel buffer length {} does not match {width}x{height}x{channels} = {expected}",
|
||||
pixels.len()
|
||||
)));
|
||||
}
|
||||
// Only the TIFF feature is enabled; anything else is a caller mistake.
|
||||
let format = image::ImageFormat::from_path(path)
|
||||
.map_err(|e| Error::new(format!("image::write_image_f32: {e}")))?;
|
||||
if format != image::ImageFormat::Tiff {
|
||||
return Err(Error::new(format!(
|
||||
"image::write_image_f32: unsupported image format for '{path}' (only TIFF is enabled)"
|
||||
)));
|
||||
}
|
||||
|
||||
let (w, h) = (width as u32, height as u32);
|
||||
let result = if channels == 3 {
|
||||
let buf = ImageBuffer::<Rgb<f32>, Vec<f32>>::from_raw(w, h, pixels.to_vec()).ok_or_else(
|
||||
|| {
|
||||
Error::new(format!(
|
||||
"image::write_image_f32: pixel buffer does not match {width}x{height}x{channels}"
|
||||
))
|
||||
},
|
||||
)?;
|
||||
buf.save_with_format(path, image::ImageFormat::Tiff)
|
||||
} else {
|
||||
let buf = ImageBuffer::<Rgba<f32>, Vec<f32>>::from_raw(w, h, pixels.to_vec()).ok_or_else(
|
||||
|| {
|
||||
Error::new(format!(
|
||||
"image::write_image_f32: pixel buffer does not match {width}x{height}x{channels}"
|
||||
))
|
||||
},
|
||||
)?;
|
||||
buf.save_with_format(path, image::ImageFormat::Tiff)
|
||||
};
|
||||
result.map_err(|e| Error::new(format!("image::write_image_f32: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn utils() -> OIIOUtils {
|
||||
OIIOUtils::new()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_type_from_format_mapping() {
|
||||
let u = utils();
|
||||
assert_eq!(u.get_oiio_base_type_from_format(PixelFormat::U8).unwrap(), 2); // UINT8
|
||||
assert_eq!(u.get_oiio_base_type_from_format(PixelFormat::U10).unwrap(), 0); // UNKNOWN
|
||||
assert_eq!(u.get_oiio_base_type_from_format(PixelFormat::U16).unwrap(), 4); // UINT16
|
||||
assert_eq!(u.get_oiio_base_type_from_format(PixelFormat::F16).unwrap(), 10); // HALF
|
||||
assert_eq!(u.get_oiio_base_type_from_format(PixelFormat::F32).unwrap(), 11); // FLOAT
|
||||
// Invalid / count fall through to UNKNOWN.
|
||||
assert_eq!(u.get_oiio_base_type_from_format(PixelFormat::Invalid).unwrap(), 0);
|
||||
assert_eq!(u.get_oiio_base_type_from_format(PixelFormat::Count).unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_from_oiio_basetype_mapping() {
|
||||
let u = utils();
|
||||
assert_eq!(u.get_format_from_oiio_basetype(2).unwrap(), PixelFormat::U8);
|
||||
assert_eq!(u.get_format_from_oiio_basetype(4).unwrap(), PixelFormat::U16);
|
||||
assert_eq!(u.get_format_from_oiio_basetype(10).unwrap(), PixelFormat::F16);
|
||||
assert_eq!(u.get_format_from_oiio_basetype(11).unwrap(), PixelFormat::F32);
|
||||
// Unknown / unmappable base types map to Invalid.
|
||||
assert_eq!(u.get_format_from_oiio_basetype(0).unwrap(), PixelFormat::Invalid); // UNKNOWN
|
||||
assert_eq!(u.get_format_from_oiio_basetype(1).unwrap(), PixelFormat::Invalid); // NONE
|
||||
assert_eq!(u.get_format_from_oiio_basetype(3).unwrap(), PixelFormat::Invalid); // INT8
|
||||
assert_eq!(u.get_format_from_oiio_basetype(12).unwrap(), PixelFormat::Invalid); // DOUBLE
|
||||
assert_eq!(u.get_format_from_oiio_basetype(15).unwrap(), PixelFormat::Invalid); // USTRINGHASH
|
||||
assert_eq!(u.get_format_from_oiio_basetype(99).unwrap(), PixelFormat::Invalid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_from_oiio_basetype_negative_is_error() {
|
||||
let u = utils();
|
||||
assert!(u.get_format_from_oiio_basetype(-1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_aspect_ratio_common_values() {
|
||||
let u = utils();
|
||||
// 1:1
|
||||
assert_eq!(u.get_pixel_aspect_ratio(1.0).unwrap(), (1, 1));
|
||||
// 16:9
|
||||
assert_eq!(u.get_pixel_aspect_ratio(16.0 / 9.0).unwrap(), (16, 9));
|
||||
// 4:3
|
||||
assert_eq!(u.get_pixel_aspect_ratio(4.0 / 3.0).unwrap(), (4, 3));
|
||||
// 2:1
|
||||
assert_eq!(u.get_pixel_aspect_ratio(2.0).unwrap(), (2, 1));
|
||||
// 1:2
|
||||
assert_eq!(u.get_pixel_aspect_ratio(0.5).unwrap(), (1, 2));
|
||||
// 1:1.5 = 2:3
|
||||
assert_eq!(u.get_pixel_aspect_ratio(2.0 / 3.0).unwrap(), (2, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_aspect_ratio_zero() {
|
||||
let u = utils();
|
||||
assert_eq!(u.get_pixel_aspect_ratio(0.0).unwrap(), (0, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_aspect_ratio_nan_returns_nan_rational() {
|
||||
let u = utils();
|
||||
assert_eq!(u.get_pixel_aspect_ratio(f64::NAN).unwrap(), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_aspect_ratio_out_of_range_returns_nan_rational() {
|
||||
let u = utils();
|
||||
let too_big = i32::MAX as f64 + 4.0;
|
||||
assert_eq!(u.get_pixel_aspect_ratio(too_big).unwrap(), (0, 0));
|
||||
assert_eq!(u.get_pixel_aspect_ratio(-too_big).unwrap(), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_aspect_ratio_reduction() {
|
||||
let u = utils();
|
||||
// 4.0 / 6.0 reduces to 2:3.
|
||||
assert_eq!(u.get_pixel_aspect_ratio(4.0 / 6.0).unwrap(), (2, 3));
|
||||
// 0.25 = 1:4.
|
||||
assert_eq!(u.get_pixel_aspect_ratio(0.25).unwrap(), (1, 4));
|
||||
// 1.5 = 3:2.
|
||||
assert_eq!(u.get_pixel_aspect_ratio(1.5).unwrap(), (3, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_aspect_ratio_round_trip() {
|
||||
let u = utils();
|
||||
// The recovered rational should reproduce the input within the
|
||||
// precision of a reduced fraction.
|
||||
for input in [0.5, 1.0, 1.333_333_333_333_333_3, 1.777_777_777_777_777_7, 2.0, 2.35] {
|
||||
let (n, d) = u.get_pixel_aspect_ratio(input).unwrap();
|
||||
if d == 0 {
|
||||
continue;
|
||||
}
|
||||
let recovered = n as f64 / d as f64;
|
||||
let err = (recovered - input).abs();
|
||||
assert!(err < 1e-9, "input={input} recovered={recovered} ({n}/{d}) err={err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oiioutils_new_is_stateless() {
|
||||
// The object is a stateless unit; construction must succeed and be
|
||||
// cheap to repeat.
|
||||
let _a = OIIOUtils::new();
|
||||
let _b = OIIOUtils::new();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_color_type_maps_formats() {
|
||||
assert_eq!(image_color_type_for(PixelFormat::U8), Some(ExtendedColorType::L8));
|
||||
assert_eq!(image_color_type_for(PixelFormat::U16), Some(ExtendedColorType::L16));
|
||||
assert_eq!(image_color_type_for(PixelFormat::F32), Some(ExtendedColorType::Rgb32F));
|
||||
// No image representation.
|
||||
assert_eq!(image_color_type_for(PixelFormat::U10), None);
|
||||
assert_eq!(image_color_type_for(PixelFormat::F16), None);
|
||||
assert_eq!(image_color_type_for(PixelFormat::Invalid), None);
|
||||
assert_eq!(image_color_type_for(PixelFormat::Count), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bits_per_channel_matches_crate_tables() {
|
||||
// Derived from the image crate's own bits-per-pixel / channel tables.
|
||||
assert_eq!(bits_per_channel(ExtendedColorType::L8), 8);
|
||||
assert_eq!(bits_per_channel(ExtendedColorType::L16), 16);
|
||||
assert_eq!(bits_per_channel(ExtendedColorType::Rgb32F), 32);
|
||||
assert_eq!(bits_per_channel(ExtendedColorType::Rgba32F), 32);
|
||||
// A channel-less type yields 0 (the upstream fallback to UNKNOWN).
|
||||
assert_eq!(bits_per_channel(ExtendedColorType::Unknown(0)), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_aspect_ratio_tiny_value_stays_representable() {
|
||||
// Very small magnitudes must not panic or produce a NaN rational
|
||||
// (av_d2q rescales internally).
|
||||
let u = utils();
|
||||
let (n, d) = u.get_pixel_aspect_ratio(1e-9).unwrap();
|
||||
assert_ne!(d, 0);
|
||||
let recovered = n as f64 / d as f64;
|
||||
assert!((recovered - 1e-9).abs() / 1e-9 < 1e-6, "got {recovered}");
|
||||
}
|
||||
|
||||
fn temp_tiff_path(name: &str) -> (std::path::PathBuf, String) {
|
||||
let dir = std::env::temp_dir().join("oakcommon-oiioutils");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join(name);
|
||||
let path_str = path.to_str().unwrap().to_string();
|
||||
(path, path_str)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_f32_write_read_round_trip() {
|
||||
// 2x2 RGBA float image through a temp TIFF.
|
||||
let w = 2;
|
||||
let h = 2;
|
||||
let c = 4;
|
||||
let pixels: Vec<f32> = vec![
|
||||
0.0, 0.25, 0.5, 1.0,
|
||||
0.75, 0.5, 0.25, 1.0,
|
||||
1.0, 0.0, 0.5, 0.0,
|
||||
0.125, 0.625, 0.875, 1.0,
|
||||
];
|
||||
|
||||
let (path, path_str) = temp_tiff_path("roundtrip.tif");
|
||||
write_image_f32(&path_str, w, h, c, &pixels).expect("write should succeed");
|
||||
|
||||
let img = read_image_f32(&path_str).expect("read should succeed");
|
||||
assert_eq!(img.width, w);
|
||||
assert_eq!(img.height, h);
|
||||
assert_eq!(img.channels, c);
|
||||
assert_eq!(img.pixels.len(), (w * h * c) as usize);
|
||||
assert_eq!(img.len(), img.pixels.len());
|
||||
assert!(!img.is_empty());
|
||||
assert_eq!(img.as_slice(), img.pixels.as_slice());
|
||||
|
||||
for (i, (a, b)) in img.pixels.iter().zip(pixels.iter()).enumerate() {
|
||||
let diff = (a - b).abs();
|
||||
assert!(diff < 1e-6, "pixel {i}: wrote {b}, read back {a}");
|
||||
}
|
||||
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_f32_write_rgb_round_trip() {
|
||||
// RGB (3-channel) writes are supported too; the read reports the
|
||||
// channel count from the file.
|
||||
let pixels: Vec<f32> = vec![0.1, 0.2, 0.3, 0.4, 0.5, 0.6];
|
||||
let (path, path_str) = temp_tiff_path("roundtrip_rgb.tif");
|
||||
write_image_f32(&path_str, 2, 1, 3, &pixels).expect("write should succeed");
|
||||
let img = read_image_f32(&path_str).expect("read should succeed");
|
||||
assert_eq!((img.width, img.height, img.channels), (2, 1, 3));
|
||||
for (a, b) in img.pixels.iter().zip(pixels.iter()) {
|
||||
assert!((a - b).abs() < 1e-6, "wrote {b}, read back {a}");
|
||||
}
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_f32_write_rejects_invalid_dimensions() {
|
||||
let err = write_image_f32("/unused.tif", 0, 1, 4, &[]).unwrap_err();
|
||||
assert!(matches!(err, Error::Failed(_)));
|
||||
let err = write_image_f32("/unused.tif", 1, -2, 4, &[]).unwrap_err();
|
||||
assert!(matches!(err, Error::Failed(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_f32_write_rejects_unsupported_channels() {
|
||||
let pixels = vec![0.0f32; 2];
|
||||
let err = write_image_f32("/unused.tif", 1, 1, 1, &pixels).unwrap_err();
|
||||
assert!(matches!(err, Error::Failed(_)));
|
||||
let err = write_image_f32("/unused.tif", 1, 1, 2, &pixels).unwrap_err();
|
||||
assert!(matches!(err, Error::Failed(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_f32_write_rejects_length_mismatch() {
|
||||
let err = write_image_f32("/unused.tif", 2, 2, 4, &[0.0f32; 3]).unwrap_err();
|
||||
assert!(matches!(err, Error::Failed(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_f32_write_rejects_non_tiff_extension() {
|
||||
let pixels = vec![0.0f32; 4];
|
||||
let err = write_image_f32("/unused.png", 1, 1, 4, &pixels).unwrap_err();
|
||||
assert!(matches!(err, Error::Failed(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_f32_read_missing_file_errors() {
|
||||
let err = read_image_f32("/nonexistent/oakcommon-oiioutils.tif").unwrap_err();
|
||||
assert!(matches!(err, Error::Failed(_)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Small helpers for crossing the Qt <-> C boundary, mirroring
|
||||
//! `include/common/qtutils.h`. No state and no handle.
|
||||
//!
|
||||
//! Port of `src/common/src/qtutils.{h,cpp}` and the thin wrapper in
|
||||
//! `src/common/c_api/qtutils.cpp`.
|
||||
|
||||
use std::ffi::c_void;
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "freebsd"))]
|
||||
use std::ffi::{c_char, c_int, CString};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// Convert an opaque pointer to its numeric representation.
|
||||
///
|
||||
/// A null pointer yields `0`. Never fails; the `Result` mirrors the C ABI
|
||||
/// shape (the c_api only rejects a null `out_value`).
|
||||
pub fn ptr_to_value(ptr: *mut c_void) -> Result<u64> {
|
||||
// CPP-PARITY: `reinterpret_cast<uintptr_t>(ptr)`; zero-extended to 64 bits.
|
||||
Ok(ptr as usize as u64)
|
||||
}
|
||||
|
||||
/// Convert a numeric representation back to an opaque pointer.
|
||||
///
|
||||
/// `0` yields a null pointer. Never fails; the `Result` mirrors the C ABI
|
||||
/// shape (the c_api only rejects a null `out_ptr`).
|
||||
pub fn value_to_ptr(value: u64) -> Result<*mut c_void> {
|
||||
// CPP-PARITY: `reinterpret_cast<T*>(value)`, truncating to the pointer
|
||||
// width like a 32-bit `uintptr_t` cast would.
|
||||
Ok(value as usize as *mut c_void)
|
||||
}
|
||||
|
||||
/// macOS/FreeBSD `struct timespec` mirror (`sys/types.h`).
|
||||
#[cfg(any(target_os = "macos", target_os = "freebsd"))]
|
||||
#[repr(C)]
|
||||
struct Timespec {
|
||||
tv_sec: i64,
|
||||
tv_nsec: i64,
|
||||
}
|
||||
|
||||
/// macOS/FreeBSD `struct stat` mirror (`sys/stat.h`), laid out field-for-field
|
||||
/// so the raw `stat()` FFI below reads `st_birthtimespec` / `st_ctimespec`.
|
||||
#[cfg(any(target_os = "macos", target_os = "freebsd"))]
|
||||
#[repr(C)]
|
||||
struct Stat {
|
||||
st_dev: i32,
|
||||
st_mode: u16,
|
||||
st_nlink: u16,
|
||||
st_ino: u64,
|
||||
st_uid: u32,
|
||||
st_gid: u32,
|
||||
st_rdev: i32,
|
||||
_st_pad: u32,
|
||||
st_atimespec: Timespec,
|
||||
st_mtimespec: Timespec,
|
||||
st_ctimespec: Timespec,
|
||||
st_birthtimespec: Timespec,
|
||||
st_size: i64,
|
||||
st_blocks: i64,
|
||||
st_blksize: i32,
|
||||
st_flags: u32,
|
||||
st_gen: u32,
|
||||
st_lspare: i32,
|
||||
st_qspare: [i64; 2],
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "freebsd"))]
|
||||
extern "C" {
|
||||
fn stat(path: *const c_char, buf: *mut Stat) -> c_int;
|
||||
}
|
||||
|
||||
/// File creation time as seconds since the Unix epoch.
|
||||
///
|
||||
/// On filesystems that record a birth time that value is used; otherwise the
|
||||
/// metadata change time is returned. Mirrors `olive::QtUtils::get_creation_date`
|
||||
/// plus the c_api wrapper, which turns a zero (epoch) result into
|
||||
/// `E_NOT_FOUND`.
|
||||
pub fn get_creation_date(path: &str) -> Result<i64> {
|
||||
#[cfg(any(target_os = "macos", target_os = "freebsd"))]
|
||||
{
|
||||
let cpath = CString::new(path).map_err(|_| Error::Invalid)?;
|
||||
let mut st = Stat {
|
||||
st_dev: 0,
|
||||
st_mode: 0,
|
||||
st_nlink: 0,
|
||||
st_ino: 0,
|
||||
st_uid: 0,
|
||||
st_gid: 0,
|
||||
st_rdev: 0,
|
||||
_st_pad: 0,
|
||||
st_atimespec: Timespec { tv_sec: 0, tv_nsec: 0 },
|
||||
st_mtimespec: Timespec { tv_sec: 0, tv_nsec: 0 },
|
||||
st_ctimespec: Timespec { tv_sec: 0, tv_nsec: 0 },
|
||||
st_birthtimespec: Timespec { tv_sec: 0, tv_nsec: 0 },
|
||||
st_size: 0,
|
||||
st_blocks: 0,
|
||||
st_blksize: 0,
|
||||
st_flags: 0,
|
||||
st_gen: 0,
|
||||
st_lspare: 0,
|
||||
st_qspare: [0, 0],
|
||||
};
|
||||
if unsafe { stat(cpath.as_ptr(), &mut st) } != 0 {
|
||||
return Err(Error::NotFound);
|
||||
}
|
||||
// CPP-PARITY: birth time, falling back to the metadata change time when
|
||||
// the birth time is unset (0 / -1) — qtutils.cpp on macOS/FreeBSD.
|
||||
let mut secs = st.st_birthtimespec.tv_sec;
|
||||
if secs == 0 || secs == -1 {
|
||||
secs = st.st_ctimespec.tv_sec;
|
||||
}
|
||||
// CPP-PARITY: the c_api maps a zero (epoch) result to E_NOT_FOUND.
|
||||
if secs == 0 {
|
||||
return Err(Error::NotFound);
|
||||
}
|
||||
Ok(secs)
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "freebsd")))]
|
||||
{
|
||||
let meta = std::fs::metadata(path).map_err(|_| Error::NotFound)?;
|
||||
// CPP-PARITY: `st_ctime` (metadata change time) has no std::fs
|
||||
// equivalent; fall back to creation then modification time.
|
||||
let t = meta
|
||||
.created()
|
||||
.or_else(|_| meta.modified())
|
||||
.map_err(|_| Error::NotFound)?;
|
||||
let secs = t
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
if secs == 0 {
|
||||
return Err(Error::NotFound);
|
||||
}
|
||||
Ok(secs)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::ffi::c_void;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use super::*;
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
#[test]
|
||||
fn ptr_to_value_null_is_zero() {
|
||||
assert_eq!(ptr_to_value(std::ptr::null_mut()).unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ptr_value_round_trip() {
|
||||
let value: u64 = 0x1234_5678_9abc_def0;
|
||||
let ptr = value_to_ptr(value).unwrap();
|
||||
assert_eq!(ptr_to_value(ptr).unwrap(), value);
|
||||
assert!(!ptr.is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_to_ptr_null_is_null() {
|
||||
assert!(value_to_ptr(0).unwrap().is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_to_ptr_accepts_any_u64() {
|
||||
// Mirrors `reinterpret_cast<void*>` which never rejects an input.
|
||||
assert!(value_to_ptr(u64::MAX).is_ok());
|
||||
assert!(value_to_ptr(1).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ptr_to_value_of_real_pointer_round_trips() {
|
||||
let data = 42i32;
|
||||
let raw = &data as *const i32 as *mut c_void;
|
||||
let n = ptr_to_value(raw).unwrap();
|
||||
let back = value_to_ptr(n).unwrap();
|
||||
assert_eq!(back as *const i32 as *const i32, &data as *const i32);
|
||||
}
|
||||
|
||||
// Stable temp-file helper: unique path under the system temp dir.
|
||||
fn temp_file_path(tag: &str) -> std::path::PathBuf {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
std::env::temp_dir().join(format!(
|
||||
"oakcommon_qtutils_{}_{}_{}.tmp",
|
||||
std::process::id(),
|
||||
tag,
|
||||
n
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_date_of_existing_file() {
|
||||
let p = temp_file_path("exists");
|
||||
std::fs::write(&p, b"hello").unwrap();
|
||||
let secs = get_creation_date(p.to_str().unwrap());
|
||||
// On macOS both paths read the same birth time; just assert success
|
||||
// and plausibility rather than exact equality across fs providers.
|
||||
match secs {
|
||||
Ok(s) => assert!(s > 0, "creation time should be in the past"),
|
||||
Err(e) => panic!("expected Ok, got {e:?}"),
|
||||
}
|
||||
// Cross-check against the std metadata creation time when available.
|
||||
if let Ok(meta) = std::fs::metadata(&p) {
|
||||
if let Ok(created) = meta.created() {
|
||||
let expected = created
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
assert_eq!(secs.unwrap(), expected);
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_date_matches_std_for_new_file() {
|
||||
let p = temp_file_path("match");
|
||||
std::fs::write(&p, b"data").unwrap();
|
||||
let ours = get_creation_date(p.to_str().unwrap()).unwrap();
|
||||
let expected = std::fs::metadata(&p)
|
||||
.and_then(|m| m.created())
|
||||
.map(|t| t.duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
assert!(expected > 0);
|
||||
assert_eq!(ours, expected);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_date_missing_file_is_not_found() {
|
||||
let p = temp_file_path("missing");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let res = get_creation_date(p.to_str().unwrap());
|
||||
assert!(matches!(res, Err(Error::NotFound)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_date_empty_path_is_not_found() {
|
||||
assert!(matches!(get_creation_date(""), Err(Error::NotFound)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_date_returns_result_ok_type() {
|
||||
let p = temp_file_path("typetest");
|
||||
std::fs::write(&p, b"x").unwrap();
|
||||
let res: Result<i64> = get_creation_date(p.to_str().unwrap());
|
||||
assert!(res.is_ok());
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_to_ptr_is_injective_over_u64() {
|
||||
// Distinct values that survive the pointer-width cast map back distinctly.
|
||||
let a = value_to_ptr(1).unwrap();
|
||||
let b = value_to_ptr(2).unwrap();
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 & bye <there></e><a q="x"y&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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -32,6 +32,8 @@ add_library(oakcommon SHARED
|
||||
loopmode.h
|
||||
subtitleparams.cpp
|
||||
subtitleparams.h
|
||||
variant.cpp
|
||||
variant.h
|
||||
videoparams.cpp
|
||||
videoparams.h
|
||||
ffmpegutils.cpp
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user