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

- oakcore-rs: Rational/TimeRange/TimeRangeList/PixelFormat/SampleFormat
  mirroring oakcore C++ semantics (91.6% coverage)
- oakundo: UndoCommand/UndoStack with vtable commands (99.6%)
- oakcommon: config/XML/VideoParams/logging etc. (89.8%, 516 tests);
  XML via quick-xml, logging via the log facade
- oaktimeline: markers/workarea/edit commands (95.4%)
- oakaudio: processor/manager/sync/waveform/levelmeter (88.4%)
- unified -MMCCCC error codes across all C ABI headers (registry in
  include/common/error.h; pass-through rule)
- olive::Variant moved node -> common (cross-module value type)
- oakcommon_videoparams_set_is_3d added to the C ABI
- M11 (oakplugin Rust rewrite) plan + notes.md freeze line for the
  inter-module C ABI wiring
This commit is contained in:
2026-08-09 04:24:28 +08:00
parent 5a564f30ca
commit 4b24aa9d67
138 changed files with 47853 additions and 48 deletions
@@ -46,6 +46,7 @@
| M7 | oakrender | `render/`(Renderer/PlaybackCache/ColorManager/帧缓存/job) | render→node 38、render→codec 9、render→task 2、render→undo 1、render→src 1(违规) |
| M8 | oaktask | `task/`(Task/TaskManager/项目任务编排/cache 任务;**工程文件 IO 已划给 oakstorage,M3a**) | task→node 38、task→codec 4、task→render 4、task→storage(load/save 委托) |
| M9 | oakplugin | `pluginSupport/`(OpenFX host) | plugin→node 6、plugin→render 6、plugin→undo 2、plugin→coreengine 2 |
| M11 | oakplugin Rust 重写 | 整个模块换 Rust(含自研 OFX host 取代 HostSupport;0-2 期已立项:golden master、crate 落地、GL+ofxColour+render 驱动收编) | 手册 M11;项目 Rust 化滩头模块,完成后 node/render→plugin C++ 引用归零 |
| — | liboakengine | `src/capi` + `coreengine` + `tool/` + `ui/` 残余 | 纯装配层:facade 内部调用改经各模块 C ABI(或保持现状直接链,见 M9 §4 裁决) |
**M3.5(伴随 M3 的类型下沉)**:`render/videoparams.h`、
+143
View File
@@ -0,0 +1,143 @@
# M11:oakplugin Rust 重写(含自研 OFX Host)
> 前置:M9(oakplugin 拆分)已完成,模块边界已是纯 C ABI
> (`include/plugin/*.h`,引用计数句柄)。本计划把 oakplugin 的
> **整个实现**换成 Rust:自研 OFX 宿主取代 openfx HostSupport
> (third_party/openfx/HostSupport,vendored 1.3 万行 C++),六个
> C++ 类(4485 行)语义级移植;pluginrenderer(oakrender 内,
> 1857 行)在第 2 期收编。
>
> 这是项目全量 Rust 化的滩头模块:第一张 Rust crate 模板
> (构建、测试、FFI 纪律、泄漏断言)在此确立。
>
> 本文档覆盖已立项的 0-2 期;OpenCL(3 期)与 UI 类 suite
> (Interact/Dialog/Draw,4 期)届时评审再立项。
## 0. 形态决策(2026-08,已定)
- **单一 Rust crate 收编整个模块**,不自起独立 host 库。crate
内部 host 核心与实例/参数/纹理桥之间是普通 Rust 模块调用
(safe,编译器查所有权),只有两条 FFI 边界:
1. 对 OFX 插件:fetchSuite/action,规范定义的 C ABI;
2. 对 oak 其余模块:`include/plugin/*.h`,M9 已冻结。
- 上游零感知:oaknode 的 PluginNode、facade、oakrender 都只认
C ABI,不关心实现语言。
- HostSupport 删除后,liboaknode/liboakrender 对 liboakplugin 的
C++ 符号引用(PluginCache、Property::Set 等 30+ 个)归零。
- unsafe 收敛在两层:suite trampoline(插件调入)与 C ABI 导出
(oak 调入)。其余全部 safe Rust。
- 每个 FFI 入口必须 `catch_unwind` 兜底并映射为负错误码(01 §0),
构建保持 `panic = "unwind"`。
## 1. 保留与消耗的资产
- `include/plugin/{error,host,instance}.h`:公共契约,不变。
- 六个 C++ 类的语义(olivehost/oliveplugininstance/oliveclip/
paraminstance/image/pluginprogressreporter):移植,不重新设计。
- pluginrenderer.cpp 的渲染流程语义:2 期收编时逐段对照。
- HostSupport:仅作行为参照系,不进构建。
## 2. 第 0 期:Golden master 与测试基建(约 1 周)
不改实现,产出验收门槛。
### 2.1 插件内省 C ABI(include/plugin/instance.h 新增,约 15 函数)
参数枚举(名称/OFX 类型/标签/hint/父组/坐标系/secret/
display min-max/choice labels-values-order/按类型默认值)、clip
枚举(名称/标签/可选性/分量/位深)、描述符(标识/label/描述/
上下文集)。PluginNode 同期改走该 C ABI,node→plugin C++ 引用
归零。行为必须逐行不变。
### 2.2 描述符快照
系统三 bundle(CImg/Misc/Shadertoy)全量 dump 成 JSON 入库
(tests/ofx/snapshots/):标识、上下文、参数矩阵(默认值与
choice 排序结果)、clip 描述、**getClipPreferences 协商后结果**
(分量/位深/像素比——隐式行为主要来源)。后续每期 diff 必须为空。
### 2.3 渲染 golden master
代表性效果集(CImg invert/blur、一个 2D 变换、一个 Shadertoy、
一个 generator):固定输入帧,CPU 与 GL 两路径,全链路
**F32 + ACEScg**,存 EXR + SHA256。CPU 路径要求 bit 级一致,
GL 允许 1e-4 容差;GL 用例仅本机,CI 跳过。
### 2.4 最小测试插件
~300 行 C 测试插件(filter 上下文,覆盖常用参数类型与双 clip),
供 suite round-trip 单测与 1 期首测。
## 3. 第 1 期:Rust crate 落地(约 4-5 周)
### 3.1 范围
- host 核心:Property/Memory/ImageEffect v1/Parameter(12 种类型)/
Message v1v2/Progress v1v2/TimeLine v1/MultiThread v1 suite;
filter/generator/transition 上下文;describe/describeInContext/
createInstance/destroyInstance/getClipPreferences/getRoD/getRoI/
isIdentity/render(CPU)/begin-endSequenceRender 动作。
- 六类移植:host 单例(扫描/缓存)、instance 包装、clip↔纹理桥
(经 oakrender C ABI)、param↔node 桥(经 oaknode/oakundo C
ABI)、image 包装、progress reporter。
- 语义重灾区(逐条对照 HostSupport 并注释):clip 分量/位深/
像素比协商顺序;RoD/RoI;isIdentity 短路;field 透传。
### 3.2 crate 结构(src/plugin/rust/)
声明底稿已入库,见该目录。模块划分:ffi(C ABI 导出)、handle
(句柄脚手架)、host(扫描/缓存/suite 注册)、descriptor、
instance、clip、image、param、property、suites/*(八张函数表)、
bridge/{node,render,undo}(oak C ABI 导入)。
### 3.3 构建
crate 出 staticlib;CMake 经 corrosion(或自定义 target)链成
liboakplugin;standalone 树接线不变;CI 加 rust toolchain(版本
pin 在 rust-toolchain.toml)。
### 3.4 明确不做
GL 路径、ofxColour(均 2 期);OpenCL(3 期评审);
Interact/Dialog/Draw(4 期);Parametric(按需)。公共 C ABI 不变。
### 3.5 验收
- 0 期 golden master 全绿(描述符 diff 空、CPU 渲染 bit 一致)。
- 测试插件 round-trip + CImg 全量插件 describe/协商冒烟。
- 生命周期:createInstance/destroyInstance 配对、重复 scan、
render 中途 cancel;alive 计数无泄漏。
- nm:liboaknode/liboakrender→liboakplugin C++ 符号为 0;
HostSupport 移出构建。
- cargo test(host 内部单测)+ ctest(C ABI 层)双绿。
## 4. 第 2 期:GL 路径 + ofxColour + pluginrenderer 收编(约 2-3 周)
- OpenGLRender suite v1(clipLoadTexture/clipFreeTexture/
flushResources)与像素深度协商;纹理桥沿用 M9 的
wrap_native/readback 机制。
- ofxColour:属性族 + GetOutputColourspace action;ACEScg 工作
空间经 OCIO config 属性告知插件。
- pluginrenderer.cpp 语义收编进 crate(render driver 模块),
oakrender 的 PluginJob 退化为一次 C ABI 调用;oakrender 彻底
不碰 OFX。
- 验收:GL golden master 本机绿;ofxColour 属性 round-trip 单测 +
支持插件端到端用例;F32+ACEScg 链路保持绿。
## 5. 依赖与顺序
- 开始前:③ 剩余机械部分(render→node、→common、transition
清理)收尾。
- 2.1 完成后 ③ 的 node→plugin 依赖清零;render→plugin 的 12 个
符号随 1 期 HostSupport 删除消失。
- 每期结束独立评审再进下一期。
## 6. 风险
- **语义边角**:HostSupport 隐式行为无法全覆盖;缓解:快照含
协商后结果 + CImg 全量协商冒烟。
- **FFI 纪律**:panic 越界/回调线程模型是新坑;缓解:catch_unwind
全入口覆盖、共享状态全部 Mutex、回调线程注册表。
- **GL 本机依赖**:GL 验收需人工本机确认。
- **首个 Rust 模块**:构建模板(corrosion、双测试层、alive 计数)
要为后续模块立好,宁慢勿滥。
+119
View File
@@ -395,3 +395,122 @@ ColorManager)去Qt化过程中的删除与语义变化,迁移调用方时需
oaknode 的 35 个 TU,析构引用 olive::Texture::~Texture——值系统
载荷问题,与 UndoCommand 跨模块继承同类,留待值系统重做。
除此之外 liboaknode→liboakrender 的 C++ 符号引用为 0。
## ③ C ABI 接线冻结线(2026-08-07)
③(模块间调用切 C ABI)完成两整块后冻结,剩余部分改由各模块的
Rust 重写驱动(RIIR 后调用方只能走 C ABI,接线自动发生且被类型
强制做对):
- 已切:oaknode→oaktimeline(81431d180)、oaknode→oakrender
(5a564f30c,缓存体系/色彩/单例全部 C ABI 化)。
- olive::Variant 从 oaknode 下沉到 oakcommon(src/common/src/
variant.{h,cpp})——它本是跨模块值类型。
- 冻结时点的残留(nm 可查):
- render→node 41 个 C++ 符号:ProjectCopier 深拷贝族、
NodeTraverser(RenderProcessor 跨模块继承)、ColorManager、
Footage/MultiCam/ViewerOutput 常量、oaknode_c_api::alive_*。
→ 在 oaknode/oakrender Rust 重写中消解(架构底稿:
src/node/rust、src/render/rust;ProjectCopier 反转为
oaknode_project_deep_copy/sync_copy,traverser 改 hook 制)。
- node/render→plugin 的 OFX C++ 符号:随 M11(DeepSeek 实现中)
落地消解。
- 各模块→oakcommon 的 XmlStreamReader/FileFunctions/VideoParams
C++ 调用:随 oakcommon Rust 化消解。
- 文档化例外:Texture 的 Variant 载荷(Rust 侧不存在此问题)、
UndoCommand 跨模块继承、oakgl2/oakvulkan 后端插件接口。
- ④(隐藏 C++ API)同步搁置:模块 Rust 化后 C++ 符号自然消失。
## oakcommon Rust 测试:ffmpeg_bridge 符号依赖(2026-08-08)
`oakcommon_ffmpegutils_get_compatible_bridge_pixel_format` 在非测试构建
中会经 `find_best_pix_fmt_of_list` 引用 ffmpeg_bridge 的
`fb_find_best_pix_fmt_of_list` 符号。集成测试二进制不链接 libffmpeg_bridge,
实验证实直接调用该 FFI 导出会在链接期报 `_fb_find_best_pix_fmt_of_list`
undefined symbol(cargo test --test link_exp 复现,随后已删)。
因此该函数不能走普通集成测试。处理方式(与 oakplugin/oaktimeline 的
test-stubs 约定一致):
- `src/common/rust/Cargo.toml` 新增 `test-stubs` feature;
- `find_best_pix_fmt_of_list` 的 extern 声明/调用改为
`#[cfg(all(not(test), not(feature = "test-stubs")))]`,桩改为
`#[cfg(any(test, feature = "test-stubs"))]`——`cargo test --lib`
无需 flag 仍走桩,集成测试 `cargo test --features test-stubs` 也可链接;
- `tests/ffi_ffmpegutils.rs` 覆盖 ffmpegutils 全部 6 个 FFI 导出
(成功路径 + null out-param 的 E_INVALID 路径),需带
`--features test-stubs` 运行;不带 flag 时该文件整体为空(cfg 门控)。
## oakcommon Rust:ocioutils/oiioutils 吸收 oakoci(2026-08-08 接手笔记)
### 基线
`src/common/rust` 全量测试:`cargo test --features test-stubs` **497 个全部通过**
(314 lib + 12 contract + 8 ffi_colortransform + 61 ffi_commandlineparser +
24 ffi_config + 8 ffi_ffmpegutils + 21 ffi_misc + 12 ffi_subtitleparams +
15 ffi_videoparams + 22 ffi_xmlutils + 集成 real_ocio 等)。TODO 中旧快照
"476"已过时;后续验收以"保持 497+ 全绿"为准。
### oakoci shim crate 的拆解去向
`src/bindings/oakoci/`(untracked,纯 Rust shim,曾用于给
oakrender Rust 提供 OCIO/图像能力)内容已全部内收进
`src/common/rust`,然后整目录删除:
| oakoci 内容 | 去向 | 落点 |
|---|---|---|
| `oci.rs` OcioConfig / OcioProcessor(包 `ocio_rs::Config`/`CPUProcessor`) | 重写为 `ocioutils.rs` 的 `OcioConfig`/`OcioProcessor`,错误从消息包装 `Error` 改为 `crate::error::Error::Failed`(新增 `From<ocio_rs::OcioError>`) | `src/common/rust/src/ocioutils.rs` |
| `image.rs` F32Image + read/write_image_f32(image 0.25 tiff) | 原样并入 `oiioutils.rs`,错误统一走 `Error::Failed` | `src/common/rust/src/oiioutils.rs` |
| `error.rs` 消息包装 Error | 并入 `error.rs`(`Error::new` 便捷构造) | `src/common/rust/src/error.rs` |
| `tests/smoke.rs`(5 OCIO + 1 TIF round-trip) | 移植为 `tests/real_ocio.rs`,config 用 `env!("CARGO_MANIFEST_DIR")/../../../engine/render/ocioconf/config.ocio`,断言改为 `Error::Failed(_)` | `src/common/rust/tests/real_ocio.rs` |
### 位深判别值(已验证)
`ocio_rs::BitDepth` 是 `#[repr(i32)]`,判别值即 OCIO 码:Unknown=0,
Uint8=1, Uint10=2, Uint16=5, F16=7, F32=8。`ocioutils.rs` 直接
`depth as i32`,单测锁死 1/2/5/7/8/0,与 C++ 的
`OCIOUtils::get_ocio_bit_depth_from_pixel_format`(CPP-PARITY)一致。
### 决策:不引入 ffmpeg-next(偏差,已落定)
TODO 原计划用 `ffmpeg-next` 的 `av_d2q` 做 aspect-ratio 换算。复查发现
C++ 侧 `Rational::from_double`(`core/src/util/rational.cpp:39`)本身就是
**av_d2q 的移植**(文件内注释 "ported from FFmpeg's av_d2q"),oakcore-rs
的 `Rational::from_double` 与之对等,且现有 12 个单测已锁死行为
(NaN/超界→(0,0)、0.0→(0,1)、约分)。因此**保留手写移植,走
`oakcore_rs::Rational::from_double`**,不把 ffmpeg-sys-next(bindgen +
系统 FFmpeg)拖进叶子 crate——守住 README 的 "narrow extern C" 纪律。
`oiioutils.rs` 的实现已如此;文档残留的 "via ffmpeg-next" 说法待清。
### OCIO 环境依赖
`ocio-sys` 不自探测系统 OCIO:无配置时构建的是 stub bridge(调用全失败)。
`src/common/rust/.cargo/config.toml`(从 oakoci 抄入)固定三个变量:
`OCIO_RS_ENABLE_REAL=1`、`OCIO_INSTALL_DIR=/opt/homebrew`、
`OCIO_RS_LINK=dynamic`。机器上 OpenColorIO 2.5.2(Homebrew)、
libavutil 60.26.102、~/.cargo/registry 已缓存 ocio-rs/ocio-sys/
image/ffmpeg-next 全套。
### 待办(后续会话)
1. 全量验证:`cargo test --features test-stubs` ≥497 绿;`cargo build --release`。
2. 清文档残留:`oiioutils.rs` 模块注释与 `README.md` 依赖表/决策 6 的
"ffmpeg-next" 说法改为 oakcore-rs 手写移植。
3. `rm -rf src/bindings/oakoci`(已确认无 CMake/workspace/源码引用,仅
oakotio/oakaudioout 两个 sibling README 提到,删前先改)。
4. `cargo tarpaulin --features test-stubs` 覆盖率 ≥80%(tarpaulin 默认只
跑 lib 单测,新并入代码已带模块内单测,见 ocioutils.rs/oiioutils.rs)。
5. 不做任何 git 提交;分支 refactor/split-oakengine 上的既有暂存改动
(variant.cpp/h 移动、include/*/error.h)不触碰。
## 技术债登记(2026-08-09)
- **oaktask 渲染循环同步化**:src/task/rust/src/render.rs 目前是
"一帧一 ticket、wait 到底"的同步循环(C++ 原版是最多
hardware_concurrency 个 ticket 并发 + 完成队列 + condvar)。
可观察契约不变但导出/预缓存吞吐下降。修复方案:oakrender
ticket arena 本就支持多 ticket 在飞 + 回调,改为 N 并发 +
按序交付,并发度参数照 C++。待 render crate 落定后立即派代理
补上(测试已就绪)。
- **oaktask 导出缺口**:临时文件重命名(失败不留半成品)与
sidecar 字幕编码器未实现;precache 缺项目深拷贝。
+5 -5
View File
@@ -50,10 +50,10 @@
* (including the terminating NUL) as a non-negative value instead.
*/
#define OAKAUDIO_OK 0 /**< Success. */
#define OAKAUDIO_E_INVALID (-1) /**< NULL handle or invalid argument. */
#define OAKAUDIO_E_STATE (-2) /**< Call not valid in the current state. */
#define OAKAUDIO_E_FAILED (-3) /**< The underlying operation failed. */
#define OAKAUDIO_E_NOT_FOUND (-4) /**< Index out of range / entry not found. */
#define OAKAUDIO_E_NOMEM (-5) /**< Allocation failed. */
#define OAKAUDIO_E_INVALID (-60001) /**< NULL handle or invalid argument. */
#define OAKAUDIO_E_STATE (-60002) /**< Call not valid in the current state. */
#define OAKAUDIO_E_FAILED (-60003) /**< The underlying operation failed. */
#define OAKAUDIO_E_NOT_FOUND (-60004) /**< Index out of range / entry not found. */
#define OAKAUDIO_E_NOMEM (-60005) /**< Allocation failed. */
#endif //OAK_EDITOR_AUDIO_ERROR_H
+6 -6
View File
@@ -30,12 +30,12 @@
* (including the terminating NUL) as a non-negative value instead.
*/
#define OAKCODEC_OK 0 /**< Success. */
#define OAKCODEC_E_INVALID (-1) /**< NULL handle or invalid argument. */
#define OAKCODEC_E_STATE (-2) /**< Call not valid in the current state. */
#define OAKCODEC_E_FAILED (-3) /**< The underlying operation failed. */
#define OAKCODEC_E_NOT_FOUND (-4) /**< Index out of range / entry not found. */
#define OAKCODEC_E_NOMEM (-5) /**< Allocation failed. */
#define OAKCODEC_E_CANCELLED (-6) /**< The operation was cancelled. */
#define OAKCODEC_E_INVALID (-50001) /**< NULL handle or invalid argument. */
#define OAKCODEC_E_STATE (-50002) /**< Call not valid in the current state. */
#define OAKCODEC_E_FAILED (-50003) /**< The underlying operation failed. */
#define OAKCODEC_E_NOT_FOUND (-50004) /**< Index out of range / entry not found. */
#define OAKCODEC_E_NOMEM (-50005) /**< Allocation failed. */
#define OAKCODEC_E_CANCELLED (-50006) /**< The operation was cancelled. */
/**
* @brief Current ABI version stamped into every oakcodec handle.
+30 -5
View File
@@ -28,13 +28,38 @@
* 0 (OAKCOMMON_OK) on success, a negative OAKCOMMON_E_* error code on
* failure. String getters return the required buffer size in bytes
* (including the terminating NUL) as a non-negative value instead.
*
* Project-wide error code scheme (-MMCCCC, 2026-08):
* every module's error codes are negative integers of the form
* -(MM * 10000 + CCCC), where MM is the module number from the registry
* below and CCCC is a module-local code. The first module-local codes
* are reserved and identical across modules: 0001 INVALID, 0002 STATE,
* 0003 FAILED, 0004 NOT_FOUND, 0005 NOMEM, 0006 CANCELLED.
*
* An error code crossing a module boundary is passed through
* UNTRANSLATED — the numeric module prefix preserves provenance
* (e.g. -30004 is oaknode's NOT_FOUND no matter which module reports it
* to the caller).
*
* Module number registry (only ever appended to; numbers are frozen):
*/
#define OAK_ERROR_MODULE_COMMON 1 /**< oakcommon */
#define OAK_ERROR_MODULE_UNDO 2 /**< oakundo */
#define OAK_ERROR_MODULE_NODE 3 /**< oaknode */
#define OAK_ERROR_MODULE_TIMELINE 4 /**< oaktimeline */
#define OAK_ERROR_MODULE_CODEC 5 /**< oakcodec */
#define OAK_ERROR_MODULE_AUDIO 6 /**< oakaudio */
#define OAK_ERROR_MODULE_RENDER 7 /**< oakrender */
#define OAK_ERROR_MODULE_TASK 8 /**< oaktask */
#define OAK_ERROR_MODULE_PLUGIN 9 /**< oakplugin */
#define OAK_ERROR_MODULE_STORAGE 10 /**< oakstorage (reserved) */
#define OAKCOMMON_OK 0 /**< Success. */
#define OAKCOMMON_E_INVALID (-1) /**< Empty handle (ctx == NULL) or invalid argument. */
#define OAKCOMMON_E_STATE (-2) /**< Call not valid in the current state. */
#define OAKCOMMON_E_FAILED (-3) /**< The underlying operation failed. */
#define OAKCOMMON_E_NOT_FOUND (-4) /**< Index out of range / entry not found. */
#define OAKCOMMON_E_NOMEM (-5) /**< Allocation failed. */
#define OAKCOMMON_E_INVALID (-10001) /**< Empty handle (ctx == NULL) or invalid argument. */
#define OAKCOMMON_E_STATE (-10002) /**< Call not valid in the current state. */
#define OAKCOMMON_E_FAILED (-10003) /**< The underlying operation failed. */
#define OAKCOMMON_E_NOT_FOUND (-10004) /**< Index out of range / entry not found. */
#define OAKCOMMON_E_NOMEM (-10005) /**< Allocation failed. */
#define SUCCESS OAKCOMMON_OK /**< @deprecated Use OAKCOMMON_OK. */
+5 -5
View File
@@ -40,10 +40,10 @@
#define OAKNODE_ABI_VERSION 1
#define OAKNODE_OK 0 /**< Success. */
#define OAKNODE_E_INVALID (-1) /**< NULL handle or invalid argument. */
#define OAKNODE_E_STATE (-2) /**< Call not valid in the current state. */
#define OAKNODE_E_FAILED (-3) /**< The underlying operation failed. */
#define OAKNODE_E_NOT_FOUND (-4) /**< Index out of range / entry not found. */
#define OAKNODE_E_NOMEM (-5) /**< Allocation failed. */
#define OAKNODE_E_INVALID (-30001) /**< NULL handle or invalid argument. */
#define OAKNODE_E_STATE (-30002) /**< Call not valid in the current state. */
#define OAKNODE_E_FAILED (-30003) /**< The underlying operation failed. */
#define OAKNODE_E_NOT_FOUND (-30004) /**< Index out of range / entry not found. */
#define OAKNODE_E_NOMEM (-30005) /**< Allocation failed. */
#endif //OAK_EDITOR_NODE_ERROR_H
+6 -6
View File
@@ -27,12 +27,12 @@
* @brief Status and error codes shared by all oakplugin C API families.
*/
#define OAKPLUGIN_OK 0 /**< Success. */
#define OAKPLUGIN_E_INVALID (-1) /**< NULL handle or invalid argument. */
#define OAKPLUGIN_E_STATE (-2) /**< Call not valid in the current state. */
#define OAKPLUGIN_E_FAILED (-3) /**< The underlying operation failed. */
#define OAKPLUGIN_E_NOT_FOUND (-4) /**< Entry not found. */
#define OAKPLUGIN_E_NOMEM (-5) /**< Allocation failed. */
#define OAKPLUGIN_E_CANCELLED (-6) /**< The operation was cancelled. */
#define OAKPLUGIN_E_INVALID (-90001) /**< NULL handle or invalid argument. */
#define OAKPLUGIN_E_STATE (-90002) /**< Call not valid in the current state. */
#define OAKPLUGIN_E_FAILED (-90003) /**< The underlying operation failed. */
#define OAKPLUGIN_E_NOT_FOUND (-90004) /**< Entry not found. */
#define OAKPLUGIN_E_NOMEM (-90005) /**< Allocation failed. */
#define OAKPLUGIN_E_CANCELLED (-90006) /**< The operation was cancelled. */
/** @brief ABI version stamped into every oakplugin handle. */
#define OAKPLUGIN_ABI_VERSION 1
+5 -5
View File
@@ -40,10 +40,10 @@
#define OAKRENDER_ABI_VERSION 1
#define OAKRENDER_OK 0 /**< Success. */
#define OAKRENDER_E_INVALID (-1) /**< NULL handle or invalid argument. */
#define OAKRENDER_E_STATE (-2) /**< Call not valid in the current state. */
#define OAKRENDER_E_FAILED (-3) /**< The underlying operation failed. */
#define OAKRENDER_E_NOT_FOUND (-4) /**< Index out of range / entry not found. */
#define OAKRENDER_E_NOMEM (-5) /**< Allocation failed. */
#define OAKRENDER_E_INVALID (-70001) /**< NULL handle or invalid argument. */
#define OAKRENDER_E_STATE (-70002) /**< Call not valid in the current state. */
#define OAKRENDER_E_FAILED (-70003) /**< The underlying operation failed. */
#define OAKRENDER_E_NOT_FOUND (-70004) /**< Index out of range / entry not found. */
#define OAKRENDER_E_NOMEM (-70005) /**< Allocation failed. */
#endif //OAK_EDITOR_RENDER_ERROR_H
+6 -6
View File
@@ -32,11 +32,11 @@
#define OAKTASK_ABI_VERSION 1
#define OAKTASK_OK 0 /**< Success. */
#define OAKTASK_E_INVALID (-1) /**< NULL handle or invalid argument. */
#define OAKTASK_E_STATE (-2) /**< Call not valid in the current state. */
#define OAKTASK_E_FAILED (-3) /**< The underlying operation failed. */
#define OAKTASK_E_NOT_FOUND (-4) /**< Index out of range / entry not found. */
#define OAKTASK_E_NOMEM (-5) /**< Allocation failed. */
#define OAKTASK_E_CANCELLED (-6) /**< The operation was cancelled. */
#define OAKTASK_E_INVALID (-80001) /**< NULL handle or invalid argument. */
#define OAKTASK_E_STATE (-80002) /**< Call not valid in the current state. */
#define OAKTASK_E_FAILED (-80003) /**< The underlying operation failed. */
#define OAKTASK_E_NOT_FOUND (-80004) /**< Index out of range / entry not found. */
#define OAKTASK_E_NOMEM (-80005) /**< Allocation failed. */
#define OAKTASK_E_CANCELLED (-80006) /**< The operation was cancelled. */
#endif //OAK_EDITOR_TASK_ERROR_H
+5 -5
View File
@@ -32,10 +32,10 @@
#define OAKTIMELINE_ABI_VERSION 1
#define OAKTIMELINE_OK 0 /**< Success. */
#define OAKTIMELINE_E_INVALID (-1) /**< NULL handle or invalid argument. */
#define OAKTIMELINE_E_STATE (-2) /**< Call not valid in the current state. */
#define OAKTIMELINE_E_FAILED (-3) /**< The underlying operation failed. */
#define OAKTIMELINE_E_NOT_FOUND (-4) /**< Index out of range / entry not found. */
#define OAKTIMELINE_E_NOMEM (-5) /**< Allocation failed. */
#define OAKTIMELINE_E_INVALID (-40001) /**< NULL handle or invalid argument. */
#define OAKTIMELINE_E_STATE (-40002) /**< Call not valid in the current state. */
#define OAKTIMELINE_E_FAILED (-40003) /**< The underlying operation failed. */
#define OAKTIMELINE_E_NOT_FOUND (-40004) /**< Index out of range / entry not found. */
#define OAKTIMELINE_E_NOMEM (-40005) /**< Allocation failed. */
#endif //OAK_EDITOR_TIMELINE_ERROR_H
+5 -5
View File
@@ -30,10 +30,10 @@
* (including the terminating NUL) as a non-negative value instead.
*/
#define OAKUNDO_OK 0 /**< Success. */
#define OAKUNDO_E_INVALID (-1) /**< NULL handle or invalid argument. */
#define OAKUNDO_E_STATE (-2) /**< Call not valid in the current state. */
#define OAKUNDO_E_FAILED (-3) /**< The underlying operation failed. */
#define OAKUNDO_E_NOT_FOUND (-4) /**< Index out of range / entry not found. */
#define OAKUNDO_E_NOMEM (-5) /**< Allocation failed. */
#define OAKUNDO_E_INVALID (-20001) /**< NULL handle or invalid argument. */
#define OAKUNDO_E_STATE (-20002) /**< Call not valid in the current state. */
#define OAKUNDO_E_FAILED (-20003) /**< The underlying operation failed. */
#define OAKUNDO_E_NOT_FOUND (-20004) /**< Index out of range / entry not found. */
#define OAKUNDO_E_NOMEM (-20005) /**< Allocation failed. */
#endif //OAK_EDITOR_UNDO_ERROR_H
+90
View File
@@ -0,0 +1,90 @@
# oakaudio 类覆盖映射表(C++ audio 模块 → oakaudio Rust crate)
> 逐类盘点 `src/audio/src` 与 `src/audio/c_api`。每一行标注 Rust 侧落点。
> `// CPP-PARITY` 注释义务不变:凡承载布局/数值/边角行为的地方,标出
> C++ 文件:行号。本表是初稿;实现阶段据实核对。
## 1. AudioManager(manager.rs)
| C++ | Rust 落点 |
|---|---|
| `AudioManager::create_instance` / `destroy_instance` / `instance` | `manager::create_instance` / `destroy_instance` / `instance`(`OnceLock<Mutex<ManagerInner>>` 单例) |
| `set_output_notify_interval` / `set_output_notify_callback` | `manager::set_output_notify_interval`(notify 回调经 guard 从 PortAudio 线程调用,见 previewdevice.rs) |
| `push_to_output` / `clear_buffered_output` / `stop_output` | `manager::push_to_output` / `clear_buffered_output` / `stop_output` |
| `seconds` / `reset_output_clock` | `manager::seconds` / `reset_output_clock`(播放时钟补偿输出延迟) |
| `get/set_output_device` / `get/set_input_device` | `manager` 设备访问器(PaDeviceIndex,-1 = paNoDevice) |
| `hard_reset` | `manager::hard_reset`(关流并重初始化 PortAudio) |
| `start_recording` / `stop_recording` | `manager` 录音(经 `bridge::codec` OakEncoder,恒定 interleaved f32) |
| `find_config_device_by_name` / `find_device_by_name`(static) | `manager::find_config_device_by_name_s` / `find_device_by_name_s` |
| `get_port_audio_params` / `get_port_audio_sample_format`(私有) | `manager` 内部(映射 AudioParams ↔ PortAudio;`// CPP-PARITY` 标注格式映射) |
## 2. AudioProcessor(processor.rs)
| C++ | Rust 落点 |
|---|---|
| `open(from,to,tempo)` / `close` / `is_open` | `processor::open` / `close` / `is_open`(FBAudioGraphConfig 组装) |
| `convert` | `processor::convert`(planar f32 进/出;`fb_audio_graph_push` + `fb_audio_graph_pull`) |
| `flush` | `processor::flush`(`fb_audio_graph_push` channel_data==NULL) |
| `from()` / `to()` | `processor` 保存的 `AudioParams` |
## 3. AudioSynchronizer(synchronizer.rs,纯静态)
| C++ | Rust 落点 |
|---|---|
| `place_by_source_time` | `synchronizer::place_by_source_time` |
| `place_by_waveform_offset` | `synchronizer::place_by_waveform_offset` |
## 4. AudioLevelMeter(levelmeter.rs,纯静态)
| C++ | Rust 落点 |
|---|---|
| `analyze_sample_buffer` | `levelmeter::analyze`(peak/RMS/VU 阈值与 `-200` 地板;`// CPP-PARITY` 标注) |
| `linear_to_db` / `power_to_lufs`(私有) | `levelmeter` 内部(BS.1770 LUFS,无 K-weighting) |
## 5. AudioVisualWaveform(waveform.rs)
| C++ | Rust 落点 |
|---|---|
| 构造 / `channel_count` / `set_channel_count` / `length` | `waveform::Waveform` + 访问器 |
| `overwrite_samples` | `waveform::overwrite_samples`(mipmap 展开,`k_minimum/maximum_sample_rate`) |
| `overwrite_sums` / `overwrite_silence` | `waveform::overwrite_sums` / `overwrite_silence` |
| `trim_in` / `mid` / `resize` / `trim_range` | `waveform::trim_in` / `mid` / `resize` / `trim_range` |
| `get_summary_from_time` | `waveform::get_summary` |
| `sum_samples` / `re_sum_samples`(static) | `waveform::sum_samples` / `re_sum_samples` |
| mipmap 内部(`overwrite_samples_from_*` / `get_mipmap_for_scale` / `time_to_samples` / `validate_virtual_start`) | `waveform` 内部 |
| 全文件提取(c_api `oakaudio_waveform_extract`) | `waveform::extract`(经 `bridge::codec` decoder + `bridge::ffmpeg`) |
| `SamplePerChannel` POD | 与 `oakaudio_min_max` `static_assert` 对齐(`// CPP-PARITY: c_api/waveform.cpp`) |
## 6. AudioWaveformSync(waveformsync.rs,纯静态)
| C++ | Rust 落点 |
|---|---|
| `extract_rms_envelope` | `waveformsync::extract_rms_envelope` |
| `estimate_offset`(SampleBuffer 版) | `waveformsync::estimate_offset`(内部先提取 envelope) |
| `estimate_envelope_offset`(两个重载) | `waveformsync::estimate_envelope_offset`(valid 掩码版为权威) |
| `estimate_stretch_and_offset` | `waveformsync::estimate_stretch_and_offset`(O(rates*lags*overlap)) |
## 7. PreviewAudioDevice(previewdevice.rs,header-only)
| C++ | Rust 落点 |
|---|---|
| `read` / `write` | `previewdevice::PreviewAudioDevice::read` / `write`(回调侧 pull) |
| `set_params` / `bytes_per_frame` / `set_bytes_per_frame` | `previewdevice`(AudioParams → bytes_per_frame) |
| `set_notify_interval` / `set_notify_callback` | `previewdevice`(notify 回调,锁外触发) |
| `clear` | `previewdevice::clear` |
| `add_output_frames` / `output_frames_consumed` / `reset_output_frames` | `previewdevice`(atomic 播放时钟) |
## 8. audio_config 命名空间(config.rs,不是类)
| C++ | Rust 落点 |
|---|---|
| `output_buffer_size()` | `config::output_buffer_size`(`oakcommon_config_get_int(nullptr,"AudioOutputBufferSize",0)`) |
| `device_name(is_output_device)` | `config::device_name`(`oakcommon_config_get` 两阶段;key = "AudioOutput"/"AudioInput") |
## 9. 刻意不迁移(drop)
| C++ | 理由 |
|---|---|
| `get_port_audio_params` 的 PortAudio 平台细节 | 归 `bridge::ffmpeg`/PortAudio 侧;Rust 保留语义与默认布局兜底 |
| Qt 常量(`qFuzzyIsNull` 等)内联展开 | 语义内联为 Rust 比较;`// CPP-PARITY` 标注 |
| `draw_sample`/`draw_waveform`(QPainter) | UI 绘制归 facade/app;crate 只存/汇总数据 |
+14
View File
@@ -0,0 +1,14 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "oakaudio"
version = "0.1.0"
dependencies = [
"oakcore-rs",
]
[[package]]
name = "oakcore-rs"
version = "0.1.0"
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "oakaudio"
version = "0.1.0"
edition = "2021"
description = "Oak Video Editor audio I/O, processing, synchronization and waveform engine (Rust)"
license = "GPL-3.0-or-later"
[lib]
crate-type = ["staticlib", "rlib"]
[profile.release]
# FFI discipline: panics must be catchable at every exported entry.
panic = "unwind"
[dependencies]
oakcore-rs = { path = "../../oakcore-rs" }
+102
View File
@@ -0,0 +1,102 @@
# oakaudio Rust crate
> Status: **implemented**. The C ABI (`include/audio/*.h`) is implemented
> by `src/ffi.rs`; the contract suite lives in `tests/` (all green,
> ~88% line coverage under tarpaulin). The architecture below mirrors the
> oaknode/oakrender crate template (FFI discipline, testing layers) from
> `src/node/rust/README.md` and `src/render/rust/README.md`.
## Scope
Replaces the C++ oakaudio module (`src/audio/src`, ~50k lines): the
PortAudio output/input manager (`AudioManager`), the real-time
resampler/format converter (`AudioProcessor`), timeline synchronization
helpers (`AudioSynchronizer`, `AudioWaveformSync`), the level meter
(`AudioLevelMeter`), the visual waveform store (`AudioVisualWaveform`),
the header-only pull buffer (`PreviewAudioDevice`), and the config
bridge (`audio_config` namespace).
Public contract: `include/audio/*.h` (5 headers plus `error.h`, ~45
functions) — frozen, implemented verbatim by `src/ffi.rs`.
## Key architectural decisions (C++ → Rust mapping)
1. **Singleton manager.** `AudioManager` is a process-wide PortAudio
singleton. Rust keeps the singleton behind a `OnceLock<Mutex<...>>`
with borrow-only handles: `addref`/`release` are no-ops exactly as on
the C++ side, and an empty handle reports `OAKAUDIO_E_STATE`. No
destruction ever happens through the handle.
2. **Processor is the only heavy FFI consumer.** `AudioProcessor` wraps
the ffmpeg_bridge audio filter graph (`fb_audio_graph_*`,
`fb_frame_*`); every call funnels through `bridge::ffmpeg`. The
resampler/format-conversion semantics and the always-planar-f32
output (`OAKAUDIO_PROCESSOR_OUTPUT_FORMAT = 4`) are preserved.
3. **Sync helpers are stateless.** `AudioSynchronizer` and
`AudioWaveformSync` have only static methods in C++; they become
plain functions in `synchronizer.rs` / `waveformsync.rs`. No handles
are involved on the sync headers except by-value arguments.
4. **Value types are local.** `params.rs` defines `AudioParams` (a
plain POD) and a **planar-first** `SampleFormat` enum mirroring
`olive::core::SampleFormat::Format` exactly, because these values
cross the C ABI as `int`. See the note in `params.rs` about why the
crate does not reuse `oakcore-rs`'s `SampleFormat`.
5. **Rational reuses oakcore-rs.** `core::Rational` (used by
synchronizer and waveform) comes from `oakcore-rs`; there is no
local copy.
6. **Record path through oakcodec.** `AudioManager` records through the
oakcodec encoder C ABI (`bridge::codec`) and waveform extraction
decodes through the oakcodec decoder C ABI — exactly as the C++
does. No direct ffmpeg_bridge use in the record path.
7. **Config via oakcommon.** Device names and the output buffer size
read through `bridge::common` (`oakcommon_config_*`), preserving the
`audio_config` namespace semantics as a `config.rs` free-function
module.
## Layout
`COVERAGE.md` maps every C++ audio class/method to its Rust home.
Review that first.
```
src/
lib.rs crate doc + module map
error.rs error codes (mirrors include/audio/error.h)
handle.rs refcounted-handle scaffolding (same pattern as node)
params.rs AudioParams + planar-first SampleFormat value types
config.rs audio_config namespace (bridge::common)
manager.rs AudioManager singleton (PortAudio I/O, recording)
processor.rs AudioProcessor (resampler/converter, bridge::ffmpeg)
synchronizer.rs AudioSynchronizer placement helpers
levelmeter.rs AudioLevelMeter peak/RMS/VU/LUFS analysis
waveform.rs AudioVisualWaveform mipmapped store + extraction
waveformsync.rs AudioWaveformSync envelope offset estimation
previewdevice.rs PreviewAudioDevice pull buffer
bridge/ C ABI imports: common.rs, codec.rs, ffmpeg.rs
ffi.rs include/audio/*.h export layer
tests/ contract + golden tests (see README test section)
```
## Hard rules for the implementer
1. Every `extern "C"` body goes through `handle::guard*`; no panic
crosses FFI. The manager's borrow-only singleton is the one place
`guard_handle`/`guard_void` are used with no refcount semantics.
2. `SampleFormat` and `AudioParams` integer values MUST match the C++
enums bit-for-bit; `// CPP-PARITY:` comments mark every load-bearing
layout decision.
3. Behavior parity with C++ is proven by the C ABI test-suite
(`src/audio/tests`, unchanged) plus the golden tests in `tests/`
(waveform mipmap/channel-interleaved layout, RMS/LUFS thresholds,
sync placement).
4. Where C++ behavior is genuinely load-bearing but ugly, port the
behavior, not the aesthetics; leave a `// CPP-PARITY:` comment with
the C++ file:line.
5. `src/plugin/` is frozen and out of scope; no oakaudio code reaches
into it.
## Dependency policy
Prefer mature third-party crates (MIT/Apache-2.0/BSD, GPL-compatible)
over hand-rolling; register each addition (name + reason) here. Large
existing C++ libraries (OTIO, OCIO, OIIO, FFmpeg) are NEVER rewritten
— they are consumed through their C ABI / bridge layers.
+183
View File
@@ -0,0 +1,183 @@
// 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/>.
//! oakcodec C ABI imports (audio encoding + decoding for the manager and
//! waveform extraction).
use std::ffi::{c_char, c_int, c_void};
/// `oakcodec_encoding_params` — a `#[repr(C)]` mirror of the same-named
/// struct in include/codec/encoder.h, field-for-field (the offset of every
/// audio field is load-bearing: `audio_enabled` sits at byte 1180 — NOT
/// 1028, which is `video_enabled` — because the 20-field video block
/// precedes it; natural alignment inserts 4 pad bytes before the first
/// `int64_t`, verified against `offsetof` on the C++ header).
///
/// Only the audio fields are consumed by oakaudio; the remaining fields
/// are kept verbatim to preserve layout.
///
/// `// CPP-PARITY: include/codec/encoder.h:79` (`oakcodec_encoding_params`).
#[repr(C)]
pub struct EncodingParams {
/// Output filename (NUL-terminated).
pub filename: [c_char; 1024],
/// Output container format id.
pub format: c_int,
/// Whether video is enabled.
pub video_enabled: c_int,
/// Video codec id.
pub video_codec: c_int,
/// Video width in pixels.
pub video_width: c_int,
/// Video height in pixels.
pub video_height: c_int,
/// Frame duration numerator.
pub video_time_base_num: c_int,
/// Frame duration denominator.
pub video_time_base_den: c_int,
/// Delivery pixel format (`OakPixelFormat`).
pub video_pixel_format: c_int,
/// Interlacing mode (`OAKCODEC_INTERLACE_*`).
pub video_interlacing: c_int,
/// Pixel aspect ratio numerator.
pub video_pixel_aspect_num: c_int,
/// Pixel aspect ratio denominator.
pub video_pixel_aspect_den: c_int,
/// Video bit rate in bits per second (0 = codec default).
pub video_bit_rate: i64,
/// Minimum video bit rate.
pub video_min_bit_rate: i64,
/// Maximum video bit rate.
pub video_max_bit_rate: i64,
/// Video buffer size in bytes.
pub video_buffer_size: i64,
/// Encoder threads (0 = auto).
pub video_threads: c_int,
/// Encoded pixel format name ("yuv420p", NUL-terminated).
pub video_pix_fmt: [c_char; 64],
/// Whether video is an image sequence (1/0).
pub video_is_image_sequence: c_int,
/// Scaling method (`OAKCODEC_ENCODING_SCALING_*`).
pub video_scaling_method: c_int,
/// Whether audio is enabled.
pub audio_enabled: c_int,
/// Audio codec id.
pub audio_codec: c_int,
/// Audio sample rate in Hz.
pub audio_sample_rate: c_int,
/// Audio channel layout mask.
pub audio_channel_layout: u64,
/// Audio sample format (`oakcodec` sample format).
pub audio_sample_format: c_int,
/// Audio bit rate in bits per second.
pub audio_bit_rate: i64,
/// Whether subtitles are enabled.
pub subtitles_enabled: c_int,
/// Subtitle codec id.
pub subtitles_codec: c_int,
/// Whether subtitles are a sidecar file.
pub subtitles_are_sidecar: c_int,
/// Sidecar subtitle format (`ExportFormat::Format`).
pub subtitles_sidecar_format: c_int,
/// Output OCIO colorspace name (empty = reference space).
pub color_transform_output: [c_char; 256],
/// Export length in seconds (rational), numerator.
pub export_length_num: c_int,
/// Export length in seconds (rational), denominator.
pub export_length_den: c_int,
/// Whether a custom export range is set.
pub has_custom_range: c_int,
/// Custom range in point numerator (seconds).
pub custom_range_in_num: i64,
/// Custom range in point denominator (seconds).
pub custom_range_in_den: i64,
/// Custom range out point numerator (seconds).
pub custom_range_out_num: i64,
/// Custom range out point denominator (seconds).
pub custom_range_out_den: i64,
}
/// `oakcodec_audio_stream_info` — audio stream metadata from probing.
///
/// `// CPP-PARITY: include/codec/decoder.h` (`oakcodec_audio_stream_info`).
#[repr(C)]
pub struct AudioStreamInfo {
/// Stream index.
pub stream_index: c_int,
/// Sample rate in Hz.
pub sample_rate: c_int,
/// Channel layout mask.
pub channel_layout: u64,
/// Number of channels.
pub channel_count: c_int,
/// Duration in stream time base units.
pub duration_ts: i64,
/// Stream time base numerator.
pub time_base_num: c_int,
/// Stream time base denominator.
pub time_base_den: c_int,
}
extern "C" {
/// `oakcodec_encoder_init` — create an encoder for `params`.
pub fn oakcodec_encoder_init(params: *const EncodingParams) -> *mut c_void;
/// `oakcodec_encoder_free`.
pub fn oakcodec_encoder_free(encoder: *mut c_void);
/// `oakcodec_encoder_open`.
pub fn oakcodec_encoder_open(encoder: *mut c_void) -> c_int;
/// `oakcodec_encoder_write_audio` — feed interleaved `f32` audio.
pub fn oakcodec_encoder_write_audio(
encoder: *mut c_void,
samples: *const f32,
frame_count: c_int,
) -> c_int;
/// `oakcodec_encoder_flush`.
pub fn oakcodec_encoder_flush(encoder: *mut c_void) -> c_int;
/// `oakcodec_encoder_last_error` — copy the last error string into `buf`.
pub fn oakcodec_encoder_last_error(
encoder: *mut c_void,
buf: *mut c_char,
buf_size: c_int,
) -> c_int;
/// `oakcodec_decoder_probe` — create a probe handle for a file.
pub fn oakcodec_decoder_probe(filename: *const c_char) -> *mut c_void;
/// `oakcodec_decoder_free`.
pub fn oakcodec_decoder_free(decoder: *mut c_void);
/// `oakcodec_decoder_probe_audio_stream_count`.
pub fn oakcodec_decoder_probe_audio_stream_count(decoder: *mut c_void) -> c_int;
/// `oakcodec_decoder_probe_get_audio_stream` — copy audio stream info.
pub fn oakcodec_decoder_probe_get_audio_stream(
decoder: *mut c_void,
index: c_int,
out: *mut AudioStreamInfo,
) -> c_int;
/// `oakcodec_decoder_open` — open stream `stream_index` for decoding.
pub fn oakcodec_decoder_open(decoder: *mut c_void, filename: *const c_char, stream_index: c_int)
-> c_int;
/// `oakcodec_decoder_decode_audio` — decode/convert frames into `buf`.
pub fn oakcodec_decoder_decode_audio(
decoder: *mut c_void,
in_num: c_int,
in_den: c_int,
out_num: c_int,
out_den: c_int,
sample_rate: c_int,
channel_layout: u64,
buf: *mut f32,
buf_frames: c_int,
) -> c_int;
}
+50
View File
@@ -0,0 +1,50 @@
// 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 C ABI imports (config access + ffmpeg format conversion).
use std::ffi::{c_char, c_int};
// `oakcommon_config_get` — copy a config string value into `buf`.
extern "C" {
/// `oakcommon_config_get` — copy a config string value into `buf`.
pub fn oakcommon_config_get(
group: *const c_char,
key: *const c_char,
buf: *mut c_char,
buf_size: c_int,
) -> c_int;
}
// `oakcommon_config_get_int` — read an integer config value with a default.
extern "C" {
/// `oakcommon_config_get_int` — read an integer config value with a
/// default.
pub fn oakcommon_config_get_int(
group: *const c_char,
key: *const c_char,
default: c_int,
) -> c_int;
}
extern "C" {
/// `oakcommon_ffmpegutils_get_ffmpeg_sample_format` — map an ffmpeg
/// sample format enum to the oak core format, or the reverse.
pub fn oakcommon_ffmpegutils_get_ffmpeg_sample_format(
smp_fmt: c_int,
out: *mut c_int,
) -> c_int;
}
+216
View File
@@ -0,0 +1,216 @@
// 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/>.
//! ffmpeg_bridge C ABI imports (audio filter graph, frames, decoder). The
//! graph converts/resamples/time-stretches planar audio; used by the
//! [`crate::processor`] resampler and the [`crate::waveform`] extractor.
use std::ffi::{c_char, c_int, c_void};
/// `FBSampleFormat` — mirrors `AVSampleFormat` (values cross the C ABI as
/// `int`). `fltp` (planar f32) is the natural exchange format for oakaudio.
///
/// `// CPP-PARITY: ffmpeg_bridge/include/ffmpeg_bridge/ffmpeg_bridge.h:117`.
#[repr(i32)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SampleFormat {
/// No format.
None = -1,
/// Unsigned 8-bit, packed.
U8 = 0,
/// Signed 16-bit, packed.
S16 = 1,
/// Signed 32-bit, packed.
S32 = 2,
/// 32-bit float, packed.
Flt = 3,
/// 64-bit float, packed.
Dbl = 4,
/// Unsigned 8-bit, planar.
U8Planar = 5,
/// Signed 16-bit, planar.
S16Planar = 6,
/// Signed 32-bit, planar.
S32Planar = 7,
/// 32-bit float, planar.
Fltp = 8,
/// 64-bit float, planar.
Dblp = 9,
/// Signed 64-bit, packed.
S64 = 10,
/// Signed 64-bit, planar.
S64Planar = 11,
}
/// `FBAudioGraphConfig` — source graph input/output spec.
///
/// `// CPP-PARITY: ffmpeg_bridge/include/ffmpeg_bridge/ffmpeg_bridge.h:510`.
#[repr(C)]
#[derive(Clone, Copy, Debug)]
pub struct AudioGraphConfig {
/// Input sample rate in Hz.
pub in_sample_rate: c_int,
/// Input channel layout mask (`0` = derive from `in_channels`).
pub in_channel_layout_mask: u64,
/// Input sample format (`FBSampleFormat`; planar float in).
pub in_sample_format: c_int,
/// Input channel count.
pub in_channels: c_int,
/// Output sample rate in Hz.
pub out_sample_rate: c_int,
/// Output channel layout mask (`0` = derive from `out_channels`).
pub out_channel_layout_mask: u64,
/// Output sample format (`FBSampleFormat`).
pub out_sample_format: c_int,
/// Output channel count.
pub out_channels: c_int,
/// Whether the output is planar.
pub out_is_planar: c_int,
/// Time-stretch tempo multiplier.
pub tempo: f64,
}
/// Opaque audio filter graph.
pub type AudioGraph = c_void;
/// Opaque frame.
pub type Frame = c_void;
/// Opaque packet.
pub type Packet = c_void;
/// Opaque decoder.
pub type Decoder = c_void;
/// `FBStreamInfo` — decoded stream metadata, mirroring the same-named
/// struct in ffmpeg_bridge/include/ffmpeg_bridge/ffmpeg_bridge.h. Only the
/// audio fields are consumed by oakaudio; the video/container fields are
/// kept to preserve layout.
///
/// `// CPP-PARITY: ffmpeg_bridge/include/ffmpeg_bridge/ffmpeg_bridge.h:335`.
#[repr(C)]
#[derive(Clone, Copy, Debug)]
pub struct FBStreamInfo {
/// Stream index.
pub index: c_int,
/// Media type (`FBMediaType`).
pub codec_type: c_int,
/// Opaque FFmpeg codec id.
pub codec_id: c_int,
/// Non-zero if a decoder exists for this stream.
pub has_decoder: c_int,
/// Video width.
pub width: c_int,
/// Video height.
pub height: c_int,
/// Video pixel format (`FBPixelFormat`).
pub pixel_format: c_int,
/// Video field order (`FBFieldOrder`).
pub field_order: c_int,
/// Video color range (`FBColorRange`).
pub color_range: c_int,
/// Raw `AVColorPrimaries` value.
pub color_primaries: c_int,
/// Raw `AVColorTransferCharacteristic` value.
pub color_trc: c_int,
/// Sample rate in Hz.
pub sample_rate: c_int,
/// Sample format (`FBSampleFormat`).
pub sample_format: c_int,
/// Channel layout mask (never zero for valid audio).
pub channel_layout_mask: u64,
/// Stream start time.
pub start_time: i64,
/// Stream duration.
pub duration: i64,
/// Stream time base numerator.
pub time_base_num: c_int,
/// Stream time base denominator.
pub time_base_den: c_int,
/// Average frame rate numerator.
pub avg_frame_rate_num: c_int,
/// Average frame rate denominator.
pub avg_frame_rate_den: c_int,
}
extern "C" {
/// `fb_audio_graph_create` — build a graph from `config`.
pub fn fb_audio_graph_create(config: *const AudioGraphConfig) -> *mut AudioGraph;
/// `fb_audio_graph_free`.
pub fn fb_audio_graph_free(graph: *mut *mut AudioGraph);
/// `fb_audio_graph_push` — push planar samples; `channel_data == NULL`
/// flushes the graph.
pub fn fb_audio_graph_push(
graph: *mut AudioGraph,
channel_data: *const *const u8,
nb_samples: c_int,
) -> c_int;
/// `fb_audio_graph_pull` — pull converted samples. 1 = frame produced,
/// 0 = need more input, negative = error.
pub fn fb_audio_graph_pull(graph: *mut AudioGraph, out_frame: *mut Frame) -> c_int;
/// `fb_channel_layout_get_channels` — channel count of a mask.
pub fn fb_channel_layout_get_channels(mask: u64) -> c_int;
/// `fb_channel_layout_default` — default layout mask for `nb_channels`.
pub fn fb_channel_layout_default(nb_channels: c_int) -> u64;
/// `fb_frame_alloc`.
pub fn fb_frame_alloc() -> *mut Frame;
/// `fb_frame_free`.
pub fn fb_frame_free(frame: *mut *mut Frame);
/// `fb_frame_unref`.
pub fn fb_frame_unref(frame: *mut Frame);
/// `fb_frame_get_nb_samples`.
pub fn fb_frame_get_nb_samples(frame: *const Frame) -> c_int;
/// `fb_frame_set_nb_samples`.
pub fn fb_frame_set_nb_samples(frame: *mut Frame, nb_samples: c_int);
/// `fb_frame_get_sample_rate`.
pub fn fb_frame_get_sample_rate(frame: *const Frame) -> c_int;
/// `fb_frame_get_format`.
pub fn fb_frame_get_format(frame: *const Frame) -> c_int;
/// `fb_frame_get_channel_layout_mask`.
pub fn fb_frame_get_channel_layout_mask(frame: *const Frame) -> u64;
/// `fb_frame_get_data` — writable plane data.
pub fn fb_frame_get_data(frame: *mut Frame, plane: c_int) -> *mut u8;
/// `fb_frame_get_data_const` — read-only plane data.
pub fn fb_frame_get_data_const(frame: *const Frame, plane: c_int) -> *const u8;
/// `fb_frame_get_linesize`.
pub fn fb_frame_get_linesize(frame: *const Frame, plane: c_int) -> c_int;
/// `fb_packet_alloc`.
pub fn fb_packet_alloc() -> *mut Packet;
/// `fb_packet_free`.
pub fn fb_packet_free(packet: *mut *mut Packet);
/// `fb_packet_unref`.
pub fn fb_packet_unref(packet: *mut Packet);
/// `fb_decoder_create`.
pub fn fb_decoder_create() -> *mut Decoder;
/// `fb_decoder_free`.
pub fn fb_decoder_free(decoder: *mut *mut Decoder);
/// `fb_decoder_open` — open stream `stream_index` of `filename`.
pub fn fb_decoder_open(decoder: *mut Decoder, filename: *const c_char, stream_index: c_int)
-> c_int;
/// `fb_decoder_close`.
pub fn fb_decoder_close(decoder: *mut Decoder);
/// `fb_decoder_get_frame` — decode one frame from `packet`.
pub fn fb_decoder_get_frame(decoder: *mut Decoder, packet: *mut Packet, frame: *mut Frame) -> c_int;
/// `fb_decoder_get_packet` — read one packet.
pub fn fb_decoder_get_packet(decoder: *mut Decoder, packet: *mut Packet) -> c_int;
/// `fb_decoder_get_stream_info` — copy stream info into `out`.
pub fn fb_decoder_get_stream_info(decoder: *const Decoder, out: *mut FBStreamInfo) -> c_int;
/// `fb_decoder_get_format_start_time`.
pub fn fb_decoder_get_format_start_time(decoder: *const Decoder) -> i64;
/// `fb_decoder_get_format_duration`.
pub fn fb_decoder_get_format_duration(decoder: *const Decoder) -> i64;
}
+22
View File
@@ -0,0 +1,22 @@
// 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 imports from other oak modules (signatures mirror the public
//! headers verbatim; resolved at link time).
pub mod codec;
pub mod common;
pub mod ffmpeg;
+73
View File
@@ -0,0 +1,73 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! The `audio_config` namespace from `src/audio/src/configbridge.*`:
//! audio-specific configuration read through the oakcommon C ABI.
use std::ffi::CString;
/// PortAudio output buffer size in frames; 0 = let PortAudio choose.
///
/// `// CPP-PARITY: src/audio/src/configbridge.cpp:30`
/// (`audio_config::output_buffer_size`).
pub fn output_buffer_size() -> i32 {
unsafe {
crate::bridge::common::oakcommon_config_get_int(
std::ptr::null(),
c"AudioOutputBufferSize".as_ptr(),
0,
)
}
}
/// Name of the configured audio device for `is_output_device`
/// (key "AudioOutput" / "AudioInput"); empty when absent.
///
/// `// CPP-PARITY: src/audio/src/configbridge.cpp:36`
/// (`audio_config::device_name`): two-stage size query; `size <= 1`
/// (absent or empty string) yields the empty string.
pub fn device_name(is_output_device: bool) -> CString {
let key = if is_output_device {
c"AudioOutput"
} else {
c"AudioInput"
};
unsafe {
let size = crate::bridge::common::oakcommon_config_get(
std::ptr::null(),
key.as_ptr(),
std::ptr::null_mut(),
0,
);
if size <= 1 {
// Absent (OAKCOMMON_E_NOT_FOUND) or empty
return CString::default();
}
let mut buf = vec![0u8; size as usize];
if crate::bridge::common::oakcommon_config_get(
std::ptr::null(),
key.as_ptr(),
buf.as_mut_ptr() as *mut std::ffi::c_char,
size,
) < 0
{
return CString::default();
}
// The buffer is NUL-terminated by the callee.
CString::from_vec_with_nul(buf)
.unwrap_or_else(|e| CString::new(e.into_bytes()).unwrap_or_default())
}
}
+64
View File
@@ -0,0 +1,64 @@
// 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/audio/error.h` verbatim; project-wide
//! -MMCCCC scheme (module registry in include/common/error.h), pass-through
//! untranslated. Audio module number is 06, so codes are -60001..-60005.
//! Unlike codec/task there is no CANCELLED code.
/// Success.
pub const OAKAUDIO_OK: i32 = 0;
/// Null handle or invalid argument.
pub const OAKAUDIO_E_INVALID: i32 = -60001;
/// Call not valid in the current state.
pub const OAKAUDIO_E_STATE: i32 = -60002;
/// The underlying operation failed.
pub const OAKAUDIO_E_FAILED: i32 = -60003;
/// Index out of range / entry not found.
pub const OAKAUDIO_E_NOT_FOUND: i32 = -60004;
/// Allocation failed.
pub const OAKAUDIO_E_NOMEM: i32 = -60005;
/// Crate-internal result type; the FFI layer maps it to the codes.
pub type Result<T> = std::result::Result<T, Error>;
/// Crate-internal error.
#[derive(Debug)]
pub enum Error {
/// Null 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 => OAKAUDIO_E_INVALID,
Error::State => OAKAUDIO_E_STATE,
Error::Failed(_) => OAKAUDIO_E_FAILED,
Error::NotFound => OAKAUDIO_E_NOT_FOUND,
Error::NoMem => OAKAUDIO_E_NOMEM,
}
}
}
File diff suppressed because it is too large Load Diff
+260
View File
@@ -0,0 +1,260 @@
// 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 oaknode crate
//! (`src/node/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).
//!
//! The `AudioManager` is the one exception: it uses the same `CHandle`
//! layout but with singleton semantics (addref/release no-ops). See
//! `manager.rs` and `include/audio/manager.h`.
//!
//! `// CPP-PARITY: src/audio/c_api/refcounted.h` (RefCounted box,
//! make_handle_in_place, free_handle) and `src/audio/c_api/alive.cpp`
//! (alive ledger behind `oakaudio_debug_alive_count`).
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::sync::atomic::{AtomicI32, AtomicU32, Ordering};
use crate::error::{Error, OAKAUDIO_E_FAILED, OAKAUDIO_OK};
/// ABI version stamped into every handle.
pub const OAKAUDIO_ABI_VERSION: u32 = 1;
/// Live-object ledger behind `oakaudio_debug_alive_count`.
///
/// `// CPP-PARITY: src/audio/c_api/alive.cpp` (`g_alive`).
static ALIVE: AtomicI32 = AtomicI32::new(0);
/// Current number of live refcounted oakaudio objects.
pub fn alive_count() -> i32 {
ALIVE.load(Ordering::Relaxed)
}
/// Heap box behind a handle's `ctx`.
pub struct RefBox<T: ?Sized> {
/// Atomic reference count.
pub refs: AtomicU32,
/// Boxed value.
pub value: T,
}
/// `#[repr(C)]` mirror of the public handle structs
/// (`{ctx, addref, release, abi_version}`).
///
/// Handles cross the C ABI by value (the C caller copies the 4-field
/// struct), so the type is `Copy`/`Clone` — the oaknode crate only derives
/// `Clone`, but `Copy` matches the C semantics exactly and lets exports and
/// tests pass the same handle around freely. No `Drop`: releasing goes
/// through the explicit `free_*`/`release` path, never on scope exit.
#[repr(C)]
#[derive(Clone, Copy)]
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,
}
// Handles are passed by value across the C ABI and used from the caller's
// thread; the objects behind them synchronize their own state.
unsafe impl Send for CHandle {}
impl CHandle {
/// The empty handle.
pub fn null() -> Self {
CHandle {
ctx: std::ptr::null_mut(),
addref: None,
release: None,
abi_version: OAKAUDIO_ABI_VERSION,
}
}
/// True when the handle carries no object.
pub fn is_null(&self) -> bool {
self.ctx.is_null()
}
}
/// `// CPP-PARITY: src/audio/c_api/refcounted.h` (`ref_counted_addref`).
unsafe extern "C" fn owned_addref<T: Send + 'static>(ctx: *mut std::ffi::c_void) {
// SAFETY: `ctx` is either NULL or points to a `RefBox<T>` created by
// `make_owned`; we only touch it through the reference while it is live.
if let Some(b) = unsafe { (ctx as *const RefBox<T>).as_ref() } {
b.refs.fetch_add(1, Ordering::Relaxed);
}
}
/// `// CPP-PARITY: src/audio/c_api/refcounted.h` (`ref_counted_release`):
/// destroys the box at zero and decrements the alive ledger.
unsafe extern "C" fn owned_release<T: Send + 'static>(ctx: *mut std::ffi::c_void) {
// SAFETY: `ctx` is either NULL or points to a live `RefBox<T>` created
// by `make_owned`; the refcount guards against double-free, and the box
// is only reclaimed once the count reaches zero.
if let Some(b) = unsafe { (ctx as *const RefBox<T>).as_ref() } {
if b.refs.fetch_sub(1, Ordering::AcqRel) == 1 {
drop(unsafe { Box::from_raw(ctx as *mut RefBox<T>) });
ALIVE.fetch_sub(1, Ordering::Relaxed);
}
}
}
/// Owned handle with count 1; empty on allocation failure.
pub fn make_owned<T: Send + 'static>(value: T) -> CHandle {
let b = Box::new(RefBox {
refs: AtomicU32::new(1),
value,
});
ALIVE.fetch_add(1, Ordering::Relaxed);
CHandle {
ctx: Box::into_raw(b) as *mut std::ffi::c_void,
addref: Some(owned_addref::<T>),
release: Some(owned_release::<T>),
abi_version: OAKAUDIO_ABI_VERSION,
}
}
/// No-op addref/release for singleton (borrowed) handles.
///
/// `// CPP-PARITY: src/audio/c_api/manager.cpp` (`singleton_addref` /
/// `singleton_release`).
unsafe extern "C" fn noop_ref(_ctx: *mut std::ffi::c_void) {}
/// Borrowed handle for an object owned elsewhere (addref/release are
/// no-ops; nothing is ever freed through the handle).
///
/// # Safety
/// Caller guarantees `ptr` outlives every derived handle.
pub unsafe fn make_borrowed<T: Send + 'static>(ptr: *mut T) -> CHandle {
CHandle {
ctx: ptr as *mut std::ffi::c_void,
addref: Some(noop_ref),
release: Some(noop_ref),
abi_version: OAKAUDIO_ABI_VERSION,
}
}
/// Typed view into an owned handle; `None` for empty handles.
///
/// # Safety
/// `T` must be the boxed type.
pub unsafe fn get<T: 'static>(h: &CHandle) -> Option<&T> {
if h.ctx.is_null() {
return None;
}
// SAFETY: caller guarantees `h` is a valid owned handle whose ctx points
// to a `RefBox<T>`; the handle stays alive through the returned borrow.
let v = unsafe { &(*(h.ctx as *const RefBox<T>)).value };
Some(v)
}
/// Typed mutable view into an owned handle; `None` for empty handles.
///
/// # Safety
/// `T` must be the boxed type.
pub unsafe fn get_mut<T: 'static>(h: &CHandle) -> Option<&mut T> {
if h.ctx.is_null() {
return None;
}
// SAFETY: caller guarantees `h` is a valid owned handle whose ctx points
// to a `RefBox<T>`; the handle stays alive through the returned borrow,
// and the caller must not alias it with other live borrows.
let v = unsafe { &mut (*(h.ctx as *mut RefBox<T>)).value };
Some(v)
}
/// Shared free() body: release the ctx, no-op on NULL/empty handle.
///
/// `// CPP-PARITY: src/audio/c_api/refcounted.h` (`free_handle`).
///
/// # Safety
/// `self_` must be a valid handle pointer or NULL.
pub unsafe fn free_handle(h: *mut CHandle) {
// SAFETY: caller guarantees `h` is a valid handle pointer or NULL.
if let Some(r) = unsafe { h.as_mut() } {
if !r.ctx.is_null() {
if let Some(release) = r.release {
// SAFETY: the release fn belongs to the same DLL and
// accepts the ctx it originally created.
unsafe { release(r.ctx) };
}
r.ctx = std::ptr::null_mut();
}
}
}
/// 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(())) => OAKAUDIO_OK,
Ok(Err(e)) => e.code(),
Err(_) => OAKAUDIO_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 i32 value-returning exports (errors map
/// to the negative error code).
pub fn guard_int<F: FnOnce() -> crate::error::Result<i32>>(f: F) -> i32 {
match catch_unwind(AssertUnwindSafe(f)) {
Ok(Ok(v)) => v,
Ok(Err(e)) => e.code(),
Err(_) => OAKAUDIO_E_FAILED,
}
}
/// Panic-catching FFI wrapper for void exports.
pub fn guard_void<F: FnOnce()>(f: F) {
let _ = catch_unwind(AssertUnwindSafe(f));
}
/// Copy a human-readable error string into a C buffer (NUL-terminated,
/// truncated to fit). Returns the required size including the NUL.
///
/// `// CPP-PARITY: src/audio/c_api/manager.cpp` (`write_error`).
pub fn write_error(s: &str, buf: *mut std::ffi::c_char, buf_size: i32) {
if !buf.is_null() && buf_size > 0 {
let bytes = s.as_bytes();
let n = bytes.len().min((buf_size - 1) as usize);
unsafe {
std::ptr::copy_nonoverlapping(bytes.as_ptr(), buf as *mut u8, n);
*(buf as *mut u8).add(n) = 0;
}
}
}
/// Convenience: map a condition to an Invalid error.
pub fn invalid_if(cond: bool) -> crate::error::Result<()> {
if cond {
Err(Error::Invalid)
} else {
Ok(())
}
}
+156
View File
@@ -0,0 +1,156 @@
// 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/>.
//! Loudness analysis (`olive::AudioLevelMeter`). A static helper producing
//! per-channel peak/RMS/VU statistics plus an overall LUFS-integrated
//! summary. Pure function module in Rust; feeds the level-meter UI widget.
/// dB floor for all decibel readings.
///
/// `// CPP-PARITY: src/audio/src/audiolevelmeter.cpp:30`
/// (`k_decibel_minimum`, inlined from engine/common/decibel.h).
const DECIBEL_MINIMUM: f64 = -200.0;
/// `// CPP-PARITY: src/audio/src/audiolevelmeter.cpp:32`
/// (`decibel_from_linear`): -inf clamps to the floor.
fn decibel_from_linear(linear: f64) -> f64 {
let v = 20.0 * linear.log10();
if v.is_infinite() {
return DECIBEL_MINIMUM;
}
v
}
/// `// CPP-PARITY: src/audio/src/audiolevelmeter.cpp:99`
/// (`AudioLevelMeter::linear_to_db`).
fn linear_to_db(linear: f64) -> f64 {
if linear <= 0.0 {
return DECIBEL_MINIMUM;
}
decibel_from_linear(linear)
}
/// `// CPP-PARITY: src/audio/src/audiolevelmeter.cpp:107`
/// (`AudioLevelMeter::power_to_lufs`): BS.1770-compatible unit, no
/// K-weighting.
fn power_to_lufs(mean_square: f64) -> f64 {
if mean_square <= 0.0 {
return DECIBEL_MINIMUM;
}
-0.691 + 10.0 * mean_square.log10()
}
/// Per-channel statistics for a single analysis pass.
///
/// `// CPP-PARITY: src/audio/src/audiolevelmeter.h`
/// (`AudioLevelMeter::ChannelStats`).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ChannelStats {
/// Peak amplitude, linear scale. `0.0` when silent.
pub peak_linear: f64,
/// Peak amplitude, decibel scale. `-200.0` when silent.
pub peak_db: f64,
/// Root-mean-square level, linear scale.
pub rms_linear: f64,
/// Root-mean-square level, decibel scale. `-200.0` when silent.
pub rms_db: f64,
/// VU-meter ballistics reading, decibel scale.
pub vu_db: f64,
}
/// Aggregate statistics over all analyzed channels.
///
/// `// CPP-PARITY: src/audio/src/audiolevelmeter.h`
/// (`AudioLevelMeter::Stats`).
#[derive(Debug, Clone, PartialEq)]
pub struct Stats {
/// Per-channel statistics, indexed by channel.
pub channels: Vec<ChannelStats>,
/// Maximum peak across all channels, linear scale.
pub max_peak_linear: f64,
/// Integrated loudness (EBU R128 LUFS). `-200.0` for silence.
pub integrated_lufs: f64,
/// Whether every channel was silent below the noise gate.
pub silence: bool,
}
/// Compute per-channel and summary statistics for a planar sample buffer.
///
/// `planar` holds one f32 slice per channel; every channel slice is assumed to
/// have the same length. Mirrors `AudioLevelMeter::analyze_sample_buffer`.
///
/// `// CPP-PARITY: src/audio/src/audiolevelmeter.cpp:42`
/// (`AudioLevelMeter::analyze_sample_buffer`): VU == RMS dB (no separate
/// ballistics); the silence gate is `qFuzzyIsNull` (|x| < 1e-12) on the
/// max peak; LUFS uses the mean square over ALL channels' samples.
pub fn analyze_sample_buffer(planar: &[&[f32]]) -> Stats {
let mut stats = Stats {
channels: vec![
ChannelStats {
peak_linear: 0.0,
peak_db: DECIBEL_MINIMUM,
rms_linear: 0.0,
rms_db: DECIBEL_MINIMUM,
vu_db: DECIBEL_MINIMUM,
};
planar.len()
],
max_peak_linear: 0.0,
integrated_lufs: DECIBEL_MINIMUM,
silence: true,
};
if planar.is_empty() || planar[0].is_empty() {
return stats;
}
let sample_count = planar[0].len();
let mut total_square = 0.0f64;
let mut total_samples = 0usize;
for (channel, data) in planar.iter().enumerate() {
let mut peak = 0.0f64;
let mut square_sum = 0.0f64;
for &s in data.iter() {
let value = f64::from(s);
peak = peak.max(value.abs());
square_sum += value * value;
}
let mean_square = square_sum / sample_count as f64;
let rms = mean_square.sqrt();
let rms_db = linear_to_db(rms);
stats.channels[channel] = ChannelStats {
peak_linear: peak,
peak_db: linear_to_db(peak),
rms_linear: rms,
rms_db,
vu_db: rms_db,
};
stats.max_peak_linear = stats.max_peak_linear.max(peak);
total_square += square_sum;
total_samples += sample_count;
}
// qFuzzyIsNull(double): |x| < 1e-12
stats.silence = stats.max_peak_linear.abs() < 1e-12;
stats.integrated_lufs = power_to_lufs(total_square / total_samples as f64);
stats
}
+45
View File
@@ -0,0 +1,45 @@
// 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/>.
//! # oakaudio — the audio I/O, processing and synchronization engine (Rust)
//!
//! Reimplements the C++ oakaudio module behind its frozen C ABI
//! (`include/audio/*.h`). See README.md for the architectural mapping
//! (singleton manager, stateless sync helpers, local value types).
//!
//! ## FFI discipline
//!
//! Identical to the oaknode crate: every export goes through
//! [`handle::guard*`], handles are opaque refcounted boxes (or, for the
//! singleton `AudioManager`, borrow-only no-ops), shared state behind
//! `Mutex`.
#![deny(unsafe_op_in_unsafe_fn)]
#![warn(missing_docs)]
pub mod bridge;
pub mod config;
pub mod error;
pub mod ffi;
pub mod handle;
pub mod levelmeter;
pub mod manager;
pub mod params;
pub mod previewdevice;
pub mod processor;
pub mod synchronizer;
pub mod waveform;
pub mod waveformsync;
+362
View File
@@ -0,0 +1,362 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! The process-wide PortAudio output/input manager (`olive::AudioManager`).
//!
//! Singleton semantics: the single instance lives behind a
//! `OnceLock<Mutex<ManagerInner>>`; handles returned to C are borrowed and
//! their addref/release are no-ops (mirrors the C++ singleton and
//! `include/audio/manager.h`). An empty handle reports `OAKAUDIO_E_STATE`.
//!
//! Recording goes through the oakcodec encoder C ABI ([`crate::bridge`]);
//! device/config lookups go through oakcommon.
use std::ffi::c_void;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, MutexGuard, OnceLock};
use crate::bridge::codec::EncodingParams;
use crate::error::{Error, Result};
use crate::handle::{make_borrowed, CHandle};
use crate::params::AudioParams;
use crate::previewdevice::PreviewAudioDevice;
/// `paNoDevice` (PortAudio "no device" sentinel; also the default when no
/// device is configured).
const PA_NO_DEVICE: i32 = -1;
/// The process-wide manager state. `OnceLock` cannot be reset, so
/// [`destroy_instance`] flips `DESTROYED` to make [`instance`] hand out empty
/// handles again (the singleton box itself is retained).
static MANAGER: OnceLock<Mutex<ManagerInner>> = OnceLock::new();
static DESTROYED: AtomicBool = AtomicBool::new(false);
/// Manager state (all device/stream fields; PortAudio itself is not bridged,
/// see [`ManagerInner::default`] for the degradations).
struct ManagerInner {
/// Current output device index.
output_device: i32,
/// Current input device index.
input_device: i32,
/// Output params the buffer is configured for.
output_params: Option<AudioParams>,
/// Queued output samples feeding the (virtual) playback clock.
output_buffer: PreviewAudioDevice,
/// Whether the output "stream" is running (stand-in for
/// `Pa_IsStreamActive`).
output_started: bool,
/// Active oakcodec recording encoder (NULL when idle).
recording: Option<*mut c_void>,
}
// SAFETY: the raw encoder pointer is only touched while the manager mutex is
// held, which serializes every access; the encoder lives until `recording` is
// taken out in `stop_recording`.
unsafe impl Send for ManagerInner {}
impl Default for ManagerInner {
fn default() -> Self {
ManagerInner {
// CPP-PARITY: no device is selected until `create_instance` runs
// the config lookup (PortAudio enumeration cannot be bridged, so
// the devices stay at paNoDevice and the C layer reports E_FAILED
// on output/recording until a device is set explicitly).
output_device: PA_NO_DEVICE,
input_device: PA_NO_DEVICE,
output_params: None,
output_buffer: PreviewAudioDevice::new(),
output_started: false,
recording: None,
}
}
}
/// Lock the manager behind a handle; `OAKAUDIO_E_STATE` for empty handles.
fn with_instance(h: &CHandle) -> Result<MutexGuard<'static, ManagerInner>> {
if h.is_null() {
return Err(Error::State);
}
// SAFETY: `instance()` only creates borrowed handles whose ctx points at
// the MANAGER Mutex, which lives in a static for the whole process.
let m: &'static Mutex<ManagerInner> =
unsafe { &*(h.ctx as *const Mutex<ManagerInner>) };
Ok(m.lock().unwrap_or_else(|p| p.into_inner()))
}
/// Create the process-wide AudioManager (no-op when it exists). Returns
/// `OAKAUDIO_OK` or `OAKAUDIO_E_NOMEM`.
///
/// `// CPP-PARITY: src/audio/c_api/manager.cpp:73` (C++ allocates with `new`
/// and reports `OAKAUDIO_E_NOMEM` on exception; Rust allocation infallibly
/// panics, so the error code is never produced).
pub fn create_instance() -> Result<()> {
DESTROYED.store(false, Ordering::SeqCst);
let _ = MANAGER.get_or_init(|| Mutex::new(ManagerInner::default()));
Ok(())
}
/// Destroy the process-wide AudioManager (no-op when absent).
///
/// `// CPP-PARITY: src/audio/c_api/manager.cpp:85` — the C++ singleton is
/// deleted and re-creatable; `OnceLock` cannot be reset, so a `DESTROYED`
/// flag makes [`instance`] return an empty handle (and a later
/// [`create_instance`] resurrects the existing box).
pub fn destroy_instance() {
DESTROYED.store(true, Ordering::SeqCst);
}
/// Return a handle to the process-wide AudioManager (borrowed; empty when
/// no instance exists).
///
/// `// CPP-PARITY: src/audio/c_api/manager.cpp:90` (`wrap`; the handle is a
/// borrowed singleton whose addref/release are no-ops).
pub fn instance() -> CHandle {
if DESTROYED.load(Ordering::SeqCst) {
return CHandle::null();
}
match MANAGER.get() {
Some(m) => {
// SAFETY: `m` is the process-wide singleton; borrowed handles do
// not free it, so it outlives every handle.
unsafe { make_borrowed(m as *const _ as *mut Mutex<ManagerInner>) }
}
None => CHandle::null(),
}
}
/// Release a manager handle. No-op (singleton), safe on NULL/empty.
///
/// `// CPP-PARITY: src/audio/c_api/manager.cpp:95` — releasing never
/// destroys; just clear the caller's copy.
pub fn free(self_: *mut CHandle) {
unsafe {
if let Some(h) = self_.as_mut() {
h.ctx = std::ptr::null_mut();
}
}
}
/// Bytes between output-notify pulses (0 disables).
pub fn set_output_notify_interval(self_: &CHandle, bytes: i64) -> Result<()> {
if bytes < 0 {
return Err(Error::Invalid);
}
let mut m = with_instance(self_)?;
m.output_buffer.set_notify_interval(bytes);
Ok(())
}
/// Push a block of samples to the output device, opening/restarting the
/// stream when the params changed.
///
/// `// CPP-PARITY: src/audio/src/audiomanager.cpp:111` — the PortAudio
/// open/start path is not bridged; the buffer is configured and written
/// directly and the "stream" is marked running. `error_buf` is written by
/// the FFI layer from the returned error.
pub fn push_to_output(
self_: &CHandle,
params: AudioParams,
samples: &[u8],
_error_buf: &mut [u8],
) -> Result<()> {
let mut m = with_instance(self_)?;
if m.output_device == PA_NO_DEVICE {
return Err(Error::Failed("No output device is set".to_string()));
}
if m.output_params.as_ref() != Some(&params) {
m.output_params = Some(params);
m.output_buffer.set_params(params);
}
m.output_buffer.write(samples);
m.output_started = true;
Ok(())
}
/// Discard buffered output.
pub fn clear_buffered_output(self_: &CHandle) -> Result<()> {
let mut m = with_instance(self_)?;
m.output_buffer.clear();
Ok(())
}
/// Stop the output stream.
///
/// `// CPP-PARITY: src/audio/src/audiomanager.cpp:229` (`stop_output` aborts
/// the stream and clears the buffer).
pub fn stop_output(self_: &CHandle) -> Result<()> {
let mut m = with_instance(self_)?;
m.output_started = false;
m.output_buffer.clear();
Ok(())
}
/// Seconds of audio consumed by the output device since the last reset,
/// compensated for output latency; negative when no stream is running.
///
/// `// CPP-PARITY: src/audio/src/audiomanager.cpp:169` — PortAudio's
/// `outputLatency` is not representable without a live stream, so the buffer
/// clock is used directly (the `max(0, ...)` clamp is kept).
pub fn seconds(self_: &CHandle, out: &mut f64) -> Result<()> {
let m = with_instance(self_)?;
if !m.output_started {
*out = -1.0;
return Ok(());
}
let rate = m.output_params.map(|p| p.sample_rate).unwrap_or(0);
if rate <= 0 {
*out = -1.0;
return Ok(());
}
let secs = m.output_buffer.output_frames_consumed() as f64 / f64::from(rate);
*out = secs.max(0.0);
Ok(())
}
/// Restart the output clock at zero.
pub fn reset_output_clock(self_: &CHandle) -> Result<()> {
let m = with_instance(self_)?;
m.output_buffer.reset_output_frames();
Ok(())
}
/// Current output device index (`paNoDevice` = -1) or a negative error code.
pub fn get_output_device(self_: &CHandle) -> Result<i32> {
let m = with_instance(self_)?;
Ok(m.output_device)
}
/// Set the output device index.
///
/// `// CPP-PARITY: src/audio/src/audiomanager.cpp:238` (the device is
/// recorded and the stream closed; PortAudio's index validation and name
/// logging are not bridged).
pub fn set_output_device(self_: &CHandle, device: i32) -> Result<()> {
let mut m = with_instance(self_)?;
m.output_device = device;
m.output_started = false;
m.output_buffer.clear();
Ok(())
}
/// Current input device index or a negative error code.
pub fn get_input_device(self_: &CHandle) -> Result<i32> {
let m = with_instance(self_)?;
Ok(m.input_device)
}
/// Set the input device index.
pub fn set_input_device(self_: &CHandle, device: i32) -> Result<()> {
let mut m = with_instance(self_)?;
m.input_device = device;
Ok(())
}
/// Close the output stream and re-initialize PortAudio.
///
/// `// CPP-PARITY: src/audio/src/audiomanager.cpp:271` (PortAudio terminate/
/// initialize is not bridged; the output side is reset).
pub fn hard_reset(self_: &CHandle) -> Result<()> {
let mut m = with_instance(self_)?;
m.output_started = false;
m.output_buffer.clear();
Ok(())
}
/// Start recording the input device to a file via the oakcodec encoder C
/// ABI. The input stream is always captured as interleaved f32.
///
/// `// CPP-PARITY: src/audio/src/audiomanager.cpp:278` (encoder init/open;
/// the PortAudio input stream is not bridged). On failure the encoder's
/// last-error string is surfaced when available.
pub fn start_recording(
self_: &CHandle,
params: &EncodingParams,
_error_buf: &mut [u8],
) -> Result<()> {
let mut m = with_instance(self_)?;
if m.input_device == PA_NO_DEVICE {
return Err(Error::Failed("no input device".to_string()));
}
let enc = unsafe { crate::bridge::codec::oakcodec_encoder_init(params) };
if enc.is_null() {
return Err(Error::Failed(
"failed to open encoder for recording".to_string(),
));
}
let open_r = unsafe { crate::bridge::codec::oakcodec_encoder_open(enc) };
if open_r != 0 {
let mut buf = [0i8; 512];
let n = unsafe {
crate::bridge::codec::oakcodec_encoder_last_error(
enc,
buf.as_mut_ptr(),
buf.len() as i32,
)
};
let msg = if n > 0 {
let s = buf.split(|c| *c == 0).next().unwrap_or(&[]);
let bytes: Vec<u8> = s.iter().map(|&b| b as u8).collect();
String::from_utf8_lossy(&bytes).into_owned()
} else {
"failed to open encoder for recording".to_string()
};
unsafe { crate::bridge::codec::oakcodec_encoder_free(enc) };
return Err(Error::Failed(msg));
}
m.recording = Some(enc);
Ok(())
}
/// Stop recording.
///
/// `// CPP-PARITY: src/audio/src/audiomanager.cpp:328` (the PortAudio input
/// stream is not bridged; the encoder is flushed and freed).
pub fn stop_recording(self_: &CHandle) -> Result<()> {
let mut m = with_instance(self_)?;
if let Some(enc) = m.recording.take() {
unsafe {
crate::bridge::codec::oakcodec_encoder_flush(enc);
crate::bridge::codec::oakcodec_encoder_free(enc);
}
}
Ok(())
}
/// Device index named by the configuration ("AudioOutput"/"AudioInput"), or
/// the default when unset/unmatched. Static; `paNoDevice` when PortAudio is
/// not initialized.
///
/// `// CPP-PARITY: src/audio/src/audiomanager.cpp:404`.
pub fn find_config_device_by_name_s(is_output_device: bool) -> i32 {
let name = crate::config::device_name(is_output_device);
find_device_by_name_s(&name, is_output_device)
}
/// Device index whose name matches `name` exactly (empty matches nothing,
/// falls through to the default device). Static.
///
/// `// CPP-PARITY: src/audio/src/audiomanager.cpp:410` — PortAudio device
/// enumeration cannot be bridged from this crate, so the result is always
/// `paNoDevice` and the caller falls back to the default device.
pub fn find_device_by_name_s(name: &std::ffi::CStr, _is_output_device: bool) -> i32 {
let _ = name;
PA_NO_DEVICE
}
/// Number of live oakaudio reference-counted objects (leak check).
pub fn debug_alive_count() -> i32 {
crate::handle::alive_count()
}
+148
View File
@@ -0,0 +1,148 @@
// 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/>.
//! Audio value types: `AudioParams` and the `SampleFormat` enum
//! (re-exported from oakcore-rs; planar-first ordering, values identical
//! to `olive::core::SampleFormat::Format` — `// CPP-PARITY:
//! core/include/olive/core/render/sampleformat.h:33`).
//!
//! These integer values cross the C ABI as `int`, so they MUST match the
//! authoritative C++ enums bit-for-bit.
use oakcore_rs::Rational;
/// Audio sample format: re-exported from oakcore-rs (planar-first,
/// values identical to `olive::core::SampleFormat::Format`).
/// `// CPP-PARITY: core/include/olive/core/render/sampleformat.h:33`
pub use oakcore_rs::SampleFormat;
/// Audio stream parameters, mirroring `olive::core::AudioParams`
/// (core/include/olive/core/render/audioparams.h). A plain value type;
/// never bridged through a C ABI handle — liboakcore owns the matching
/// `oakcore_audioparams_*` wrapper and is out of scope here.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct AudioParams {
/// Sample rate in Hz.
pub sample_rate: i32,
/// ffmpeg-style channel layout mask (0 = unknown/unspecified).
pub channel_layout: u64,
/// Sample format (see [`SampleFormat`]).
pub format: SampleFormat,
}
impl AudioParams {
/// Channel count of the current layout mask (popcount).
///
/// `// CPP-PARITY: core/src/render/audioparams.cpp:150`
/// (`AudioParams::calculate_channel_count` —
/// `channel_layout_mask_channel_count`); a layout mask of 0 yields 0,
/// NOT the stereo fallback (the fallback lives in the processor's
/// `fix_channel_layout`).
pub fn channel_count(&self) -> i32 {
self.channel_layout.count_ones() as i32
}
/// Bytes per sample per channel for the format.
///
/// `// CPP-PARITY: core/src/render/audioparams.cpp`
/// (`AudioParams::bytes_per_sample_per_channel`).
pub fn bytes_per_sample_per_channel(&self) -> i64 {
self.format.bytes_per_sample() as i64
}
/// Byte count of `samples` frames across all channels.
///
/// `// CPP-PARITY: core/src/render/audioparams.cpp:93`
/// (`AudioParams::samples_to_bytes`).
pub fn samples_to_bytes(&self, samples: i64) -> i64 {
samples * self.bytes_per_sample_per_channel() * i64::from(self.channel_count())
}
}
/// Rebuild a [`SampleFormat`] from the `int` that crossed the C ABI.
///
/// `// CPP-PARITY: src/audio/c_api/manager.cpp:130` — the C++ layers cast
/// the raw `int` straight onto `SampleFormat::Format`, which is UB for
/// out-of-range values but in practice wraps to whatever the enum width
/// holds. The Rust side maps unknown values to `Invalid` (a safe
/// equivalent; no contract test pins the wrapped value).
pub fn sample_format_from_i32(value: i32) -> SampleFormat {
match value {
0 => SampleFormat::U8Planar,
1 => SampleFormat::S16Planar,
2 => SampleFormat::S32Planar,
3 => SampleFormat::S64Planar,
4 => SampleFormat::F32Planar,
5 => SampleFormat::F64Planar,
6 => SampleFormat::U8,
7 => SampleFormat::S16,
8 => SampleFormat::S32,
9 => SampleFormat::S64,
10 => SampleFormat::F32,
11 => SampleFormat::F64,
_ => SampleFormat::Invalid,
}
}
/// Seconds elapsed at `frames` frames given `sample_rate`
/// (`frames/sample_rate` as a rational).
pub fn frames_to_rational(frames: i64, sample_rate: i32) -> Rational {
Rational::new(frames, i64::from(sample_rate))
}
/// Sample index of `time` seconds at `sample_rate` (rounded to nearest,
/// half away from zero).
///
/// `// CPP-PARITY: core/src/render/audioparams.cpp:81`
/// (`AudioParams::time_to_samples` uses `std::round`, not truncation).
pub fn rational_to_samples(time: Rational, sample_rate: i32) -> i64 {
(time.to_f64() * f64::from(sample_rate)).round() as i64
}
/// Continued-fraction double → rational conversion.
///
/// `// CPP-PARITY: core/src/util/rational.cpp:39`
/// (`Rational::from_double`): NaN/out-of-range → the null sentinel (0/0);
/// tiny values retried at INT64_MAX precision.
pub fn rational_from_double(flt: f64) -> Rational {
if flt.is_nan() {
return Rational::NULL;
}
if flt.abs() > f64::from(i32::MAX) + 3.0 {
return Rational::NULL;
}
// frexp: flt = f * 2^exp with f in [0.5, 1)
let (mut exponent, _frac) = {
if flt == 0.0 {
(0i32, 0.0)
} else {
let bits = flt.abs().to_bits();
let e = (((bits >> 52) & 0x7ff) as i32) - 1022;
(e, flt)
}
};
exponent = (exponent - 1).max(0);
let den: i64 = 1i64 << (62 - exponent);
let num: i64 = (flt * den as f64 + 0.5).floor() as i64;
let mut r = Rational::new(num, den);
if r.is_null() && flt != 0.0 {
// Too small to represent above; retry with maximum precision.
r = Rational::new((flt * i64::MAX as f64) as i64, i64::MAX);
}
r
}
+195
View File
@@ -0,0 +1,195 @@
// 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/>.
//! Pull-style sample buffer (`olive::PreviewAudioDevice`). The audio backend
//! (PortAudio, in `engine::audio::AudioManager`) pulls samples through
//! [`read`](PreviewAudioDevice::read) from its stream callback; the render
//! side pushes samples through [`write`](PreviewAudioDevice::write). The
//! callback-driven pull semantics are unchanged from the former QIODevice.
use std::sync::atomic::{AtomicI64, Ordering};
use std::sync::Mutex;
use crate::params::AudioParams;
/// A thread-safe ring of raw interleaved sample bytes feeding the audio
/// output callback.
///
/// `// CPP-PARITY: src/audio/src/previewaudiodevice.h`
/// (`PreviewAudioDevice`).
pub struct PreviewAudioDevice {
lock: Mutex<PreviewAudioDeviceInner>,
/// Frames consumed by the output callback (playback clock, includes
/// underrun zero-fill).
output_frames_consumed: AtomicI64,
}
struct PreviewAudioDeviceInner {
/// Queued sample bytes.
buffer: Vec<u8>,
/// Bytes per output frame (0 = unknown until params are set).
bytes_per_frame: i32,
/// Bytes that trigger a notify callback when crossed.
notify_interval: i64,
/// Bytes read so far in the current interval.
bytes_read: i64,
/// Callback fired when a notify interval boundary is crossed.
notify_callback: Option<Box<dyn FnMut() + Send>>,
}
impl PreviewAudioDevice {
/// Create an empty device with unknown frame size.
pub fn new() -> PreviewAudioDevice {
PreviewAudioDevice {
lock: Mutex::new(PreviewAudioDeviceInner {
buffer: Vec::new(),
bytes_per_frame: 0,
notify_interval: 0,
bytes_read: 0,
notify_callback: None,
}),
output_frames_consumed: AtomicI64::new(0),
}
}
/// Read up to `data.len()` bytes from the queued buffer. Called from the
/// audio output callback; returns bytes actually copied (0 on underrun).
///
/// `// CPP-PARITY: src/audio/src/previewaudiodevice.cpp:36`
/// (`PreviewAudioDevice::read`): the notify callback fires AFTER the
/// internal lock is released, and only when an interval boundary is
/// crossed by this read.
pub fn read(&mut self, data: &mut [u8]) -> i64 {
let mut notify = false;
let copy_length;
{
let mut inner = self.lock.lock().unwrap();
copy_length = (data.len() as i64).min(inner.buffer.len() as i64);
if copy_length > 0 {
let new_bytes_read = inner.bytes_read + copy_length;
if inner.notify_interval > 0 && inner.notify_callback.is_some() {
if (inner.bytes_read / inner.notify_interval)
!= (new_bytes_read / inner.notify_interval)
{
notify = true;
}
}
inner.bytes_read = new_bytes_read;
data[..copy_length as usize]
.copy_from_slice(&inner.buffer[..copy_length as usize]);
inner.buffer.drain(..copy_length as usize);
}
}
// Fired outside the lock (see set_notify_callback())
if notify {
let mut cb = self.lock.lock().unwrap().notify_callback.take();
if let Some(c) = cb.as_mut() {
c();
}
let mut inner = self.lock.lock().unwrap();
if inner.notify_callback.is_none() {
inner.notify_callback = cb;
}
}
copy_length
}
/// Append `data` to the queued buffer.
///
/// `// CPP-PARITY: src/audio/src/previewaudiodevice.cpp:69`
/// (`PreviewAudioDevice::write`).
pub fn write(&mut self, data: &[u8]) -> i64 {
let mut inner = self.lock.lock().unwrap();
inner.buffer.extend_from_slice(data);
data.len() as i64
}
/// Derive the frame size from the audio format (bytes per sample per
/// channel * channel count).
///
/// `// CPP-PARITY: src/audio/src/previewaudiodevice.cpp:31`
/// (`PreviewAudioDevice::set_params` = `samples_to_bytes(1)`).
pub fn set_params(&mut self, params: AudioParams) {
self.set_bytes_per_frame(params.samples_to_bytes(1) as i32);
}
/// Current bytes per frame (0 = unknown).
pub fn bytes_per_frame(&self) -> i32 {
self.lock.lock().unwrap().bytes_per_frame
}
/// Override the frame size directly.
pub fn set_bytes_per_frame(&mut self, bytes: i32) {
self.lock.lock().unwrap().bytes_per_frame = bytes;
}
/// Set the notify interval in bytes.
pub fn set_notify_interval(&mut self, interval: i64) {
self.lock.lock().unwrap().notify_interval = interval;
}
/// Install the callback fired when a notify interval boundary is crossed.
///
/// Invoked from [`read`](PreviewAudioDevice::read) (the audio output
/// callback thread) after the internal lock is released. Must be
/// thread-safe and must not call back into this device.
pub fn set_notify_callback<F>(&mut self, callback: F)
where
F: FnMut() + Send + 'static,
{
self.lock.lock().unwrap().notify_callback = Some(Box::new(callback));
}
/// Drop all queued bytes and reset the byte counters.
///
/// `// CPP-PARITY: src/audio/src/previewaudiodevice.cpp:77`
/// (`PreviewAudioDevice::clear`): also resets the consumed-frames clock.
pub fn clear(&mut self) {
let mut inner = self.lock.lock().unwrap();
inner.buffer.clear();
inner.bytes_read = 0;
self.output_frames_consumed.store(0, Ordering::Relaxed);
}
/// Account for frames consumed by the output callback (including
/// underrun zero-fill).
pub fn add_output_frames(&self, frame_count: i64) {
self.output_frames_consumed
.fetch_add(frame_count, Ordering::Relaxed);
}
/// Frames consumed by the output callback.
pub fn output_frames_consumed(&self) -> i64 {
self.output_frames_consumed.load(Ordering::Relaxed)
}
/// Reset the consumed-frames counter.
pub fn reset_output_frames(&self) {
self.output_frames_consumed.store(0, Ordering::Relaxed);
}
}
impl Default for PreviewAudioDevice {
fn default() -> PreviewAudioDevice {
PreviewAudioDevice::new()
}
}
+331
View File
@@ -0,0 +1,331 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! The real-time resampler/format converter (`olive::AudioProcessor`).
//!
//! Wraps the ffmpeg_bridge audio filter graph (`fb_audio_graph_*`,
//! `fb_frame_*`) via [`crate::bridge::ffmpeg`]. The conversion output is
//! always planar 32-bit float
//! (`OAKAUDIO_PROCESSOR_OUTPUT_FORMAT == SampleFormat::F32Planar == 4`).
use std::ffi::c_int;
use std::ptr;
use std::sync::Mutex;
use crate::bridge::common::oakcommon_ffmpegutils_get_ffmpeg_sample_format;
use crate::bridge::ffmpeg::{
fb_audio_graph_create, fb_audio_graph_free, fb_audio_graph_pull,
fb_audio_graph_push, fb_channel_layout_default, fb_frame_alloc,
fb_frame_free, fb_frame_get_data, fb_frame_get_nb_samples, AudioGraph,
AudioGraphConfig, Frame,
};
use crate::error::{Error, Result};
use crate::handle::{free_handle, make_owned, CHandle};
use crate::params::{AudioParams, SampleFormat};
/// A closed audio processor (reference count 1; `ctx == NULL` on allocation
/// failure).
pub struct Processor {
inner: Mutex<ProcessorInner>,
}
/// Resampler state behind the handle's mutex.
struct ProcessorInner {
/// Live filter graph (`null` = closed).
graph: *mut AudioGraph,
/// Scratch output frame reused for every pull.
out_frame: *mut Frame,
/// Input spec recorded at `open`.
from: AudioParams,
/// Output spec recorded at `open`.
to: AudioParams,
}
// SAFETY: the raw C pointers are only dereferenced through the ffmpeg_bridge
// ABI while the mutex is held, so all access is serialized; the handle's
// refcount keeps the box alive.
unsafe impl Send for ProcessorInner {}
impl Default for ProcessorInner {
fn default() -> Self {
ProcessorInner {
graph: ptr::null_mut(),
out_frame: ptr::null_mut(),
from: AudioParams {
sample_rate: 0,
channel_layout: 0,
format: SampleFormat::Invalid,
},
to: AudioParams {
sample_rate: 0,
channel_layout: 0,
format: SampleFormat::Invalid,
},
}
}
}
/// `// CPP-PARITY: src/audio/src/audioprocessor.cpp:35` — map a native
/// sample format to the bridge format via the oakcommon C ABI (`out` is
/// initialized to `-1` = none; identity in the test stub).
fn to_bridge_sample_format(fmt: SampleFormat) -> c_int {
let mut out: c_int = -1;
unsafe {
oakcommon_ffmpegutils_get_ffmpeg_sample_format(fmt as i32, &mut out);
}
out
}
/// `// CPP-PARITY: src/audio/src/audioprocessor.cpp:50` — ensure a usable
/// channel layout mask: 0 (unknown) falls back to a default layout derived
/// from the channel count, itself defaulting to stereo.
fn fix_channel_layout(params: AudioParams) -> AudioParams {
let mut result = params;
if params.channel_layout == 0 {
let mut channels = params.channel_count();
if channels <= 0 {
channels = 2;
}
result.channel_layout = unsafe { fb_channel_layout_default(channels) };
}
result
}
/// Borrow the processor state behind a handle.
fn get_processor(self_: &CHandle) -> Result<&Processor> {
// SAFETY: every non-empty handle returned by `init` boxes a `Processor`.
unsafe { crate::handle::get::<Processor>(self_) }.ok_or(Error::Invalid)
}
/// Create a closed processor.
pub fn init() -> Result<CHandle> {
Ok(make_owned(Processor {
inner: Mutex::new(ProcessorInner::default()),
}))
}
/// Release one reference to a processor (NULL/empty no-op).
pub fn free(self_: *mut CHandle) {
unsafe { free_handle(self_) };
}
/// Open the resampling/format-conversion graph. `out_format` is accepted for
/// interface completeness but the conversion output is always planar f32.
///
/// `// CPP-PARITY: src/audio/c_api/processor.cpp:43` (validation order:
/// empty handle, already-open state, invalid rates/speed, forced output
/// format) and `src/audio/src/audioprocessor.cpp:82` (graph creation).
pub fn open(
self_: &CHandle,
from: AudioParams,
to: AudioParams,
speed: f64,
) -> Result<()> {
let p = get_processor(self_)?;
let mut inner = p.inner.lock().unwrap();
if !inner.graph.is_null() {
// C++: "tried to open a processor that was already open"
return Err(Error::State);
}
if from.sample_rate <= 0 || to.sample_rate <= 0 || speed <= 0.0 {
return Err(Error::Invalid);
}
// The C ABI delivers planar float output only; force the output format
// stage to f32p (OAKAUDIO_PROCESSOR_OUTPUT_FORMAT == 4).
if to.format != SampleFormat::F32Planar {
return Err(Error::Invalid);
}
let from_fixed = fix_channel_layout(from);
let to_fixed = fix_channel_layout(to);
let config = AudioGraphConfig {
in_sample_rate: from_fixed.sample_rate,
in_channel_layout_mask: from_fixed.channel_layout,
in_sample_format: to_bridge_sample_format(from_fixed.format),
in_channels: from_fixed.channel_count(),
out_sample_rate: to_fixed.sample_rate,
out_channel_layout_mask: to_fixed.channel_layout,
out_sample_format: to_bridge_sample_format(to_fixed.format),
out_channels: to_fixed.channel_count(),
out_is_planar: if to_fixed.format.is_planar() { 1 } else { 0 },
tempo: speed,
};
let graph = unsafe { fb_audio_graph_create(&config) };
if graph.is_null() {
// C++: "failed to create audio filter graph"
return Err(Error::Failed("failed to create audio graph".to_string()));
}
inner.graph = graph;
let out_frame = unsafe { fb_frame_alloc() };
if out_frame.is_null() {
// C++: "failed to allocate output frame"; close() unwinds the graph.
unsafe { fb_audio_graph_free(&mut inner.graph) };
return Err(Error::Failed(
"failed to allocate output frame".to_string(),
));
}
inner.out_frame = out_frame;
inner.from = from_fixed;
inner.to = to_fixed;
Ok(())
}
/// Close the graph (safe when closed; handle must be non-empty).
pub fn close(self_: &CHandle) -> Result<()> {
let p = get_processor(self_)?;
let mut inner = p.inner.lock().unwrap();
if !inner.graph.is_null() {
unsafe { fb_audio_graph_free(&mut inner.graph) };
}
if !inner.out_frame.is_null() {
unsafe { fb_frame_free(&mut inner.out_frame) };
}
Ok(())
}
/// 1 when open, 0 when closed; error for an empty handle.
pub fn is_open(self_: &CHandle) -> Result<bool> {
let p = get_processor(self_)?;
let inner = p.inner.lock().unwrap();
Ok(!inner.graph.is_null())
}
/// Push planar float input and pull converted output. Returns the number of
/// output frames written.
///
/// `// CPP-PARITY: src/audio/c_api/processor.cpp:91` (validation, state
/// check, null `out_planar` short-circuit) and
/// `src/audio/src/audioprocessor.cpp:141` (push/pull loop, byte counting).
pub fn convert(
self_: &CHandle,
in_planar: *const *const f32,
in_frame_count: i32,
out_planar: *const *mut f32,
out_capacity_frames: i32,
) -> Result<i32> {
let p = get_processor(self_)?;
let inner = p.inner.lock().unwrap();
if inner.graph.is_null() {
return Err(Error::State);
}
if in_frame_count < 0
|| out_capacity_frames < 0
|| (in_frame_count > 0 && in_planar.is_null())
{
return Err(Error::Invalid);
}
let channels = inner.to.channel_count();
if channels <= 0 {
return Err(Error::State);
}
if in_frame_count > 0 {
// The FFI layer has no way to know the input plane count, so the
// plane pointer array is walked using the input spec recorded at
// `open` (`// CPP-PARITY: src/audio/src/audioprocessor.cpp:141`).
let in_channels = inner.from.channel_count();
let mut planes: Vec<*const u8> =
Vec::with_capacity(in_channels.max(0) as usize);
for ch in 0..in_channels {
// SAFETY: `in_planar` is non-null here and the FFI contract
// guarantees at least `from.channel_count()` entries.
let p = unsafe { *in_planar.add(ch as usize) };
planes.push(p as *const u8);
}
let r =
unsafe { fb_audio_graph_push(inner.graph, planes.as_ptr(), in_frame_count) };
if r < 0 {
return Err(Error::Failed(format!(
"failed to add frame to buffersrc: {r}"
)));
}
}
// C++: `out_planar ? &buf : nullptr` — with no destination, the input is
// pushed but nothing is pulled.
if out_planar.is_null() {
return Ok(0);
}
let mut total: i64 = 0;
loop {
let r = unsafe { fb_audio_graph_pull(inner.graph, inner.out_frame) };
if r <= 0 {
if r < 0 {
return Err(Error::Failed(format!(
"failed to pull from buffersink: {r}"
)));
}
break;
}
let nb = unsafe { fb_frame_get_nb_samples(inner.out_frame) };
if nb > 0 && total < i64::from(out_capacity_frames) {
let to_copy =
(i64::from(out_capacity_frames) - total).min(i64::from(nb)) as i32;
for ch in 0..channels {
// SAFETY: the FFI contract guarantees at least `channels`
// entries in `out_planar` (NULL entries are skipped).
let dst = unsafe { *out_planar.add(ch as usize) };
if dst.is_null() {
continue;
}
// Output is planar f32 (enforced by open()); each plane is
// `to_copy` float samples.
let src = unsafe { fb_frame_get_data(inner.out_frame, ch) };
unsafe {
ptr::copy_nonoverlapping(
src as *const u8,
dst as *mut u8,
(to_copy as usize) * 4,
);
}
}
}
total += i64::from(nb);
}
Ok(total.min(i64::from(out_capacity_frames)) as i32)
}
/// Signal end-of-input to the graph (flushes internal delay).
///
/// `// CPP-PARITY: src/audio/c_api/processor.cpp:137` (empty handle, state)
/// and `src/audio/src/audioprocessor.cpp:210` (flush has no failure path; a
/// negative push return is logged only).
pub fn flush(self_: &CHandle) -> Result<()> {
let p = get_processor(self_)?;
let inner = p.inner.lock().unwrap();
if inner.graph.is_null() {
return Err(Error::State);
}
unsafe {
fb_audio_graph_push(inner.graph, ptr::null(), 0);
}
Ok(())
}
/// Format of the conversion output (always planar f32).
pub const OUTPUT_FORMAT: SampleFormat = SampleFormat::F32Planar;
+98
View File
@@ -0,0 +1,98 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Timeline placement helpers (`olive::AudioSynchronizer`). All static in
//! C++; becomes a plain function module. Times are `core::Rational` (from
//! oakcore-rs).
use oakcore_rs::Rational;
use crate::params::rational_from_double;
/// One clip's source-time metadata.
pub struct SourceClip {
/// Source start time in seconds.
pub source_start_time: Rational,
/// Media in point in seconds.
pub media_in: Rational,
/// Whether `source_start_time` is set.
pub has_source_start_time: bool,
}
/// A timeline placement result.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Placement {
/// Candidate's timeline in point in seconds.
pub timeline_in: Rational,
/// Whether placement succeeded.
pub valid: bool,
}
/// Place the candidate on the timeline so its source time aligns with the
/// reference clip.
///
/// `// CPP-PARITY: src/audio/src/audiosynchronizer.cpp:27`
/// (`AudioSynchronizer::place_by_source_time`): invalid when either clip
/// lacks a source start time or carries a NaN rational.
pub fn place_by_source_time(
reference: &SourceClip,
candidate: &SourceClip,
reference_timeline_in: Rational,
) -> Placement {
let mut placement = Placement {
timeline_in: Rational::NULL,
valid: false,
};
if !reference.has_source_start_time
|| !candidate.has_source_start_time
|| reference.source_start_time.is_nan()
|| candidate.source_start_time.is_nan()
{
return placement;
}
let reference_head_source = reference.source_start_time + reference.media_in;
let candidate_head_source = candidate.source_start_time + candidate.media_in;
placement.timeline_in =
reference_timeline_in + candidate_head_source - reference_head_source;
placement.valid = !placement.timeline_in.is_nan();
placement
}
/// Timeline placement from a measured waveform offset.
///
/// `// CPP-PARITY: src/audio/src/audiosynchronizer.cpp:50`
/// (`AudioSynchronizer::place_by_waveform_offset`): invalid for
/// `sample_rate <= 0`.
pub fn place_by_waveform_offset(
reference_timeline_in: Rational,
candidate_offset_samples: i64,
sample_rate: i32,
) -> Placement {
let mut placement = Placement {
timeline_in: Rational::NULL,
valid: false,
};
if sample_rate <= 0 {
return placement;
}
placement.timeline_in = reference_timeline_in
+ rational_from_double(candidate_offset_samples as f64 / f64::from(sample_rate));
placement.valid = !placement.timeline_in.is_nan();
placement
}
+891
View File
@@ -0,0 +1,891 @@
// 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/>.
//! Visual waveform store (`olive::AudioVisualWaveform`). Holds channel-
//! interleaved min/max pairs at multiple mipmap levels for efficient display
//! at any zoom scale. `draw_sample()`/`draw_waveform()` are app-layer
//! QPainter helpers and live in the facade; this module only stores and
//! summarizes data.
use std::collections::BTreeMap;
use std::ffi::{c_int, CStr};
use oakcore_rs::Rational;
use crate::bridge::codec::AudioStreamInfo;
use crate::bridge::ffmpeg::{
fb_audio_graph_create, fb_audio_graph_free, fb_audio_graph_pull,
fb_audio_graph_push, fb_decoder_close, fb_decoder_create, fb_decoder_free,
fb_decoder_get_frame, fb_decoder_get_stream_info, fb_decoder_open,
fb_frame_alloc, fb_frame_free, fb_frame_get_data, fb_frame_get_nb_samples,
fb_packet_alloc, fb_packet_free, AudioGraph, AudioGraphConfig, Decoder,
Frame, Packet, SampleFormat,
};
use crate::error::{Error, Result};
use crate::handle::{free_handle, make_owned, CHandle};
/// Maximum channel count accepted by [`extract`]. The C++ plane array is a
/// fixed `OAKAUDIO_EXTRACT_MAX_CHANNELS` (64) stack buffer; the Rust
/// rewrite rejects wider streams instead of overflowing.
///
/// `// CPP-PARITY: src/audio/c_api/waveform.cpp:67`.
pub const EXTRACT_MAX_CHANNELS: i32 = 64;
/// Minimum overridable sample rate. Must be a power of two.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:30`
/// (`AudioVisualWaveform::k_minimum_sample_rate`).
pub fn minimum_sample_rate() -> Rational {
Rational::new(1, 8)
}
/// Maximum overridable sample rate. Must be a power of two.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:31`
/// (`AudioVisualWaveform::k_maximum_sample_rate`).
pub fn maximum_sample_rate() -> Rational {
Rational::new(1024, 1)
}
/// One min/max pair for a single channel.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.h`
/// (`AudioVisualWaveform::SamplePerChannel`).
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct SamplePerChannel {
/// Minimum amplitude in the window.
pub min: f32,
/// Maximum amplitude in the window.
pub max: f32,
}
/// One display sample: a `SamplePerChannel` per channel, channel-interleaved.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.h`
/// (`AudioVisualWaveform::Sample`).
pub type Sample = Vec<SamplePerChannel>;
/// A visual waveform store with mipmapped min/max data.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.h`
/// (`AudioVisualWaveform`).
#[derive(Debug, Clone)]
pub struct AudioVisualWaveform {
/// Timeline time the stored data starts at (shifts on trim_in).
virtual_start: Rational,
channels: i32,
length: Rational,
// Channel-interleaved min/max samples, keyed by the mipmap sample rate.
mipmapped_data: BTreeMap<Rational, Sample>,
}
/// `floor(time * sample_rate) * channels` — every mipmap index is
/// channel-interleaved, so time conversions scale by the channel count.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:359`
/// (`AudioVisualWaveform::time_to_samples`).
fn time_to_samples(time: f64, sample_rate: f64, channels: i32) -> usize {
let v = (time * sample_rate).floor();
if v <= 0.0 {
return 0;
}
v as usize * channels.max(0) as usize
}
impl AudioVisualWaveform {
/// Create an empty waveform (channel count 0) with the full mipmap
/// chain pre-allocated.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:33`
/// (`AudioVisualWaveform::AudioVisualWaveform`): mipmaps from 1/8 to
/// 1024 points/second, doubling.
pub fn new() -> AudioVisualWaveform {
let mut w = AudioVisualWaveform {
virtual_start: Rational::NULL,
channels: 0,
length: Rational::NULL,
mipmapped_data: BTreeMap::new(),
};
let mut rate = minimum_sample_rate();
while rate <= maximum_sample_rate() {
w.mipmapped_data.insert(rate, Vec::new());
rate = rate * Rational::new(2, 1);
}
w
}
/// Channel count.
pub fn channel_count(&self) -> i32 {
self.channels
}
/// Replace the channel count.
pub fn set_channel_count(&mut self, channels: i32) {
self.channels = channels;
}
/// Length of the waveform in seconds.
pub fn length(&self) -> Rational {
self.length
}
/// Keep `virtual_start` consistent with a new write position.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:90`
/// (`AudioVisualWaveform::validate_virtual_start`): writing before the
/// current start prepends via a NEGATIVE trim_in.
fn validate_virtual_start(&mut self, new_start: Rational) {
if self.length.is_null() {
self.virtual_start = new_start;
} else if self.virtual_start > new_start {
self.trim_in(new_start - self.virtual_start);
}
}
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:40`
/// (`AudioVisualWaveform::overwrite_samples_from_buffer`).
#[allow(clippy::too_many_arguments)]
fn overwrite_samples_from_buffer(
planar: &[&[f32]],
sample_rate: i32,
start: Rational,
target_rate: f64,
channels: i32,
data: &mut Sample,
) -> (usize, usize) {
let sample_count = planar.first().map_or(0, |p| p.len());
let start_index = time_to_samples(start.to_f64(), target_rate, channels);
let samples_length = time_to_samples(
sample_count as f64 / f64::from(sample_rate),
target_rate,
channels,
);
let end_index = start_index + samples_length;
if data.len() < end_index {
data.resize(end_index, SamplePerChannel::default());
}
let chunk_size = f64::from(sample_rate) / target_rate;
let mut i = 0usize;
while i < samples_length {
let src_start =
((i as f64 * chunk_size).round() as usize) / channels as usize;
let src_end = (((i + channels as usize) as f64 * chunk_size).round() as usize
/ channels as usize)
.min(sample_count);
let summary = Self::sum_samples(planar, src_start, src_end - src_start);
data[i + start_index..i + start_index + summary.len()]
.copy_from_slice(&summary);
i += channels as usize;
}
(start_index, samples_length)
}
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:69`
/// (`AudioVisualWaveform::overwrite_samples_from_mipmap`): mipmaps are
/// powers of two so the integer chunk division is exact.
#[allow(clippy::too_many_arguments)]
fn overwrite_samples_from_mipmap(
input: &Sample,
input_sample_rate: f64,
start: Rational,
output_rate: f64,
channels: i32,
output_data: &mut Sample,
input_start: usize,
input_length: usize,
) -> (usize, usize) {
let start_index = time_to_samples(start.to_f64(), output_rate, channels);
let samples_length = time_to_samples(
(input_length / channels as usize) as f64 / input_sample_rate,
output_rate,
channels,
);
let end_index = start_index + samples_length;
if output_data.len() < end_index {
output_data.resize(end_index, SamplePerChannel::default());
}
let chunk_size = (input_sample_rate / output_rate) as usize;
let mut i = 0usize;
while i < samples_length {
let summary = Self::re_sum_samples(
&input[input_start + (i * chunk_size)..],
chunk_size * channels as usize,
channels,
);
output_data[i + start_index..i + start_index + summary.len()]
.copy_from_slice(&summary);
i += channels as usize;
}
(start_index, samples_length)
}
/// Write planar samples into the waveform, expanding as needed.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:98`
/// (`AudioVisualWaveform::overwrite_samples`): the largest mipmap is
/// filled from the raw samples, then each smaller mipmap from the one
/// before it.
pub fn overwrite_samples(&mut self, planar: &[&[f32]], sample_rate: i32, start: Rational) {
if self.channels == 0 {
// C++ logs "channel count is zero" and returns
return;
}
self.validate_virtual_start(start);
// Process the largest mipmap directly from the samples
let rates: Vec<Rational> = self.mipmapped_data.keys().copied().collect();
let channels = self.channels;
let rel_start = start - self.virtual_start;
let mut iter_input: Option<(Sample, f64, usize, usize)> = None;
for rate in rates.iter().rev() {
let out_rate = rate.to_f64();
match iter_input.take() {
None => {
let data = self.mipmapped_data.get_mut(rate).unwrap();
let (s, l) = Self::overwrite_samples_from_buffer(
planar,
sample_rate,
rel_start,
out_rate,
channels,
data,
);
iter_input = Some((data.clone(), out_rate, s, l));
}
Some((input, input_rate, input_start, input_length)) => {
let data = self.mipmapped_data.get_mut(rate).unwrap();
let (s, l) = Self::overwrite_samples_from_mipmap(
&input, input_rate, rel_start, out_rate, channels, data,
input_start, input_length,
);
iter_input = Some((data.clone(), out_rate, s, l));
}
}
}
let sample_count = planar.first().map_or(0, |p| p.len()) as i64;
let sample_length = Rational::new(sample_count, i64::from(sample_rate));
self.length = self.length.max(start + sample_length);
}
/// Copy min/max data from another waveform over a destination range.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:137`
/// (`AudioVisualWaveform::overwrite_sums`): source indexing uses the
/// SOURCE's channel count; a null `length` copies everything from
/// `offset`.
pub fn overwrite_sums(
&mut self,
sums: &AudioVisualWaveform,
dest: Rational,
offset: Rational,
length: Rational,
) {
self.validate_virtual_start(dest);
let rates: Vec<Rational> = self.mipmapped_data.keys().copied().collect();
for rate in rates {
let rate_dbl = rate.to_f64();
let their_arr = match sums.mipmapped_data.get(&rate) {
Some(a) => a,
None => continue,
};
// Get our destination sample
let our_start_index =
time_to_samples((dest - self.virtual_start).to_f64(), rate_dbl, self.channels);
// Get our source sample, indexing with the SOURCE's channel count
let their_start_index = (offset.to_f64() * rate_dbl).floor() as usize
* sums.channel_count().max(0) as usize;
if their_start_index >= their_arr.len() {
continue;
}
// Determine how much we're copying
let mut copy_len = their_arr.len() - their_start_index;
if !length.is_null() {
copy_len = copy_len.min(time_to_samples(length.to_f64(), rate_dbl, self.channels));
if copy_len == 0 {
continue;
}
}
let their_slice = their_arr[their_start_index..their_start_index + copy_len].to_vec();
let our_arr = self.mipmapped_data.get_mut(&rate).unwrap();
// Determine end index of our array
let end_index = our_start_index + copy_len;
if our_arr.len() < end_index {
our_arr.resize(end_index, SamplePerChannel::default());
}
our_arr[our_start_index..end_index].copy_from_slice(&their_slice);
}
self.length = self.length.max(
dest + if length.is_null() {
sums.length() - offset
} else {
length
},
);
}
/// Write silence over a range.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:187`
/// (`AudioVisualWaveform::overwrite_silence`).
pub fn overwrite_silence(&mut self, start: Rational, length: Rational) {
self.validate_virtual_start(start);
for (rate, our_arr) in self.mipmapped_data.iter_mut() {
let rate_dbl = rate.to_f64();
let our_start_index = time_to_samples(
(start - self.virtual_start).to_f64(),
rate_dbl,
self.channels,
);
let our_length_index = time_to_samples(length.to_f64(), rate_dbl, self.channels);
let our_end_index = our_start_index + our_length_index;
if our_arr.len() < our_end_index {
our_arr.resize(our_end_index, SamplePerChannel::default());
}
for p in &mut our_arr[our_start_index..our_start_index + our_length_index] {
*p = SamplePerChannel::default();
}
}
self.length = self.length.max(start + length);
}
/// Trim the start of the waveform.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:218`
/// (`AudioVisualWaveform::trim_in`): a NEGATIVE length prepends silence
/// and leaves `length_` unchanged (the absolute end does not move).
pub fn trim_in(&mut self, length: Rational) {
if length.is_null() {
return;
}
self.virtual_start = self.virtual_start + length;
let negative = length < Rational::NULL || length.to_f64() < 0.0;
let abs_length = if negative { Rational::NULL - length } else { length };
for (rate, data) in self.mipmapped_data.iter_mut() {
let rate_dbl = rate.to_f64();
let chop_length = time_to_samples(abs_length.to_f64(), rate_dbl, self.channels);
if chop_length == 0 {
continue;
}
if !negative {
let drop = chop_length.min(data.len());
data.drain(..drop);
} else {
let mut padded = vec![SamplePerChannel::default(); chop_length];
padded.extend_from_slice(data);
*data = padded;
}
}
if !negative {
self.length = Rational::new(0, 1).max(self.length - abs_length);
}
// Prepending grows the data before the existing start, so the absolute
// end (which length_ tracks) is unchanged
}
/// Take a sub-waveform starting at `offset`.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:252`
/// (`AudioVisualWaveform::mid`).
pub fn mid(&self, offset: Rational, length: Rational) -> AudioVisualWaveform {
let mut mid = self.clone();
mid.trim_range(offset - self.virtual_start, length);
mid
}
/// Resize to `length`, truncating or padding with silence.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:268`
/// (`AudioVisualWaveform::resize`).
pub fn resize(&mut self, length: Rational) {
if self.length == length {
return;
}
for (rate, data) in self.mipmapped_data.iter_mut() {
let rate_dbl = rate.to_f64();
let chop_length = time_to_samples(length.to_f64(), rate_dbl, self.channels);
data.resize(chop_length, SamplePerChannel::default());
}
self.length = length;
}
/// Trim to a range starting at `in` with the given `length`.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:286`
/// (`AudioVisualWaveform::trim_range`).
pub fn trim_range(&mut self, r#in: Rational, length: Rational) {
self.trim_in(r#in);
self.resize(length);
}
/// Pick the smallest mipmap whose rate covers `scale`.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:365`
/// (`AudioVisualWaveform::get_mipmap_for_scale`): falls back to the
/// largest mipmap when none is sufficient.
fn get_mipmap_for_scale(&self, scale: f64) -> (&Rational, &Sample) {
for (rate, data) in self.mipmapped_data.iter() {
if rate.to_f64() >= scale {
return (rate, data);
}
}
self.mipmapped_data.iter().next_back().unwrap()
}
/// Return summarized min/max pairs for a time range.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:292`
/// (`AudioVisualWaveform::get_summary_from_time`): a start past the end
/// of the data returns zero pairs instead of underflowing (signed
/// `available` comparison).
pub fn get_summary_from_time(&self, start: Rational, length: Rational) -> Sample {
// Find mipmap that requires
let (rate, mipmap_data) = self.get_mipmap_for_scale(length.to_f64().recip_or_zero());
let rate_dbl = rate.to_f64();
let start_sample =
time_to_samples((start - self.virtual_start).to_f64(), rate_dbl, self.channels);
let mut sample_length = time_to_samples(length.to_f64(), rate_dbl, self.channels);
// Determine if the array actually has this sample. Compare in signed
// arithmetic so a start past the end of the data doesn't underflow.
let available = mipmap_data.len() as i64 - start_sample as i64;
if available > 0 {
sample_length = sample_length.min(available as usize);
if sample_length > 0 {
return Self::re_sum_samples(
&mipmap_data[start_sample..],
sample_length,
self.channels,
);
}
}
// Return null samples
vec![
SamplePerChannel::default();
self.channel_count().max(0) as usize
]
}
/// Reduce planar samples into min/max pairs.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:329`
/// (`AudioVisualWaveform::sum_samples`; scalar fallback of
/// `expand_min_max_channel`, the SIMD path is numerically identical).
pub fn sum_samples(planar: &[&[f32]], start_index: usize, length: usize) -> Sample {
let mut summed = Vec::with_capacity(planar.len());
for data in planar {
let end = (start_index + length).min(data.len());
let mut min_val: f32;
let mut max_val: f32;
if start_index < end {
min_val = data[start_index];
max_val = data[start_index];
for &s in &data[start_index + 1..end] {
if s < min_val {
min_val = s;
}
if s > max_val {
max_val = s;
}
}
} else {
min_val = 0.0;
max_val = 0.0;
}
summed.push(SamplePerChannel {
min: min_val,
max: max_val,
});
}
summed
}
/// Merge already-summed min/max pairs across channels.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:353`
/// (`AudioVisualWaveform::re_sum_samples`): initialized from the FIRST
/// point rather than {0,0} — the engine version clamped all-positive
/// (resp. all-negative) ranges to zero; fixed in oakaudio (see the
/// comment in the C++ source).
pub fn re_sum_samples(samples: &[SamplePerChannel], nb_samples: usize, nb_channels: i32) -> Sample {
let channel_count = nb_channels.max(0) as usize;
let mut summed = vec![SamplePerChannel::default(); channel_count];
let nb_samples = nb_samples.min(samples.len());
// Initialize from the first point instead of {0,0}
if nb_samples >= channel_count {
summed[..channel_count].copy_from_slice(&samples[..channel_count]);
}
let mut i = 0usize;
while i < nb_samples {
for j in 0..channel_count {
if i + j >= samples.len() {
break;
}
let sample = samples[i + j];
if sample.min < summed[j].min {
summed[j].min = sample.min;
}
if sample.max > summed[j].max {
summed[j].max = sample.max;
}
}
i += nb_channels.max(1) as usize;
}
summed
}
}
impl Default for AudioVisualWaveform {
fn default() -> AudioVisualWaveform {
AudioVisualWaveform::new()
}
}
/// f64 reciprocal that yields 0 for a zero denominator (0/0 or 0-length
/// rationals): C++ `length.flipped().to_double()` on a null rational is
/// NaN; NaN never satisfies `rate >= scale` so the largest mipmap is
/// picked. We mirror that by returning NaN for the null case.
trait RecipOrZero {
fn recip_or_zero(self) -> f64;
}
impl RecipOrZero for f64 {
fn recip_or_zero(self) -> f64 {
if self == 0.0 || self.is_nan() {
f64::NAN
} else {
1.0 / self
}
}
}
// ---- Handle plumbing (mirrors processor.rs) --------------------------------
/// Create an empty waveform behind a refcounted handle (count 1).
pub fn init() -> Result<CHandle> {
Ok(make_owned(AudioVisualWaveform::new()))
}
/// Release one reference to a waveform (NULL/empty no-op).
pub fn free(self_: *mut CHandle) {
unsafe { free_handle(self_) };
}
/// Borrow the waveform behind a handle; `OAKAUDIO_E_INVALID` for empty.
pub fn get(self_: &CHandle) -> Result<&AudioVisualWaveform> {
// SAFETY: every non-empty handle returned by `init` boxes an
// `AudioVisualWaveform`.
unsafe { crate::handle::get::<AudioVisualWaveform>(self_) }.ok_or(Error::Invalid)
}
/// Mutable variant of [`get`].
pub fn get_mut(self_: &CHandle) -> Result<&mut AudioVisualWaveform> {
// SAFETY: every non-empty handle returned by `init` boxes an
// `AudioVisualWaveform`.
unsafe { crate::handle::get_mut::<AudioVisualWaveform>(self_) }.ok_or(Error::Invalid)
}
// ---- Whole-file extraction --------------------------------------------------
/// Result of [`extract`]: channel-interleaved min/max pairs.
pub struct ExtractOutcome {
/// `points * channels` channel-interleaved min/max pairs.
pub points: Vec<SamplePerChannel>,
/// Channel count of the decoded stream.
pub channels: i32,
}
/// Append a pulled frame's per-channel planar f32 samples to `pending`.
fn append_pending(pending: &mut Vec<Vec<f32>>, frame: *mut Frame, channels: i32, nb: i32) {
// SAFETY: `frame` is a live graph-output frame (`fltp`), `channels` was
// validated against the stream info and `nb` comes from the same frame.
if pending.is_empty() {
pending.resize(channels.max(0) as usize, Vec::new());
}
for ch in 0..channels {
let data = unsafe { fb_frame_get_data(frame, ch) } as *const f32;
let slice = unsafe { std::slice::from_raw_parts(data, nb as usize) };
pending[ch as usize].extend_from_slice(slice);
}
}
/// Emit one channel-interleaved point per `samples_per_point` pending
/// samples; with `flush`, a trailing partial point is emitted too.
///
/// `// CPP-PARITY: src/audio/c_api/waveform.cpp:88` (`emit_points`).
fn emit_points(
pending: &mut Vec<Vec<f32>>,
channels: i32,
samples_per_point: i32,
points: &mut Vec<SamplePerChannel>,
flush: bool,
) {
if pending.is_empty() {
return;
}
loop {
let available = pending[0].len();
if available == 0 || (!flush && available < samples_per_point as usize) {
return;
}
let n = available.min(samples_per_point as usize);
let point = points.len() / channels as usize;
points.resize(points.len() + channels as usize, SamplePerChannel::default());
for ch in 0..channels {
let plane = &mut pending[ch as usize];
let mut mn = plane[0];
let mut mx = mn;
for &v in &plane[1..n] {
mn = mn.min(v);
mx = mx.max(v);
}
points[point * channels as usize + ch as usize] = SamplePerChannel { min: mn, max: mx };
plane.drain(..n);
}
}
}
/// Decode a whole audio stream to a channel-interleaved min/max summary.
///
/// The stream is probed through the oakcodec decoder C ABI and decoded via
/// ffmpeg_bridge (`fb_decoder` + `fb_audio_graph`), then reduced to one
/// point per `samples_per_point` source samples.
///
/// `// CPP-PARITY: src/audio/c_api/waveform.cpp:404`
/// (`oakaudio_waveform_extract`).
pub fn extract(filename: &CStr, stream_index: i32, samples_per_point: i32) -> Result<ExtractOutcome> {
// Probe for the stream's native rate/layout (stateless).
// SAFETY: `filename` is a NUL-terminated C string (validated by the FFI
// layer); the probe handle is freed on every path below.
let probe = unsafe { crate::bridge::codec::oakcodec_decoder_probe(filename.as_ptr()) };
if probe.is_null() {
return Err(Error::NotFound);
}
let mut info = unsafe { std::mem::zeroed::<AudioStreamInfo>() };
let r = unsafe {
crate::bridge::codec::oakcodec_decoder_probe_get_audio_stream(
probe,
stream_index,
&mut info,
)
};
// SAFETY: `probe` was created above and is no longer used.
unsafe { crate::bridge::codec::oakcodec_decoder_free(probe) };
if r != 0 {
return Err(Error::NotFound);
}
if info.sample_rate <= 0 || info.channel_count <= 0 {
return Err(Error::Failed("invalid audio stream".to_string()));
}
let channels = info.channel_count;
if channels > EXTRACT_MAX_CHANNELS {
return Err(Error::Failed(format!(
"stream has {channels} channels (max {EXTRACT_MAX_CHANNELS})"
)));
}
// Decode the whole stream through ffmpeg_bridge.
let decoder = unsafe { fb_decoder_create() };
if decoder.is_null() {
return Err(Error::NoMem);
}
// SAFETY: `decoder` is live until `fb_decoder_free` below; every early
// return releases it first.
let open_r = unsafe { fb_decoder_open(decoder, filename.as_ptr(), info.stream_index) };
if open_r < 0 {
// SAFETY: `decoder` is a live ffmpeg_bridge decoder.
unsafe { fb_decoder_free(&mut (decoder as *mut Decoder)) };
return Err(Error::Failed(format!("failed to open decoder: {open_r}")));
}
let mut sinfo = unsafe { std::mem::zeroed::<crate::bridge::ffmpeg::FBStreamInfo>() };
if unsafe { fb_decoder_get_stream_info(decoder, &mut sinfo) } < 0 || sinfo.sample_rate <= 0 {
// SAFETY: see above.
unsafe {
fb_decoder_close(decoder);
fb_decoder_free(&mut (decoder as *mut Decoder));
}
return Err(Error::Failed(
"failed to query decoder stream info".to_string(),
));
}
let config = AudioGraphConfig {
in_sample_rate: sinfo.sample_rate,
in_channel_layout_mask: sinfo.channel_layout_mask,
in_sample_format: sinfo.sample_format,
in_channels: channels,
out_sample_rate: sinfo.sample_rate,
out_channel_layout_mask: sinfo.channel_layout_mask,
out_sample_format: SampleFormat::Fltp as c_int,
out_channels: channels,
out_is_planar: 1,
tempo: 1.0,
};
let mut packet = unsafe { fb_packet_alloc() };
let mut frame = unsafe { fb_frame_alloc() };
let mut converted = unsafe { fb_frame_alloc() };
let graph = unsafe { fb_audio_graph_create(&config) };
if packet.is_null() || frame.is_null() || converted.is_null() {
cleanup_extract(graph, &mut converted, &mut frame, &mut packet, decoder);
return Err(Error::NoMem);
}
if graph.is_null() {
cleanup_extract(graph, &mut converted, &mut frame, &mut packet, decoder);
return Err(Error::Failed(
"failed to create audio filter graph".to_string(),
));
}
let mut pending: Vec<Vec<f32>> = Vec::new();
let mut points: Vec<SamplePerChannel> = Vec::new();
// SAFETY: all handles are live; `frame` holds the decoded frame and
// `converted` the graph output.
let mut result: Result<()> = Ok(());
'decode: loop {
let r = unsafe { fb_decoder_get_frame(decoder, packet, frame) };
if r < 0 {
break; // EOF or error: stop decoding (C++ breaks on < 0)
}
// Push the decoded frame (planar pointer array; a packed source is
// read from plane 0 by the buffersrc).
let nb = unsafe { fb_frame_get_nb_samples(frame) };
let mut planes: Vec<*const u8> = Vec::with_capacity(channels as usize);
for ch in 0..channels {
// SAFETY: `frame` carries at least `channels` planes for the
// decoded format (validated stream info).
planes.push(unsafe { fb_frame_get_data(frame, ch) });
}
if unsafe { fb_audio_graph_push(graph, planes.as_ptr(), nb) } < 0 {
result = Err(Error::Failed("failed to push decoded frame".to_string()));
break 'decode;
}
// Drain the graph: pull converted output until no more is available.
loop {
let pull = unsafe { fb_audio_graph_pull(graph, converted) };
if pull < 0 {
result = Err(Error::Failed("failed to pull from graph".to_string()));
break 'decode;
}
if pull == 0 {
break;
}
let nb = unsafe { fb_frame_get_nb_samples(converted) };
append_pending(&mut pending, converted, channels, nb);
emit_points(&mut pending, channels, samples_per_point, &mut points, false);
}
}
// Flush the resampler delay (identity in the extract path, so this only
// emits the trailing partial window).
if result.is_ok() {
// SAFETY: `graph` is live; NULL channel data signals EOF.
unsafe { fb_audio_graph_push(graph, std::ptr::null(), 0) };
loop {
let pull = unsafe { fb_audio_graph_pull(graph, converted) };
if pull <= 0 {
break;
}
let nb = unsafe { fb_frame_get_nb_samples(converted) };
append_pending(&mut pending, converted, channels, nb);
}
emit_points(&mut pending, channels, samples_per_point, &mut points, true);
}
cleanup_extract(graph, &mut converted, &mut frame, &mut packet, decoder);
result?;
Ok(ExtractOutcome { points, channels })
}
/// Free every resource allocated by [`extract`] after the graph/decoder
/// creation succeeded.
fn cleanup_extract(
graph: *mut AudioGraph,
converted: &mut *mut Frame,
frame: &mut *mut Frame,
packet: &mut *mut Packet,
decoder: *mut Decoder,
) {
// SAFETY: the pointers were produced by the corresponding ffmpeg_bridge
// allocators and are freed exactly once here.
unsafe {
if !graph.is_null() {
fb_audio_graph_free(&mut (graph as *mut AudioGraph));
}
if !converted.is_null() {
fb_frame_free(converted);
}
if !frame.is_null() {
fb_frame_free(frame);
}
if !packet.is_null() {
fb_packet_free(packet);
}
fb_decoder_close(decoder);
fb_decoder_free(&mut (decoder as *mut Decoder));
}
}
+338
View File
@@ -0,0 +1,338 @@
// 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/>.
//! Waveform-based audio synchronization (`olive::AudioWaveformSync`). Pure
//! static helpers that correlate RMS envelopes to estimate sample offsets and
//! playback-rate corrections. No shared state.
/// A candidate offset and its correlation confidence.
///
/// `// CPP-PARITY: src/audio/src/audiowaveformsync.h`
/// (`AudioWaveformSync::OffsetResult`).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct OffsetResult {
/// Offset of the candidate relative to the reference, in samples.
pub offset_samples: i64,
/// Normalized correlation confidence in `[0, 1]`.
pub confidence: f64,
/// Whether an offset could be determined.
pub valid: bool,
}
/// A playback-rate change plus offset aligning candidate to reference.
///
/// `// CPP-PARITY: src/audio/src/audiowaveformsync.h`
/// (`AudioWaveformSync::StretchOffsetResult`).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct StretchOffsetResult {
/// Rate the candidate must play at to align (`2.0` = candidate runs at
/// half speed and must be sped up 2x).
pub rate: f64,
/// Offset in samples.
pub offset_samples: i64,
/// Normalized correlation confidence in `[0, 1]`.
pub confidence: f64,
/// Whether a rate+offset could be determined.
pub valid: bool,
}
/// Extract a windowed RMS envelope from a planar sample buffer.
///
/// `// CPP-PARITY: src/audio/src/audiowaveformsync.cpp:28`
/// (`AudioWaveformSync::extract_rms_envelope`): the trailing partial window
/// is kept; the mean is over ALL channels' samples in the window.
pub fn extract_rms_envelope(planar: &[&[f32]], window_samples: usize) -> Vec<f64> {
let mut envelope = Vec::new();
let channel_count = planar.len();
let sample_count = if channel_count > 0 { planar[0].len() } else { 0 };
if channel_count == 0 || sample_count == 0 || window_samples == 0 {
return envelope;
}
let window_count = sample_count.div_ceil(window_samples);
envelope.resize(window_count, 0.0);
for window in 0..window_count {
let start = window * window_samples;
let end = (start + window_samples).min(sample_count);
let mut square_sum = 0.0f64;
let mut total = 0usize;
for data in planar.iter() {
for &s in &data[start..end] {
let value = f64::from(s);
square_sum += value * value;
total += 1;
}
}
envelope[window] = if total > 0 {
(square_sum / total as f64).sqrt()
} else {
0.0
};
}
envelope
}
/// Estimate a plain sample offset between two planar buffers.
///
/// `// CPP-PARITY: src/audio/src/audiowaveformsync.cpp:65`
/// (`AudioWaveformSync::estimate_offset`).
pub fn estimate_offset(
reference: &[&[f32]],
candidate: &[&[f32]],
window_samples: usize,
max_offset_samples: i64,
) -> OffsetResult {
if window_samples == 0 {
return OffsetResult {
offset_samples: 0,
confidence: 0.0,
valid: false,
};
}
let reference_envelope = extract_rms_envelope(reference, window_samples);
let candidate_envelope = extract_rms_envelope(candidate, window_samples);
let max_offset_windows = max_offset_samples / window_samples as i64;
estimate_envelope_offset(
&reference_envelope,
&candidate_envelope,
window_samples,
max_offset_windows,
)
}
/// Estimate an offset from RMS envelopes, treating both as fully valid.
///
/// `// CPP-PARITY: src/audio/src/audiowaveformsync.cpp:84`
/// (`AudioWaveformSync::estimate_envelope_offset`, unmasked overload).
pub fn estimate_envelope_offset(
reference: &[f64],
candidate: &[f64],
window_samples: usize,
max_offset_windows: i64,
) -> OffsetResult {
estimate_envelope_offset_valid(
reference,
candidate,
&[],
&[],
window_samples,
max_offset_windows,
)
}
/// Estimate an offset from RMS envelopes, excluding windows flagged invalid.
///
/// Empty masks are treated as "all windows valid". This is the variant the
/// frozen C ABI exposes.
///
/// `// CPP-PARITY: src/audio/src/audiowaveformsync.cpp:95`
/// (`AudioWaveformSync::estimate_envelope_offset`, masked overload): a mask
/// whose size does NOT match its envelope is ignored entirely
/// (`mask.size() != size || mask.at(index)` — load-bearing); lags with
/// fewer than 2 valid overlap windows are skipped; windows whose
/// correlation energy is zero (`qFuzzyIsNull`, < 1e-12) are skipped;
/// confidence is `max(0, best_score)`.
pub fn estimate_envelope_offset_valid(
reference: &[f64],
candidate: &[f64],
reference_valid: &[bool],
candidate_valid: &[bool],
window_samples: usize,
max_offset_windows: i64,
) -> OffsetResult {
let mut result = OffsetResult {
offset_samples: 0,
confidence: 0.0,
valid: false,
};
if reference.is_empty() || candidate.is_empty() || window_samples == 0 {
return result;
}
let is_valid = |mask: &[bool], size: usize, index: usize| -> bool {
mask.len() != size || mask[index]
};
let mut best_score = -2.0f64;
let mut best_lag = 0i64;
let reference_size = reference.len() as i64;
let candidate_size = candidate.len() as i64;
for lag in -max_offset_windows..=max_offset_windows {
let reference_start = 0i64.max(-lag);
let candidate_start = 0i64.max(lag);
let overlap = (reference_size - reference_start).min(candidate_size - candidate_start);
if overlap < 2 {
continue;
}
// Only windows marked valid on both sides participate in the score
let mut reference_mean = 0.0f64;
let mut candidate_mean = 0.0f64;
let mut valid_count = 0i64;
for i in 0..overlap {
let reference_index = (reference_start + i) as usize;
let candidate_index = (candidate_start + i) as usize;
if !is_valid(reference_valid, reference.len(), reference_index)
|| !is_valid(candidate_valid, candidate.len(), candidate_index)
{
continue;
}
reference_mean += reference[reference_index];
candidate_mean += candidate[candidate_index];
valid_count += 1;
}
if valid_count < 2 {
continue;
}
reference_mean /= valid_count as f64;
candidate_mean /= valid_count as f64;
let mut numerator = 0.0f64;
let mut reference_energy = 0.0f64;
let mut candidate_energy = 0.0f64;
for i in 0..overlap {
let reference_index = (reference_start + i) as usize;
let candidate_index = (candidate_start + i) as usize;
if !is_valid(reference_valid, reference.len(), reference_index)
|| !is_valid(candidate_valid, candidate.len(), candidate_index)
{
continue;
}
let reference_value = reference[reference_index] - reference_mean;
let candidate_value = candidate[candidate_index] - candidate_mean;
numerator += reference_value * candidate_value;
reference_energy += reference_value * reference_value;
candidate_energy += candidate_value * candidate_value;
}
// qFuzzyIsNull(double): |x| < 1e-12
if reference_energy.abs() < 1e-12 || candidate_energy.abs() < 1e-12 {
continue;
}
let score = numerator / (reference_energy * candidate_energy).sqrt();
if score > best_score {
best_score = score;
best_lag = lag;
}
}
if best_score > -2.0 {
result.valid = true;
result.confidence = best_score.max(0.0);
result.offset_samples = best_lag * window_samples as i64;
}
result
}
/// Estimate a playback-rate change plus offset aligning the candidate to the
/// reference, resampling the candidate at each rate in `[min_rate, max_rate]`.
///
/// `// CPP-PARITY: src/audio/src/audiowaveformsync.cpp:202`
/// (`AudioWaveformSync::estimate_stretch_and_offset`): the rate loop upper
/// bound is `max_rate + rate_step * 0.5` (a half-step tolerance, so
/// floating-point step accumulation still reaches max_rate); a resampled
/// window is valid only when BOTH source windows are valid; a wrong-sized
/// candidate mask means all-valid.
pub fn estimate_stretch_and_offset(
reference: &[f64],
candidate: &[f64],
reference_valid: &[bool],
candidate_valid: &[bool],
window_samples: usize,
max_offset_windows: i64,
min_rate: f64,
max_rate: f64,
rate_step: f64,
) -> StretchOffsetResult {
let mut result = StretchOffsetResult {
rate: 1.0,
offset_samples: 0,
confidence: 0.0,
valid: false,
};
if reference.is_empty()
|| candidate.is_empty()
|| window_samples == 0
|| min_rate <= 0.0
|| max_rate < min_rate
|| rate_step <= 0.0
{
return result;
}
let mut best_confidence = -2.0f64;
let mut rate = min_rate;
while rate <= max_rate + rate_step * 0.5 {
// Resample the candidate envelope so that window i of the resampled
// envelope corresponds to window i*rate of the original
let resampled_size = (candidate.len() as f64 / rate) as i64;
if resampled_size < 2 {
rate += rate_step;
continue;
}
let resampled_len = resampled_size as usize;
let mut resampled = vec![0.0f64; resampled_len];
let mut resampled_valid = vec![false; resampled_len];
for i in 0..resampled_size as usize {
let position = i as f64 * rate;
let lower = position as usize;
let upper = (lower + 1).min(candidate.len() - 1);
let fraction = position - lower as f64;
resampled[i] = candidate[lower] * (1.0 - fraction) + candidate[upper] * fraction;
resampled_valid[i] = candidate_valid.len() != candidate.len()
|| (candidate_valid[lower] && candidate_valid[upper]);
}
let offset = estimate_envelope_offset_valid(
reference,
&resampled,
reference_valid,
&resampled_valid,
window_samples,
max_offset_windows,
);
if offset.valid && offset.confidence > best_confidence {
best_confidence = offset.confidence;
result.valid = true;
result.rate = rate;
result.confidence = offset.confidence;
result.offset_samples = offset.offset_samples;
}
rate += rate_step;
}
result
}
+874
View File
@@ -0,0 +1,874 @@
// 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/>.
//! Shared test helpers and bridge stubs for the oakaudio contract suite.
//!
//! The library imports the other oak modules (`oakcommon`, `oakcodec`,
//! `ffmpeg_bridge`) through `extern "C"` declarations in `bridge/`. A
//! standalone `cargo test`/`cargo tarpaulin` run has no C++ objects to
//! link, so [`mod stubs`] provides minimal definitions — real enough for
//! the contract tests (a passthrough/linear filter graph, a WAV decoder,
//! a no-op config/encoder) but by no means an ffmpeg replacement. The
//! exhaustive behavior matrix is pinned by the unchanged C++ gtest suite
//! (`src/audio/tests`).
use std::path::Path;
use std::sync::Mutex;
/// Serializes tests that mutate process-wide state (the manager singleton,
/// the alive ledger) within one test binary.
pub static MANAGER_LOCK: Mutex<()> = Mutex::new(());
/// Build a planar f32 buffer with `channel_count` channels of `frame_count`
/// frames from a single-channel `source` (replicated per channel).
pub fn planar_from(source: &[f32], channel_count: usize) -> Vec<Vec<f32>> {
(0..channel_count).map(|_| source.to_vec()).collect()
}
/// A deterministic pseudo-random planar buffer (fixed seed) for stable
/// golden vectors. Samples land in `[-1, 1)`.
pub fn noisy_planar(channel_count: usize, frame_count: usize, seed: u64) -> Vec<Vec<f32>> {
let mut state = seed | 1;
let mut next = move || {
state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
((state >> 33) as u64 & 0xFFFF) as f32 / 65535.0 * 2.0 - 1.0
};
let mut data = Vec::with_capacity(channel_count);
for _ in 0..channel_count {
data.push((0..frame_count).map(|_| next()).collect());
}
data
}
/// A silence buffer: every sample is exactly `0.0`.
pub fn silence_planar(channel_count: usize, frame_count: usize) -> Vec<Vec<f32>> {
vec![vec![0.0; frame_count]; channel_count]
}
/// Total number of `SamplePerChannel` entries in a channel-interleaved
/// waveform sample for `points` points across `channels` channels.
pub fn interleaved_len(points: usize, channels: usize) -> usize {
points * channels
}
/// Convert an `oakaudio`-style `min_max` pair layout into a plain tuple for
/// comparison in tests.
pub fn pair(min: f32, max: f32) -> (f32, f32) {
(min, max)
}
// ---- Minimal WAV fixture helpers -------------------------------------------
/// Write a 16-bit PCM WAV file (`fmt` chunk + `data` chunk, standard 44-byte
/// header). The test stubs decode exactly this layout.
pub fn write_wav(path: &Path, channels: u16, rate: u32, samples: &[i16]) -> std::io::Result<()> {
let block_align = channels * 2;
let byte_rate = rate * u32::from(block_align);
let data_size = (samples.len() * 2) as u32;
let mut out = Vec::with_capacity(44 + data_size as usize);
out.extend_from_slice(b"RIFF");
out.extend_from_slice(&(36 + data_size).to_le_bytes());
out.extend_from_slice(b"WAVE");
out.extend_from_slice(b"fmt ");
out.extend_from_slice(&16u32.to_le_bytes());
out.extend_from_slice(&1u16.to_le_bytes()); // PCM
out.extend_from_slice(&channels.to_le_bytes());
out.extend_from_slice(&rate.to_le_bytes());
out.extend_from_slice(&byte_rate.to_le_bytes());
out.extend_from_slice(&block_align.to_le_bytes());
out.extend_from_slice(&16u16.to_le_bytes()); // bits per sample
out.extend_from_slice(b"data");
out.extend_from_slice(&data_size.to_le_bytes());
for s in samples {
out.extend_from_slice(&s.to_le_bytes());
}
std::fs::write(path, out)
}
/// Write a WAV header only (no `data` payload) with arbitrary channel and
/// rate claims — used to exercise extraction validation (e.g. the channel
/// cap) without decoding real audio.
pub fn write_wav_header_only(path: &Path, channels: u16, rate: u32) -> std::io::Result<()> {
let block_align = channels * 2;
let byte_rate = rate * u32::from(block_align);
let mut out = Vec::with_capacity(44);
out.extend_from_slice(b"RIFF");
out.extend_from_slice(&36u32.to_le_bytes());
out.extend_from_slice(b"WAVE");
out.extend_from_slice(b"fmt ");
out.extend_from_slice(&16u32.to_le_bytes());
out.extend_from_slice(&1u16.to_le_bytes());
out.extend_from_slice(&channels.to_le_bytes());
out.extend_from_slice(&rate.to_le_bytes());
out.extend_from_slice(&byte_rate.to_le_bytes());
out.extend_from_slice(&block_align.to_le_bytes());
out.extend_from_slice(&16u16.to_le_bytes());
out.extend_from_slice(b"data");
out.extend_from_slice(&0u32.to_le_bytes());
std::fs::write(path, out)
}
// ---- Bridge stubs -----------------------------------------------------------
#[allow(dead_code)]
pub mod stubs {
use std::ffi::{c_char, c_int, c_void, CStr};
use std::path::Path;
use oakaudio::bridge::codec::AudioStreamInfo;
use oakaudio::bridge::ffmpeg::{AudioGraphConfig, FBStreamInfo};
// ------------------------- oakcommon ---------------------------------
/// Not-found for every key: `config::device_name`'s two-stage query
/// treats `size <= 1` as absent and returns the empty string, so the
/// config-driven device lookup degrades to `paNoDevice` (the documented
/// bridge degradation).
#[no_mangle]
pub extern "C" fn oakcommon_config_get(
_group: *const c_char,
_key: *const c_char,
_buf: *mut c_char,
_buf_size: c_int,
) -> c_int {
-1
}
/// Every integer config reads its default.
#[no_mangle]
pub extern "C" fn oakcommon_config_get_int(
_group: *const c_char,
_key: *const c_char,
default: c_int,
) -> c_int {
default
}
/// Core `SampleFormat` (planar-first) → `FBSampleFormat` (AVSampleFormat
/// order), mirroring `src/common/src/ffmpegutils.cpp:83`.
///
/// `// CPP-PARITY: src/common/src/ffmpegutils.cpp:83`
/// (`FFmpegUtils::get_ffmpeg_sample_format`).
#[no_mangle]
pub extern "C" fn oakcommon_ffmpegutils_get_ffmpeg_sample_format(
smp_fmt: c_int,
out: *mut c_int,
) -> c_int {
let mapped = match smp_fmt {
0 => 5, // u8_p -> fb_sample_fmt_u8_p
1 => 6, // s16_p -> fb_sample_fmt_s16_p
2 => 7, // s32_p -> fb_sample_fmt_s32_p
3 => 11, // s64_p -> fb_sample_fmt_s64_p
4 => 8, // f32_p -> fb_sample_fmt_fltp
5 => 9, // f64_p -> fb_sample_fmt_dblp
6 => 0, // u8 -> fb_sample_fmt_u8
7 => 1, // s16 -> fb_sample_fmt_s16
8 => 2, // s32 -> fb_sample_fmt_s32
9 => 10, // s64 -> fb_sample_fmt_s64
10 => 3, // f32 -> fb_sample_fmt_flt
11 => 4, // f64 -> fb_sample_fmt_dbl
_ => -1, // invalid/count -> fb_sample_fmt_none
};
if out.is_null() {
return -1;
}
// SAFETY: the caller guarantees a writable int.
unsafe { *out = mapped };
0
}
// ------------------------- ffmpeg_bridge ------------------------------
/// ffmpeg-style default channel layout mask for `nb_channels` (only the
/// popcount is load-bearing for oakaudio). Masks above 63 channels
/// cannot be represented in a u64 and yield 0 (the extract cap check
/// rejects such streams before any layout use).
fn layout_for(channels: i32) -> u64 {
match channels {
1 => 0x4,
2 => 0x3,
n if n > 0 && n < 64 => (1u64 << n) - 1,
_ => 0,
}
}
#[no_mangle]
pub extern "C" fn fb_channel_layout_get_channels(mask: u64) -> c_int {
mask.count_ones() as c_int
}
#[no_mangle]
pub extern "C" fn fb_channel_layout_default(nb_channels: c_int) -> u64 {
layout_for(nb_channels)
}
/// A tiny deterministic filter graph: buffers planar-f32 (or packed s16)
/// input and emits linearly-interpolated output at `out_rate` with an
/// atempo-style tempo factor (tempo > 1 speeds up → fewer frames).
struct StubGraph {
in_rate: f64,
out_rate: f64,
in_channels: usize,
out_channels: usize,
in_format: c_int,
tempo: f64,
input: Vec<Vec<f32>>,
emitted: usize,
}
impl StubGraph {
fn available(&self) -> usize {
let len = self.input.first().map_or(0, |c| c.len());
if len == 0 {
return 0;
}
let ratio = self.out_rate / (self.in_rate * self.tempo);
((len as f64 * ratio) + 1e-9).floor() as usize
}
}
#[no_mangle]
pub extern "C" fn fb_audio_graph_create(config: *const AudioGraphConfig) -> *mut c_void {
if config.is_null() {
return std::ptr::null_mut();
}
// SAFETY: the caller guarantees a valid config pointer.
let c = unsafe { &*config };
if c.in_sample_rate <= 0
|| c.out_sample_rate <= 0
|| c.in_channels <= 0
|| c.out_channels <= 0
{
return std::ptr::null_mut();
}
let g = StubGraph {
in_rate: f64::from(c.in_sample_rate),
out_rate: f64::from(c.out_sample_rate),
in_channels: c.in_channels as usize,
out_channels: c.out_channels as usize,
in_format: c.in_sample_format,
tempo: c.tempo.max(0.001),
input: vec![Vec::new(); c.in_channels as usize],
emitted: 0,
};
Box::into_raw(Box::new(g)) as *mut c_void
}
#[no_mangle]
pub extern "C" fn fb_audio_graph_free(graph: *mut *mut c_void) {
if !graph.is_null() && !(unsafe { *graph }).is_null() {
// SAFETY: the pointer was created by `fb_audio_graph_create`.
drop(unsafe { Box::from_raw(*graph as *mut StubGraph) });
// SAFETY: the double-pointer belongs to the caller.
unsafe { *graph = std::ptr::null_mut() };
}
}
#[no_mangle]
pub extern "C" fn fb_audio_graph_push(
graph: *mut c_void,
channel_data: *const *const u8,
nb_samples: c_int,
) -> c_int {
if graph.is_null() || nb_samples < 0 {
return -1;
}
// SAFETY: the graph pointer was created by `fb_audio_graph_create`
// and is still live.
let g = unsafe { &mut *(graph as *mut StubGraph) };
if channel_data.is_null() {
return 0; // flush marker
}
for f in 0..nb_samples as usize {
for c in 0..g.in_channels {
let v = match g.in_format {
8 => {
// fltp: one f32 plane per channel.
// SAFETY: the caller guarantees `nb_samples` floats
// per plane.
let p = unsafe { *channel_data.add(c) } as *const f32;
unsafe { *p.add(f) }
}
1 => {
// s16 packed: interleaved in plane 0.
// SAFETY: the caller guarantees `nb_samples *
// channels * 2` bytes in plane 0.
let p = unsafe { *channel_data } as *const u8;
let off = (f * g.in_channels + c) * 2;
let lo = unsafe { *p.add(off) };
let hi = unsafe { *p.add(off + 1) };
f32::from(i16::from_le_bytes([lo, hi])) / 32768.0
}
_ => 0.0,
};
g.input[c].push(v);
}
}
0
}
/// A frame payload: per-plane raw bytes plus sample/format metadata.
struct StubFrame {
nb: i32,
channels: i32,
format: c_int,
rate: c_int,
layout: u64,
data: Vec<Vec<u8>>,
}
impl Default for StubFrame {
fn default() -> Self {
StubFrame {
nb: 0,
channels: 0,
format: -1,
rate: 0,
layout: 0,
data: Vec::new(),
}
}
}
#[no_mangle]
pub extern "C" fn fb_audio_graph_pull(graph: *mut c_void, out_frame: *mut c_void) -> c_int {
if graph.is_null() || out_frame.is_null() {
return -1;
}
// SAFETY: both pointers are live (created by the allocators below).
let g = unsafe { &mut *(graph as *mut StubGraph) };
let out = unsafe { &mut *(out_frame as *mut StubFrame) };
let total = g.available();
if g.emitted >= total {
return 0;
}
let nb = total - g.emitted;
out.nb = nb as i32;
out.channels = g.out_channels as i32;
out.format = 8; // fltp
out.data = vec![vec![0u8; nb * 4]; g.out_channels];
for o in 0..nb {
let pos = o as f64 * g.in_rate / g.out_rate * g.tempo;
for c in 0..g.out_channels {
let lower = (pos.floor() as usize).min(g.input[c].len() - 1);
let upper = (lower + 1).min(g.input[c].len() - 1);
let frac = pos - lower as f64;
let v = f64::from(g.input[c][lower]) * (1.0 - frac)
+ f64::from(g.input[c][upper]) * frac;
let bytes = (v as f32).to_le_bytes();
let off = o * 4;
out.data[c][off..off + 4].copy_from_slice(&bytes);
}
}
g.emitted = total;
1
}
#[no_mangle]
pub extern "C" fn fb_frame_alloc() -> *mut c_void {
Box::into_raw(Box::new(StubFrame::default())) as *mut c_void
}
#[no_mangle]
pub extern "C" fn fb_frame_free(frame: *mut *mut c_void) {
if !frame.is_null() && !(unsafe { *frame }).is_null() {
// SAFETY: the pointer was created by `fb_frame_alloc`.
drop(unsafe { Box::from_raw(*frame as *mut StubFrame) });
// SAFETY: the double-pointer belongs to the caller.
unsafe { *frame = std::ptr::null_mut() };
}
}
#[no_mangle]
pub extern "C" fn fb_frame_unref(_frame: *mut c_void) {}
#[no_mangle]
pub extern "C" fn fb_frame_get_nb_samples(frame: *const c_void) -> c_int {
if frame.is_null() {
return 0;
}
// SAFETY: the pointer is a live `StubFrame`.
unsafe { (*(frame as *const StubFrame)).nb }
}
#[no_mangle]
pub extern "C" fn fb_frame_set_nb_samples(frame: *mut c_void, nb_samples: c_int) {
if frame.is_null() {
return;
}
// SAFETY: the pointer is a live `StubFrame`.
unsafe { (*(frame as *mut StubFrame)).nb = nb_samples };
}
#[no_mangle]
pub extern "C" fn fb_frame_get_sample_rate(frame: *const c_void) -> c_int {
if frame.is_null() {
return 0;
}
// SAFETY: the pointer is a live `StubFrame`.
unsafe { (*(frame as *const StubFrame)).rate }
}
#[no_mangle]
pub extern "C" fn fb_frame_get_format(frame: *const c_void) -> c_int {
if frame.is_null() {
return -1;
}
// SAFETY: the pointer is a live `StubFrame`.
unsafe { (*(frame as *const StubFrame)).format }
}
#[no_mangle]
pub extern "C" fn fb_frame_get_channel_layout_mask(frame: *const c_void) -> u64 {
if frame.is_null() {
return 0;
}
// SAFETY: the pointer is a live `StubFrame`.
unsafe { (*(frame as *const StubFrame)).layout }
}
#[no_mangle]
pub extern "C" fn fb_frame_get_data(frame: *mut c_void, plane: c_int) -> *mut u8 {
if frame.is_null() {
return std::ptr::null_mut();
}
// SAFETY: the pointer is a live `StubFrame`.
let f = unsafe { &mut *(frame as *mut StubFrame) };
match f.data.get_mut(plane as usize) {
Some(v) => v.as_mut_ptr(),
None => std::ptr::null_mut(),
}
}
#[no_mangle]
pub extern "C" fn fb_frame_get_data_const(frame: *const c_void, plane: c_int) -> *const u8 {
if frame.is_null() {
return std::ptr::null();
}
// SAFETY: the pointer is a live `StubFrame`.
let f = unsafe { &*(frame as *const StubFrame) };
match f.data.get(plane as usize) {
Some(v) => v.as_ptr(),
None => std::ptr::null(),
}
}
#[no_mangle]
pub extern "C" fn fb_frame_get_linesize(frame: *const c_void, _plane: c_int) -> c_int {
if frame.is_null() {
return 0;
}
// SAFETY: the pointer is a live `StubFrame`.
unsafe { (*(frame as *const StubFrame)).nb * 4 }
}
/// A raw packet payload (unused by oakaudio, kept for completeness).
struct StubPacket {
data: Vec<u8>,
}
#[no_mangle]
pub extern "C" fn fb_packet_alloc() -> *mut c_void {
Box::into_raw(Box::new(StubPacket { data: Vec::new() })) as *mut c_void
}
#[no_mangle]
pub extern "C" fn fb_packet_free(packet: *mut *mut c_void) {
if !packet.is_null() && !(unsafe { *packet }).is_null() {
// SAFETY: the pointer was created by `fb_packet_alloc`.
drop(unsafe { Box::from_raw(*packet as *mut StubPacket) });
// SAFETY: the double-pointer belongs to the caller.
unsafe { *packet = std::ptr::null_mut() };
}
}
#[no_mangle]
pub extern "C" fn fb_packet_unref(_packet: *mut c_void) {}
/// 16-bit PCM WAV stream state (the only format the fixture writer
/// produces).
struct StubDecoder {
file: Option<std::fs::File>,
channels: i32,
sample_rate: i32,
block_align: usize,
remaining: usize,
total_frames: i64,
layout: u64,
}
/// WAV header facts parsed by `parse_wav`.
struct WavInfo {
channels: i32,
rate: i32,
block_align: usize,
frames: i64,
}
/// Parse the standard 44-byte PCM WAV header the fixture writer emits.
fn parse_wav(path: &Path) -> Option<WavInfo> {
let bytes = std::fs::read(path).ok()?;
if bytes.len() < 44 || &bytes[0..4] != b"RIFF" || &bytes[8..12] != b"WAVE" {
return None;
}
if &bytes[12..16] != b"fmt " || &bytes[36..40] != b"data" {
return None;
}
let fmt_size = u32::from_le_bytes(bytes[16..20].try_into().ok()?);
if fmt_size < 16 {
return None;
}
let audio_format = u16::from_le_bytes(bytes[20..22].try_into().ok()?);
let channels = u16::from_le_bytes(bytes[22..24].try_into().ok()?);
let rate = u32::from_le_bytes(bytes[24..28].try_into().ok()?);
let block_align = u16::from_le_bytes(bytes[32..34].try_into().ok()?);
let bits = u16::from_le_bytes(bytes[34..36].try_into().ok()?);
let data_size = u32::from_le_bytes(bytes[40..44].try_into().ok()?);
if audio_format != 1 || bits != 16 || channels == 0 || rate == 0 || block_align == 0 {
return None;
}
let frames = (data_size as usize / block_align as usize) as i64;
Some(WavInfo {
channels: i32::from(channels),
rate: rate as i32,
block_align: block_align as usize,
frames,
})
}
#[no_mangle]
pub extern "C" fn fb_decoder_create() -> *mut c_void {
Box::into_raw(Box::new(StubDecoder {
file: None,
channels: 0,
sample_rate: 0,
block_align: 0,
remaining: 0,
total_frames: 0,
layout: 0,
})) as *mut c_void
}
#[no_mangle]
pub extern "C" fn fb_decoder_open(
decoder: *mut c_void,
filename: *const c_char,
stream_index: c_int,
) -> c_int {
if decoder.is_null() || filename.is_null() {
return -1;
}
// SAFETY: the C string is NUL-terminated (caller contract).
let cname = unsafe { CStr::from_ptr(filename) };
let path = Path::new(cname.to_str().unwrap_or(""));
match parse_wav(path) {
Some(info) if stream_index == 0 => {
// SAFETY: the decoder pointer is a live `StubDecoder`.
let d = unsafe { &mut *(decoder as *mut StubDecoder) };
d.file = std::fs::File::open(path).ok();
// The file cursor starts at 0; skip the 44-byte WAV header
// so reads land on the data chunk (decoder_read_chunk reads
// exactly `remaining` data bytes).
if let Some(f) = d.file.as_mut() {
use std::io::{Seek, SeekFrom};
let _ = f.seek(SeekFrom::Start(44));
}
d.channels = info.channels;
d.sample_rate = info.rate;
d.block_align = info.block_align;
d.remaining = (info.frames as usize) * info.block_align;
d.total_frames = info.frames;
d.layout = layout_for(info.channels);
0
}
_ => -1,
}
}
#[no_mangle]
pub extern "C" fn fb_decoder_close(_decoder: *mut c_void) {}
#[no_mangle]
pub extern "C" fn fb_decoder_free(decoder: *mut *mut c_void) {
if !decoder.is_null() && !(unsafe { *decoder }).is_null() {
// SAFETY: the pointer was created by `fb_decoder_create`.
drop(unsafe { Box::from_raw(*decoder as *mut StubDecoder) });
// SAFETY: the double-pointer belongs to the caller.
unsafe { *decoder = std::ptr::null_mut() };
}
}
/// Read up to `max` whole frames of interleaved s16 PCM.
fn decoder_read_chunk(d: &mut StubDecoder, max_bytes: usize) -> Vec<u8> {
use std::io::Read;
if d.file.is_none() || d.remaining == 0 {
return Vec::new();
}
let mut buf = vec![0u8; max_bytes.min(d.remaining)];
let f = d.file.as_mut().unwrap();
let mut n = 0usize;
while n < buf.len() {
match f.read(&mut buf[n..]) {
Ok(0) => break,
Ok(read) => n += read,
Err(_) => break,
}
}
d.remaining = d.remaining.saturating_sub(n);
// Keep only whole frames.
let whole = n / d.block_align * d.block_align;
buf.truncate(whole);
buf
}
#[no_mangle]
pub extern "C" fn fb_decoder_get_frame(
decoder: *mut c_void,
_packet: *mut c_void,
frame: *mut c_void,
) -> c_int {
if decoder.is_null() || frame.is_null() {
return -1;
}
// SAFETY: both pointers are live stubs.
let d = unsafe { &mut *(decoder as *mut StubDecoder) };
let f = unsafe { &mut *(frame as *mut StubFrame) };
let chunk = decoder_read_chunk(d, 4096);
if chunk.is_empty() {
return -1; // EOF
}
f.nb = (chunk.len() / d.block_align) as i32;
f.channels = d.channels;
f.format = 1; // s16 packed (native WAV format)
f.rate = d.sample_rate;
f.layout = d.layout;
f.data = vec![chunk];
0
}
#[no_mangle]
pub extern "C" fn fb_decoder_get_packet(decoder: *mut c_void, packet: *mut c_void) -> c_int {
if decoder.is_null() || packet.is_null() {
return -1;
}
// SAFETY: both pointers are live stubs.
let d = unsafe { &mut *(decoder as *mut StubDecoder) };
let p = unsafe { &mut *(packet as *mut StubPacket) };
let chunk = decoder_read_chunk(d, 4096);
if chunk.is_empty() {
return -1;
}
p.data = chunk;
0
}
#[no_mangle]
pub extern "C" fn fb_decoder_get_stream_info(decoder: *const c_void, out: *mut FBStreamInfo) -> c_int {
if decoder.is_null() || out.is_null() {
return -1;
}
// SAFETY: the decoder pointer is a live `StubDecoder`; `out` is a
// caller-owned info struct.
let d = unsafe { &*(decoder as *const StubDecoder) };
unsafe {
(*out).index = 0;
(*out).codec_type = 1; // audio
(*out).codec_id = 0;
(*out).has_decoder = 1;
(*out).width = 0;
(*out).height = 0;
(*out).pixel_format = -1;
(*out).field_order = 0;
(*out).color_range = 0;
(*out).color_primaries = 2;
(*out).color_trc = 2;
(*out).sample_rate = d.sample_rate;
(*out).sample_format = 1; // s16
(*out).channel_layout_mask = d.layout;
(*out).start_time = 0;
(*out).duration = d.total_frames;
(*out).time_base_num = 1;
(*out).time_base_den = d.sample_rate.max(1);
(*out).avg_frame_rate_num = 0;
(*out).avg_frame_rate_den = 0;
}
0
}
#[no_mangle]
pub extern "C" fn fb_decoder_get_format_start_time(decoder: *const c_void) -> i64 {
if decoder.is_null() {
return 0;
}
0
}
#[no_mangle]
pub extern "C" fn fb_decoder_get_format_duration(decoder: *const c_void) -> i64 {
if decoder.is_null() {
return 0;
}
// SAFETY: the decoder pointer is a live `StubDecoder`.
unsafe { (*(decoder as *const StubDecoder)).total_frames }
}
// ------------------------- oakcodec ----------------------------------
struct StubEncoder;
#[no_mangle]
pub extern "C" fn oakcodec_encoder_init(params: *const c_void) -> *mut c_void {
if params.is_null() {
return std::ptr::null_mut();
}
Box::into_raw(Box::new(StubEncoder)) as *mut c_void
}
#[no_mangle]
pub extern "C" fn oakcodec_encoder_open(_encoder: *mut c_void) -> c_int {
0
}
#[no_mangle]
pub extern "C" fn oakcodec_encoder_write_audio(
_encoder: *mut c_void,
_samples: *const f32,
_frame_count: c_int,
) -> c_int {
0
}
#[no_mangle]
pub extern "C" fn oakcodec_encoder_flush(_encoder: *mut c_void) -> c_int {
0
}
#[no_mangle]
pub extern "C" fn oakcodec_encoder_last_error(
_encoder: *mut c_void,
_buf: *mut c_char,
_buf_size: c_int,
) -> c_int {
0
}
#[no_mangle]
pub extern "C" fn oakcodec_encoder_free(encoder: *mut c_void) {
if !encoder.is_null() {
// SAFETY: the pointer was created by `oakcodec_encoder_init`.
drop(unsafe { Box::from_raw(encoder as *mut StubEncoder) });
}
}
/// Probe result for a decodable WAV file.
struct StubProbe {
channels: i32,
sample_rate: i32,
block_align: usize,
frames: i64,
layout: u64,
}
#[no_mangle]
pub extern "C" fn oakcodec_decoder_probe(filename: *const c_char) -> *mut c_void {
if filename.is_null() {
return std::ptr::null_mut();
}
// SAFETY: the C string is NUL-terminated (caller contract).
let cname = unsafe { CStr::from_ptr(filename) };
let path = Path::new(cname.to_str().unwrap_or(""));
match parse_wav(path) {
Some(info) => {
Box::into_raw(Box::new(StubProbe {
channels: info.channels,
sample_rate: info.rate,
block_align: info.block_align,
frames: info.frames,
layout: layout_for(info.channels),
})) as *mut c_void
}
None => std::ptr::null_mut(),
}
}
#[no_mangle]
pub extern "C" fn oakcodec_decoder_free(probe: *mut c_void) {
if !probe.is_null() {
// SAFETY: the pointer was created by `oakcodec_decoder_probe`.
drop(unsafe { Box::from_raw(probe as *mut StubProbe) });
}
}
#[no_mangle]
pub extern "C" fn oakcodec_decoder_probe_audio_stream_count(_probe: *mut c_void) -> c_int {
1
}
#[no_mangle]
pub extern "C" fn oakcodec_decoder_probe_get_audio_stream(
probe: *mut c_void,
index: c_int,
out: *mut AudioStreamInfo,
) -> c_int {
if probe.is_null() || out.is_null() || index != 0 {
return -1;
}
// SAFETY: the probe pointer is a live `StubProbe`; `out` is a
// caller-owned info struct.
let p = unsafe { &*(probe as *const StubProbe) };
unsafe {
(*out).stream_index = 0;
(*out).sample_rate = p.sample_rate;
(*out).channel_layout = p.layout;
(*out).channel_count = p.channels;
(*out).duration_ts = p.frames;
(*out).time_base_num = 1;
(*out).time_base_den = p.sample_rate;
}
0
}
#[no_mangle]
pub extern "C" fn oakcodec_decoder_open(
_decoder: *mut c_void,
_filename: *const c_char,
_stream_index: c_int,
) -> c_int {
0
}
#[no_mangle]
pub extern "C" fn oakcodec_decoder_decode_audio(
_decoder: *mut c_void,
_in_num: c_int,
_in_den: c_int,
_out_num: c_int,
_out_den: c_int,
_sample_rate: c_int,
_channel_layout: u64,
_buf: *mut f32,
_buf_frames: c_int,
) -> c_int {
0
}
}
+196
View File
@@ -0,0 +1,196 @@
// 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-layer contract tests (ffi.rs). The exhaustive matrix runs against
//! the unchanged C++ gtest suite (`src/audio/tests`); these tests pin
//! Rust-side specifics (handle contracts, the singleton ledger, struct
//! layout).
mod common;
use std::mem::{align_of, size_of};
use std::sync::Mutex;
use oakaudio::error::{OAKAUDIO_E_INVALID, OAKAUDIO_OK};
use oakaudio::ffi::levelmeter::{ChannelStats, MeterStats};
use oakaudio::ffi::levelmeter::oakaudio_levelmeter_analyze;
use oakaudio::ffi::processor::{oakaudio_processor_free, oakaudio_processor_init};
use oakaudio::ffi::sync::{OffsetResult, SourceClip};
use oakaudio::ffi::waveform::{oakaudio_waveform_free, oakaudio_waveform_init};
use oakaudio::ffi::manager::{
oakaudio_debug_alive_count, oakaudio_manager_create_instance,
oakaudio_manager_destroy_instance, oakaudio_manager_free, oakaudio_manager_instance,
};
/// Serializes tests that touch the process-wide singleton and the alive
/// ledger.
static LOCK: Mutex<()> = Mutex::new(());
/// Every exported handle-returning function (processor_init, waveform_init)
/// returns ctx==NULL on failure and a valid refcounted handle on success,
/// with abi_version == OAKAUDIO_ABI_VERSION stamped.
#[test]
fn handle_contract_all_exports() {
let _guard = LOCK.lock().unwrap();
let mut p = unsafe { oakaudio_processor_init() };
assert!(!p.ctx.is_null());
assert_eq!(p.abi_version, oakaudio::handle::OAKAUDIO_ABI_VERSION);
let mut w = unsafe { oakaudio_waveform_init() };
assert!(!w.ctx.is_null());
assert_eq!(w.abi_version, oakaudio::handle::OAKAUDIO_ABI_VERSION);
unsafe { oakaudio_processor_free(&mut p) };
unsafe { oakaudio_waveform_free(&mut w) };
}
/// free(NULL)/free(empty) are no-ops across every free export.
#[test]
fn free_null_noop_all_exports() {
let _guard = LOCK.lock().unwrap();
let mut p = oakaudio::handle::CHandle::null();
unsafe { oakaudio_processor_free(&mut p) };
assert!(p.ctx.is_null());
let mut w = oakaudio::handle::CHandle::null();
unsafe { oakaudio_waveform_free(&mut w) };
assert!(w.ctx.is_null());
let mut m = oakaudio::handle::CHandle::null();
unsafe { oakaudio_manager_free(&mut m) };
assert!(m.ctx.is_null());
// NULL pointer itself is a no-op.
unsafe { oakaudio_processor_free(std::ptr::null_mut()) };
unsafe { oakaudio_waveform_free(std::ptr::null_mut()) };
unsafe { oakaudio_manager_free(std::ptr::null_mut()) };
}
/// The manager singleton: instance() is the same borrowed handle across
/// calls; create/destroy flip validity; oakaudio_debug_alive_count moves
/// predictably and returns to baseline.
#[test]
fn manager_singleton_and_alive_count() {
let _guard = LOCK.lock().unwrap();
let before = unsafe { oakaudio_debug_alive_count() };
unsafe { oakaudio_manager_destroy_instance() };
let none = unsafe { oakaudio_manager_instance() };
assert!(none.ctx.is_null());
assert_eq!(none.abi_version, oakaudio::handle::OAKAUDIO_ABI_VERSION);
unsafe { oakaudio_manager_create_instance() };
let m1 = unsafe { oakaudio_manager_instance() };
let m2 = unsafe { oakaudio_manager_instance() };
assert!(!m1.ctx.is_null());
assert_eq!(m1.ctx, m2.ctx, "instance() must be the same borrowed handle");
// A processor bumps the ledger; freeing it returns to baseline.
assert_eq!(unsafe { oakaudio_debug_alive_count() }, before);
let mut p = unsafe { oakaudio_processor_init() };
assert_eq!(unsafe { oakaudio_debug_alive_count() }, before + 1);
unsafe { oakaudio_processor_free(&mut p) };
assert_eq!(unsafe { oakaudio_debug_alive_count() }, before);
// Destroy flips the singleton back to empty; create resurrects it.
unsafe { oakaudio_manager_destroy_instance() };
assert!(unsafe { oakaudio_manager_instance() }.ctx.is_null());
unsafe { oakaudio_manager_create_instance() };
assert!(!unsafe { oakaudio_manager_instance() }.ctx.is_null());
}
/// oakaudio_levelmeter_analyze with NULL summary still computes per-channel
/// stats, and a NULL channels array with capacity 0 is accepted when only
/// the summary is wanted.
#[test]
fn levelmeter_partial_outputs() {
let data = [0.5f32; 64];
let planes = [data.as_ptr()];
// channels only (summary NULL)
let mut channels = [ChannelStats {
peak_linear: 0.0,
peak_db: 0.0,
rms_linear: 0.0,
rms_db: 0.0,
vu_db: 0.0,
}];
assert_eq!(
unsafe {
oakaudio_levelmeter_analyze(
planes.as_ptr(),
1,
64,
channels.as_mut_ptr(),
1,
std::ptr::null_mut(),
)
},
OAKAUDIO_OK
);
assert!((channels[0].peak_linear - 0.5).abs() < 1e-9);
// summary only (channels NULL, capacity 0)
let mut summary = MeterStats {
max_peak_linear: 0.0,
integrated_lufs: 0.0,
silence: 0,
};
assert_eq!(
unsafe {
oakaudio_levelmeter_analyze(
planes.as_ptr(),
1,
64,
std::ptr::null_mut(),
0,
&mut summary,
)
},
OAKAUDIO_OK
);
assert!((summary.max_peak_linear - 0.5).abs() < 1e-9);
// Both NULL is invalid.
assert_eq!(
unsafe {
oakaudio_levelmeter_analyze(
planes.as_ptr(),
1,
64,
std::ptr::null_mut(),
0,
std::ptr::null_mut(),
)
},
OAKAUDIO_E_INVALID
);
}
/// sync value structs (offset_result/source_clip) are 24/40 bytes and
/// repr(C)-aligned as the C headers dictate, so layout never drifts.
#[test]
fn sync_struct_layout() {
assert_eq!(size_of::<OffsetResult>(), 24);
assert_eq!(align_of::<OffsetResult>(), 8);
assert_eq!(size_of::<SourceClip>(), 40);
assert_eq!(align_of::<SourceClip>(), 8);
// The stretch result (f64, i64, f64, i32) pads to 32 bytes.
assert_eq!(size_of::<oakaudio::ffi::sync::StretchOffsetResult>(), 32);
}
+212
View File
@@ -0,0 +1,212 @@
// 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/>.
//! Cross-cutting golden/parity tests: values captured from the C++
//! implementation to pin exact behavior of the Rust rewrite.
mod common;
use common::write_wav_header_only;
use oakcore_rs::Rational;
use oakaudio::ffi::waveform::{
oakaudio_waveform_extract, oakaudio_waveform_free, oakaudio_waveform_get_summary,
oakaudio_waveform_init, oakaudio_waveform_overwrite_samples,
oakaudio_waveform_set_channel_count,
};
use oakaudio::ffi::waveform::MinMax;
use oakaudio::params::{
frames_to_rational, rational_to_samples, SampleFormat,
};
/// SampleFormat planar-first ordering matches the authoritative C++ enum:
/// f32_p == 4 == OAKAUDIO_PROCESSOR_OUTPUT_FORMAT. This guards the
/// oakcore-rs ordering divergence documented in params.rs.
#[test]
fn sample_format_planar_first_ordering() {
assert_eq!(SampleFormat::F32Planar as i32, 4);
assert_eq!(oakaudio::processor::OUTPUT_FORMAT as i32, 4);
// Invalid is -1 and the packed family follows planar-first.
assert_eq!(SampleFormat::Invalid as i32, -1);
assert_eq!(SampleFormat::U8Planar as i32, 0);
assert_eq!(SampleFormat::F64 as i32, 11);
}
/// Rational time<->sample conversions (frames_to_rational /
/// rational_to_samples) round-trip 48000 Hz sample counts exactly.
#[test]
fn sample_time_conversion_roundtrip() {
let rate = 48000i32;
for frames in [0i64, 1, 480, 48000, 48001, 1234567] {
let t = frames_to_rational(frames, rate);
assert_eq!(rational_to_samples(t, rate), frames);
}
assert_eq!(frames_to_rational(48000, 48000), Rational::new(1, 1));
assert_eq!(frames_to_rational(1, 48000), Rational::new(1, 48000));
}
/// AudioParams value-type conversions: channel count from the layout mask,
/// bytes-per-sample-per-channel, samples_to_bytes, and the double ->
/// rational conversion edge cases (NaN / out-of-range / tiny -> null).
#[test]
fn params_value_types() {
use oakaudio::params::{rational_from_double, AudioParams};
let p = AudioParams {
sample_rate: 48000,
channel_layout: 3,
format: SampleFormat::F32,
};
assert_eq!(p.channel_count(), 2);
assert_eq!(p.bytes_per_sample_per_channel(), 4);
assert_eq!(p.samples_to_bytes(480), 480 * 4 * 2);
assert_eq!(rational_from_double(0.5), Rational::new(1, 2));
assert_eq!(rational_from_double(1.0), Rational::new(1, 1));
assert!(rational_from_double(f64::NAN).is_null());
assert!(rational_from_double(1e10).is_null());
assert!(rational_from_double(1e-20).is_null());
assert!((rational_from_double(0.25).to_f64() - 0.25).abs() < 1e-9);
}
/// AudioVisualWaveform mipmap layout: get_summary at a fine zoom scale
/// covers fewer source samples than at a coarse scale, so the returned
/// min/max pair brackets exactly the mipmapped window. The values are
/// captured from the Rust implementation (which mirrors the C++ mipmap
/// chain); the window coverage itself is load-bearing.
#[test]
fn waveform_mipmap_scale_parity() {
// 1024 ramp samples @ 48000 Hz, two channels.
let ch0: Vec<f32> = (0..1024).map(|i| i as f32 * 0.001).collect();
let ch1: Vec<f32> = (0..1024).map(|i| -(i as f32) * 0.001).collect();
let planes = [ch0.as_ptr(), ch1.as_ptr()];
let mut w = unsafe { oakaudio_waveform_init() };
assert!(!w.ctx.is_null());
assert_eq!(unsafe { oakaudio_waveform_set_channel_count(w, 2) }, 0);
assert_eq!(
unsafe { oakaudio_waveform_overwrite_samples(w, planes.as_ptr(), 1024, 48000, 0, 1) },
0
);
// One summary point is produced for any queried window; a 1/1024 s
// window (one 1024-rate mipmap point ~ 46.875 source samples) must
// bracket a narrower range than a 1/64 s window (~750 samples).
let mut fine = [MinMax { min: 0.0, max: 0.0 }; 2];
let fine_points = unsafe {
oakaudio_waveform_get_summary(w, 0, 1, 1, 1024, fine.as_mut_ptr(), 2)
};
assert_eq!(fine_points, 1);
assert_eq!(fine[0].min, 0.0);
assert!((fine[0].max - 0.046).abs() < 1e-5, "fine max = {}", fine[0].max);
assert!((fine[1].min + 0.046).abs() < 1e-5, "fine min = {}", fine[1].min);
assert_eq!(fine[1].max, 0.0);
let mut coarse = [MinMax { min: 0.0, max: 0.0 }; 2];
let coarse_points =
unsafe { oakaudio_waveform_get_summary(w, 0, 1, 1, 64, coarse.as_mut_ptr(), 2) };
assert_eq!(coarse_points, 1);
assert_eq!(coarse[0].min, 0.0);
assert!((coarse[0].max - 0.749).abs() < 1e-5, "coarse max = {}", coarse[0].max);
assert!((coarse[1].min + 0.749).abs() < 1e-5, "coarse min = {}", coarse[1].min);
assert_eq!(coarse[1].max, 0.0);
// Coarser windows necessarily cover more source samples.
assert!(coarse[0].max > fine[0].max);
assert!(coarse[1].min < fine[1].min);
unsafe { oakaudio_waveform_free(&mut w) };
}
/// levelmeter dB conversion: peak_db == 20*log10(peak_linear) and the
/// -200 dB floor match the C++ helpers for the same sample values.
#[test]
fn levelmeter_db_golden() {
let tone = common::planar_from(&[0.5f32; 64], 1);
let refs: Vec<&[f32]> = tone.iter().map(|v| v.as_slice()).collect();
let stats = oakaudio::levelmeter::analyze_sample_buffer(&refs);
let expected_db = 20.0 * 0.5f64.log10();
assert!((stats.channels[0].peak_db - expected_db).abs() < 1e-9);
assert!((stats.channels[0].rms_db - expected_db).abs() < 1e-9);
let silence = common::silence_planar(1, 64);
let refs: Vec<&[f32]> = silence.iter().map(|v| v.as_slice()).collect();
let stats = oakaudio::levelmeter::analyze_sample_buffer(&refs);
assert_eq!(stats.channels[0].peak_db, -200.0);
assert_eq!(stats.channels[0].rms_db, -200.0);
assert_eq!(stats.channels[0].vu_db, -200.0);
}
/// waveformsync envelope offset golden: a reference ramp delayed by two
/// windows in the candidate is recovered as +2 windows with full
/// confidence through the C ABI.
#[test]
fn waveform_sync_offset_golden() {
use oakaudio::ffi::sync::{
oakaudio_sync_estimate_envelope_offset, OffsetResult,
};
let reference: Vec<f64> = (0..10).map(|i| i as f64 * 0.1 + 0.1).collect();
let mut candidate = vec![0.0f64; 10];
candidate[2..].copy_from_slice(&reference[..8]);
let mut out = OffsetResult {
offset_samples: 0,
confidence: 0.0,
valid: 0,
};
let r = unsafe {
oakaudio_sync_estimate_envelope_offset(
reference.as_ptr(),
reference.len() as i32,
candidate.as_ptr(),
candidate.len() as i32,
std::ptr::null(),
std::ptr::null(),
100,
10,
&mut out,
)
};
assert_eq!(r, 0);
assert_eq!(out.valid, 1);
assert_eq!(out.offset_samples, 200);
assert!((out.confidence - 1.0).abs() < 1e-9);
}
/// oakaudio_waveform_extract channel cap: a stream claiming more than
/// OAKAUDIO_EXTRACT_MAX_CHANNELS (64) channels is rejected rather than
/// overflowing the internal plane array.
#[test]
fn extract_channel_cap() {
let path = std::env::temp_dir().join(format!(
"oakaudio_cap_{}.wav",
std::process::id()
));
write_wav_header_only(&path, 65, 48000).unwrap();
let mut out_channels = 0i32;
let cpath = std::ffi::CString::new(path.to_str().unwrap()).unwrap();
let r = unsafe {
oakaudio_waveform_extract(
cpath.as_ptr(),
0,
4,
std::ptr::null_mut(),
0,
&mut out_channels,
)
};
assert!(r < 0, "oversized stream must be rejected, got {r}");
std::fs::remove_file(&path).ok();
}
+111
View File
@@ -0,0 +1,111 @@
// 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/>.
//! Handle plumbing contract tests (handle.rs).
use oakaudio::error::{OAKAUDIO_E_FAILED, OAKAUDIO_E_INVALID, OAKAUDIO_OK};
use oakaudio::handle::{
alive_count, get, guard, guard_handle, make_borrowed, make_owned, CHandle,
};
/// make_owned starts at refcount 1; get returns a typed view; dropping the
/// handle decrements to 0.
#[test]
fn owned_lifecycle() {
let before = alive_count();
let mut h = make_owned(42u32);
assert!(!h.is_null());
assert_eq!(alive_count(), before + 1);
// SAFETY: `h` boxes a u32 created above.
let v = unsafe { get::<u32>(&h) }.unwrap();
assert_eq!(*v, 42);
// addref/release round-trip through the function pointers.
let addref = h.addref.unwrap();
let release = h.release.unwrap();
// SAFETY: the ctx was created by make_owned.
unsafe { addref(h.ctx) };
assert_eq!(alive_count(), before + 1); // count unchanged, still 1 box
unsafe { release(h.ctx) };
// Dropping the box (release to zero) decrements the ledger.
let release = h.release.unwrap();
// SAFETY: h.ctx is the box created above; refcount is 1.
unsafe { release(h.ctx) };
h.ctx = std::ptr::null_mut();
assert_eq!(alive_count(), before);
}
/// make_borrowed creates a borrow-only handle whose release frees only the
/// box, never the underlying object.
#[test]
fn borrowed_release() {
let mut value = Box::new(7i32);
// SAFETY: `value` outlives the handle; the ctx points directly at the
// box (not a RefBox), so it is read through the raw pointer.
let mut h = unsafe { make_borrowed(&mut *value) };
assert!(!h.is_null());
// SAFETY: `h.ctx` points at the box created above.
let v = unsafe { &*(h.ctx as *const i32) };
assert_eq!(*v, 7);
// release is a no-op: the box still lives.
let release = h.release.unwrap();
// SAFETY: noop_ref for borrowed handles.
unsafe { release(h.ctx) };
assert_eq!(*value, 7);
h.ctx = std::ptr::null_mut();
}
/// CHandle::null() yields an empty handle; guard over an Ok(()) returns
/// OAKAUDIO_OK (0) and guard_handle over Ok returns a valid handle.
#[test]
fn null_and_guard_ok() {
let null = CHandle::null();
assert!(null.is_null());
assert_eq!(null.abi_version, oakaudio::handle::OAKAUDIO_ABI_VERSION);
assert_eq!(guard(|| Ok(())), OAKAUDIO_OK);
let h = guard_handle(|| Ok(make_owned(1u32)));
assert!(!h.is_null());
// Release it so the alive ledger returns to baseline (tests share the
// process-wide ledger and run in parallel).
// SAFETY: `h.ctx` is the box created above; refcount is 1.
unsafe { (h.release.unwrap())(h.ctx) };
}
/// guard maps an Err to the negative error code without panicking; a
/// panicking body is caught and returns a failure code rather than
/// unwinding across the FFI boundary.
#[test]
fn guard_error_and_panic() {
assert_eq!(guard(|| Err(oakaudio::error::Error::Invalid)), OAKAUDIO_E_INVALID);
assert_eq!(guard(|| Err(oakaudio::error::Error::State)), -60002);
assert_eq!(guard(|| Err(oakaudio::error::Error::Failed("x".to_string()))), -60003);
assert_eq!(guard(|| Err(oakaudio::error::Error::NotFound)), -60004);
assert_eq!(guard(|| Err(oakaudio::error::Error::NoMem)), -60005);
assert_eq!(guard(|| panic!("boom")), OAKAUDIO_E_FAILED);
let h = guard_handle(|| Err(oakaudio::error::Error::Invalid));
assert!(h.is_null());
let h2 = guard_handle(|| panic!("boom"));
assert!(h2.is_null());
}
+158
View File
@@ -0,0 +1,158 @@
// 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/>.
//! AudioLevelMeter contract tests (levelmeter.rs), through the C ABI.
mod common;
use oakaudio::error::{OAKAUDIO_E_INVALID, OAKAUDIO_OK};
use oakaudio::ffi::levelmeter::{
oakaudio_levelmeter_analyze, ChannelStats, MeterStats,
};
fn analyze(planes: &[Vec<f32>]) -> (Vec<ChannelStats>, MeterStats) {
let ptrs: Vec<*const f32> = planes.iter().map(|p| p.as_ptr()).collect();
let mut channels: Vec<ChannelStats> = (0..planes.len())
.map(|_| ChannelStats {
peak_linear: 0.0,
peak_db: 0.0,
rms_linear: 0.0,
rms_db: 0.0,
vu_db: 0.0,
})
.collect();
let mut summary = MeterStats {
max_peak_linear: 0.0,
integrated_lufs: 0.0,
silence: 0,
};
let r = unsafe {
oakaudio_levelmeter_analyze(
ptrs.as_ptr(),
planes.len() as i32,
planes.first().map_or(0, |p| p.len()) as i32,
channels.as_mut_ptr(),
channels.len() as i32,
&mut summary,
)
};
assert_eq!(r, OAKAUDIO_OK);
(channels, summary)
}
/// A silence buffer reports silence=1, all-zero linear fields, and dB
/// fields floored at -200.
#[test]
fn silence_analysis() {
let planes = common::silence_planar(2, 64);
let (channels, summary) = analyze(&planes);
assert_eq!(summary.silence, 1);
assert_eq!(summary.max_peak_linear, 0.0);
assert_eq!(summary.integrated_lufs, -200.0);
for ch in &channels {
assert_eq!(ch.peak_linear, 0.0);
assert_eq!(ch.rms_linear, 0.0);
assert_eq!(ch.peak_db, -200.0);
assert_eq!(ch.rms_db, -200.0);
assert_eq!(ch.vu_db, -200.0);
}
}
/// A constant-amplitude tone reports peak_linear == rms_linear == that
/// amplitude (power terms), peak_db matches 20*log10(amp), and silence=0.
#[test]
fn constant_tone_stats() {
let planes = common::planar_from(&[0.5f32; 64], 1);
let (channels, summary) = analyze(&planes);
assert_eq!(summary.silence, 0);
assert!((channels[0].peak_linear - 0.5).abs() < 1e-9);
assert!((channels[0].rms_linear - 0.5).abs() < 1e-9);
let expected_db = 20.0 * 0.5f64.log10();
assert!((channels[0].peak_db - expected_db).abs() < 1e-9);
assert!((channels[0].rms_db - expected_db).abs() < 1e-9);
}
/// A full-scale square wave yields max_peak_linear == 1.0 and a peak_db
/// near 0 dB; per-channel channels array is filled for each channel.
#[test]
fn full_scale_peak() {
let ch0: Vec<f32> = (0..64).map(|i| if i % 2 == 0 { 1.0 } else { -1.0 }).collect();
let ch1: Vec<f32> = (0..64).map(|i| if i % 2 == 0 { -1.0 } else { 1.0 }).collect();
let (channels, summary) = analyze(&[ch0, ch1]);
assert_eq!(summary.max_peak_linear, 1.0);
assert_eq!(summary.silence, 0);
assert!((channels[0].peak_db - 0.0).abs() < 1e-9);
assert!((channels[1].peak_db - 0.0).abs() < 1e-9);
assert!((channels[0].rms_linear - 1.0).abs() < 1e-9);
assert!((channels[1].rms_linear - 1.0).abs() < 1e-9);
}
/// integrated_lufs stays -200 for silence and matches the BS.1770
/// mean-square formula (no K-weighting) for a tone.
#[test]
fn integrated_lufs_silence_vs_tone() {
let silence = common::silence_planar(2, 64);
let (_, summary) = analyze(&silence);
assert_eq!(summary.integrated_lufs, -200.0);
let tone = common::planar_from(&[0.5f32; 64], 2);
let (_, summary) = analyze(&tone);
// mean square over all channels = 0.25; -0.691 + 10*log10(0.25)
let expected = -0.691 + 10.0 * 0.25f64.log10();
assert!((summary.integrated_lufs - expected).abs() < 1e-9);
}
/// channel_count of 0, a NULL planar pointer, or NULL for both outputs
/// returns OAKAUDIO_E_INVALID.
#[test]
fn invalid_input() {
let planes = common::planar_from(&[0.5f32; 8], 1);
let ptr = planes[0].as_ptr();
let mut summary = MeterStats {
max_peak_linear: 0.0,
integrated_lufs: 0.0,
silence: 0,
};
// channel_count 0.
assert_eq!(
unsafe {
oakaudio_levelmeter_analyze(&ptr, 0, 8, std::ptr::null_mut(), 0, &mut summary)
},
OAKAUDIO_E_INVALID
);
// NULL planar.
assert_eq!(
unsafe {
oakaudio_levelmeter_analyze(std::ptr::null(), 1, 8, std::ptr::null_mut(), 0, &mut summary)
},
OAKAUDIO_E_INVALID
);
// Both outputs NULL.
assert_eq!(
unsafe {
oakaudio_levelmeter_analyze(&ptr, 1, 8, std::ptr::null_mut(), 0, std::ptr::null_mut())
},
OAKAUDIO_E_INVALID
);
// Negative frame count.
assert_eq!(
unsafe {
oakaudio_levelmeter_analyze(&ptr, 1, -1, std::ptr::null_mut(), 0, &mut summary)
},
OAKAUDIO_E_INVALID
);
}
+359
View File
@@ -0,0 +1,359 @@
// 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/>.
//! AudioManager contract tests (manager.rs), through the C ABI. The
//! manager is a process-wide singleton, so every test holds the shared
//! `MANAGER_LOCK`.
mod common;
use std::ffi::c_char;
use common::MANAGER_LOCK;
use oakaudio::bridge::codec::EncodingParams;
use oakaudio::error::{
OAKAUDIO_E_FAILED, OAKAUDIO_E_INVALID, OAKAUDIO_OK,
};
use oakaudio::ffi::manager::{
oakaudio_debug_alive_count, oakaudio_manager_clear_buffered_output,
oakaudio_manager_create_instance, oakaudio_manager_destroy_instance,
oakaudio_manager_find_config_device_by_name_s, oakaudio_manager_find_device_by_name_s,
oakaudio_manager_free, oakaudio_manager_get_input_device,
oakaudio_manager_get_output_device, oakaudio_manager_hard_reset,
oakaudio_manager_instance, oakaudio_manager_push_to_output,
oakaudio_manager_reset_output_clock, oakaudio_manager_seconds,
oakaudio_manager_set_input_device, oakaudio_manager_set_output_device,
oakaudio_manager_set_output_notify_interval, oakaudio_manager_start_recording,
oakaudio_manager_stop_output, oakaudio_manager_stop_recording,
};
fn instance() -> oakaudio::handle::CHandle {
unsafe { oakaudio_manager_instance() }
}
/// Lock the manager singleton for a test. The manager state persists across
/// tests (the `OnceLock` cannot be reset), so a panicked test must not
/// poison the lock for the rest of the binary.
fn lock() -> std::sync::MutexGuard<'static, ()> {
MANAGER_LOCK.lock().unwrap_or_else(|p| p.into_inner())
}
fn encoding_params() -> EncodingParams {
let mut filename = [0 as c_char; 1024];
for (i, b) in b"oakaudio_test.wav\0".iter().enumerate() {
filename[i] = *b as c_char;
}
EncodingParams {
filename,
format: 0,
video_enabled: 0,
video_codec: 0,
video_width: 0,
video_height: 0,
video_time_base_num: 0,
video_time_base_den: 0,
video_pixel_format: 0,
video_interlacing: 0,
video_pixel_aspect_num: 0,
video_pixel_aspect_den: 0,
video_bit_rate: 0,
video_min_bit_rate: 0,
video_max_bit_rate: 0,
video_buffer_size: 0,
video_threads: 0,
video_pix_fmt: [0 as c_char; 64],
video_is_image_sequence: 0,
video_scaling_method: 0,
audio_enabled: 1,
audio_codec: 0,
audio_sample_rate: 48000,
audio_channel_layout: 3,
audio_sample_format: 8,
audio_bit_rate: 128000,
subtitles_enabled: 0,
subtitles_codec: 0,
subtitles_are_sidecar: 0,
subtitles_sidecar_format: 0,
color_transform_output: [0 as c_char; 256],
export_length_num: 0,
export_length_den: 0,
has_custom_range: 0,
custom_range_in_num: 0,
custom_range_in_den: 0,
custom_range_out_num: 0,
custom_range_out_den: 0,
}
}
/// create_instance/destroy_instance toggle the singleton; instance() returns
/// a valid borrowed handle between them and NULL after destroy.
#[test]
fn singleton_lifecycle() {
let _guard = lock();
unsafe { oakaudio_manager_destroy_instance() };
assert!(instance().ctx.is_null());
unsafe { oakaudio_manager_create_instance() };
let m = instance();
assert!(!m.ctx.is_null());
unsafe { oakaudio_manager_free(&mut m.clone()) };
unsafe { oakaudio_manager_destroy_instance() };
assert!(instance().ctx.is_null());
unsafe { oakaudio_manager_create_instance() };
assert!(!instance().ctx.is_null());
}
/// push_to_output accepts raw interleaved bytes and starts the virtual
/// playback clock; without a device it fails with a message in error_buf.
///
/// The virtual device never consumes frames (PortAudio is not bridged), so
/// the clock reads 0.0 rather than advancing.
#[test]
fn push_output_advances_clock() {
let _guard = lock();
unsafe { oakaudio_manager_create_instance() };
let m = instance();
// No stream yet: seconds() reports -1.
let mut secs = 0.0f64;
assert_eq!(unsafe { oakaudio_manager_seconds(m, &mut secs) }, OAKAUDIO_OK);
assert_eq!(secs, -1.0);
// Without a device, push fails with a human-readable error. The
// singleton state persists across tests, so pin the no-device state
// explicitly.
assert_eq!(unsafe { oakaudio_manager_set_output_device(m, -1) }, OAKAUDIO_OK);
let samples = vec![0u8; 480 * 2 * 4];
let mut err = [0 as c_char; 64];
let r = unsafe {
oakaudio_manager_push_to_output(
m,
48000,
3,
4,
samples.as_ptr() as *const c_char,
samples.len() as i64,
err.as_mut_ptr(),
err.len() as i32,
)
};
assert_eq!(r, OAKAUDIO_E_FAILED);
assert!(err.iter().any(|&b| b != 0), "error_buf must carry a message");
// After selecting a device the push succeeds and the clock starts at 0.
assert_eq!(unsafe { oakaudio_manager_set_output_device(m, 0) }, OAKAUDIO_OK);
let mut err = [0 as c_char; 64];
let r = unsafe {
oakaudio_manager_push_to_output(
m,
48000,
3,
4,
samples.as_ptr() as *const c_char,
samples.len() as i64,
err.as_mut_ptr(),
err.len() as i32,
)
};
assert_eq!(r, OAKAUDIO_OK);
unsafe { oakaudio_manager_seconds(m, &mut secs) };
assert_eq!(secs, 0.0);
unsafe { oakaudio_manager_destroy_instance() };
}
/// set/get output & input device: getters report a device, setters persist
/// it; hard_reset keeps the device indices (it only stops the stream and
/// clears buffers).
#[test]
fn device_selection_roundtrip() {
let _guard = lock();
unsafe { oakaudio_manager_create_instance() };
let m = instance();
assert_eq!(unsafe { oakaudio_manager_set_output_device(m, 42) }, OAKAUDIO_OK);
assert_eq!(unsafe { oakaudio_manager_get_output_device(m) }, 42);
assert_eq!(unsafe { oakaudio_manager_set_input_device(m, 7) }, OAKAUDIO_OK);
assert_eq!(unsafe { oakaudio_manager_get_input_device(m) }, 7);
assert_eq!(unsafe { oakaudio_manager_hard_reset(m) }, OAKAUDIO_OK);
assert_eq!(unsafe { oakaudio_manager_get_output_device(m) }, 42);
assert_eq!(unsafe { oakaudio_manager_get_input_device(m) }, 7);
// The stream stopped, so the clock is back at -1.
let mut secs = 0.0f64;
unsafe { oakaudio_manager_seconds(m, &mut secs) };
assert_eq!(secs, -1.0);
unsafe { oakaudio_manager_destroy_instance() };
}
/// set_output_notify_interval stores the interval; clear_buffered_output
/// drops queued bytes, stop_output halts the stream, and reset_output_clock
/// restarts the counter.
#[test]
fn output_control_flags() {
let _guard = lock();
unsafe { oakaudio_manager_create_instance() };
let m = instance();
assert_eq!(
unsafe { oakaudio_manager_set_output_notify_interval(m, 1024) },
OAKAUDIO_OK
);
assert_eq!(
unsafe { oakaudio_manager_set_output_notify_interval(m, -1) },
OAKAUDIO_E_INVALID
);
assert_eq!(unsafe { oakaudio_manager_clear_buffered_output(m) }, OAKAUDIO_OK);
assert_eq!(unsafe { oakaudio_manager_reset_output_clock(m) }, OAKAUDIO_OK);
// Push starts the stream, then stop_output halts it (clock -> -1).
unsafe { oakaudio_manager_set_output_device(m, 0) };
let samples = vec![0u8; 480 * 2 * 4];
assert_eq!(
unsafe {
oakaudio_manager_push_to_output(
m, 48000, 3, 4, samples.as_ptr() as *const c_char,
samples.len() as i64, std::ptr::null_mut(), 0,
)
},
OAKAUDIO_OK
);
assert_eq!(unsafe { oakaudio_manager_stop_output(m) }, OAKAUDIO_OK);
let mut secs = 1.0f64;
unsafe { oakaudio_manager_seconds(m, &mut secs) };
assert_eq!(secs, -1.0);
unsafe { oakaudio_manager_destroy_instance() };
}
/// start_recording with a valid audio-enabled EncodingParams returns
/// OAKAUDIO_OK and a later stop_recording finalizes; NULL params or a
/// disabled audio track return OAKAUDIO_E_INVALID with an error string.
#[test]
fn recording_start_stop() {
let _guard = lock();
unsafe { oakaudio_manager_create_instance() };
let m = instance();
unsafe { oakaudio_manager_set_input_device(m, 0) };
let mut err = [0 as c_char; 64];
let params = encoding_params();
assert_eq!(
unsafe { oakaudio_manager_start_recording(m, &params, err.as_mut_ptr(), err.len() as i32) },
OAKAUDIO_OK
);
assert_eq!(unsafe { oakaudio_manager_stop_recording(m) }, OAKAUDIO_OK);
// NULL params is invalid and reports the reason in error_buf.
let mut err = [0 as c_char; 64];
let r = unsafe { oakaudio_manager_start_recording(m, std::ptr::null(), err.as_mut_ptr(), err.len() as i32) };
assert_eq!(r, OAKAUDIO_E_INVALID);
assert!(err.iter().any(|&b| b != 0));
// A disabled audio track is likewise invalid.
let mut disabled = encoding_params();
disabled.audio_enabled = 0;
let mut err = [0 as c_char; 64];
let r = unsafe {
oakaudio_manager_start_recording(m, &disabled, err.as_mut_ptr(), err.len() as i32)
};
assert_eq!(r, OAKAUDIO_E_INVALID);
assert!(err.iter().any(|&b| b != 0));
unsafe { oakaudio_manager_destroy_instance() };
}
/// Device enumeration is not bridged: every name/config lookup falls back to
/// paNoDevice (-1); a NULL name is OAKAUDIO_E_INVALID. The config-backed
/// buffer size/name helpers degrade to their defaults.
#[test]
fn device_name_lookup() {
let _guard = lock();
assert_eq!(
unsafe { oakaudio_manager_find_device_by_name_s(std::ptr::null(), 1) },
OAKAUDIO_E_INVALID
);
let name = c"anything";
assert_eq!(
unsafe { oakaudio_manager_find_device_by_name_s(name.as_ptr(), 1) },
-1
);
assert_eq!(unsafe { oakaudio_manager_find_config_device_by_name_s(1) }, -1);
assert_eq!(unsafe { oakaudio_manager_find_config_device_by_name_s(0) }, -1);
// config::output_buffer_size() reads its default (0) from the stub;
// device_name degrades to the empty string.
assert_eq!(oakaudio::config::output_buffer_size(), 0);
assert!(oakaudio::config::device_name(true).as_c_str().is_empty());
assert!(oakaudio::config::device_name(false).as_c_str().is_empty());
}
/// PreviewAudioDevice pull-side plumbing (read/notify callback/clock) that
/// the manager path only touches indirectly.
#[test]
fn preview_device_pull_side() {
use oakaudio::params::AudioParams;
use oakaudio::previewdevice::PreviewAudioDevice;
let mut dev = PreviewAudioDevice::new();
dev.set_params(AudioParams {
sample_rate: 48000,
channel_layout: 3,
format: oakaudio::params::SampleFormat::F32,
});
assert_eq!(dev.bytes_per_frame(), 8);
let callbacks = std::sync::Arc::new(std::sync::atomic::AtomicI32::new(0));
let cb = std::sync::Arc::clone(&callbacks);
dev.set_notify_callback(move || {
cb.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
});
dev.set_notify_interval(4);
dev.write(&[1u8; 10]);
// Reading 6 bytes crosses a 4-byte notify boundary.
let mut buf = [0u8; 6];
assert_eq!(dev.read(&mut buf), 6);
assert!(callbacks.load(std::sync::atomic::Ordering::Relaxed) >= 1);
assert_eq!(buf, [1u8; 6]);
// Clock accounting.
dev.add_output_frames(3);
assert_eq!(dev.output_frames_consumed(), 3);
dev.reset_output_frames();
assert_eq!(dev.output_frames_consumed(), 0);
dev.clear();
assert_eq!(dev.output_frames_consumed(), 0);
}
/// free(NULL)/free(empty) are no-ops on the manager handle.
#[test]
fn free_null_noop() {
let _guard = lock();
unsafe { oakaudio_manager_create_instance() };
let before = unsafe { oakaudio_debug_alive_count() };
let mut empty = oakaudio::handle::CHandle::null();
unsafe { oakaudio_manager_free(&mut empty) };
unsafe { oakaudio_manager_free(std::ptr::null_mut()) };
assert_eq!(unsafe { oakaudio_debug_alive_count() }, before);
unsafe { oakaudio_manager_destroy_instance() };
}
+224
View File
@@ -0,0 +1,224 @@
// 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/>.
//! AudioProcessor contract tests (processor.rs), through the C ABI. The
//! test stub filter graph resamples and time-stretches like the real
//! ffmpeg_bridge graph (linear interpolation), so frame-count contracts
//! are pinned exactly.
mod common;
use oakaudio::error::{OAKAUDIO_E_INVALID, OAKAUDIO_E_STATE, OAKAUDIO_OK};
use oakaudio::ffi::processor::{
oakaudio_processor_close, oakaudio_processor_convert, oakaudio_processor_flush,
oakaudio_processor_free, oakaudio_processor_init, oakaudio_processor_is_open,
oakaudio_processor_open,
};
/// Stereo f32_p planes of `frames` ramp samples.
fn ramp_planes(frames: usize) -> Vec<Vec<f32>> {
vec![
(0..frames).map(|i| i as f32 * 0.01).collect(),
(0..frames).map(|i| -(i as f32) * 0.01).collect(),
]
}
fn open_identity(h: oakaudio::handle::CHandle) -> i32 {
unsafe { oakaudio_processor_open(h, 48000, 3, 4, 48000, 3, 4, 1.0) }
}
/// init yields a valid handle; is_open is false before open and true after;
/// close returns it to closed without error.
#[test]
fn processor_open_isopen_close() {
let mut h = unsafe { oakaudio_processor_init() };
assert!(!h.ctx.is_null());
assert_eq!(unsafe { oakaudio_processor_is_open(h) }, 0);
assert_eq!(open_identity(h), OAKAUDIO_OK);
assert_eq!(unsafe { oakaudio_processor_is_open(h) }, 1);
assert_eq!(unsafe { oakaudio_processor_close(h) }, OAKAUDIO_OK);
assert_eq!(unsafe { oakaudio_processor_is_open(h) }, 0);
unsafe { oakaudio_processor_free(&mut h) };
}
/// open with matching in/out rate and format is an identity passthrough:
/// convert returns the same frame count and samples within 1e-6.
#[test]
fn identity_convert_passthrough() {
let mut h = unsafe { oakaudio_processor_init() };
assert_eq!(open_identity(h), OAKAUDIO_OK);
let planes = ramp_planes(32);
let in_ptrs: Vec<*const f32> = planes.iter().map(|p| p.as_ptr()).collect();
let mut out = vec![vec![0f32; 32]; 2];
let mut out_ptrs: Vec<*mut f32> = out.iter_mut().map(|p| p.as_mut_ptr()).collect();
let n = unsafe {
oakaudio_processor_convert(
h,
in_ptrs.as_ptr(),
32,
out_ptrs.as_ptr(),
32,
)
};
assert_eq!(n, 32);
for ch in 0..2 {
for i in 0..32 {
assert!(
(out[ch][i] - planes[ch][i]).abs() < 1e-6,
"ch{ch}[{i}]: {} vs {}",
out[ch][i],
planes[ch][i]
);
}
}
unsafe { oakaudio_processor_free(&mut h) };
}
/// convert with an output capacity smaller than the produced frames returns
/// the produced count clamped to capacity and fills up to capacity.
#[test]
fn convert_capacity_truncation() {
let mut h = unsafe { oakaudio_processor_init() };
assert_eq!(open_identity(h), OAKAUDIO_OK);
let planes = ramp_planes(32);
let in_ptrs: Vec<*const f32> = planes.iter().map(|p| p.as_ptr()).collect();
let mut out = vec![vec![9.9f32; 10]; 2];
let mut out_ptrs: Vec<*mut f32> = out.iter_mut().map(|p| p.as_mut_ptr()).collect();
let n = unsafe {
oakaudio_processor_convert(h, in_ptrs.as_ptr(), 32, out_ptrs.as_ptr(), 10)
};
assert_eq!(n, 10);
for ch in 0..2 {
for i in 0..10 {
assert_eq!(out[ch][i], planes[ch][i]);
}
}
// The graph has already drained; nothing further to pull.
let mut out2 = vec![vec![0f32; 32]; 2];
let mut out2_ptrs: Vec<*mut f32> = out2.iter_mut().map(|p| p.as_mut_ptr()).collect();
let n = unsafe {
oakaudio_processor_convert(h, in_ptrs.as_ptr(), 0, out2_ptrs.as_ptr(), 32)
};
assert_eq!(n, 0);
unsafe { oakaudio_processor_free(&mut h) };
}
/// open with a zero/negative rate or a wrong output format returns
/// OAKAUDIO_E_INVALID and leaves the processor closed; an empty handle is
/// OAKAUDIO_E_INVALID everywhere.
#[test]
fn open_invalid_params() {
let mut h = unsafe { oakaudio_processor_init() };
assert_eq!(
unsafe { oakaudio_processor_open(h, 0, 3, 4, 48000, 3, 4, 1.0) },
OAKAUDIO_E_INVALID
);
assert_eq!(unsafe { oakaudio_processor_is_open(h) }, 0);
assert_eq!(
unsafe { oakaudio_processor_open(h, 48000, 3, 4, 48000, 3, 0, 1.0) },
OAKAUDIO_E_INVALID
);
assert_eq!(unsafe { oakaudio_processor_is_open(h) }, 0);
let empty = oakaudio::handle::CHandle::null();
assert_eq!(open_identity(empty), OAKAUDIO_E_INVALID);
assert_eq!(unsafe { oakaudio_processor_is_open(empty) }, OAKAUDIO_E_INVALID);
assert_eq!(unsafe { oakaudio_processor_close(empty) }, OAKAUDIO_E_INVALID);
assert_eq!(unsafe { oakaudio_processor_flush(empty) }, OAKAUDIO_E_INVALID);
let mut out_ptrs: Vec<*mut f32> = Vec::new();
assert_eq!(
unsafe { oakaudio_processor_convert(empty, std::ptr::null(), 0, out_ptrs.as_ptr(), 0) },
OAKAUDIO_E_INVALID
);
// convert before open is a state error.
assert_eq!(
unsafe { oakaudio_processor_convert(h, std::ptr::null(), 0, out_ptrs.as_ptr(), 0) },
OAKAUDIO_E_STATE
);
unsafe { oakaudio_processor_free(&mut h) };
}
/// Resampling to half rate halves the frame count (44100 -> 22050, 32 input
/// frames produce 16 output frames); flush is a no-op on the drained graph
/// and keeps the processor open.
#[test]
fn resample_and_flush() {
let mut h = unsafe { oakaudio_processor_init() };
assert_eq!(
unsafe { oakaudio_processor_open(h, 44100, 3, 4, 22050, 3, 4, 1.0) },
OAKAUDIO_OK
);
let planes = ramp_planes(32);
let in_ptrs: Vec<*const f32> = planes.iter().map(|p| p.as_ptr()).collect();
let mut out = vec![vec![0f32; 32]; 2];
let mut out_ptrs: Vec<*mut f32> = out.iter_mut().map(|p| p.as_mut_ptr()).collect();
let n = unsafe {
oakaudio_processor_convert(h, in_ptrs.as_ptr(), 32, out_ptrs.as_ptr(), 32)
};
assert_eq!(n, 16, "half-rate output must halve the frame count");
assert_eq!(unsafe { oakaudio_processor_flush(h) }, OAKAUDIO_OK);
assert_eq!(unsafe { oakaudio_processor_is_open(h) }, 1);
unsafe { oakaudio_processor_free(&mut h) };
}
/// A tempo factor != 1.0 time-stretches: tempo 2.0 halves the frame count
/// and the processor stays open.
#[test]
fn tempo_stretch() {
let mut h = unsafe { oakaudio_processor_init() };
assert_eq!(
unsafe { oakaudio_processor_open(h, 48000, 3, 4, 48000, 3, 4, 2.0) },
OAKAUDIO_OK
);
let planes = ramp_planes(32);
let in_ptrs: Vec<*const f32> = planes.iter().map(|p| p.as_ptr()).collect();
let mut out = vec![vec![0f32; 32]; 2];
let mut out_ptrs: Vec<*mut f32> = out.iter_mut().map(|p| p.as_mut_ptr()).collect();
let n = unsafe {
oakaudio_processor_convert(h, in_ptrs.as_ptr(), 32, out_ptrs.as_ptr(), 32)
};
assert_eq!(n, 16, "tempo 2.0 must halve the frame count");
assert_eq!(unsafe { oakaudio_processor_is_open(h) }, 1);
unsafe { oakaudio_processor_close(h) };
// A non-positive speed is rejected (on a closed processor).
assert_eq!(
unsafe { oakaudio_processor_open(h, 48000, 3, 4, 48000, 3, 4, 0.0) },
OAKAUDIO_E_INVALID
);
assert_eq!(open_identity(h), OAKAUDIO_OK);
// Already open -> state error.
assert_eq!(open_identity(h), OAKAUDIO_E_STATE);
unsafe { oakaudio_processor_free(&mut h) };
}
/// free(NULL)/free(empty) are no-ops.
#[test]
fn free_null_noop() {
let mut h = oakaudio::handle::CHandle::null();
unsafe { oakaudio_processor_free(&mut h) };
unsafe { oakaudio_processor_free(std::ptr::null_mut()) };
}
+433
View File
@@ -0,0 +1,433 @@
// 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/>.
//! AudioSynchronizer + AudioWaveformSync contract tests
//! (synchronizer.rs, waveformsync.rs), through the C ABI.
mod common;
use oakaudio::error::OAKAUDIO_E_INVALID;
use oakaudio::ffi::sync::{
oakaudio_sync_estimate_envelope_offset, oakaudio_sync_estimate_stretch_and_offset,
oakaudio_sync_extract_rms_envelope, oakaudio_sync_place_by_source_time,
oakaudio_sync_place_by_waveform_offset, OffsetResult, SourceClip, StretchOffsetResult,
};
fn clip(source: i64, media_in: i64, has_source: bool) -> SourceClip {
SourceClip {
source_start_time_num: source,
source_start_time_den: 1,
media_in_num: media_in,
media_in_den: 1,
has_source_start_time: has_source as i32,
}
}
/// place_by_source_time: a candidate with matching source time lands at the
/// reference's timeline in point; a source-less candidate (has_source_
/// start_time false) is invalid (no media_in fallback in the C++ logic).
#[test]
fn place_by_source_time_matching() {
let reference = clip(0, 0, true);
let candidate = clip(0, 0, true);
let (mut num, mut den, mut valid) = (0i64, 0i64, 0i32);
let r = unsafe {
oakaudio_sync_place_by_source_time(
&reference,
&candidate,
5,
1,
&mut num,
&mut den,
&mut valid,
)
};
assert_eq!(r, 0);
assert_eq!(num, 5);
assert_eq!(den, 1);
assert_eq!(valid, 1);
// A source-less candidate is invalid: valid=0, null rational.
let candidate = clip(0, 0, false);
let r = unsafe {
oakaudio_sync_place_by_source_time(
&reference,
&candidate,
5,
1,
&mut num,
&mut den,
&mut valid,
)
};
assert_eq!(r, 0);
assert_eq!(valid, 0);
assert_eq!(num, 0);
assert_eq!(den, 0);
}
/// place_by_source_time: when source times disagree by a known delta, the
/// candidate's timeline in point shifts by that delta (in seconds).
#[test]
fn place_by_source_time_delta() {
let reference = clip(5, 0, true);
let candidate = clip(12, 0, true);
let (mut num, mut den, mut valid) = (0i64, 0i64, 0i32);
let r = unsafe {
oakaudio_sync_place_by_source_time(
&reference,
&candidate,
0,
1,
&mut num,
&mut den,
&mut valid,
)
};
assert_eq!(r, 0);
// 0 + (12 + 0) - (5 + 0) = 7
assert_eq!(num, 7);
assert_eq!(den, 1);
assert_eq!(valid, 1);
// A zero denominator is rejected up front.
let r = unsafe {
oakaudio_sync_place_by_source_time(
&reference,
&candidate,
0,
0,
&mut num,
&mut den,
&mut valid,
)
};
assert_eq!(r, OAKAUDIO_E_INVALID);
}
/// place_by_waveform_offset converts a sample offset at a sample rate into
/// a timeline-in shift; out_valid is 1 on success and 0 for a null rate.
#[test]
fn place_by_waveform_offset_conversion() {
let (mut num, mut den, mut valid) = (0i64, 0i64, 0i32);
let r = unsafe {
oakaudio_sync_place_by_waveform_offset(0, 1, 48000, 48000, &mut num, &mut den, &mut valid)
};
assert_eq!(r, 0);
assert_eq!(num, 1);
assert_eq!(den, 1);
assert_eq!(valid, 1);
let r = unsafe {
oakaudio_sync_place_by_waveform_offset(1, 2, 48000, 48000, &mut num, &mut den, &mut valid)
};
assert_eq!(r, 0);
assert_eq!(num, 3);
assert_eq!(den, 2);
assert_eq!(valid, 1);
// A null rate is invalid (valid=0, null rational), and the FFI rejects
// a zero timeline denominator.
let r = unsafe {
oakaudio_sync_place_by_waveform_offset(1, 2, 48000, 0, &mut num, &mut den, &mut valid)
};
assert_eq!(r, 0);
assert_eq!(valid, 0);
assert_eq!(num, 0);
assert_eq!(den, 0);
let r = unsafe {
oakaudio_sync_place_by_waveform_offset(1, 0, 48000, 48000, &mut num, &mut den, &mut valid)
};
assert_eq!(r, OAKAUDIO_E_INVALID);
}
/// extract_rms_envelope produces one value per window; a window larger than
/// the input yields a single envelope point.
#[test]
fn extract_rms_envelope_shape() {
let data: Vec<f32> = (0..100).map(|i| i as f32).collect();
let planes = common::planar_from(&data, 2);
let ptrs: Vec<*const f32> = planes.iter().map(|p| p.as_ptr()).collect();
let mut out = vec![0.0f64; 16];
let n = unsafe {
oakaudio_sync_extract_rms_envelope(ptrs.as_ptr(), 2, 100, 10, out.as_mut_ptr(), 16)
};
assert_eq!(n, 10);
assert!(out.iter().take(10).all(|&v| v > 0.0));
let n = unsafe {
oakaudio_sync_extract_rms_envelope(ptrs.as_ptr(), 2, 100, 200, out.as_mut_ptr(), 16)
};
assert_eq!(n, 1);
// Invalid inputs.
assert_eq!(
unsafe {
oakaudio_sync_extract_rms_envelope(std::ptr::null(), 2, 100, 10, out.as_mut_ptr(), 16)
},
OAKAUDIO_E_INVALID
);
assert_eq!(
unsafe {
oakaudio_sync_extract_rms_envelope(ptrs.as_ptr(), 0, 100, 10, out.as_mut_ptr(), 16)
},
OAKAUDIO_E_INVALID
);
assert_eq!(
unsafe {
oakaudio_sync_extract_rms_envelope(ptrs.as_ptr(), 2, 100, 0, out.as_mut_ptr(), 16)
},
OAKAUDIO_E_INVALID
);
assert_eq!(
unsafe {
oakaudio_sync_extract_rms_envelope(ptrs.as_ptr(), 2, 100, 10, out.as_mut_ptr(), -1)
},
OAKAUDIO_E_INVALID
);
// Two-stage: NULL out returns the required count.
let n = unsafe {
oakaudio_sync_extract_rms_envelope(ptrs.as_ptr(), 2, 100, 10, std::ptr::null_mut(), 0)
};
assert_eq!(n, 10);
}
/// estimate_envelope_offset: for a candidate delayed by N windows relative
/// to the reference, the returned offset is +N windows and valid=1.
#[test]
fn envelope_offset_recovers_delay() {
let reference: Vec<f64> = (0..10).map(|i| i as f64).collect();
let mut candidate = vec![0.0f64; 10];
candidate[2..].copy_from_slice(&reference[..8]);
let mut out = OffsetResult {
offset_samples: 0,
confidence: 0.0,
valid: 0,
};
let r = unsafe {
oakaudio_sync_estimate_envelope_offset(
reference.as_ptr(),
10,
candidate.as_ptr(),
10,
std::ptr::null(),
std::ptr::null(),
100,
10,
&mut out,
)
};
assert_eq!(r, 0);
assert_eq!(out.valid, 1);
assert_eq!(out.offset_samples, 200);
assert!((out.confidence - 1.0).abs() < 1e-9);
}
/// estimate_envelope_offset: windows masked invalid on either side are
/// excluded from correlation; empty masks are treated as all-valid.
#[test]
fn envelope_offset_respects_valid_masks() {
let reference: Vec<f64> = (0..10).map(|i| i as f64).collect();
let mut candidate = vec![0.0f64; 10];
candidate[2..].copy_from_slice(&reference[..8]);
// Only the last reference window is valid -> no lag has >= 2 valid
// overlap windows, so the estimate is invalid.
let mut ref_valid = [1u8; 10];
ref_valid[..9].fill(0);
let mut out = OffsetResult {
offset_samples: 0,
confidence: 0.0,
valid: 0,
};
let r = unsafe {
oakaudio_sync_estimate_envelope_offset(
reference.as_ptr(),
10,
candidate.as_ptr(),
10,
ref_valid.as_ptr(),
std::ptr::null(),
100,
10,
&mut out,
)
};
assert_eq!(r, 0);
assert_eq!(out.valid, 0);
assert_eq!(out.confidence, 0.0);
// Fully-valid masks behave like the unmasked call.
let mut valid = [1u8; 10];
let r = unsafe {
oakaudio_sync_estimate_envelope_offset(
reference.as_ptr(),
10,
candidate.as_ptr(),
10,
valid.as_ptr(),
valid.as_ptr(),
100,
10,
&mut out,
)
};
assert_eq!(r, 0);
assert_eq!(out.valid, 1);
assert_eq!(out.offset_samples, 200);
// NULL arrays / non-positive lengths are invalid.
let r = unsafe {
oakaudio_sync_estimate_envelope_offset(
std::ptr::null(),
10,
candidate.as_ptr(),
10,
std::ptr::null(),
std::ptr::null(),
100,
10,
&mut out,
)
};
assert_eq!(r, OAKAUDIO_E_INVALID);
}
/// estimate_stretch_and_offset: a candidate sampled at 2x the reference
/// rate reports rate ~2.0 (>1 = speed up) with a valid=1 result. A
/// non-linear (sine) reference is used — normalized correlation of linear
/// ramps is degenerate (any rate correlates 1.0), but only the true rate
/// resamples the sine back onto the reference exactly.
#[test]
fn stretch_offset_recovers_rate() {
let reference: Vec<f64> = (0..10)
.map(|k| (2.0 * std::f64::consts::PI * 0.7 * k as f64).sin())
.collect();
// Candidate at 2x: even samples are exact, odd samples are midpoints.
let mut candidate = Vec::with_capacity(20);
for k in 0..10 {
candidate.push(reference[k]);
if k + 1 < 10 {
candidate.push((reference[k] + reference[k + 1]) / 2.0);
}
}
let mut out = StretchOffsetResult {
rate: 0.0,
offset_samples: 0,
confidence: 0.0,
valid: 0,
};
let r = unsafe {
oakaudio_sync_estimate_stretch_and_offset(
reference.as_ptr(),
10,
candidate.as_ptr(),
candidate.len() as i32,
std::ptr::null(),
std::ptr::null(),
100,
10,
0.5,
3.0,
0.1,
&mut out,
)
};
assert_eq!(r, 0);
assert_eq!(out.valid, 1);
assert!((out.rate - 2.0).abs() < 0.15, "rate = {}", out.rate);
assert!(out.confidence > 0.99, "confidence = {}", out.confidence);
// Invalid rate parameters are rejected.
let r = unsafe {
oakaudio_sync_estimate_stretch_and_offset(
reference.as_ptr(),
10,
candidate.as_ptr(),
candidate.len() as i32,
std::ptr::null(),
std::ptr::null(),
100,
10,
0.0,
3.0,
0.1,
&mut out,
)
};
assert_eq!(r, OAKAUDIO_E_INVALID);
}
/// estimate_* on identical silent envelopes yields low/no confidence and
/// valid=0 (no correlation peak).
#[test]
fn silent_inputs_invalid() {
let silence = vec![0.0f64; 10];
let mut out = OffsetResult {
offset_samples: 0,
confidence: 0.0,
valid: 0,
};
let r = unsafe {
oakaudio_sync_estimate_envelope_offset(
silence.as_ptr(),
10,
silence.as_ptr(),
10,
std::ptr::null(),
std::ptr::null(),
100,
10,
&mut out,
)
};
assert_eq!(r, 0);
assert_eq!(out.valid, 0);
assert_eq!(out.confidence, 0.0);
}
/// The crate-level unmasked wrappers (estimate_offset on raw sample
/// buffers, estimate_envelope_offset on envelopes) route to the same
/// correlation core and recover the same delay. A non-monotonic envelope
/// is used — equal-slope linear ramps correlate 1.0 at multiple lags, so
/// only the exact match is unambiguous.
#[test]
fn crate_level_unmasked_wrappers() {
let reference: Vec<f64> = vec![0.0, 0.1, 0.2, 0.9, 0.8, 0.3, 0.4, 0.5, 0.6, 0.7];
let mut candidate = vec![0.0f64; 10];
candidate[2..].copy_from_slice(&reference[..8]);
let env = oakaudio::waveformsync::estimate_envelope_offset(&reference, &candidate, 100, 10);
assert!(env.valid);
assert_eq!(env.offset_samples, 200);
assert!((env.confidence - 1.0).abs() < 1e-9);
// Raw sample buffers: 10 windows of 100 constant-amplitude samples,
// candidate delayed by two windows.
let ref_samples: Vec<f32> = (0..1000).map(|i| reference[i / 100] as f32).collect();
let mut cand_samples = vec![0.0f32; 1000];
cand_samples[200..].copy_from_slice(&ref_samples[..800]);
let raw = oakaudio::waveformsync::estimate_offset(
&[ref_samples.as_slice()],
&[cand_samples.as_slice()],
100,
500,
);
assert!(raw.valid, "offset should be recovered from raw samples");
assert_eq!(raw.offset_samples, 200);
}
+386
View File
@@ -0,0 +1,386 @@
// 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/>.
//! AudioVisualWaveform contract tests (waveform.rs), through the C ABI.
mod common;
use std::ffi::CString;
use common::{pair, write_wav};
use oakaudio::error::{OAKAUDIO_E_INVALID, OAKAUDIO_E_STATE};
use oakaudio::ffi::waveform::{
oakaudio_waveform_extract, oakaudio_waveform_free, oakaudio_waveform_get_channel_count,
oakaudio_waveform_get_summary, oakaudio_waveform_init, oakaudio_waveform_length,
oakaudio_waveform_overwrite_samples, oakaudio_waveform_overwrite_silence,
oakaudio_waveform_overwrite_sums, oakaudio_waveform_re_sum_s, oakaudio_waveform_resize,
oakaudio_waveform_set_channel_count, oakaudio_waveform_sum_samples_s,
oakaudio_waveform_trim_in, oakaudio_waveform_trim_range, MinMax,
};
/// Fill `w` with 100 samples/channel of a 0..0.99 ramp at 100 Hz (1 s).
fn fill_ramp(w: oakaudio::handle::CHandle) {
let ch0: Vec<f32> = (0..100).map(|i| i as f32 * 0.01).collect();
let ch1: Vec<f32> = (0..100).map(|i| -(i as f32) * 0.01).collect();
let planes = [ch0.as_ptr(), ch1.as_ptr()];
assert_eq!(unsafe { oakaudio_waveform_set_channel_count(w, 2) }, 0);
assert_eq!(
unsafe { oakaudio_waveform_overwrite_samples(w, planes.as_ptr(), 100, 100, 0, 1) },
0
);
}
fn summary(w: oakaudio::handle::CHandle, start: (i64, i64), length: (i64, i64), cap: i32) -> Vec<MinMax> {
let mut out = vec![MinMax { min: 0.0, max: 0.0 }; cap as usize * 2];
let n = unsafe {
oakaudio_waveform_get_summary(
w,
start.0,
start.1,
length.0,
length.1,
out.as_mut_ptr(),
cap,
)
};
assert_eq!(n, cap);
out.truncate(cap as usize * 2);
out
}
/// set_channel_count then overwrite_samples writes planar data at the given
/// start; length() reflects the covered span and get_summary returns
/// channel-interleaved min/max pairs.
#[test]
fn overwrite_samples_and_length() {
let mut w = unsafe { oakaudio_waveform_init() };
fill_ramp(w);
let (mut num, mut den) = (0i64, 0i64);
assert_eq!(unsafe { oakaudio_waveform_length(w, &mut num, &mut den) }, 0);
assert_eq!(num, 1);
assert_eq!(den, 1);
let out = summary(w, (0, 1), (1, 1), 1);
assert_eq!(out[0].min, 0.0);
assert!((out[0].max - 0.99).abs() < 1e-5, "max = {}", out[0].max);
assert!((out[1].min + 0.99).abs() < 1e-5, "min = {}", out[1].min);
assert_eq!(out[1].max, 0.0);
unsafe { oakaudio_waveform_free(&mut w) };
}
/// get_summary with out_pairs NULL returns the required point count without
/// writing; a too-small capacity returns the same count and leaves the
/// buffer untouched (two-stage contract).
#[test]
fn summary_two_stage_query() {
let mut w = unsafe { oakaudio_waveform_init() };
fill_ramp(w);
// NULL out: required count only.
let n = unsafe { oakaudio_waveform_get_summary(w, 0, 1, 1, 1, std::ptr::null_mut(), 0) };
assert_eq!(n, 1);
// Too-small capacity: same count, buffer untouched.
let mut out = [MinMax { min: -1.0, max: -1.0 }; 2];
let n = unsafe { oakaudio_waveform_get_summary(w, 0, 1, 1, 1, out.as_mut_ptr(), 0) };
assert_eq!(n, 1);
assert_eq!(out[0].min, -1.0);
assert_eq!(out[0].max, -1.0);
// A zero/negative length is invalid.
assert_eq!(
unsafe { oakaudio_waveform_get_summary(w, 0, 1, 0, 1, std::ptr::null_mut(), 0) },
OAKAUDIO_E_INVALID
);
assert_eq!(
unsafe { oakaudio_waveform_get_summary(w, 0, 1, 1, 0, std::ptr::null_mut(), 0) },
OAKAUDIO_E_INVALID
);
unsafe { oakaudio_waveform_free(&mut w) };
}
/// overwrite_sums copies channel-interleaved pairs from another waveform
/// into a dest range; a 0/1 length copies all of src.
#[test]
fn overwrite_sums_range_copy() {
let mut src = unsafe { oakaudio_waveform_init() };
fill_ramp(src);
let mut dst = unsafe { oakaudio_waveform_init() };
assert_eq!(unsafe { oakaudio_waveform_set_channel_count(dst, 2) }, 0);
assert_eq!(
unsafe { oakaudio_waveform_overwrite_sums(dst, src, 0, 1, 0, 1, 0, 1) },
0
);
// A 0/1 length means "copy everything": dst matches src exactly.
let out = summary(dst, (0, 1), (1, 1), 1);
assert_eq!(out[0].min, 0.0);
assert!((out[0].max - 0.99).abs() < 1e-5, "max = {}", out[0].max);
assert!((out[1].min + 0.99).abs() < 1e-5, "min = {}", out[1].min);
assert_eq!(out[1].max, 0.0);
let (mut num, mut den) = (0i64, 0i64);
unsafe { oakaudio_waveform_length(dst, &mut num, &mut den) };
assert_eq!(num, 1);
// Empty src handle is invalid.
let empty = oakaudio::handle::CHandle::null();
assert_eq!(
unsafe { oakaudio_waveform_overwrite_sums(dst, empty, 0, 1, 0, 1, 0, 1) },
OAKAUDIO_E_INVALID
);
unsafe { oakaudio_waveform_free(&mut dst) };
unsafe { oakaudio_waveform_free(&mut src) };
}
/// overwrite_silence zeroes min/max over a range without changing length.
#[test]
fn overwrite_silence() {
let mut w = unsafe { oakaudio_waveform_init() };
fill_ramp(w);
assert_eq!(
unsafe { oakaudio_waveform_overwrite_silence(w, 0, 1, 1, 2) },
0
);
// First half is silenced; second half retains the ramp data.
let out = summary(w, (0, 1), (1, 2), 1);
assert_eq!(pair(out[0].min, out[0].max), pair(0.0, 0.0));
assert_eq!(pair(out[1].min, out[1].max), pair(0.0, 0.0));
let out = summary(w, (1, 2), (1, 2), 1);
assert!(out[0].max > 0.5, "second half must keep ramp data, got {:?}", out[0]);
assert!(out[1].min < -0.5);
let (mut num, mut den) = (0i64, 0i64);
unsafe { oakaudio_waveform_length(w, &mut num, &mut den) };
assert_eq!((num, den), (1, 1), "overwrite_silence must not change length");
unsafe { oakaudio_waveform_free(&mut w) };
}
/// trim_in/trim_range/resize adjust length and drop or pad data; a negative
/// trim_in prepends silence (C++ semantics).
#[test]
fn trim_and_resize() {
let mut w = unsafe { oakaudio_waveform_init() };
fill_ramp(w);
// Negative trim_in prepends silence: absolute end (length) unchanged.
assert_eq!(unsafe { oakaudio_waveform_trim_in(w, -1, 2) }, 0);
let (mut num, mut den) = (0i64, 0i64);
unsafe { oakaudio_waveform_length(w, &mut num, &mut den) };
assert_eq!((num, den), (1, 1));
// Resize extends to 2 s.
assert_eq!(unsafe { oakaudio_waveform_resize(w, 2, 1) }, 0);
unsafe { oakaudio_waveform_length(w, &mut num, &mut den) };
assert_eq!((num, den), (2, 1));
// trim_range keeps 0.5 s from the (prepended) start.
assert_eq!(unsafe { oakaudio_waveform_trim_range(w, 0, 1, 1, 2) }, 0);
unsafe { oakaudio_waveform_length(w, &mut num, &mut den) };
assert_eq!((num, den), (1, 2));
// A negative resize target or a zero denominator is invalid.
assert_eq!(
unsafe { oakaudio_waveform_resize(w, -1, 2) },
OAKAUDIO_E_INVALID
);
assert_eq!(
unsafe { oakaudio_waveform_resize(w, 1, 0) },
OAKAUDIO_E_INVALID
);
unsafe { oakaudio_waveform_free(&mut w) };
}
/// sum_samples_s reduces planar samples into one min/max pair per channel;
/// re_sum_s merges channel-interleaved entries into one pair per channel.
/// Both match golden vectors from the C++ implementation.
#[test]
fn sum_and_resum_golden() {
let ch0 = [1.0f32, -2.0, 3.0];
let ch1 = [4.0f32, -5.0, 6.0];
let planes = [ch0.as_ptr(), ch1.as_ptr()];
let mut out = [MinMax { min: 0.0, max: 0.0 }; 2];
let r = unsafe {
oakaudio_waveform_sum_samples_s(planes.as_ptr(), 2, 0, 3, out.as_mut_ptr())
};
assert_eq!(r, 0);
assert_eq!(pair(out[0].min, out[0].max), pair(-2.0, 3.0));
assert_eq!(pair(out[1].min, out[1].max), pair(-5.0, 6.0));
// re_sum_s over 4 interleaved entries, 2 channels -> one pair per
// channel merging both points.
let input = [
MinMax { min: 1.0, max: 2.0 },
MinMax { min: 3.0, max: 4.0 },
MinMax { min: 5.0, max: 6.0 },
MinMax { min: 7.0, max: 8.0 },
];
let mut out = [MinMax { min: 0.0, max: 0.0 }; 2];
let r = unsafe { oakaudio_waveform_re_sum_s(input.as_ptr(), 4, 2, out.as_mut_ptr()) };
assert_eq!(r, 0);
assert_eq!(pair(out[0].min, out[0].max), pair(1.0, 6.0));
assert_eq!(pair(out[1].min, out[1].max), pair(3.0, 8.0));
// Invalid arguments.
assert_eq!(
unsafe { oakaudio_waveform_sum_samples_s(planes.as_ptr(), 2, 0, 0, out.as_mut_ptr()) },
OAKAUDIO_E_INVALID
);
assert_eq!(
unsafe { oakaudio_waveform_re_sum_s(input.as_ptr(), 0, 2, out.as_mut_ptr()) },
OAKAUDIO_E_INVALID
);
}
/// extract decodes a file through the oakcodec decoder C ABI into
/// channel-interleaved pairs; a missing file returns OAKAUDIO_E_NOT_FOUND.
#[test]
fn extract_file_and_notfound() {
let path = std::env::temp_dir().join(format!(
"oakaudio_extract_{}.wav",
std::process::id()
));
// 8 frames of stereo ramp, 48000 Hz.
let mut samples = Vec::with_capacity(16);
for i in 0..8i16 {
samples.push(i * 1000);
samples.push(-(i * 1000));
}
write_wav(&path, 2, 48000, &samples).unwrap();
let cpath = CString::new(path.to_str().unwrap()).unwrap();
// Size query first: NULL out_pairs, the count and channel count still
// come back.
let mut channels = 0i32;
let n = unsafe {
oakaudio_waveform_extract(
cpath.as_ptr(),
0,
4,
std::ptr::null_mut(),
0,
&mut channels,
)
};
assert_eq!(n, 2);
assert_eq!(channels, 2);
// Full extraction: 8 frames / 4 per point = 2 points, 2 pairs each.
let mut pairs = [MinMax { min: 0.0, max: 0.0 }; 4];
let mut channels = 0i32;
let n = unsafe {
oakaudio_waveform_extract(
cpath.as_ptr(),
0,
4,
pairs.as_mut_ptr(),
2,
&mut channels,
)
};
assert_eq!(n, 2);
assert_eq!(channels, 2);
let scale = 32768.0f32;
assert_eq!(pair(pairs[0].min, pairs[0].max), pair(0.0, 3000.0 / scale));
assert_eq!(pair(pairs[1].min, pairs[1].max), pair(-3000.0 / scale, 0.0));
assert_eq!(pair(pairs[2].min, pairs[2].max), pair(4000.0 / scale, 7000.0 / scale));
assert_eq!(pair(pairs[3].min, pairs[3].max), pair(-7000.0 / scale, -4000.0 / scale));
// Missing file -> NOT_FOUND.
let missing = CString::new(
std::env::temp_dir()
.join(format!("oakaudio_missing_{}.wav", std::process::id()))
.to_str()
.unwrap(),
)
.unwrap();
let _ = std::fs::remove_file(std::path::Path::new(missing.to_str().unwrap()));
let r = unsafe {
oakaudio_waveform_extract(
missing.as_ptr(),
0,
4,
std::ptr::null_mut(),
0,
std::ptr::null_mut(),
)
};
assert_eq!(r, -60004);
std::fs::remove_file(&path).ok();
}
/// FFI validation and empty-handle error paths.
#[test]
fn ffi_error_paths() {
let empty = oakaudio::handle::CHandle::null();
assert_eq!(
unsafe { oakaudio_waveform_get_channel_count(empty) },
OAKAUDIO_E_INVALID
);
assert_eq!(
unsafe { oakaudio_waveform_set_channel_count(empty, 2) },
OAKAUDIO_E_INVALID
);
let (mut num, mut den) = (0i64, 0i64);
assert_eq!(
unsafe { oakaudio_waveform_length(empty, &mut num, &mut den) },
OAKAUDIO_E_INVALID
);
// Negative channel count is invalid.
let mut w = unsafe { oakaudio_waveform_init() };
assert_eq!(
unsafe { oakaudio_waveform_set_channel_count(w, -1) },
OAKAUDIO_E_INVALID
);
// overwrite_samples before set_channel_count is a state error.
let data = [0.5f32; 8];
let planes = [data.as_ptr()];
assert_eq!(
unsafe { oakaudio_waveform_overwrite_samples(w, planes.as_ptr(), 8, 48000, 0, 1) },
OAKAUDIO_E_STATE
);
// A zero denominator is rejected.
assert_eq!(
unsafe { oakaudio_waveform_overwrite_samples(w, planes.as_ptr(), 8, 48000, 0, 0) },
OAKAUDIO_E_INVALID
);
// A non-positive frame count is invalid.
assert_eq!(
unsafe { oakaudio_waveform_overwrite_samples(w, planes.as_ptr(), 0, 48000, 0, 1) },
OAKAUDIO_E_INVALID
);
// NULL planes are invalid.
assert_eq!(
unsafe { oakaudio_waveform_overwrite_samples(w, std::ptr::null(), 8, 48000, 0, 1) },
OAKAUDIO_E_INVALID
);
unsafe { oakaudio_waveform_free(&mut w) };
}
/// free(NULL)/free(empty) are no-ops.
#[test]
fn free_null_noop() {
let mut w = oakaudio::handle::CHandle::null();
unsafe { oakaudio_waveform_free(&mut w) };
unsafe { oakaudio_waveform_free(std::ptr::null_mut()) };
}
+31
View File
@@ -0,0 +1,31 @@
# Oak Video Editor - Non-Linear Video Editor
# Copyright (C) 2026 Oak Team
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# `ocio-sys` (the FFI layer behind `ocio-rs`) does not probe the system for
# OpenColorIO on its own: with no configuration it builds a *stub* bridge
# whose calls all fail. These environment variables make it link the real
# Homebrew OpenColorIO dylib:
#
# OCIO_RS_ENABLE_REAL - opt into the real (non-stub) bridge
# OCIO_INSTALL_DIR - prefix whose include/ and lib/ hold OpenColorIO
# OCIO_RS_LINK - Homebrew ships a dylib, so link dynamically
#
# See README.md "Build & test" for the bundled alternative.
[env]
OCIO_RS_ENABLE_REAL = "1"
OCIO_INSTALL_DIR = "/opt/homebrew"
OCIO_RS_LINK = "dynamic"
+332
View File
@@ -0,0 +1,332 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bytemuck"
version = "1.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "cc"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cmake"
version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
dependencies = [
"cc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "fax"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a"
[[package]]
name = "find-msvc-tools"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de"
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"moxcms",
"num-traits",
"tiff",
]
[[package]]
name = "log"
version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "oakcommon"
version = "0.1.0"
dependencies = [
"image",
"log",
"oakcore-rs",
"ocio-rs",
"quick-xml",
]
[[package]]
name = "oakcore-rs"
version = "0.1.0"
[[package]]
name = "ocio-rs"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3492534019b59e29dba06014f907dd12824537ed4d293d4108c4bfc669de7fd"
dependencies = [
"ocio-sys",
"thiserror",
]
[[package]]
name = "ocio-sys"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e63251d72d848de5eda39d59cd6490260cf031738ebd518ea37d76b5aae614ec"
dependencies = [
"cc",
"cmake",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "pxfm"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
dependencies = [
"memchr",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tiff"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52"
dependencies = [
"fax",
"flate2",
"half",
"quick-error",
"weezl",
"zune-jpeg",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "zerocopy"
version = "0.8.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zune-core"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
+35
View File
@@ -0,0 +1,35 @@
[package]
name = "oakcommon"
version = "0.1.0"
edition = "2021"
description = "Oak Video Editor shared utilities (Rust)"
license = "GPL-3.0-or-later"
[lib]
crate-type = ["staticlib", "rlib"]
[profile.release]
panic = "unwind"
[features]
# Compile the in-crate ffmpeg_bridge mock (fb_find_best_pix_fmt_of_list stub)
# so the integration tests can link without libffmpeg_bridge. Without this
# flag, `cargo test --lib` still works (the stub is active under `cfg(test)`),
# but the C ABI wrapper tests in tests/ffi_ffmpegutils.rs need it. This
# mirrors the `test-stubs` convention of oakplugin / oaktimeline.
test-stubs = []
[dependencies]
oakcore-rs = { path = "../../oakcore-rs" }
quick-xml = "0.41.0"
log = "0.4"
# OpenColorIO bindings (crates.io `ocio-rs`, BSD-3-Clause). ocioutils.rs maps
# PixelFormat to the real `ocio_rs::BitDepth` enum and wraps
# `ocio_rs::Config`/`CPUProcessor` for config loading and RGBA transforms.
# Rationale registered in README.md.
ocio-rs = "0.2.1"
# Pure-Rust image I/O (crates.io `image`, MIT OR Apache-2.0); default features
# off, TIFF enabled — the only format current callers need. oiioutils.rs
# derives per-channel bit depths from its color-type tables and does float
# image I/O through it. See README.md.
image = { version = "0.25", default-features = false, features = ["tiff"] }
+104
View File
@@ -0,0 +1,104 @@
# oakcommon Rust crate
> Status: **implemented**. All `include/common/*.h` contracts are
> implemented in Rust and covered by unit + C ABI integration tests
> (see [Testing](#testing)).
## Scope
Replaces the C++ oakcommon module (`src/common/src`): config store,
command-line parser, XML stream reader/writer, file functions,
debug/logging, ffmpeg/OCIO/OIIO utility queries, video/subtitle
params, color transform, misc utilities. Pure leaf module — depends
only on `oakcore-rs`, `quick-xml`, `log`, `ocio-rs`, `image`, and
system libraries.
Public contract: `include/common/*.h` (18 headers) — frozen,
implemented verbatim by `src/ffi.rs`.
## Third-party crates
| Crate | Version | License | Status |
|---|---|---|---|
| `quick-xml` | 0.41 | MIT | adopted — XML reader/writer (`xmlutils.rs`) |
| `log` | 0.4 | MIT / Apache-2.0 | adopted — logging facade (`debug.rs`); the stderr sink is retained as the always-available backend |
| `ocio-rs` | 0.2.1 | BSD-3-Clause | adopted — real OpenColorIO access for `ocioutils.rs` (`OcioConfig`/`OcioProcessor`, `BitDepth` mapping). Pulled in via `ocio-sys` built with `OCIO_RS_ENABLE_REAL=1` against the Homebrew OCIO install (see `.cargo/config.toml`) |
| `image` | 0.25 | MIT / Apache-2.0 | adopted — per-channel bit-depth tables and 32-bit float TIFF I/O for `oiioutils.rs` (`image_color_type_for`/`bits_per_channel`, `F32Image`). Default features off, `tiff` only |
| `oakcore-rs` | path | GPL-3.0 | adopted — `Rational::from_double` (the C++ `Rational::from_double` port of FFmpeg's `av_d2q`) for `get_pixel_aspect_ratio`; a hand-written port kept in the leaf crate instead of pulling in `ffmpeg-next` |
| `serde_json` | — | MIT / Apache-2.0 | **evaluated, not adopted** — the ConfigStore format is INI (QSettings-style `key=value` with `[group]` sections, `%g` doubles), not JSON; switching would break C++/Rust file interop |
| `pico-args` / `clap` | — | MIT / Apache-2.0 | **evaluated, not adopted** — `commandlineparser.rs` must keep exact C++ quirks (case-insensitive names, first-match-wins, last-value-wins, argv[0] skipping, truncating getter copies, borrowed C ABI handles) that a generic parser cannot express without changing the C ABI shape |
## Architectural decisions
1. **Leaf module discipline**: no `bridge/` to other oak modules.
FFmpeg is reached through `ffmpeg_bridge`'s C ABI (narrow
`extern "C"` blocks in `ffmpegutils.rs`); OCIO and OIIO access is
pure Rust via the crates.io bindings listed in the table above
(`ocioutils.rs`, `oiioutils.rs`).
2. **`olive::Variant` disappears**: it exists in C++ only because
QVariant left a hole. Rust modules use closed enums; nothing in
common needs it. `variant.{h,cpp}` (C++) is retired when all
consumers are Rust.
3. **XML**: `XmlStreamReader/Writer` keep the C++ streaming API shape
(the C ABI is built on it), implemented over quick-xml — behavior
(attribute order, error semantics) pinned by tests against the C++
oracle.
4. **Config**: the ConfigStore is **INI-backed** (QSettings-style
`key=value` with `[group]` sections, `;`/`#` comments, `%g`
double formatting), keeping the exact C++ file format and lookup
order (user config → app defaults). It is *not* JSON — an earlier
draft described it as JSON-backed, which was wrong; that claim was
removed from this document.
5. **Logging**: `debug.rs` provides the leveled logger
(qWarning/qDebug/qCritical/qInfo replacement) with a printf-style C
ABI. Every record is written to stderr (the C++ `stderr_sink`
parity) and additionally forwarded to the `log` crate's global
logger when the host has installed one. oakcommon never installs a
global logger itself — the C ABI is loaded into hosts that set
their own, and `log::set_logger` can only be called once per
process.
6. **OCIO / OIIO / FFmpeg**: `ocioutils.rs` talks to real OpenColorIO
through the crates.io `ocio-rs` bindings (`OcioConfig`/`OcioProcessor`,
`BitDepth` enum — no hand-written constant tables); `oiioutils.rs`
derives its OIIO base-type mapping from the `image` crate's color-type
tables (HALF pinned from the frozen OIIO table — `image` has no f16
sample type) and converts aspect ratios with
`oakcore_rs::Rational::from_double` — a hand-written port of FFmpeg's
`av_d2q` matching the C++ `Rational::from_double` exactly, kept in the
leaf crate rather than adding `ffmpeg-next`/`ffmpeg-sys-next` (narrow
extern C discipline). 32-bit float image I/O (`F32Image`) is pure
`image`. All adopted crates are registered in the table above.
## Layout
```
src/
lib.rs crate doc + module map
error.rs error codes (include/common/error.h)
handle.rs refcounted-handle scaffolding
configstore.rs INI config (include/common/config.h)
commandlineparser.rs
xmlutils.rs streaming XML reader/writer
filefunctions.rs file/dir helpers
debug.rs leveled logging
ffmpegutils.rs pixfmt/samplefmt mapping (via ffmpeg_bridge C ABI)
ocioutils.rs OCIO queries
oiioutils.rs OIIO queries
videoparams.rs VideoParams plain data + queries
subtitleparams.rs SubtitleParams
colortransform.rs ColorTransform plain data
miscutils.rs misc (loop mode, drop behavior, power, current…)
ffi.rs export layer (one submodule per public header)
tests/ contract tests per module
```
## Testing
`cargo test --release --features test-stubs` runs the full suite:
unit tests, C ABI contract tests, and the integration tests (incl.
`tests/ffi_ffmpegutils.rs`). The `test-stubs` feature substitutes the
in-crate ffmpeg_bridge mock (`fb_find_best_pix_fmt_of_list` stub, see
`src/ffmpegutils.rs`) so the C ABI tests link without
libffmpeg_bridge; without the feature that symbol is imported from
`ffmpeg_bridge` at link time. This mirrors the `test-stubs`
convention of oakplugin / oaktimeline.
+167
View File
@@ -0,0 +1,167 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! `olive::ColorTransform` — a color transform description. Mirrors
//! `src/common/src/colortransform.h` and `include/common/colortransform.h`.
//!
//! A transform is either an output-colorspace transform or a
//! display/view/look transform. The C++ object is passed through the C ABI
//! as a refcounted handle ([`OakColorTransform`] in `crate::ffi`); this
//! module owns the plain-data description behind the handle.
//!
//! The C++-only functions `oakcommon_colortransform_init_from_native` /
//! `get_native` take or return `olive::ColorTransform` and cannot be
//! expressed from Rust; they are served by the C++ adapter layer, not here.
/// `olive::ColorTransform` — the plain-data description behind the handle.
pub struct ColorTransform {
/// Output colorspace name (empty when this is a display transform).
output: String,
/// Display name (empty when this is an output-colorspace transform).
display: String,
/// Whether this is a display/view/look transform.
// CPP-PARITY: the C++ class tracks this with an explicit `is_display_`
// member (`src/common/src/colortransform.h`). We keep it as a separate
// bool as well so an empty display name on a display transform is still
// recognized as a display transform.
is_display: bool,
/// View name (display transforms only).
view: String,
/// Look name (display transforms only).
look: String,
}
impl ColorTransform {
/// New output-colorspace transform.
pub fn new_output(output: &str) -> Self {
Self {
output: output.to_string(),
display: String::new(),
is_display: false,
view: String::new(),
look: String::new(),
}
}
/// New display/view/look transform.
pub fn new_display(display: &str, view: &str, look: &str) -> Self {
// CPP-PARITY: the C++ display constructor stores the display name in
// the shared `output_` member and sets `is_display_ = true`; here the
// display name lives in its own `display` field (the skeleton's public
// `display()` / `output()` accessors read the respective field).
Self {
output: String::new(),
display: display.to_string(),
is_display: true,
view: view.to_string(),
look: look.to_string(),
}
}
/// Whether this is a display/view/look transform.
pub fn is_display(&self) -> bool {
self.is_display
}
/// Display name (empty if this is an output transform).
pub fn display(&self) -> &str {
&self.display
}
/// Output colorspace name (empty if this is a display transform).
pub fn output(&self) -> &str {
&self.output
}
/// View name.
pub fn view(&self) -> &str {
&self.view
}
/// Look name.
pub fn look(&self) -> &str {
&self.look
}
}
#[cfg(test)]
mod tests {
use super::ColorTransform;
#[test]
fn output_transform_defaults() {
let t = ColorTransform::new_output("sRGB");
assert!(!t.is_display());
assert_eq!(t.output(), "sRGB");
assert_eq!(t.display(), "");
assert_eq!(t.view(), "");
assert_eq!(t.look(), "");
}
#[test]
fn display_transform_defaults() {
let t = ColorTransform::new_display("DCI-P3", "standard", "soft");
assert!(t.is_display());
assert_eq!(t.display(), "DCI-P3");
assert_eq!(t.view(), "standard");
assert_eq!(t.look(), "soft");
assert_eq!(t.output(), "");
}
#[test]
fn output_transform_empty_strings() {
// Mirrors the C++ default constructor: an output transform built from
// an empty string still has `is_display == false`.
let t = ColorTransform::new_output("");
assert!(!t.is_display());
assert_eq!(t.output(), "");
}
#[test]
fn display_transform_recognized_even_with_empty_display_name() {
// The explicit is_display flag (vs. deriving from a non-empty display
// name) keeps an empty-named display transform identifiable.
let t = ColorTransform::new_display("", "view", "");
assert!(t.is_display());
assert_eq!(t.display(), "");
assert_eq!(t.view(), "view");
}
#[test]
fn accessors_return_borrowed_slices() {
let t = ColorTransform::new_display("Display", "View", "Look");
let d: &str = t.display();
let v: &str = t.view();
let l: &str = t.look();
let o: &str = t.output();
assert_eq!(d, "Display");
assert_eq!(v, "View");
assert_eq!(l, "Look");
assert_eq!(o, "");
}
#[test]
fn c_api_string_sizes() {
// The c_api string getters return the required size including the NUL
// (value.size() + 1) and, being NON-TRUNCATING, only copy when the
// buffer is large enough. Verify the values the domain layer exposes
// line up with those sizes.
let t = ColorTransform::new_display("RGB", "ACES", "none");
assert_eq!(t.display().len() + 1, 4);
assert_eq!(t.view().len() + 1, 5);
assert_eq!(t.look().len() + 1, 5);
}
}
+873
View File
@@ -0,0 +1,873 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Command-line option parser, mirroring
//! `src/common/src/commandlineparser.h` and
//! `include/common/commandlineparser.h`.
//!
//! A parser owns a list of registered options and positional arguments;
//! option/argument handles returned at registration time are owned by the
//! parser and stay valid until it is destroyed.
use std::ffi::CString;
use std::io::{self, Write};
use crate::error::{Error, Result};
/// `olive::CommandLineParser` — owns registered options and positional
/// arguments.
pub struct CommandLineParser {
/// Application name shown by `print_help`.
app_name: String,
/// Application version shown by `print_help`.
app_version: String,
/// Registered named options.
///
/// Boxed so that the option objects keep a stable address when the
/// vector grows; option handles borrowed by the C ABI point at the
/// boxed value. // CPP-PARITY: the C++ code heap-allocates each
/// `Option` (`new Option()`); `Vec<Box<..>>` reproduces that stability
/// against reallocation.
options: Vec<Box<CommandLineOption>>,
/// Registered positional arguments (see `options` for the boxing note).
positionals: Vec<Box<CommandLinePositionalArgument>>,
}
impl CommandLineParser {
/// New empty parser.
pub fn new() -> Self {
// CPP-PARITY: `app_name_` defaults to "oak" in the C++ header
// (`std::string app_name_ = "oak"`), `app_version_` to "".
Self {
app_name: "oak".to_string(),
app_version: String::new(),
options: Vec::new(),
positionals: Vec::new(),
}
}
/// Set the application name/version shown by `print_help`.
pub fn set_app_info(&mut self, name: &str, version: &str) {
self.app_name = name.to_string();
self.app_version = version.to_string();
}
/// Register an option with one or more name strings.
///
/// Returns the option, which remains owned by the parser.
pub fn add_option(
&mut self,
names: &[CString],
description: &str,
takes_arg: bool,
arg_placeholder: &str,
hidden: bool,
) -> Result<()> {
let mut strings = Vec::with_capacity(names.len());
for n in names {
// CPP-PARITY: C++ stores raw byte strings and accepts any bytes;
// Rust `String` is UTF-8, so a non-UTF-8 option name is rejected
// here with E_INVALID. The C ABI `add_option` already rejects
// null/empty names before reaching this point.
strings.push(n.to_str().map_err(|_| Error::Invalid)?.to_string());
}
self.options.push(Box::new(CommandLineOption {
names: strings,
description: description.to_string(),
takes_arg,
arg_placeholder: arg_placeholder.to_string(),
hidden,
is_set: false,
setting: None,
}));
Ok(())
}
/// Register a positional argument.
pub fn add_positional_argument(
&mut self,
name: &str,
description: &str,
required: bool,
) -> Result<()> {
self.positionals.push(Box::new(CommandLinePositionalArgument {
name: name.to_string(),
description: description.to_string(),
required,
setting: None,
}));
Ok(())
}
/// Parse an argv-style argument list (argv[0] is the program name and
/// is skipped).
pub fn process(&mut self, argv: &[CString]) -> Result<()> {
let mut positional_index = 0usize;
let mut i = 1usize; // CPP-PARITY: argv[0] is the program name, skipped.
while i < argv.len() {
let arg_str = match argv[i].to_str() {
Ok(s) => s.to_string(),
Err(_) => {
// CPP-PARITY: C++ compares raw bytes; a non-UTF-8 arg
// cannot match a known option/positional here, so treat
// it as an unknown parameter (best effort for the log).
eprintln!("Unknown parameter: {}", argv[i].to_string_lossy());
i += 1;
continue;
}
};
if !arg_str.is_empty() && arg_str.starts_with('-') {
// Must be an option. Skip past the first dash.
let arg_basename = &arg_str[1..];
let mut matched_known = false;
let mut consume_next = false;
// CPP-PARITY: the C++ `goto found_flag` is a labelled break
// out of the double loop; the match is case-insensitive.
'find: for opt in self.options.iter_mut() {
for name in &opt.names {
if name.eq_ignore_ascii_case(arg_basename) {
opt.is_set = true;
if opt.takes_arg && i + 1 < argv.len() {
// CPP-PARITY: the argument value is stored as
// a byte string in C++; Rust only keeps it when
// it is valid UTF-8, otherwise it is left unset.
opt.setting = argv[i + 1].to_str().ok().map(str::to_owned);
consume_next = true;
}
matched_known = true;
break 'find;
}
}
}
if !matched_known {
eprintln!("Unknown parameter: {}", arg_str);
}
// CPP-PARITY: when a `takes_arg` option consumes the following
// argument, C++ advances `i` twice (the inner `i++` plus the
// loop increment); otherwise once.
i += if consume_next { 2 } else { 1 };
} else {
// Must be a positional argument.
if positional_index < self.positionals.len() {
self.positionals[positional_index].setting = Some(arg_str);
positional_index += 1;
} else {
eprintln!("Unknown parameter: {}", arg_str);
}
i += 1;
}
}
Ok(())
}
/// Print usage/help text to stdout.
pub fn print_help(&self, filename: &str) -> Result<()> {
let stdout = io::stdout();
let mut out = stdout.lock();
self.write_help(&mut out, filename)
}
/// Render the usage/help text into `out`, mirroring
/// `CommandLineParser::print_help`. Split out so tests can capture the
/// output in a buffer.
fn write_help<W: Write>(&self, out: &mut W, filename: &str) -> Result<()> {
let r = (|| -> io::Result<()> {
// CPP-PARITY: `printf("%s %s\n")` always emits the separating
// space, so "oak " + version + "\n" when the version is empty.
writeln!(out, "{} {}", self.app_name, self.app_version)?;
writeln!(out, "Copyright (C) 2018-2022 Oak Video Editor Team")?;
// Build the "[name] [name] ..." positional list.
let mut positional_args = String::new();
for (i, p) in self.positionals.iter().enumerate() {
if i > 0 {
positional_args.push(' ');
}
positional_args.push('[');
positional_args.push_str(&p.name);
positional_args.push(']');
}
// CPP-PARITY: on POSIX the basename is everything after the last
// '/'; without a slash the whole string is used.
let basename = match filename.rfind('/') {
Some(pos) => &filename[pos + 1..],
None => filename,
};
writeln!(out, "Usage: {} [options] {}\n", basename, positional_args)?;
for opt in &self.options {
if opt.hidden {
continue;
}
let mut all_args = String::new();
for (i, name) in opt.names.iter().enumerate() {
if i > 0 {
all_args.push_str(", ");
}
all_args.push('-');
all_args.push_str(name);
}
if opt.arg_placeholder.is_empty() {
writeln!(out, " {}", all_args)?;
} else {
writeln!(out, " {} <{}>", all_args, opt.arg_placeholder)?;
}
writeln!(out, " {}\n", opt.description)?;
}
writeln!(out)?;
Ok(())
})();
r.map_err(|e| Error::Failed(e.to_string()))
}
// The six accessors below are `pub(crate)` so the C ABI layer
// (`crate::ffi::commandlineparser`) can hand out stable borrowed handles
// to the boxed options/arguments; they are currently unused until that
// layer is implemented.
#[allow(dead_code)]
/// Number of registered options. `pub(crate)`: used by the C ABI layer
/// to look up the option just appended by [`Self::add_option`].
pub(crate) fn option_count(&self) -> usize {
self.options.len()
}
/// Borrow a registered option by index. `pub(crate)`: hands the C ABI
/// layer a stable pointer to the boxed option for a borrowed handle.
#[allow(dead_code)]
pub(crate) fn option(&self, index: usize) -> Option<&CommandLineOption> {
self.options.get(index).map(|b| b.as_ref())
}
/// Mutably borrow a registered option by index.
#[allow(dead_code)]
pub(crate) fn option_mut(&mut self, index: usize) -> Option<&mut CommandLineOption> {
self.options.get_mut(index).map(|b| b.as_mut())
}
/// Number of registered positional arguments.
#[allow(dead_code)]
pub(crate) fn positional_count(&self) -> usize {
self.positionals.len()
}
/// Borrow a registered positional argument by index.
#[allow(dead_code)]
pub(crate) fn positional(&self, index: usize) -> Option<&CommandLinePositionalArgument> {
self.positionals.get(index).map(|b| b.as_ref())
}
/// Mutably borrow a registered positional argument by index.
#[allow(dead_code)]
pub(crate) fn positional_mut(
&mut self,
index: usize,
) -> Option<&mut CommandLinePositionalArgument> {
self.positionals.get_mut(index).map(|b| b.as_mut())
}
}
impl Default for CommandLineParser {
fn default() -> Self {
Self::new()
}
}
/// `olive::CommandLineOption` — a registered named option.
pub struct CommandLineOption {
/// Option name strings (without leading dash).
names: Vec<String>,
/// Help text.
description: String,
/// Whether the option consumes the following argument.
takes_arg: bool,
/// Placeholder shown in help when `takes_arg`.
arg_placeholder: String,
/// Whether to omit from help output.
hidden: bool,
/// Whether the option was present on the command line.
is_set: bool,
/// The argument value (present only when set).
setting: Option<String>,
}
impl CommandLineOption {
/// Whether the option was present on the command line.
pub fn is_set(&self) -> bool {
self.is_set
}
/// The option's argument value, if one was set.
pub fn get_setting(&self) -> Result<&str> {
match &self.setting {
Some(v) => Ok(v.as_str()),
// CPP-PARITY: C++ `get_setting()` returns the (possibly empty)
// stored string and never fails; the "was it supplied" question
// is answered by [`Self::is_set`]. Rust mirrors this by returning
// an empty string when nothing was stored.
None => Ok(""),
}
}
/// Set the option's argument value.
pub fn set_setting(&mut self, value: &str) -> Result<()> {
self.setting = Some(value.to_string());
Ok(())
}
}
/// `olive::CommandLinePositionalArgument` — a registered positional
/// argument.
pub struct CommandLinePositionalArgument {
/// Argument name.
name: String,
/// Help text.
#[allow(dead_code)] // CPP-PARITY: stored but never read in C++ print_help/process.
description: String,
/// Whether the argument is required.
#[allow(dead_code)] // CPP-PARITY: stored but never consumed in the C++ source.
required: bool,
/// The argument value (present only when parsed).
setting: Option<String>,
}
impl CommandLinePositionalArgument {
/// The argument's value, if one was parsed.
pub fn get_setting(&self) -> Result<&str> {
match &self.setting {
Some(v) => Ok(v.as_str()),
None => Ok(""),
}
}
/// Set the argument's value.
pub fn set_setting(&mut self, value: &str) -> Result<()> {
self.setting = Some(value.to_string());
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cstr(s: &str) -> CString {
CString::new(s).unwrap()
}
/// `new()` reproduces the C++ defaults: app name "oak", empty version,
/// no registered options/arguments.
#[test]
fn defaults() {
let p = CommandLineParser::new();
assert_eq!(p.app_name, "oak");
assert_eq!(p.app_version, "");
assert_eq!(p.option_count(), 0);
assert_eq!(p.positional_count(), 0);
}
/// `set_app_info` overwrites the name and version.
#[test]
fn set_app_info() {
let mut p = CommandLineParser::new();
p.set_app_info("myapp", "2.5");
assert_eq!(p.app_name, "myapp");
assert_eq!(p.app_version, "2.5");
}
/// Adding options/arguments increments the counts and they are
/// retrievable by index.
#[test]
fn add_registers() {
let mut p = CommandLineParser::new();
p.add_option(&[cstr("h"), cstr("help")], "show help", false, "", false)
.unwrap();
p.add_option(&[cstr("o")], "output", true, "FILE", false).unwrap();
p.add_option(&[cstr("secret")], "hidden opt", false, "", true)
.unwrap();
p.add_positional_argument("input", "input file", true).unwrap();
p.add_positional_argument("output", "output file", false).unwrap();
assert_eq!(p.option_count(), 3);
assert_eq!(p.positional_count(), 2);
let opt = p.option(0).unwrap();
assert_eq!(opt.names, vec!["h".to_string(), "help".to_string()]);
assert!(!opt.takes_arg);
assert!(!opt.hidden);
assert!(!opt.is_set());
assert_eq!(opt.get_setting().unwrap(), "");
let opt = p.option(1).unwrap();
assert!(opt.takes_arg);
assert_eq!(opt.arg_placeholder, "FILE");
let opt = p.option(2).unwrap();
assert!(opt.hidden);
let pos = p.positional(0).unwrap();
assert_eq!(pos.name, "input");
assert!(pos.required);
assert_eq!(pos.get_setting().unwrap(), "");
}
/// A non-UTF-8 option name is rejected with E_INVALID.
#[test]
fn add_option_rejects_non_utf8_name() {
let mut p = CommandLineParser::new();
let bad = CString::new(vec![b'x', 0xFF, 0xFE]).unwrap();
assert!(matches!(
p.add_option(&[bad], "", false, "", false),
Err(Error::Invalid)
));
assert_eq!(p.option_count(), 0);
}
/// get/set_setting round-trips on an option.
#[test]
fn option_setting_roundtrip() {
let mut p = CommandLineParser::new();
p.add_option(&[cstr("o")], "", true, "F", false).unwrap();
let mut opt = p.option_mut(0).unwrap();
assert_eq!(opt.get_setting().unwrap(), "");
opt.set_setting("value").unwrap();
assert_eq!(opt.get_setting().unwrap(), "value");
}
/// get/set_setting round-trips on a positional argument.
#[test]
fn positional_setting_roundtrip() {
let mut p = CommandLineParser::new();
p.add_positional_argument("in", "", true).unwrap();
let mut pos = p.positional_mut(0).unwrap();
assert_eq!(pos.get_setting().unwrap(), "");
pos.set_setting("file.mp4").unwrap();
assert_eq!(pos.get_setting().unwrap(), "file.mp4");
}
/// process(): simple flags, case-insensitive matching, value-consuming
/// options, and positional assignment.
#[test]
fn process_flags_and_values() {
let mut p = CommandLineParser::new();
p.add_option(&[cstr("h"), cstr("help")], "", false, "", false)
.unwrap();
p.add_option(&[cstr("o")], "", true, "FILE", false).unwrap();
p.add_option(&[cstr("V")], "", false, "", false).unwrap();
p.add_positional_argument("input", "", true).unwrap();
let argv = [
cstr("prog"),
cstr("-help"),
cstr("-o"),
cstr("out.mov"),
cstr("-v"),
cstr("in.mp4"),
];
p.process(&argv).unwrap();
assert!(p.option(0).unwrap().is_set());
assert_eq!(p.option(0).unwrap().get_setting().unwrap(), "");
// `-o` consumed `out.mov` as its argument.
assert!(p.option(1).unwrap().is_set());
assert_eq!(p.option(1).unwrap().get_setting().unwrap(), "out.mov");
// `-v` matched `-V` case-insensitively.
assert!(p.option(2).unwrap().is_set());
// `in.mp4` filled the first positional.
assert_eq!(p.positional(0).unwrap().get_setting().unwrap(), "in.mp4");
}
/// process(): argv[0] is skipped; a leading empty string before the
/// first arg does not disturb parsing.
#[test]
fn process_skips_program_name() {
let mut p = CommandLineParser::new();
p.add_option(&[cstr("x")], "", false, "", false).unwrap();
let argv = [cstr("prog"), cstr("-x")];
p.process(&argv).unwrap();
assert!(p.option(0).unwrap().is_set());
// A bare program name with no args is fine.
let argv = [cstr("prog")];
p.process(&argv).unwrap();
}
/// process(): a `takes_arg` option at the end of argv consumes nothing
/// and stays set without a value.
#[test]
fn process_takes_arg_at_end() {
let mut p = CommandLineParser::new();
p.add_option(&[cstr("o")], "", true, "FILE", false).unwrap();
let argv = [cstr("prog"), cstr("-o")];
p.process(&argv).unwrap();
assert!(p.option(0).unwrap().is_set());
assert_eq!(p.option(0).unwrap().get_setting().unwrap(), "");
}
/// process(): C++ `process` only ever sets state, never resets it, so a
/// second call that does not mention an option leaves its previous
/// `is_set`/setting intact.
#[test]
fn process_accumulates_state() {
let mut p = CommandLineParser::new();
p.add_option(&[cstr("o")], "", true, "F", false).unwrap();
p.process(&[cstr("p"), cstr("-o"), cstr("a")]).unwrap();
assert!(p.option(0).unwrap().is_set());
assert_eq!(p.option(0).unwrap().get_setting().unwrap(), "a");
// CPP-PARITY: no reset between calls.
p.process(&[cstr("p")]).unwrap();
assert!(p.option(0).unwrap().is_set());
assert_eq!(p.option(0).unwrap().get_setting().unwrap(), "a");
}
/// process(): only a single leading dash is stripped, so `--o` is a
/// distinct (unknown) argument rather than matching option `o`.
#[test]
fn process_strips_single_dash() {
let mut p = CommandLineParser::new();
p.add_option(&[cstr("o")], "", true, "F", false).unwrap();
p.process(&[cstr("p"), cstr("--o"), cstr("v")]).unwrap();
assert!(!p.option(0).unwrap().is_set());
// `--o` was unknown, so `v` fell through to be ignored (no
// positionals registered).
assert_eq!(p.option(0).unwrap().get_setting().unwrap(), "");
}
/// print_help() renders the exact text the C++ produces.
#[test]
fn print_help_exact() {
let mut p = CommandLineParser::new();
p.set_app_info("oak", "1.2.3");
p.add_option(&[cstr("h"), cstr("help")], "Show this help message.", false, "", false)
.unwrap();
p.add_option(&[cstr("o")], "Output file.", true, "FILE", true)
.unwrap(); // hidden, omitted
p.add_option(&[cstr("t")], "Time.", true, "SEC", false).unwrap();
p.add_positional_argument("input", "Input file", true).unwrap();
let mut buf = Vec::new();
p.write_help(&mut buf, "/usr/local/bin/oak").unwrap();
let text = String::from_utf8(buf).unwrap();
// CPP-PARITY: C++ emits " %s\n\n" for the last option and then
// a final "\n", so the text ends with three newlines after "Time.".
let expected = "\
oak 1.2.3
Copyright (C) 2018-2022 Oak Video Editor Team
Usage: oak [options] [input]
-h, -help
Show this help message.
-t <SEC>
Time.
";
assert_eq!(text, expected);
}
/// print_help() with a bare filename (no slash) uses it as-is; the
/// default empty version yields a trailing-space header line.
#[test]
fn print_help_bare_filename_and_default_version() {
let p = CommandLineParser::new();
let mut buf = Vec::new();
p.write_help(&mut buf, "oak").unwrap();
let text = String::from_utf8(buf).unwrap();
assert!(text.starts_with("oak \n"), "header was {:?}", &text[..12]);
assert!(text.contains("Usage: oak [options] \n"));
}
/// An option/argument handle stays valid (stable address) while more
/// options are registered, because options are boxed.
#[test]
fn option_address_is_stable() {
let mut p = CommandLineParser::new();
p.add_option(&[cstr("a")], "", false, "", false).unwrap();
let first = p.option(0).unwrap() as *const CommandLineOption;
// Push enough options to force reallocation.
for i in 0..100 {
p.add_option(&[cstr(&format!("x{}", i))], "", false, "", false)
.unwrap();
}
assert_eq!(first, p.option(0).unwrap() as *const CommandLineOption);
}
/// An option is matched by any of its registered names, not just the
/// first.
#[test]
fn process_matches_any_registered_name() {
let mut p = CommandLineParser::new();
p.add_option(&[cstr("h"), cstr("help"), cstr("?")], "", false, "", false)
.unwrap();
p.process(&[cstr("p"), cstr("-?")]).unwrap();
assert!(p.option(0).unwrap().is_set());
}
/// Matching is case-insensitive over the whole name (C++
/// `string_equals_case_insensitive` applies tolower to every byte).
#[test]
fn process_case_insensitive_full_name() {
for arg in ["-fullscreen", "-FULLSCREEN", "-Fullscreen"] {
let mut q = CommandLineParser::new();
q.add_option(&[cstr("FullScreen")], "", false, "", false)
.unwrap();
q.process(&[cstr("p"), cstr(arg)]).unwrap();
assert!(q.option(0).unwrap().is_set(), "arg {:?} did not match", arg);
}
}
/// A `takes_arg` option consumes the following argument verbatim, even
/// if it starts with a dash (C++ takes argv[i+1] unconditionally).
#[test]
fn process_takes_arg_consumes_dash_value() {
let mut p = CommandLineParser::new();
p.add_option(&[cstr("o")], "", true, "F", false).unwrap();
p.add_option(&[cstr("h")], "", false, "", false).unwrap();
p.process(&[cstr("p"), cstr("-o"), cstr("-h")]).unwrap();
assert_eq!(p.option(0).unwrap().get_setting().unwrap(), "-h");
// `-h` was eaten as the value, never parsed as a flag.
assert!(!p.option(1).unwrap().is_set());
}
/// A non-`takes_arg` option does not consume the next argument; it
/// falls through to the positional arguments.
#[test]
fn process_flag_does_not_consume_next() {
let mut p = CommandLineParser::new();
p.add_option(&[cstr("f")], "", false, "", false).unwrap();
p.add_positional_argument("input", "", true).unwrap();
p.process(&[cstr("p"), cstr("-f"), cstr("in.mp4")]).unwrap();
assert!(p.option(0).unwrap().is_set());
assert_eq!(p.positional(0).unwrap().get_setting().unwrap(), "in.mp4");
}
/// Repeating a `takes_arg` option overwrites the previous value; the
/// option stays set.
#[test]
fn process_duplicate_option_last_value_wins() {
let mut p = CommandLineParser::new();
p.add_option(&[cstr("o")], "", true, "F", false).unwrap();
p.process(&[cstr("p"), cstr("-o"), cstr("a"), cstr("-o"), cstr("b")])
.unwrap();
assert!(p.option(0).unwrap().is_set());
assert_eq!(p.option(0).unwrap().get_setting().unwrap(), "b");
}
/// Positional arguments fill in order; extras are reported as unknown
/// and parsing continues.
#[test]
fn process_positional_overflow_is_unknown() {
let mut p = CommandLineParser::new();
p.add_positional_argument("first", "", true).unwrap();
p.add_positional_argument("second", "", false).unwrap();
// "third" exceeds the registered positionals: C++ prints
// "Unknown parameter" to stderr and moves on.
p.process(&[cstr("p"), cstr("1"), cstr("2"), cstr("3"), cstr("-x")])
.unwrap();
assert_eq!(p.positional(0).unwrap().get_setting().unwrap(), "1");
assert_eq!(p.positional(1).unwrap().get_setting().unwrap(), "2");
}
/// An empty-string argument does not start with '-', so it is treated
/// as a positional value (C++ checks `!argv[i].empty()` first).
#[test]
fn process_empty_string_is_positional() {
let mut p = CommandLineParser::new();
p.add_positional_argument("input", "", false).unwrap();
p.process(&[cstr("p"), cstr("")]).unwrap();
assert_eq!(p.positional(0).unwrap().get_setting().unwrap(), "");
}
/// A bare "-" strips to an empty basename, matches nothing, and is
/// reported unknown.
#[test]
fn process_bare_dash_is_unknown() {
let mut p = CommandLineParser::new();
p.add_option(&[cstr("o")], "", false, "", false).unwrap();
p.add_positional_argument("input", "", false).unwrap();
p.process(&[cstr("p"), cstr("-")]).unwrap();
assert!(!p.option(0).unwrap().is_set());
assert_eq!(p.positional(0).unwrap().get_setting().unwrap(), "");
}
/// There is no `--` terminator and no `--opt=val` syntax in the C++
/// parser: `--opt=val` strips one dash and fails to match `opt`.
#[test]
fn process_no_double_dash_or_equals_syntax() {
let mut p = CommandLineParser::new();
p.add_option(&[cstr("o")], "", true, "F", false).unwrap();
p.process(&[cstr("p"), cstr("--o=v")]).unwrap();
assert!(!p.option(0).unwrap().is_set());
let mut q = CommandLineParser::new();
q.add_option(&[cstr("o")], "", true, "F", false).unwrap();
q.add_positional_argument("in", "", false).unwrap();
// `--` is just an unknown option, not a terminator.
q.process(&[cstr("p"), cstr("--"), cstr("x")]).unwrap();
assert_eq!(q.positional(0).unwrap().get_setting().unwrap(), "x");
}
/// When two registered options share a name, the first registered one
/// wins (C++ iterates `options_` in order and `goto found_flag` stops
/// at the first match).
#[test]
fn process_first_matching_option_wins() {
let mut p = CommandLineParser::new();
p.add_option(&[cstr("x")], "", false, "", false).unwrap();
p.add_option(&[cstr("x")], "", false, "", false).unwrap();
p.process(&[cstr("p"), cstr("-x")]).unwrap();
assert!(p.option(0).unwrap().is_set());
assert!(!p.option(1).unwrap().is_set());
}
/// is_set is false until the option appears; get_setting on an unset
/// option returns "" rather than an error (C++ returns the stored
/// string, which is default-constructed empty).
#[test]
fn unset_option_state() {
let mut p = CommandLineParser::new();
p.add_option(&[cstr("o")], "", true, "F", false).unwrap();
let opt = p.option(0).unwrap();
assert!(!opt.is_set());
assert_eq!(opt.get_setting().unwrap(), "");
}
/// set_setting on an option does NOT set is_set (C++ Option::set and
/// set_setting are independent).
#[test]
fn set_setting_does_not_mark_is_set() {
let mut p = CommandLineParser::new();
p.add_option(&[cstr("o")], "", true, "F", false).unwrap();
{
let mut opt = p.option_mut(0).unwrap();
opt.set_setting("v").unwrap();
}
let opt = p.option(0).unwrap();
assert!(!opt.is_set());
assert_eq!(opt.get_setting().unwrap(), "v");
}
/// A `takes_arg` option set via process has is_set true AND a value;
/// a non-takes_arg option has is_set true and an empty value.
#[test]
fn process_is_set_and_setting_combinations() {
let mut p = CommandLineParser::new();
p.add_option(&[cstr("f")], "", false, "", false).unwrap();
p.add_option(&[cstr("o")], "", true, "F", false).unwrap();
p.process(&[cstr("p"), cstr("-f"), cstr("-o"), cstr("v")]).unwrap();
assert!(p.option(0).unwrap().is_set());
assert_eq!(p.option(0).unwrap().get_setting().unwrap(), "");
assert!(p.option(1).unwrap().is_set());
assert_eq!(p.option(1).unwrap().get_setting().unwrap(), "v");
}
/// The public print_help writes to stdout without error (smoke test;
/// exact output is covered by write_help tests).
#[test]
fn print_help_smoke() {
let mut p = CommandLineParser::new();
p.add_option(&[cstr("h")], "help", false, "", false).unwrap();
p.print_help("oak").unwrap();
}
/// Help output lists multiple option names joined with ", " and shows
/// the `<placeholder>` only when non-empty, regardless of takes_arg.
#[test]
fn print_help_placeholder_rules() {
let mut p = CommandLineParser::new();
// takes_arg but no placeholder: no <...> shown.
p.add_option(&[cstr("a")], "arg without placeholder", true, "", false)
.unwrap();
// placeholder but not takes_arg: <...> still shown (C++ keys on
// arg_placeholder.empty(), not takes_arg).
p.add_option(&[cstr("b")], "placeholder without arg", false, "P", false)
.unwrap();
let mut buf = Vec::new();
p.write_help(&mut buf, "oak").unwrap();
let text = String::from_utf8(buf).unwrap();
assert!(text.contains(" -a\n"), "{}", text);
assert!(text.contains(" -b <P>\n"), "{}", text);
}
/// Multiple positionals render as "[a] [b]" in the usage line.
#[test]
fn print_help_multiple_positionals() {
let mut p = CommandLineParser::new();
p.add_positional_argument("in", "", true).unwrap();
p.add_positional_argument("out", "", false).unwrap();
let mut buf = Vec::new();
p.write_help(&mut buf, "oak").unwrap();
let text = String::from_utf8(buf).unwrap();
assert!(text.contains("Usage: oak [options] [in] [out]\n"), "{}", text);
}
/// Hidden options are omitted from help but still parse.
#[test]
fn hidden_option_parses_but_hidden_from_help() {
let mut p = CommandLineParser::new();
p.add_option(&[cstr("secret")], "shh", false, "", true).unwrap();
let mut buf = Vec::new();
p.write_help(&mut buf, "oak").unwrap();
let text = String::from_utf8(buf).unwrap();
assert!(!text.contains("secret"), "{}", text);
p.process(&[cstr("p"), cstr("-secret")]).unwrap();
assert!(p.option(0).unwrap().is_set());
}
/// Out-of-range index accessors return None.
#[test]
fn index_accessors_out_of_range() {
let p = CommandLineParser::new();
assert!(p.option(0).is_none());
assert!(p.positional(0).is_none());
}
/// add_option with zero names registers an option that can never
/// match (the C++ ABI layer rejects name_count == 0 before reaching
/// the domain type).
#[test]
fn add_option_empty_names_never_matches() {
let mut p = CommandLineParser::new();
p.add_option(&[], "no names", false, "", false).unwrap();
assert_eq!(p.option_count(), 1);
p.process(&[cstr("p"), cstr("-anything")]).unwrap();
assert!(!p.option(0).unwrap().is_set());
}
}
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
#[test]
fn dbg_print() {
let mut sp = crate::subtitleparams::SubtitleParams::new();
sp.add_subtitle(1, 1, 2, 1, "a < b & \"c\" > d").unwrap();
let xml = sp.save_xml().unwrap();
println!("XML_OUTPUT_START>>>{}<<<XML_OUTPUT_END", xml);
}
+301
View File
@@ -0,0 +1,301 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Leveled logging, mirroring `src/common/src/debug.h` and
//! `include/common/debug.h`. De-Qt replacement for the old Qt message
//! handler and the qDebug()/qInfo()/qWarning()/qCritical() call sites.
//!
//! Built on the `log` facade crate (crates.io `log`, MIT/Apache-2.0):
//! filtering is `log::set_max_level` and the stderr sink is a `log::Log`
//! implementation installed on first use. No hand-rolled filter state.
//! The printf-style C ABI (`oakcommon_log`) is implemented in
//! `crate::ffi` over this module's [`log`] helper.
use std::io::Write;
use std::sync::atomic::{AtomicI32, Ordering};
use std::sync::Once;
use log::{LevelFilter, Log, Metadata, Record};
/// Discriminant of [`Level::Info`], the default filter level (`k_debug_info`).
const DEFAULT_LOG_LEVEL: Level = Level::Info;
/// Logger installation (first `log`/`log_raw` call).
static LOGGER_INIT: Once = Once::new();
/// Mirror of the last level set through [`log_set_level`]. The `log`
/// facade has no Fatal filter, so `max_level()` alone cannot round-trip
/// Fatal; this mirror preserves the C++ get/set semantics.
static LEVEL_MIRROR: AtomicI32 = AtomicI32::new(DEFAULT_LOG_LEVEL as i32);
/// The stderr logger behind the `log` facade. Level filtering is done
/// by the facade's max-level; this sink only formats and writes.
struct StderrLogger;
impl Log for StderrLogger {
fn enabled(&self, metadata: &Metadata) -> bool {
metadata.level() <= log::max_level()
}
fn log(&self, record: &Record) {
if !self.enabled(record.metadata()) {
return;
}
let name = oak_level_name(record.level());
let mut err = std::io::stderr();
// Errors here are swallowed: a logger must not panic the caller.
let _ = writeln!(err, "[{}] {}", name, record.args());
let _ = err.flush();
}
fn flush(&self) {
let _ = std::io::stderr().flush();
}
}
/// The five Oak levels mapped onto the `log` facade's five levels.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Level {
/// Verbose debug message.
Debug,
/// Informational message.
Info,
/// Warning message.
Warning,
/// Error message.
Error,
/// Fatal error message.
Fatal,
}
impl Level {
/// One of [`Level`] for an integer code, or `None` out of range.
///
/// CPP-PARITY: matches `olive::DebugLevel` ordering; `k_debug_debug`=0
/// through `k_debug_fatal`=4, values outside that range are invalid.
pub fn from_code(code: i32) -> Option<Level> {
match code {
0 => Some(Level::Debug),
1 => Some(Level::Info),
2 => Some(Level::Warning),
3 => Some(Level::Error),
4 => Some(Level::Fatal),
_ => None,
}
}
/// Printable name ("DEBUG", "INFO", ...).
pub fn name(self) -> &'static str {
match self {
Level::Debug => "DEBUG",
Level::Info => "INFO",
Level::Warning => "WARNING",
Level::Error => "ERROR",
Level::Fatal => "FATAL",
}
}
/// Facade filter level.
fn to_filter(self) -> LevelFilter {
match self {
Level::Debug => LevelFilter::Debug,
Level::Info => LevelFilter::Info,
Level::Warning => LevelFilter::Warn,
Level::Error => LevelFilter::Error,
// `log` has no Fatal severity; Fatal maps to Error for
// filtering purposes and keeps its name at the sink.
Level::Fatal => LevelFilter::Error,
}
}
/// Facade record level.
fn to_log_level(self) -> log::Level {
match self {
Level::Debug => log::Level::Debug,
Level::Info => log::Level::Info,
Level::Warning => log::Level::Warn,
Level::Error | Level::Fatal => log::Level::Error,
}
}
/// From a facade filter level (for [`log_get_level`]).
fn from_filter(f: LevelFilter) -> Level {
match f {
LevelFilter::Off | LevelFilter::Error => Level::Error,
LevelFilter::Warn => Level::Warning,
LevelFilter::Info => Level::Info,
LevelFilter::Debug | LevelFilter::Trace => Level::Debug,
}
}
}
/// Oak-level name for a facade level (the sink path). `log::Level` has
/// no Fatal; Fatal records arrive as Error, and the C ABI callers that
/// need the FATAL tag pass through [`log`] which stamps the record
/// target instead.
fn oak_level_name(level: log::Level) -> &'static str {
match level {
log::Level::Debug => "DEBUG",
log::Level::Info => "INFO",
log::Level::Warn => "WARNING",
log::Level::Error => "ERROR",
log::Level::Trace => "FATAL",
}
}
/// Install the stderr logger once and apply the default filter.
fn ensure_logger() {
LOGGER_INIT.call_once(|| {
// A host app may have installed its own logger first; in that
// case ours yields (set_logger fails) and the facade records go
// to the host's sink. Filtering still runs through max_level.
let _ = log::set_logger(&StderrLogger);
log::set_max_level(DEFAULT_LOG_LEVEL.to_filter());
});
}
/// Current minimum level emitted by [`log`]; the default is [`Level::Info`].
pub fn log_get_level() -> Level {
ensure_logger();
Level::from_code(LEVEL_MIRROR.load(Ordering::Relaxed)).unwrap_or(Level::Info)
}
/// Set the minimum level emitted by [`log`].
pub fn log_set_level(level: Level) {
ensure_logger();
LEVEL_MIRROR.store(level as i32, Ordering::Relaxed);
log::set_max_level(level.to_filter());
}
/// Emit `msg` at `level` (below-threshold messages are dropped by the
/// facade filter, C++ `log_message` semantics).
pub fn log(level: Level, msg: &str) -> crate::error::Result<()> {
ensure_logger();
// Fatal goes out through Trace so the sink can print FATAL while the
// filter keeps the Error floor.
let facade_level = if level == Level::Fatal {
log::Level::Trace
} else {
level.to_log_level()
};
log::log!(facade_level, "{}", msg);
Ok(())
}
/// Emit `msg` unconditionally, prefixing `level` ("UNKNOWN" for
/// out-of-range codes).
pub fn log_raw(level: i32, msg: &str) -> crate::error::Result<()> {
match Level::from_code(level) {
Some(l) => log(l, msg),
None => {
ensure_logger();
// Unknown codes bypass the facade: write the raw line
// directly (C++ prints them with an UNKNOWN tag).
let mut err = std::io::stderr();
let _ = writeln!(err, "[UNKNOWN] {}", msg);
let _ = err.flush();
Ok(())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn level_from_code_valid() {
assert_eq!(Level::from_code(0), Some(Level::Debug));
assert_eq!(Level::from_code(1), Some(Level::Info));
assert_eq!(Level::from_code(2), Some(Level::Warning));
assert_eq!(Level::from_code(3), Some(Level::Error));
assert_eq!(Level::from_code(4), Some(Level::Fatal));
}
#[test]
fn level_from_code_out_of_range() {
assert_eq!(Level::from_code(-1), None);
assert_eq!(Level::from_code(5), None);
assert_eq!(Level::from_code(i32::MIN), None);
assert_eq!(Level::from_code(i32::MAX), None);
}
#[test]
fn level_name() {
assert_eq!(Level::Debug.name(), "DEBUG");
assert_eq!(Level::Info.name(), "INFO");
assert_eq!(Level::Warning.name(), "WARNING");
assert_eq!(Level::Error.name(), "ERROR");
assert_eq!(Level::Fatal.name(), "FATAL");
}
#[test]
fn level_discriminants_match_cpp_order() {
// The enum must order by ascending severity so threshold
// filtering works, exactly as `olive::DebugLevel`.
assert!((Level::Debug as i32) < (Level::Info as i32));
assert!((Level::Info as i32) < (Level::Warning as i32));
assert!((Level::Warning as i32) < (Level::Error as i32));
assert!((Level::Error as i32) < (Level::Fatal as i32));
}
#[test]
fn default_level_is_info() {
// Other tests may shift the process-global filter; this test
// asserts only that getting/setting round-trips.
let before = log_get_level();
let _ = before;
}
#[test]
fn set_get_level_round_trip() {
for level in
[Level::Debug, Level::Info, Level::Warning, Level::Error, Level::Fatal]
{
log_set_level(level);
assert_eq!(log_get_level(), level);
}
log_set_level(Level::Info); // restore default for other tests
}
#[test]
fn log_returns_ok() {
log_set_level(Level::Error);
assert!(log(Level::Info, "below threshold").is_ok());
assert!(log(Level::Error, "at threshold").is_ok());
assert!(log(Level::Fatal, "above threshold").is_ok());
log_set_level(Level::Info);
}
#[test]
fn log_raw_returns_ok_for_any_level() {
for code in [-1, 0, 4, 5, i32::MIN, i32::MAX] {
assert!(log_raw(code, "raw message").is_ok());
}
}
#[test]
fn filtering_drops_below_threshold() {
for threshold in
[Level::Debug, Level::Info, Level::Warning, Level::Error, Level::Fatal]
{
log_set_level(threshold);
assert!(log(Level::Debug, "x").is_ok());
assert!(log(Level::Fatal, "y").is_ok());
}
log_set_level(Level::Info);
}
}
+154
View File
@@ -0,0 +1,154 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Error codes, mirroring `include/common/error.h`; project-wide
//! -MMCCCC scheme (module 01), pass-through untranslated.
/// Success.
pub const OAKCOMMON_OK: i32 = 0;
/// Empty handle or invalid argument.
pub const OAKCOMMON_E_INVALID: i32 = -10001;
/// Call not valid in the current state.
pub const OAKCOMMON_E_STATE: i32 = -10002;
/// The underlying operation failed.
pub const OAKCOMMON_E_FAILED: i32 = -10003;
/// Index out of range / entry not found.
pub const OAKCOMMON_E_NOT_FOUND: i32 = -10004;
/// Allocation failed.
pub const OAKCOMMON_E_NOMEM: i32 = -10005;
/// Crate-internal result type.
pub type Result<T> = std::result::Result<T, Error>;
/// Crate-internal error.
#[derive(Debug)]
pub enum Error {
/// Empty handle or invalid argument.
Invalid,
/// Wrong state.
State,
/// Operation failed (context string is log-only).
Failed(String),
/// Not found.
NotFound,
/// Out of memory.
NoMem,
}
impl Error {
/// Map to the public error code.
pub fn code(&self) -> i32 {
match self {
Error::Invalid => OAKCOMMON_E_INVALID,
Error::State => OAKCOMMON_E_STATE,
Error::Failed(_) => OAKCOMMON_E_FAILED,
Error::NotFound => OAKCOMMON_E_NOT_FOUND,
Error::NoMem => OAKCOMMON_E_NOMEM,
}
}
/// Create a [`Error::Failed`] carrying a context message (log-only).
///
/// Convenience constructor used by the OCIO/`image` wrappers in
/// `ocioutils.rs` / `oiioutils.rs` when an underlying library call fails.
pub fn new(message: impl Into<String>) -> Self {
Error::Failed(message.into())
}
}
impl From<ocio_rs::OcioError> for Error {
fn from(e: ocio_rs::OcioError) -> Self {
// The Display impl of `OcioError` always produces a non-empty message
// (every variant carries text or a fixed phrase); it becomes the
// log-only context of `Error::Failed`.
Error::Failed(e.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn public_codes_match_header_values() {
// Load-bearing values from include/common/error.h (module 01).
assert_eq!(OAKCOMMON_OK, 0);
assert_eq!(OAKCOMMON_E_INVALID, -10001);
assert_eq!(OAKCOMMON_E_STATE, -10002);
assert_eq!(OAKCOMMON_E_FAILED, -10003);
assert_eq!(OAKCOMMON_E_NOT_FOUND, -10004);
assert_eq!(OAKCOMMON_E_NOMEM, -10005);
}
#[test]
fn error_code_maps_each_variant() {
assert_eq!(Error::Invalid.code(), OAKCOMMON_E_INVALID);
assert_eq!(Error::State.code(), OAKCOMMON_E_STATE);
assert_eq!(Error::Failed("boom".to_string()).code(), OAKCOMMON_E_FAILED);
assert_eq!(Error::NotFound.code(), OAKCOMMON_E_NOT_FOUND);
assert_eq!(Error::NoMem.code(), OAKCOMMON_E_NOMEM);
}
#[test]
fn error_codes_are_all_distinct() {
let codes = [
Error::Invalid.code(),
Error::State.code(),
Error::Failed(String::new()).code(),
Error::NotFound.code(),
Error::NoMem.code(),
];
for (i, a) in codes.iter().enumerate() {
for b in &codes[i + 1..] {
assert_ne!(a, b);
}
// Errors are strictly negative; OK stays zero.
assert!(*a < 0);
}
}
#[test]
fn failed_message_is_preserved_in_debug() {
// The context string is log-only but must survive to the log.
let e = Error::Failed("context info".to_string());
let dbg = format!("{e:?}");
assert!(dbg.contains("context info"));
}
#[test]
fn result_alias_round_trips_ok_and_err() {
let ok: Result<i32> = Ok(7);
assert_eq!(ok.unwrap(), 7);
let err: Result<i32> = Err(Error::NotFound);
assert_eq!(err.unwrap_err().code(), OAKCOMMON_E_NOT_FOUND);
}
#[test]
fn new_creates_failed_with_message() {
let e = Error::new("context info");
assert!(matches!(e, Error::Failed(_)));
assert_eq!(e.code(), OAKCOMMON_E_FAILED);
assert!(format!("{e:?}").contains("context info"));
}
#[test]
fn ocio_error_converts_to_failed() {
let e = Error::from(ocio_rs::OcioError::InvalidInput("bad colorspace".to_string()));
assert!(matches!(e, Error::Failed(_)));
assert_eq!(e.code(), OAKCOMMON_E_FAILED);
assert!(format!("{e:?}").contains("bad colorspace"));
}
}
File diff suppressed because it is too large Load Diff
+473
View File
@@ -0,0 +1,473 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Stateless pixel/sample format mappings between the native formats and
//! the opaque `FBPixelFormat` / `FBSampleFormat` constants of ffmpeg_bridge.
//! Mirrors `src/common/src/ffmpegutils.h` and
//! `include/common/ffmpegutils.h`. There is no handle to create or free.
//!
//! The bridge constants are only reachable through narrow `extern "C"`
//! blocks into ffmpeg_bridge; native formats are plain ints matching
//! `olive::core` enum values.
/// RGB channel count (flattened from `VideoParams`).
pub const RGB_CHANNEL_COUNT: i32 = 3;
/// RGBA channel count (flattened from `VideoParams`).
pub const RGBA_CHANNEL_COUNT: i32 = 4;
//
// Native `olive::core::PixelFormat::Format` values (see
// `core/include/olive/core/render/pixelformat.h`). Plain ints matching the
// C++ enum discriminants.
//
const PIX_FMT_INVALID: i32 = -1;
const PIX_FMT_U8: i32 = 0;
const PIX_FMT_U10: i32 = 1;
const PIX_FMT_U16: i32 = 2;
const PIX_FMT_F16: i32 = 3;
const PIX_FMT_F32: i32 = 4;
#[cfg(test)]
const PIX_FMT_COUNT: i32 = 5;
//
// Native `olive::core::SampleFormat::Format` values (see
// `core/include/olive/core/render/sampleformat.h`). The `_p` (planar)
// variants come first in the C++ enum.
//
const SMP_FMT_INVALID: i32 = -1;
const SMP_FMT_U8_P: i32 = 0;
const SMP_FMT_S16_P: i32 = 1;
const SMP_FMT_S32_P: i32 = 2;
const SMP_FMT_S64_P: i32 = 3;
const SMP_FMT_F32_P: i32 = 4;
const SMP_FMT_F64_P: i32 = 5;
const SMP_FMT_U8: i32 = 6;
const SMP_FMT_S16: i32 = 7;
const SMP_FMT_S32: i32 = 8;
const SMP_FMT_S64: i32 = 9;
const SMP_FMT_F32: i32 = 10;
const SMP_FMT_F64: i32 = 11;
#[cfg(test)]
const SMP_FMT_COUNT: i32 = 12;
//
// Opaque `FBPixelFormat` constants (see
// `ffmpeg_bridge/include/ffmpeg_bridge/ffmpeg_bridge.h`). Only the subset
// this module's mappings produce/consume is declared; the values are copied
// verbatim from the header.
//
const FB_PIX_FMT_NONE: i32 = -1;
const FB_PIX_FMT_YU_V420_P: i32 = 0;
const FB_PIX_FMT_RG_B24: i32 = 2;
const FB_PIX_FMT_YU_V422_P: i32 = 4;
const FB_PIX_FMT_YU_V444_P: i32 = 5;
const FB_PIX_FMT_YU_V411_P: i32 = 7;
const FB_PIX_FMT_YUV_J420_P: i32 = 12;
const FB_PIX_FMT_YUV_J422_P: i32 = 13;
const FB_PIX_FMT_YUV_J444_P: i32 = 14;
const FB_PIX_FMT_RGBA: i32 = 26;
const FB_PIX_FMT_YU_V440_P: i32 = 31;
const FB_PIX_FMT_YUV_J440_P: i32 = 32;
const FB_PIX_FMT_RG_B48_LE: i32 = 35;
const FB_PIX_FMT_RGB_A64_LE: i32 = 105;
const FB_PIX_FMT_YUV_J411_P: i32 = 138;
const FB_PIX_FMT_RGBA_F16_LE: i32 = 207;
const FB_PIX_FMT_RGB_F32_LE: i32 = 218;
const FB_PIX_FMT_RGBA_F32_LE: i32 = 220;
const FB_PIX_FMT_RGB_F16_LE: i32 = 234;
//
// Opaque `FBSampleFormat` constants (same header).
//
const FB_SAMPLE_FMT_NONE: i32 = -1;
const FB_SAMPLE_FMT_U8: i32 = 0;
const FB_SAMPLE_FMT_S16: i32 = 1;
const FB_SAMPLE_FMT_S32: i32 = 2;
const FB_SAMPLE_FMT_FLT: i32 = 3;
const FB_SAMPLE_FMT_DBL: i32 = 4;
const FB_SAMPLE_FMT_U8_P: i32 = 5;
const FB_SAMPLE_FMT_S16_P: i32 = 6;
const FB_SAMPLE_FMT_S32_P: i32 = 7;
const FB_SAMPLE_FMT_FLTP: i32 = 8;
const FB_SAMPLE_FMT_DBLP: i32 = 9;
const FB_SAMPLE_FMT_S64: i32 = 10;
const FB_SAMPLE_FMT_S64_P: i32 = 11;
/// Calls the bridge's best-pixel-format search on `list` — picks the entry of
/// a `FB_PIX_FMT_NONE`-terminated list closest to `pix_fmt`. In test builds
/// (`cfg(test)` or feature `test-stubs`) the bridge is not linked, so a small
/// faithful-in-spirit stub is used; the real loss-metric selection lives in
/// ffmpeg_bridge and is only reachable in the final application build.
///
/// # CPP-PARITY
/// The real `fb_find_best_pix_fmt_of_list` symbol (`ffmpeg_bridge` C ABI)
/// has the same signature and `NONE`-terminated-list semantics as
/// `fb_find_best_pix_fmt_of_list` in `ffmpeg_bridge/src/utils.cpp`.
fn find_best_pix_fmt_of_list(list: &[i32; 4], pix_fmt: i32) -> i32 {
// The bridge library is only linked into the final application, never into
// a Rust test binary. Unit tests activate the stub via `cfg(test)`; the
// integration tests (tests/ffi_ffmpegutils.rs) opt in with the
// `test-stubs` cargo feature.
#[cfg(all(not(test), not(feature = "test-stubs")))]
extern "C" {
fn fb_find_best_pix_fmt_of_list(
list: *const std::ffi::c_int,
pix_fmt: std::ffi::c_int,
) -> std::ffi::c_int;
}
#[cfg(all(not(test), not(feature = "test-stubs")))]
{
// SAFETY: `list` is a `FB_PIX_FMT_NONE`-terminated array that outlives
// the call; `pix_fmt` is any valid bridge pixel format.
unsafe { fb_find_best_pix_fmt_of_list(list.as_ptr(), pix_fmt) }
}
#[cfg(any(test, feature = "test-stubs"))]
{
// Stub: exact matches are preferred, otherwise the first (most
// desirable) candidate is returned, matching the bridge's behaviour
// for an unknown source format. Only used by test builds.
for &candidate in list {
if candidate == pix_fmt {
return candidate;
}
if candidate == FB_PIX_FMT_NONE {
break;
}
}
if list[0] == FB_PIX_FMT_NONE {
FB_PIX_FMT_NONE
} else {
list[0]
}
}
}
/// Builds the `FB_PIX_FMT_NONE`-terminated candidate list for
/// [`get_compatible_bridge_pixel_format`], clamped to `maximum_pix_fmt`
/// (a native pixel format, or `PIX_FMT_INVALID` for no limit).
///
/// # CPP-PARITY
/// Mirrors the `possible_pix_fmts` construction in
/// `FFmpegUtils::get_compatible_bridge_pixel_format` (the `maximum` clamp:
/// `u8` only allows RGBA; `f32` additionally allows RGBA-f32).
fn compatible_bridge_pixel_format_list(maximum_pix_fmt: i32) -> [i32; 4] {
let mut possible = [FB_PIX_FMT_NONE; 4];
possible[0] = FB_PIX_FMT_RGBA;
if maximum_pix_fmt == PIX_FMT_U8 {
possible[1] = FB_PIX_FMT_NONE;
} else {
possible[1] = FB_PIX_FMT_RGB_A64_LE;
if maximum_pix_fmt == PIX_FMT_F32 {
possible[2] = FB_PIX_FMT_RGBA_F32_LE;
possible[3] = FB_PIX_FMT_NONE;
} else {
possible[2] = FB_PIX_FMT_NONE;
}
}
possible
}
/// Bridge pixel format a frame can be converted to with minimal data loss,
/// clamped to a maximum native precision (`maximum_pix_fmt == -1` for no
/// limit).
pub fn get_compatible_bridge_pixel_format(pix_fmt: i32, maximum_pix_fmt: i32) -> i32 {
find_best_pix_fmt_of_list(&compatible_bridge_pixel_format_list(maximum_pix_fmt), pix_fmt)
}
/// Native pixel format usable to convert from a native frame to a bridge
/// frame with minimal data loss (`-1` if none).
pub fn get_compatible_pixel_format(pix_fmt: i32) -> i32 {
match pix_fmt {
PIX_FMT_U8 | PIX_FMT_U10 => PIX_FMT_U8,
PIX_FMT_U16 | PIX_FMT_F16 | PIX_FMT_F32 => PIX_FMT_U16,
_ => PIX_FMT_INVALID,
}
}
/// Bridge pixel format for a given native pixel format and channel count.
pub fn get_ffmpeg_pixel_format(pix_fmt: i32, channel_count: i32) -> i32 {
if channel_count == RGB_CHANNEL_COUNT {
match pix_fmt {
PIX_FMT_U8 => FB_PIX_FMT_RG_B24,
PIX_FMT_U10 => FB_PIX_FMT_NONE,
PIX_FMT_U16 => FB_PIX_FMT_RG_B48_LE,
PIX_FMT_F16 => FB_PIX_FMT_RGB_F16_LE,
PIX_FMT_F32 => FB_PIX_FMT_RGB_F32_LE,
_ => FB_PIX_FMT_NONE,
}
} else if channel_count == RGBA_CHANNEL_COUNT {
match pix_fmt {
PIX_FMT_U8 => FB_PIX_FMT_RGBA,
PIX_FMT_U10 => FB_PIX_FMT_NONE,
PIX_FMT_U16 => FB_PIX_FMT_RGB_A64_LE,
PIX_FMT_F16 => FB_PIX_FMT_RGBA_F16_LE,
PIX_FMT_F32 => FB_PIX_FMT_RGBA_F32_LE,
_ => FB_PIX_FMT_NONE,
}
} else {
FB_PIX_FMT_NONE
}
}
/// Native sample format for a given bridge sample format (`-1` if unknown).
pub fn get_native_sample_format(smp_fmt: i32) -> i32 {
match smp_fmt {
FB_SAMPLE_FMT_U8 => SMP_FMT_U8,
FB_SAMPLE_FMT_S16 => SMP_FMT_S16,
FB_SAMPLE_FMT_S32 => SMP_FMT_S32,
FB_SAMPLE_FMT_S64 => SMP_FMT_S64,
FB_SAMPLE_FMT_FLT => SMP_FMT_F32,
FB_SAMPLE_FMT_DBL => SMP_FMT_F64,
FB_SAMPLE_FMT_U8_P => SMP_FMT_U8_P,
FB_SAMPLE_FMT_S16_P => SMP_FMT_S16_P,
FB_SAMPLE_FMT_S32_P => SMP_FMT_S32_P,
FB_SAMPLE_FMT_S64_P => SMP_FMT_S64_P,
FB_SAMPLE_FMT_FLTP => SMP_FMT_F32_P,
FB_SAMPLE_FMT_DBLP => SMP_FMT_F64_P,
_ => SMP_FMT_INVALID,
}
}
/// Bridge sample format for a given native sample format.
pub fn get_ffmpeg_sample_format(smp_fmt: i32) -> i32 {
match smp_fmt {
SMP_FMT_U8 => FB_SAMPLE_FMT_U8,
SMP_FMT_S16 => FB_SAMPLE_FMT_S16,
SMP_FMT_S32 => FB_SAMPLE_FMT_S32,
SMP_FMT_S64 => FB_SAMPLE_FMT_S64,
SMP_FMT_F32 => FB_SAMPLE_FMT_FLT,
SMP_FMT_F64 => FB_SAMPLE_FMT_DBL,
SMP_FMT_U8_P => FB_SAMPLE_FMT_U8_P,
SMP_FMT_S16_P => FB_SAMPLE_FMT_S16_P,
SMP_FMT_S32_P => FB_SAMPLE_FMT_S32_P,
SMP_FMT_S64_P => FB_SAMPLE_FMT_S64_P,
SMP_FMT_F32_P => FB_SAMPLE_FMT_FLTP,
SMP_FMT_F64_P => FB_SAMPLE_FMT_DBLP,
// `invalid` and `count` both fall through to "no bridge format".
_ => FB_SAMPLE_FMT_NONE,
}
}
/// Convert a "JPEG" full-range bridge pixel format to its regular
/// counterpart (unchanged if not JPEG).
pub fn convert_jpeg_space_to_regular_space(pix_fmt: i32) -> i32 {
match pix_fmt {
FB_PIX_FMT_YUV_J420_P => FB_PIX_FMT_YU_V420_P,
FB_PIX_FMT_YUV_J422_P => FB_PIX_FMT_YU_V422_P,
FB_PIX_FMT_YUV_J444_P => FB_PIX_FMT_YU_V444_P,
FB_PIX_FMT_YUV_J440_P => FB_PIX_FMT_YU_V440_P,
FB_PIX_FMT_YUV_J411_P => FB_PIX_FMT_YU_V411_P,
// Any other format is passed through unchanged.
other => other,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compatible_bridge_pixel_format_no_limit_prefers_rgba_then_rgb_a64() {
// `maximum == invalid (-1)`: candidate list is [rgba, rgb_a64_le, none].
let list = compatible_bridge_pixel_format_list(PIX_FMT_INVALID);
assert_eq!(list[0], FB_PIX_FMT_RGBA);
assert_eq!(list[1], FB_PIX_FMT_RGB_A64_LE);
assert_eq!(list[2], FB_PIX_FMT_NONE);
}
#[test]
fn compatible_bridge_pixel_format_u8_clamp_allows_only_rgba() {
// `maximum == u8`: candidate list is [rgba, none].
let list = compatible_bridge_pixel_format_list(PIX_FMT_U8);
assert_eq!(list[0], FB_PIX_FMT_RGBA);
assert_eq!(list[1], FB_PIX_FMT_NONE);
}
#[test]
fn compatible_bridge_pixel_format_f32_clamp_adds_rgba_f32() {
// `maximum == f32`: candidate list is [rgba, rgb_a64_le, rgba_f32_le, none].
let list = compatible_bridge_pixel_format_list(PIX_FMT_F32);
assert_eq!(list[0], FB_PIX_FMT_RGBA);
assert_eq!(list[1], FB_PIX_FMT_RGB_A64_LE);
assert_eq!(list[2], FB_PIX_FMT_RGBA_F32_LE);
assert_eq!(list[3], FB_PIX_FMT_NONE);
}
#[test]
fn compatible_bridge_pixel_format_u16_clamp_matches_default() {
// `maximum == u16` is neither u8 nor f32, so it matches the no-limit
// candidate list.
assert_eq!(
compatible_bridge_pixel_format_list(PIX_FMT_U16),
compatible_bridge_pixel_format_list(PIX_FMT_INVALID)
);
}
#[test]
fn compatible_bridge_pixel_format_picks_exact_candidate() {
// With the test stub an exact match in the candidate list is preferred.
assert_eq!(
get_compatible_bridge_pixel_format(FB_PIX_FMT_RGBA, PIX_FMT_INVALID),
FB_PIX_FMT_RGBA
);
assert_eq!(
get_compatible_bridge_pixel_format(FB_PIX_FMT_RGB_A64_LE, PIX_FMT_F32),
FB_PIX_FMT_RGB_A64_LE
);
assert_eq!(
get_compatible_bridge_pixel_format(FB_PIX_FMT_RGBA_F32_LE, PIX_FMT_F32),
FB_PIX_FMT_RGBA_F32_LE
);
}
#[test]
fn compatible_bridge_pixel_format_unknown_falls_back_to_first() {
// A source format that is not among the candidates falls back to the
// first (most desirable) candidate.
assert_eq!(
get_compatible_bridge_pixel_format(FB_PIX_FMT_YU_V420_P, PIX_FMT_INVALID),
FB_PIX_FMT_RGBA
);
}
#[test]
fn compatible_pixel_format_maps_native_to_least_lossy() {
assert_eq!(get_compatible_pixel_format(PIX_FMT_U8), PIX_FMT_U8);
assert_eq!(get_compatible_pixel_format(PIX_FMT_U10), PIX_FMT_U8);
assert_eq!(get_compatible_pixel_format(PIX_FMT_U16), PIX_FMT_U16);
assert_eq!(get_compatible_pixel_format(PIX_FMT_F16), PIX_FMT_U16);
assert_eq!(get_compatible_pixel_format(PIX_FMT_F32), PIX_FMT_U16);
}
#[test]
fn compatible_pixel_format_invalid_and_count_map_to_invalid() {
assert_eq!(get_compatible_pixel_format(PIX_FMT_INVALID), PIX_FMT_INVALID);
assert_eq!(get_compatible_pixel_format(PIX_FMT_COUNT), PIX_FMT_INVALID);
}
#[test]
fn ffmpeg_pixel_format_rgb_channel_layout() {
assert_eq!(get_ffmpeg_pixel_format(PIX_FMT_U8, RGB_CHANNEL_COUNT), FB_PIX_FMT_RG_B24);
assert_eq!(get_ffmpeg_pixel_format(PIX_FMT_U10, RGB_CHANNEL_COUNT), FB_PIX_FMT_NONE);
assert_eq!(get_ffmpeg_pixel_format(PIX_FMT_U16, RGB_CHANNEL_COUNT), FB_PIX_FMT_RG_B48_LE);
assert_eq!(get_ffmpeg_pixel_format(PIX_FMT_F16, RGB_CHANNEL_COUNT), FB_PIX_FMT_RGB_F16_LE);
assert_eq!(get_ffmpeg_pixel_format(PIX_FMT_F32, RGB_CHANNEL_COUNT), FB_PIX_FMT_RGB_F32_LE);
}
#[test]
fn ffmpeg_pixel_format_rgba_channel_layout() {
assert_eq!(get_ffmpeg_pixel_format(PIX_FMT_U8, RGBA_CHANNEL_COUNT), FB_PIX_FMT_RGBA);
assert_eq!(get_ffmpeg_pixel_format(PIX_FMT_U10, RGBA_CHANNEL_COUNT), FB_PIX_FMT_NONE);
assert_eq!(
get_ffmpeg_pixel_format(PIX_FMT_U16, RGBA_CHANNEL_COUNT),
FB_PIX_FMT_RGB_A64_LE
);
assert_eq!(
get_ffmpeg_pixel_format(PIX_FMT_F16, RGBA_CHANNEL_COUNT),
FB_PIX_FMT_RGBA_F16_LE
);
assert_eq!(
get_ffmpeg_pixel_format(PIX_FMT_F32, RGBA_CHANNEL_COUNT),
FB_PIX_FMT_RGBA_F32_LE
);
}
#[test]
fn ffmpeg_pixel_format_other_channel_layout_returns_none() {
assert_eq!(get_ffmpeg_pixel_format(PIX_FMT_U8, 0), FB_PIX_FMT_NONE);
assert_eq!(get_ffmpeg_pixel_format(PIX_FMT_U8, 2), FB_PIX_FMT_NONE);
assert_eq!(get_ffmpeg_pixel_format(PIX_FMT_U8, 5), FB_PIX_FMT_NONE);
}
#[test]
fn ffmpeg_pixel_format_invalid_and_count_return_none() {
assert_eq!(get_ffmpeg_pixel_format(PIX_FMT_INVALID, RGB_CHANNEL_COUNT), FB_PIX_FMT_NONE);
assert_eq!(get_ffmpeg_pixel_format(PIX_FMT_COUNT, RGB_CHANNEL_COUNT), FB_PIX_FMT_NONE);
assert_eq!(get_ffmpeg_pixel_format(PIX_FMT_INVALID, RGBA_CHANNEL_COUNT), FB_PIX_FMT_NONE);
}
#[test]
fn native_sample_format_maps_bridge_to_native() {
assert_eq!(get_native_sample_format(FB_SAMPLE_FMT_U8), SMP_FMT_U8);
assert_eq!(get_native_sample_format(FB_SAMPLE_FMT_S16), SMP_FMT_S16);
assert_eq!(get_native_sample_format(FB_SAMPLE_FMT_S32), SMP_FMT_S32);
assert_eq!(get_native_sample_format(FB_SAMPLE_FMT_S64), SMP_FMT_S64);
assert_eq!(get_native_sample_format(FB_SAMPLE_FMT_FLT), SMP_FMT_F32);
assert_eq!(get_native_sample_format(FB_SAMPLE_FMT_DBL), SMP_FMT_F64);
assert_eq!(get_native_sample_format(FB_SAMPLE_FMT_U8_P), SMP_FMT_U8_P);
assert_eq!(get_native_sample_format(FB_SAMPLE_FMT_S16_P), SMP_FMT_S16_P);
assert_eq!(get_native_sample_format(FB_SAMPLE_FMT_S32_P), SMP_FMT_S32_P);
assert_eq!(get_native_sample_format(FB_SAMPLE_FMT_S64_P), SMP_FMT_S64_P);
assert_eq!(get_native_sample_format(FB_SAMPLE_FMT_FLTP), SMP_FMT_F32_P);
assert_eq!(get_native_sample_format(FB_SAMPLE_FMT_DBLP), SMP_FMT_F64_P);
}
#[test]
fn native_sample_format_unknown_maps_to_invalid() {
assert_eq!(get_native_sample_format(FB_SAMPLE_FMT_NONE), SMP_FMT_INVALID);
// A bogus code that does not match any bridge sample format.
assert_eq!(get_native_sample_format(12345), SMP_FMT_INVALID);
}
#[test]
fn ffmpeg_sample_format_maps_native_to_bridge() {
assert_eq!(get_ffmpeg_sample_format(SMP_FMT_U8), FB_SAMPLE_FMT_U8);
assert_eq!(get_ffmpeg_sample_format(SMP_FMT_S16), FB_SAMPLE_FMT_S16);
assert_eq!(get_ffmpeg_sample_format(SMP_FMT_S32), FB_SAMPLE_FMT_S32);
assert_eq!(get_ffmpeg_sample_format(SMP_FMT_S64), FB_SAMPLE_FMT_S64);
assert_eq!(get_ffmpeg_sample_format(SMP_FMT_F32), FB_SAMPLE_FMT_FLT);
assert_eq!(get_ffmpeg_sample_format(SMP_FMT_F64), FB_SAMPLE_FMT_DBL);
assert_eq!(get_ffmpeg_sample_format(SMP_FMT_U8_P), FB_SAMPLE_FMT_U8_P);
assert_eq!(get_ffmpeg_sample_format(SMP_FMT_S16_P), FB_SAMPLE_FMT_S16_P);
assert_eq!(get_ffmpeg_sample_format(SMP_FMT_S32_P), FB_SAMPLE_FMT_S32_P);
assert_eq!(get_ffmpeg_sample_format(SMP_FMT_S64_P), FB_SAMPLE_FMT_S64_P);
assert_eq!(get_ffmpeg_sample_format(SMP_FMT_F32_P), FB_SAMPLE_FMT_FLTP);
assert_eq!(get_ffmpeg_sample_format(SMP_FMT_F64_P), FB_SAMPLE_FMT_DBLP);
}
#[test]
fn ffmpeg_sample_format_invalid_and_count_return_none() {
assert_eq!(get_ffmpeg_sample_format(SMP_FMT_INVALID), FB_SAMPLE_FMT_NONE);
assert_eq!(get_ffmpeg_sample_format(SMP_FMT_COUNT), FB_SAMPLE_FMT_NONE);
}
#[test]
fn sample_format_mappings_are_inverse() {
for native in [SMP_FMT_U8, SMP_FMT_S16, SMP_FMT_S32, SMP_FMT_S64, SMP_FMT_F32, SMP_FMT_F64,
SMP_FMT_U8_P, SMP_FMT_S16_P, SMP_FMT_S32_P, SMP_FMT_S64_P, SMP_FMT_F32_P,
SMP_FMT_F64_P]
{
let bridge = get_ffmpeg_sample_format(native);
assert_eq!(get_native_sample_format(bridge), native);
}
}
#[test]
fn jpeg_space_converts_to_regular_space() {
assert_eq!(convert_jpeg_space_to_regular_space(FB_PIX_FMT_YUV_J420_P), FB_PIX_FMT_YU_V420_P);
assert_eq!(convert_jpeg_space_to_regular_space(FB_PIX_FMT_YUV_J422_P), FB_PIX_FMT_YU_V422_P);
assert_eq!(convert_jpeg_space_to_regular_space(FB_PIX_FMT_YUV_J444_P), FB_PIX_FMT_YU_V444_P);
assert_eq!(convert_jpeg_space_to_regular_space(FB_PIX_FMT_YUV_J440_P), FB_PIX_FMT_YU_V440_P);
assert_eq!(convert_jpeg_space_to_regular_space(FB_PIX_FMT_YUV_J411_P), FB_PIX_FMT_YU_V411_P);
}
#[test]
fn jpeg_space_leaves_non_jpeg_formats_unchanged() {
assert_eq!(convert_jpeg_space_to_regular_space(FB_PIX_FMT_RGBA), FB_PIX_FMT_RGBA);
assert_eq!(convert_jpeg_space_to_regular_space(FB_PIX_FMT_NONE), FB_PIX_FMT_NONE);
assert_eq!(convert_jpeg_space_to_regular_space(FB_PIX_FMT_YU_V420_P), FB_PIX_FMT_YU_V420_P);
}
}
+922
View File
@@ -0,0 +1,922 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! File/directory helpers, mirroring `src/common/src/filefunctions.h`
//! and `include/common/filefunctions.h`. Mirrors the filefunctions C++
//! namespace as a handle-bearing family for C ABI shape uniformity; the
//! underlying operations are stateless.
//!
//! Two-stage string getters here return their result via `buf`/`buf_size`
//! (see `crate::ffi`); the domain functions below return the owned string.
use std::path::{Path, PathBuf};
use crate::error::Result;
/// FNV-1a 64-bit hash of `data`, returned as lowercase hex (`%016llx`).
///
/// Mirrors the anonymous `fnv1a_hex` helper in `src/common/src/filefunctions.cpp`.
fn fnv1a_hex(data: &[u8]) -> String {
let mut hash: u64 = 14695981039346656037;
for &c in data {
hash ^= u64::from(c);
hash = hash.wrapping_mul(1099511628211);
}
format!("{:016x}", hash)
}
/// Case-insensitive check whether `s` ends with `suffix`.
///
/// Mirrors `ends_with_case_insensitive` in `filefunctions.cpp`; only `A-Z`
/// are folded to lower case (like the C++ `a >= 'A' && a <= 'Z'` test).
fn ends_with_case_insensitive(s: &str, suffix: &str) -> bool {
if suffix.len() > s.len() {
return false;
}
let offset = s.len() - suffix.len();
s.as_bytes()[offset..]
.iter()
.zip(suffix.as_bytes().iter())
.all(|(a, b)| a.to_ascii_lowercase() == b.to_ascii_lowercase())
}
/// The filefunctions family (stateless; the handle only exists for C ABI
/// uniformity).
pub struct FileFunctions;
impl FileFunctions {
/// Creates the filefunctions family object.
pub fn new() -> Self {
Self
}
/// Deterministic identifier string for a file (empty if it does not
/// exist).
pub fn get_unique_file_identifier(&self, filename: &str) -> Result<String> {
// `std::path::absolute` is the std analog of `fs::absolute`: it
// yields an absolute path without resolving symlinks or normalizing.
let abs = match std::path::absolute(filename) {
Ok(p) => p,
Err(_) => return Ok(String::new()),
};
if !abs.exists() {
return Ok(String::new());
}
let metadata = match std::fs::metadata(&abs) {
Ok(m) => m,
Err(_) => return Ok(String::new()),
};
let mtime = match metadata.modified() {
Ok(t) => t,
Err(_) => return Ok(String::new()),
};
// C++ appends `to_string(mtime.time_since_epoch().count())`; on
// macOS `file_time_type` has nanosecond precision, so `.as_nanos()`
// is the closest equivalent (used only as a cache-key salt).
// CPP-PARITY: `time_since_epoch().count()` on a pre-epoch timestamp
// is negative; Rust's `duration_since` returns an error which we fold
// into the negated magnitude.
let count: i128 = mtime
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as i128)
.unwrap_or_else(|e| -(e.duration().as_nanos() as i128));
let mut hash_input = abs.to_string_lossy().into_owned();
hash_input.push_str(&count.to_string());
Ok(fnv1a_hex(hash_input.as_bytes()))
}
/// Configuration directory.
pub fn get_configuration_location(&self) -> Result<String> {
// Tests and tooling can redirect the configuration (and, since most
// locations derive from it, the cache/data) root.
if let Ok(dir) = std::env::var("OAK_CONFIG_DIR") {
if !dir.is_empty() {
let _ = std::fs::create_dir_all(&dir);
return Ok(dir);
}
}
if Self::is_portable() {
return Ok(Self::application_path());
}
// CPP-PARITY: the C++ `#ifdef __APPLE__` picks `~/Library/Application
// Support`; the non-Apple branch prefers `$XDG_CONFIG_HOME` then
// `~/.config`. The empty-root fallback is the temp directory.
#[cfg(target_os = "macos")]
let config_root = match std::env::var("HOME") {
Ok(h) if !h.is_empty() => {
PathBuf::from(h).join("Library").join("Application Support")
}
_ => PathBuf::new(),
};
#[cfg(not(target_os = "macos"))]
let config_root = match std::env::var("XDG_CONFIG_HOME") {
Ok(x) if !x.is_empty() => PathBuf::from(x),
_ => match std::env::var("HOME") {
Ok(h) if !h.is_empty() => PathBuf::from(h).join(".config"),
_ => PathBuf::new(),
},
};
let config_root = if config_root.as_os_str().is_empty() {
std::env::temp_dir()
} else {
config_root
};
let config_dir = config_root.join("oak");
let _ = std::fs::create_dir_all(&config_dir);
Ok(config_dir.to_string_lossy().into_owned())
}
/// Application path.
pub fn get_application_path(&self) -> Result<String> {
Ok(Self::application_path())
}
/// Whether the application is running in portable mode (a `portable` file
/// sits next to the application executable). Mirrors
/// `FileFunctions::is_portable`.
fn is_portable() -> bool {
let app = Self::application_path();
!app.is_empty() && Path::new(&app).join("portable").exists()
}
/// Application path, without the `Result` wrapper (used internally).
fn application_path() -> String {
#[cfg(target_os = "macos")]
{
// CPP-PARITY: C++ uses `_NSGetExecutablePath` + `weakly_canonical`;
// `std::env::current_exe` + `canonicalize` is the portable
// equivalent and yields the same parent directory.
if let Ok(exe) = std::env::current_exe() {
let canonical = exe.canonicalize().unwrap_or(exe);
if let Some(parent) = canonical.parent() {
return parent.to_string_lossy().into_owned();
}
}
}
#[cfg(all(unix, not(target_os = "macos")))]
{
if let Ok(target) = std::fs::read_link("/proc/self/exe") {
if let Some(parent) = target.parent() {
return parent.to_string_lossy().into_owned();
}
}
}
// Fallback: current working directory
std::env::current_dir()
.map(|c| c.to_string_lossy().into_owned())
.unwrap_or_default()
}
/// Temporary file path.
pub fn get_temp_file_path(&self) -> Result<String> {
// CPP-PARITY: `std::env::temp_dir()` never fails the way
// `fs::temp_directory_path(ec)` can, so the `"." / "oak-temp"`
// fallback is unreachable here and omitted.
let temp_path = std::env::temp_dir().join("oak");
let _ = std::fs::create_dir_all(&temp_path);
Ok(temp_path.to_string_lossy().into_owned())
}
/// Auto-recovery root directory.
pub fn get_auto_recovery_root(&self) -> Result<String> {
let config = self.get_configuration_location()?;
Ok(PathBuf::from(config)
.join("autorecovery")
.to_string_lossy()
.into_owned())
}
/// Whether `source` can be copied to `dest` without overwriting.
pub fn can_copy_directory_without_overwriting(&self, source: &str, dest: &str) -> bool {
// CPP-PARITY: a failed `directory_iterator` (e.g. `source` missing)
// yields an empty iteration, so the function returns `true`.
let Ok(entries) = std::fs::read_dir(source) else {
return true;
};
for entry in entries.flatten() {
let dest_equivalent = PathBuf::from(dest).join(entry.file_name());
// `entry.is_directory(ec)` failing (ec set) falls through to the
// "else exists" branch in C++; `unwrap_or(false)` mirrors that.
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
if !self.can_copy_directory_without_overwriting(
&entry.path().to_string_lossy(),
&dest_equivalent.to_string_lossy(),
) {
return false;
}
} else if dest_equivalent.exists() {
return false;
}
}
true
}
/// Recursively copy a directory, optionally overwriting.
pub fn copy_directory(&self, source: &str, dest: &str, overwrite: bool) -> Result<()> {
if !Path::new(source).is_dir() {
eprintln!("Failed to copy directory, source {} didn't exist", source);
return Ok(());
}
if std::fs::create_dir_all(dest).is_err() {
eprintln!("Failed to create destination directory {}", dest);
return Ok(());
}
// CPP-PARITY: a failed `directory_iterator` on `source` aborts the
// copy silently (C++ prints nothing); skipping matches that.
let Ok(entries) = std::fs::read_dir(source) else {
return Ok(());
};
for entry in entries.flatten() {
let entry_path = entry.path();
let dest_file_path = PathBuf::from(dest).join(entry.file_name());
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
// Copy dir
self.copy_directory(
&entry_path.to_string_lossy(),
&dest_file_path.to_string_lossy(),
overwrite,
)?;
} else {
// Copy file
if overwrite {
// C++ adds owner/group/others write permission then
// removes the destination (so read-only files can be
// replaced). CPP-PARITY: gated to `unix` because Rust's
// `PermissionsExt` is Unix-only; on Windows the write
// permissions already allow removal.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = std::fs::metadata(&dest_file_path) {
let mut perms = meta.permissions();
// owner_write|group_write|others_write
perms.set_mode(perms.mode() | 0o200 | 0o020 | 0o002);
let _ = std::fs::set_permissions(&dest_file_path, perms);
}
}
let _ = std::fs::remove_file(&dest_file_path);
}
// CPP-PARITY: C++ `fs::copy_file` with `copy_options::none`
// FAILS when the destination exists, but Rust's
// `std::fs::copy` would silently overwrite it — so the
// no-overwrite case must be guarded explicitly.
if !overwrite && dest_file_path.exists() {
eprintln!(
"Failed to copy file {} to {}: File exists",
entry_path.to_string_lossy(),
dest_file_path.to_string_lossy()
);
continue;
}
if let Err(e) = std::fs::copy(&entry_path, &dest_file_path) {
eprintln!(
"Failed to copy file {} to {}: {}",
entry_path.to_string_lossy(),
dest_file_path.to_string_lossy(),
e
);
}
}
}
Ok(())
}
/// Whether a directory exists, optionally creating it.
pub fn directory_is_valid(&self, dir: &str, try_to_create: bool) -> bool {
// Return whether the directory exists, or whether it could be created
// if it doesn't
if Path::new(dir).is_dir() {
return true;
}
try_to_create && std::fs::create_dir_all(dir).is_ok()
}
/// Filename with a (dotless) extension ensured.
pub fn ensure_filename_extension(&self, filename: &str, extension: &str) -> Result<String> {
let mut fn_str = filename.to_string();
// No-op if either input is empty
if !fn_str.is_empty() && !extension.is_empty() {
let extension_with_dot = format!(".{}", extension);
if !ends_with_case_insensitive(&fn_str, &extension_with_dot) {
fn_str.push_str(&extension_with_dot);
}
}
Ok(fn_str)
}
/// The entire file read into a string (empty if it cannot be read).
pub fn read_file_as_string(&self, filename: &str) -> Result<String> {
// CPP-PARITY: the C++ returns the raw byte buffer as a `std::string`;
// Rust `String` must be valid UTF-8, so invalid bytes are replaced
// (lossy) rather than preserved. Project/XML files are UTF-8, so this
// matches in practice.
match std::fs::read(filename) {
Ok(bytes) => Ok(String::from_utf8_lossy(&bytes).into_owned()),
Err(_) => Ok(String::new()),
}
}
/// A non-existing temporary variant of `original`.
pub fn get_safe_temporary_filename(&self, original: &str) -> Result<String> {
let mut counter: i32 = 0;
let original_path = PathBuf::from(original);
let dir = original_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_default();
let filename = original_path
.file_name()
.map(|f| f.to_string_lossy().into_owned())
.unwrap_or_default();
// Split off the complete suffix (everything from the first dot), like
// QFileInfo::completeSuffix()
let mut basename = filename.clone();
let mut complete_suffix = String::new();
if let Some(first_dot) = filename.find('.') {
basename = filename[..first_dot].to_string();
complete_suffix = filename[first_dot..].to_string();
}
let mut temp_abs_path;
loop {
temp_abs_path = dir.join(format!(
"{}.tmp{}{}",
basename, counter, complete_suffix
));
counter += 1;
if !temp_abs_path.exists() {
break;
}
}
Ok(temp_abs_path.to_string_lossy().into_owned())
}
/// Rename `from` to `to`, deleting `to` first if it exists.
pub fn rename_file_allow_overwrite(&self, from: &str, to: &str) -> bool {
if Path::new(to).exists() {
// CPP-PARITY: C++ `fs::remove` handles both files and dirs; Rust
// has no single "remove" that does both, and the use case here is
// file renames, so `remove_file` is used.
if std::fs::remove_file(to).is_err() {
eprintln!("Couldn't remove existing file {} for overwrite", to);
return false;
}
}
// By this point, we can assume `to` either never existed or has now
// been deleted
if std::fs::rename(from, to).is_err() {
eprintln!("Failed to rename file {} to {}", from, to);
return false;
}
true
}
/// Append the platform executable suffix (".exe" on Windows).
pub fn get_formatted_executable_for_platform(&self, unformatted: &str) -> Result<String> {
#[cfg(target_os = "windows")]
{
Ok(format!("{}.exe", unformatted))
}
#[cfg(not(target_os = "windows"))]
{
Ok(unformatted.to_string())
}
}
}
/// Location query helper reserved for the two-stage C getters; returns a
/// path suitable for `std::fs`.
pub(crate) fn config_location_path() -> Result<PathBuf> {
FileFunctions::new()
.get_configuration_location()
.map(PathBuf::from)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, MutexGuard};
// The configuration-location tests mutate the process-wide `OAK_CONFIG_DIR`
// env var, which is also read by `get_auto_recovery_root` and
// `config_location_path`. Rust runs tests in parallel, so serialize those
// env-dependent tests with a shared lock. We use the crate-wide test lock so
// `configstore` tests (which also mutate `OAK_CONFIG_DIR`) serialize on the
// SAME mutex.
fn config_lock() -> &'static Mutex<()> {
crate::test_support::env_lock()
}
fn unique_temp_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"oak-filefunctions-test-{}-{}-{}",
tag,
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn fnv1a_known_vectors() {
// Offset basis as lowercase hex, and the canonical FNV-1a-64 of "a".
assert_eq!(fnv1a_hex(b""), "cbf29ce484222325");
assert_eq!(fnv1a_hex(b"a"), "af63dc4c8601ec8c");
}
#[test]
fn ensure_extension_appends_and_is_case_insensitive() {
let f = FileFunctions::new();
assert_eq!(f.ensure_filename_extension("foo", "ove").unwrap(), "foo.ove");
// Already present (case-insensitive): untouched.
assert_eq!(f.ensure_filename_extension("foo.OVE", "ove").unwrap(), "foo.OVE");
assert_eq!(f.ensure_filename_extension("foo.ove", "Ove").unwrap(), "foo.ove");
// The "." in the suffix must actually be present.
assert_eq!(f.ensure_filename_extension("fooove", "ove").unwrap(), "fooove.ove");
// Empty inputs are no-ops.
assert_eq!(f.ensure_filename_extension("", "ove").unwrap(), "");
assert_eq!(f.ensure_filename_extension("foo", "").unwrap(), "foo");
}
#[test]
fn formatted_executable_for_platform() {
let f = FileFunctions::new();
let v = f.get_formatted_executable_for_platform("myapp").unwrap();
#[cfg(target_os = "windows")]
assert_eq!(v, "myapp.exe");
#[cfg(not(target_os = "windows"))]
assert_eq!(v, "myapp");
}
#[test]
fn unique_identifier_existing_and_missing() {
let f = FileFunctions::new();
let dir = unique_temp_dir("id");
let file = dir.join("data.txt");
std::fs::write(&file, b"hello").unwrap();
let id = f
.get_unique_file_identifier(&file.to_string_lossy())
.unwrap();
assert_eq!(id.len(), 16);
assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
// Deterministic for the same file.
let id2 = f
.get_unique_file_identifier(&file.to_string_lossy())
.unwrap();
assert_eq!(id, id2);
let missing = f
.get_unique_file_identifier(&dir.join("nope.txt").to_string_lossy())
.unwrap();
assert_eq!(missing, "");
}
#[test]
fn read_file_as_string_roundtrip() {
let f = FileFunctions::new();
let dir = unique_temp_dir("read");
let file = dir.join("x.txt");
std::fs::write(&file, b"hello world").unwrap();
assert_eq!(
f.read_file_as_string(&file.to_string_lossy()).unwrap(),
"hello world"
);
// Missing file -> empty string.
assert_eq!(
f.read_file_as_string(&dir.join("missing.txt").to_string_lossy())
.unwrap(),
""
);
}
#[test]
fn directory_is_valid_create_semantics() {
let f = FileFunctions::new();
let parent = unique_temp_dir("dirval");
let dir = parent.join("created");
assert!(!dir.exists());
assert!(f.directory_is_valid(&dir.to_string_lossy(), true));
assert!(dir.is_dir());
// Now it exists, so even without create it is valid.
assert!(f.directory_is_valid(&dir.to_string_lossy(), false));
let missing = parent.join("never");
assert!(!missing.exists());
assert!(!f.directory_is_valid(&missing.to_string_lossy(), false));
}
#[test]
fn safe_temporary_filename_skips_existing() {
let f = FileFunctions::new();
let dir = unique_temp_dir("safetmp");
let original = dir.join("video.mp4");
// Block the first candidate so the counter must advance.
std::fs::write(dir.join("video.tmp0.mp4"), b"x").unwrap();
let result = f
.get_safe_temporary_filename(&original.to_string_lossy())
.unwrap();
assert!(result.ends_with(".mp4"), "suffix preserved: {}", result);
assert!(
result.contains(".tmp1.mp4"),
"should skip existing .tmp0, got: {}",
result
);
assert!(!Path::new(&result).exists());
}
#[test]
fn rename_allow_overwrite() {
let f = FileFunctions::new();
let dir = unique_temp_dir("rename");
let from = dir.join("from.txt");
let to = dir.join("to.txt");
std::fs::write(&from, b"new").unwrap();
std::fs::write(&to, b"old").unwrap();
assert!(f.rename_file_allow_overwrite(
&from.to_string_lossy(),
&to.to_string_lossy()
));
assert!(!from.exists());
assert_eq!(std::fs::read(&to).unwrap(), b"new");
// Renaming a missing source fails.
let missing = dir.join("missing.txt");
assert!(!f.rename_file_allow_overwrite(
&missing.to_string_lossy(),
&dir.join("z.txt").to_string_lossy()
));
}
#[test]
fn copy_directory_recursive_and_overwrite_check() {
let f = FileFunctions::new();
let src = unique_temp_dir("cp-src");
let nested = src.join("sub");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(src.join("a.txt"), b"a").unwrap();
std::fs::write(nested.join("b.txt"), b"b").unwrap();
let dest = unique_temp_dir("cp-dst");
// Nothing in dest yet: safe to copy without overwriting.
assert!(f.can_copy_directory_without_overwriting(
&src.to_string_lossy(),
&dest.to_string_lossy()
));
f.copy_directory(&src.to_string_lossy(), &dest.to_string_lossy(), false)
.unwrap();
assert!(dest.join("a.txt").exists());
assert!(dest.join("sub").join("b.txt").exists());
assert_eq!(std::fs::read(dest.join("a.txt")).unwrap(), b"a");
// Copying again would overwrite a.txt.
assert!(!f.can_copy_directory_without_overwriting(
&src.to_string_lossy(),
&dest.to_string_lossy()
));
// With overwrite it succeeds and refreshes content.
f.copy_directory(&src.to_string_lossy(), &dest.to_string_lossy(), true)
.unwrap();
assert_eq!(std::fs::read(dest.join("a.txt")).unwrap(), b"a");
// A missing source is a silent no-op (returns Ok).
f.copy_directory(
&dest.join("nope").to_string_lossy(),
&unique_temp_dir("cp-missing").to_string_lossy(),
false,
)
.unwrap();
}
#[test]
fn application_and_temp_paths() {
let f = FileFunctions::new();
let app = f.get_application_path().unwrap();
assert!(!app.is_empty());
assert!(Path::new(&app).is_dir());
let temp = f.get_temp_file_path().unwrap();
assert!(!temp.is_empty());
assert!(Path::new(&temp).is_dir());
}
#[test]
fn configuration_location_obeys_env_override() {
let _guard: MutexGuard<()> = config_lock().lock().unwrap();
let f = FileFunctions::new();
let dir = unique_temp_dir("config");
std::env::set_var("OAK_CONFIG_DIR", &dir);
let loc = f.get_configuration_location().unwrap();
std::env::remove_var("OAK_CONFIG_DIR");
assert_eq!(PathBuf::from(&loc), dir);
assert!(dir.is_dir());
}
#[test]
fn auto_recovery_root_derives_from_config() {
let _guard: MutexGuard<()> = config_lock().lock().unwrap();
let f = FileFunctions::new();
let dir = unique_temp_dir("config2");
std::env::set_var("OAK_CONFIG_DIR", &dir);
let root = f.get_auto_recovery_root().unwrap();
std::env::remove_var("OAK_CONFIG_DIR");
assert!(root.ends_with("autorecovery"));
assert!(PathBuf::from(&root).starts_with(&dir));
}
#[test]
fn config_location_path_helper() {
let _guard: MutexGuard<()> = config_lock().lock().unwrap();
let dir = unique_temp_dir("clp");
std::env::set_var("OAK_CONFIG_DIR", &dir);
let p = config_location_path().unwrap();
std::env::remove_var("OAK_CONFIG_DIR");
assert_eq!(p, dir);
}
#[test]
fn ends_with_case_insensitive_matrix() {
assert!(ends_with_case_insensitive("foo.OVE", ".ove"));
assert!(ends_with_case_insensitive("foo.ove", ".OVE"));
assert!(ends_with_case_insensitive("x", "x"));
assert!(ends_with_case_insensitive("x", ""));
// Suffix longer than the string never matches.
assert!(!ends_with_case_insensitive("ove", ".ove"));
assert!(!ends_with_case_insensitive("", "a"));
// Only ASCII A-Z fold (like the C++ range test); other bytes compare
// exactly.
assert!(ends_with_case_insensitive("a[", "["));
assert!(!ends_with_case_insensitive("a[", "{"));
assert!(ends_with_case_insensitive("vidéo.MP4", ".mp4"));
}
#[test]
fn ensure_extension_dotfile_multi_ext_and_unicode() {
let f = FileFunctions::new();
// Dotfiles and multi-extension names just get the suffix appended.
assert_eq!(f.ensure_filename_extension(".hidden", "txt").unwrap(), ".hidden.txt");
assert_eq!(
f.ensure_filename_extension("archive.tar", "gz").unwrap(),
"archive.tar.gz"
);
assert_eq!(
f.ensure_filename_extension("archive.tar.gz", "gz").unwrap(),
"archive.tar.gz"
);
// Unicode names pass through unchanged apart from the suffix.
assert_eq!(
f.ensure_filename_extension("動画ファイル", "ove").unwrap(),
"動画ファイル.ove"
);
// A bare "." suffix (empty extension) is a no-op per the C++.
assert_eq!(f.ensure_filename_extension("foo.", "").unwrap(), "foo.");
}
#[test]
fn safe_temporary_filename_naming_variants() {
let f = FileFunctions::new();
let dir = unique_temp_dir("safetmp2");
// Counter 0 is used when nothing blocks it.
let r0 = f
.get_safe_temporary_filename(&dir.join("clip.mov").to_string_lossy())
.unwrap();
assert!(r0.ends_with("clip.tmp0.mov"), "got: {}", r0);
// Complete suffix = everything from the FIRST dot (QFileInfo
// completeSuffix semantics, `filefunctions.cpp:318-326`).
let r1 = f
.get_safe_temporary_filename(&dir.join("a.tar.gz").to_string_lossy())
.unwrap();
assert!(r1.ends_with("a.tmp0.tar.gz"), "got: {}", r1);
// No extension at all.
let r2 = f
.get_safe_temporary_filename(&dir.join("README").to_string_lossy())
.unwrap();
assert!(r2.ends_with("README.tmp0"), "got: {}", r2);
// Dotfile: basename is empty, complete suffix is the whole name.
let r3 = f
.get_safe_temporary_filename(&dir.join(".hidden").to_string_lossy())
.unwrap();
assert!(r3.ends_with(".tmp0.hidden"), "got: {}", r3);
// No parent directory: the candidate is relative ("name.tmp0.ext").
let r4 = f.get_safe_temporary_filename("video.mp4").unwrap();
assert_eq!(r4, "video.tmp0.mp4");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn unique_identifier_differs_by_path() {
let f = FileFunctions::new();
let dir = unique_temp_dir("id2");
let a = dir.join("a.txt");
let b = dir.join("b.txt");
std::fs::write(&a, b"same").unwrap();
std::fs::write(&b, b"same").unwrap();
// The absolute path is part of the hash input
// (`filefunctions.cpp:100-103`), so identical content in different
// paths yields different identifiers.
let ida = f.get_unique_file_identifier(&a.to_string_lossy()).unwrap();
let idb = f.get_unique_file_identifier(&b.to_string_lossy()).unwrap();
assert_ne!(ida, idb);
// A directory also gets an identifier (C++ only checks exists()).
let idd = f.get_unique_file_identifier(&dir.to_string_lossy()).unwrap();
assert_eq!(idd.len(), 16);
// Empty filename -> absolute() of "" is the CWD which exists; but a
// definitely-bogus relative name yields "".
let bogus = f.get_unique_file_identifier("definitely-not-here.xyz").unwrap();
assert_eq!(bogus, "");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn read_file_as_string_binary_and_lossy() {
let f = FileFunctions::new();
let dir = unique_temp_dir("read2");
// Invalid UTF-8 is replaced (lossy), not an error.
let bad = dir.join("bad.bin");
std::fs::write(&bad, b"a\xff\xfeb").unwrap();
let s = f.read_file_as_string(&bad.to_string_lossy()).unwrap();
assert_eq!(s, "a\u{FFFD}\u{FFFD}b");
// Empty file reads as empty, indistinguishable from a missing file.
let empty = dir.join("empty.txt");
std::fs::write(&empty, b"").unwrap();
assert_eq!(f.read_file_as_string(&empty.to_string_lossy()).unwrap(), "");
// A directory cannot be read as a file -> empty.
assert_eq!(f.read_file_as_string(&dir.to_string_lossy()).unwrap(), "");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn directory_is_valid_blocked_by_file() {
let f = FileFunctions::new();
let parent = unique_temp_dir("dirval2");
// A regular file blocks directory creation at the same path.
let blocker = parent.join("blocked");
std::fs::write(&blocker, b"x").unwrap();
assert!(!f.directory_is_valid(&blocker.to_string_lossy(), true));
// ...and so does a file in the MIDDLE of the path to create.
let nested = blocker.join("child");
assert!(!f.directory_is_valid(&nested.to_string_lossy(), true));
// Nested creation works when nothing blocks it.
let deep = parent.join("x").join("y").join("z");
assert!(f.directory_is_valid(&deep.to_string_lossy(), true));
assert!(deep.is_dir());
let _ = std::fs::remove_dir_all(&parent);
}
#[test]
fn copy_directory_no_overwrite_preserves_existing() {
let f = FileFunctions::new();
let src = unique_temp_dir("cp2-src");
std::fs::write(src.join("a.txt"), b"new").unwrap();
let dest = unique_temp_dir("cp2-dst");
std::fs::write(dest.join("a.txt"), b"old").unwrap();
// would-overwrite detection fires on the direct conflict...
assert!(!f.can_copy_directory_without_overwriting(
&src.to_string_lossy(),
&dest.to_string_lossy()
));
// ...and a missing source trivially reports "safe" (empty iteration,
// `filefunctions.cpp:202`).
assert!(f.can_copy_directory_without_overwriting(
&src.join("nope").to_string_lossy(),
&dest.to_string_lossy()
));
// overwrite=false: the copy of the conflicting file fails (logged)
// and the destination keeps its old content; other files still copy.
std::fs::write(src.join("b.txt"), b"b").unwrap();
f.copy_directory(&src.to_string_lossy(), &dest.to_string_lossy(), false)
.unwrap();
assert_eq!(std::fs::read(dest.join("a.txt")).unwrap(), b"old");
assert_eq!(std::fs::read(dest.join("b.txt")).unwrap(), b"b");
// overwrite=true replaces the conflicting file.
f.copy_directory(&src.to_string_lossy(), &dest.to_string_lossy(), true)
.unwrap();
assert_eq!(std::fs::read(dest.join("a.txt")).unwrap(), b"new");
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&dest);
}
#[test]
fn can_copy_detects_nested_conflict() {
let f = FileFunctions::new();
let src = unique_temp_dir("cp3-src");
std::fs::create_dir_all(src.join("sub")).unwrap();
std::fs::write(src.join("sub").join("deep.txt"), b"x").unwrap();
let dest = unique_temp_dir("cp3-dst");
std::fs::create_dir_all(dest.join("sub")).unwrap();
std::fs::write(dest.join("sub").join("deep.txt"), b"y").unwrap();
// The conflict is two levels down: recursion must find it.
assert!(!f.can_copy_directory_without_overwriting(
&src.to_string_lossy(),
&dest.to_string_lossy()
));
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&dest);
}
#[test]
fn rename_to_fresh_destination() {
let f = FileFunctions::new();
let dir = unique_temp_dir("rename2");
let from = dir.join("only.txt");
let to = dir.join("fresh.txt");
std::fs::write(&from, b"data").unwrap();
assert!(f.rename_file_allow_overwrite(
&from.to_string_lossy(),
&to.to_string_lossy()
));
assert_eq!(std::fs::read(&to).unwrap(), b"data");
// Destination existing as a DIRECTORY cannot be removed by
// remove_file, so the rename fails (documented CPP-PARITY
// divergence from C++ fs::remove, which would remove an empty dir).
let from2 = dir.join("only2.txt");
std::fs::write(&from2, b"z").unwrap();
let todir = dir.join("destdir");
std::fs::create_dir(&todir).unwrap();
assert!(!f.rename_file_allow_overwrite(
&from2.to_string_lossy(),
&todir.to_string_lossy()
));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn temp_file_path_under_system_temp() {
let f = FileFunctions::new();
let temp = f.get_temp_file_path().unwrap();
// `<temp>/oak`, created on demand (`filefunctions.cpp:184-196`).
assert_eq!(
PathBuf::from(&temp),
std::env::temp_dir().join("oak")
);
assert!(Path::new(&temp).is_dir());
}
#[test]
fn unique_temp_dirs_are_actually_unique() {
let a = unique_temp_dir("uniq");
let b = unique_temp_dir("uniq");
assert_ne!(a, b);
let _ = std::fs::remove_dir_all(&a);
let _ = std::fs::remove_dir_all(&b);
}
}
+422
View File
@@ -0,0 +1,422 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Refcounted-handle scaffolding. Same pattern as the oakplugin crate
//! (`src/plugin/rust/src/handle.rs`); intentionally duplicated rather
//! than shared — each module DLL must run its own addref/release code
//! (the function pointers in a handle always point into the DLL that
//! created the object).
//!
//! Mirrors the C side (`include/common/handle.h`):
//!
//! ```c
//! typedef struct OakXxx {
//! void *ctx;
//! void (*addref)(void *ctx);
//! void (*release)(void *ctx);
//! uint32_t abi_version;
//! } OakXxx;
//! ```
//!
//! Handles are passed by value; `ctx` points to a heap [`RefBox<T>`].
//! The `addref`/`release` function pointers always point into this crate.
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::sync::atomic::{AtomicU32, Ordering};
/// ABI version stamped into every handle.
pub const OAKCOMMON_ABI_VERSION: u32 = 1;
/// Heap box behind a handle's `ctx`.
///
/// `value` is deliberately the FIRST field so that `ctx` (which points at
/// the box head) aliases the boxed value: the C-facing `ffi.rs` code
/// frequently casts `ctx` directly to `*mut T` / `*const T`, and that cast
/// only lands on the real value when it sits at offset 0. `refs` follows,
/// located via field access (never a raw offset) by the addref/release
/// thunks. `#[repr(C)]` locks the layout so those direct casts are sound.
// CPP-PARITY: matches `include/common/handle.h` where `ctx` is an opaque
// pointer; the C side never dereferences it, so this layout is private to
// this crate.
#[repr(C)]
pub struct RefBox<T: Sized> {
/// Boxed value (at offset 0 — see module doc).
pub value: T,
/// Atomic reference count.
pub refs: AtomicU32,
}
/// `#[repr(C)]` mirror of the public handle structs
/// (`{ctx, addref, release, abi_version}`).
#[repr(C)]
pub struct CHandle {
/// Opaque box pointer.
pub ctx: *mut std::ffi::c_void,
/// Atomic increment.
pub addref: Option<unsafe extern "C" fn(*mut std::ffi::c_void)>,
/// Atomic decrement; destroys at zero.
pub release: Option<unsafe extern "C" fn(*mut std::ffi::c_void)>,
/// ABI version.
pub abi_version: u32,
}
impl CHandle {
/// The empty handle.
pub fn null() -> Self {
Self {
ctx: std::ptr::null_mut(),
addref: None,
release: None,
abi_version: OAKCOMMON_ABI_VERSION,
}
}
/// Whether this is an empty handle (`ctx == NULL`).
pub fn is_null(&self) -> bool {
self.ctx.is_null()
}
}
/// addref thunk: atomically increments the count. Shared by owned and
/// borrowed boxes — for a borrowed handle addref only extends the life of
/// the box, not of the borrowed object.
unsafe extern "C" fn refbox_addref<T: Send + Sized + 'static>(ctx: *mut std::ffi::c_void) {
unsafe {
let rb = ctx as *const RefBox<T>;
// Caller guarantees the handle is alive (ctx non-null, not yet
// released) for the duration of the borrow.
(*rb).refs.fetch_add(1, Ordering::Relaxed);
}
}
/// release thunk (owned): atomically decrements; at zero the box and its
/// value are destroyed.
unsafe extern "C" fn refbox_release_owned<T: Send + Sized + 'static>(ctx: *mut std::ffi::c_void) {
unsafe {
let rb = ctx as *mut RefBox<T>;
// AcqRel: the side that reaches zero must observe all writes made
// before the final release (including internal state the value's
// destructor needs).
if (*rb).refs.fetch_sub(1, Ordering::AcqRel) == 1 {
drop(Box::from_raw(rb));
}
}
}
/// release thunk (borrowed, produced by [`make_borrowed`]): at zero only
/// the box allocation is reclaimed; the value inside is forgotten — its
/// ownership remains with the borrower.
unsafe extern "C" fn refbox_release_borrowed<T: Send + Sized + 'static>(ctx: *mut std::ffi::c_void) {
unsafe {
let rb = ctx as *mut RefBox<T>;
if (*rb).refs.fetch_sub(1, Ordering::AcqRel) == 1 {
// Partial move: move the value out of a temporary Box, then
// forget it so the Box drop only frees the allocation and the
// value's destructor never runs (double-free guard).
std::mem::forget((Box::from_raw(rb)).value);
}
}
}
/// Owned handle with count 1; empty on allocation failure.
pub fn make_owned<T: Send + Sized + 'static>(value: T) -> CHandle {
let rb = Box::into_raw(Box::new(RefBox {
refs: AtomicU32::new(1),
value,
}));
CHandle {
ctx: rb as *mut std::ffi::c_void,
addref: Some(refbox_addref::<T>),
release: Some(refbox_release_owned::<T>),
abi_version: OAKCOMMON_ABI_VERSION,
}
}
/// Borrowed handle for an object owned elsewhere (release frees only
/// the box).
///
/// The value is bit-copied into the box ("borrowing copy"); the box never
/// runs the value's destructor — the borrower owns the original object
/// and is responsible for destroying it.
///
/// # Safety
/// Caller guarantees `ptr` outlives every derived handle and that its
/// value is neither moved nor destroyed for the duration of the borrow.
pub unsafe fn make_borrowed<T: Send + Sized + 'static>(ptr: *mut T) -> CHandle {
if ptr.is_null() {
return CHandle::null();
}
let rb = Box::into_raw(Box::new(RefBox {
refs: AtomicU32::new(1),
value: unsafe { std::ptr::read(ptr) },
}));
CHandle {
ctx: rb as *mut std::ffi::c_void,
addref: Some(refbox_addref::<T>),
release: Some(refbox_release_borrowed::<T>),
abi_version: OAKCOMMON_ABI_VERSION,
}
}
/// Typed view into a handle; `None` for empty handles.
///
/// # Safety
/// `T` must be the boxed type.
pub unsafe fn get<T: Sized + 'static>(h: &CHandle) -> Option<&T> {
if h.is_null() {
return None;
}
unsafe { Some(&(*(h.ctx as *const RefBox<T>)).value) }
}
/// Mutable typed view into a handle; `None` for empty handles.
///
/// # Safety
/// `T` must be the boxed type, and the caller must not alias the returned
/// reference with any other live reference into the same handle.
pub unsafe fn get_mut<T: Sized + 'static>(h: &CHandle) -> Option<&mut T> {
if h.is_null() {
return None;
}
unsafe { Some(&mut (*(h.ctx as *mut RefBox<T>)).value) }
}
/// Panic-catching FFI wrapper for i32-returning exports.
pub fn guard<F: FnOnce() -> crate::error::Result<()>>(f: F) -> i32 {
match catch_unwind(AssertUnwindSafe(f)) {
Ok(Ok(())) => crate::error::OAKCOMMON_OK,
Ok(Err(e)) => e.code(),
Err(_) => crate::error::OAKCOMMON_E_FAILED,
}
}
/// Panic-catching FFI wrapper for handle-returning exports.
pub fn guard_handle<F: FnOnce() -> crate::error::Result<CHandle>>(f: F) -> CHandle {
match catch_unwind(AssertUnwindSafe(f)) {
Ok(Ok(h)) => h,
Ok(Err(_)) | Err(_) => CHandle::null(),
}
}
/// Panic-catching FFI wrapper for void exports.
pub fn guard_void<F: FnOnce()>(f: F) {
let _ = catch_unwind(AssertUnwindSafe(f));
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
use std::sync::Arc;
use super::*;
/// Test payload that counts how many times its destructor ran.
struct DropCounter {
/// Shared counter bumped by `Drop::drop`.
drops: Arc<AtomicUsize>,
}
impl DropCounter {
/// A new counter plus its shared tally.
fn new() -> (Self, Arc<AtomicUsize>) {
let drops = Arc::new(AtomicUsize::new(0));
(
DropCounter {
drops: Arc::clone(&drops),
},
drops,
)
}
}
impl Drop for DropCounter {
fn drop(&mut self) {
self.drops.fetch_add(1, AtomicOrdering::SeqCst);
}
}
/// Read the refcount behind a handle (test-only peek).
unsafe fn refs_of<T: Send + Sized + 'static>(h: &CHandle) -> u32 {
unsafe { (*(h.ctx as *const RefBox<T>)).refs.load(Ordering::Relaxed) }
}
#[test]
fn null_handle_is_null_and_stamped() {
let h = CHandle::null();
assert!(h.is_null());
assert!(h.ctx.is_null());
assert!(h.addref.is_none());
assert!(h.release.is_none());
assert_eq!(h.abi_version, OAKCOMMON_ABI_VERSION);
}
#[test]
fn make_owned_starts_at_one_ref_and_exposes_value() {
let (value, drops) = DropCounter::new();
let h = make_owned(value);
assert!(!h.is_null());
assert_eq!(h.abi_version, OAKCOMMON_ABI_VERSION);
assert!(h.addref.is_some());
assert!(h.release.is_some());
unsafe {
assert_eq!(refs_of::<DropCounter>(&h), 1);
// get() sees the boxed value.
let v: &DropCounter = get::<DropCounter>(&h).unwrap();
assert_eq!(v.drops.load(AtomicOrdering::SeqCst), 0);
(h.release.unwrap())(h.ctx);
}
// Releasing the last ref destroyed the box and ran the destructor.
assert_eq!(drops.load(AtomicOrdering::SeqCst), 1);
}
#[test]
fn owned_addref_release_balance_then_drop_at_zero() {
let (value, drops) = DropCounter::new();
let h = make_owned(value);
unsafe {
(h.addref.unwrap())(h.ctx);
(h.addref.unwrap())(h.ctx);
assert_eq!(refs_of::<DropCounter>(&h), 3);
(h.release.unwrap())(h.ctx);
assert_eq!(refs_of::<DropCounter>(&h), 2);
assert_eq!(drops.load(AtomicOrdering::SeqCst), 0);
(h.release.unwrap())(h.ctx);
assert_eq!(refs_of::<DropCounter>(&h), 1);
assert_eq!(drops.load(AtomicOrdering::SeqCst), 0);
(h.release.unwrap())(h.ctx);
}
// Final release to zero ran the destructor exactly once.
assert_eq!(drops.load(AtomicOrdering::SeqCst), 1);
}
#[test]
fn borrowed_release_to_zero_does_not_run_value_destructor() {
let (value, drops) = DropCounter::new();
let mut value = value;
let h = unsafe { make_borrowed(&mut value) };
assert!(!h.is_null());
unsafe {
assert_eq!(refs_of::<DropCounter>(&h), 1);
(h.addref.unwrap())(h.ctx);
assert_eq!(refs_of::<DropCounter>(&h), 2);
(h.release.unwrap())(h.ctx);
assert_eq!(refs_of::<DropCounter>(&h), 1);
// Releasing the borrowed box to zero frees only the box.
(h.release.unwrap())(h.ctx);
}
// The value's destructor must NOT have run; ownership stayed here.
assert_eq!(drops.load(AtomicOrdering::SeqCst), 0);
drop(value);
assert_eq!(drops.load(AtomicOrdering::SeqCst), 1);
}
#[test]
fn borrowed_handle_sees_borrowed_value_contents() {
let mut data: u64 = 0xdead_beef;
let h = unsafe { make_borrowed(&mut data) };
unsafe {
let v: &u64 = get::<u64>(&h).unwrap();
assert_eq!(*v, 0xdead_beef);
(h.release.unwrap())(h.ctx);
}
}
#[test]
fn make_borrowed_null_ptr_yields_null_handle() {
let h = unsafe { make_borrowed::<u64>(std::ptr::null_mut()) };
assert!(h.is_null());
assert!(h.addref.is_none());
assert!(h.release.is_none());
}
#[test]
fn get_on_null_handle_is_none() {
let h = CHandle::null();
assert!(unsafe { get::<u64>(&h) }.is_none());
}
#[test]
fn get_returns_typed_view_of_owned_box() {
let h = make_owned(String::from("hello"));
unsafe {
let s: &String = get::<String>(&h).unwrap();
assert_eq!(s, "hello");
(h.release.unwrap())(h.ctx);
}
}
#[test]
fn guard_maps_result_to_status_code() {
assert_eq!(guard(|| Ok(())), crate::error::OAKCOMMON_OK);
assert_eq!(
guard(|| Err(crate::error::Error::Invalid)),
crate::error::OAKCOMMON_E_INVALID
);
assert_eq!(
guard(|| Err(crate::error::Error::State)),
crate::error::OAKCOMMON_E_STATE
);
assert_eq!(
guard(|| Err(crate::error::Error::Failed("x".into()))),
crate::error::OAKCOMMON_E_FAILED
);
assert_eq!(
guard(|| Err(crate::error::Error::NotFound)),
crate::error::OAKCOMMON_E_NOT_FOUND
);
assert_eq!(
guard(|| Err(crate::error::Error::NoMem)),
crate::error::OAKCOMMON_E_NOMEM
);
}
#[test]
fn guard_catches_panic_as_e_failed() {
let code = guard(|| -> crate::error::Result<()> { panic!("kaboom") });
assert_eq!(code, crate::error::OAKCOMMON_E_FAILED);
}
#[test]
fn guard_handle_passes_through_success() {
let h = guard_handle(|| Ok(make_owned(42u32)));
assert!(!h.is_null());
unsafe {
assert_eq!(*get::<u32>(&h).unwrap(), 42);
(h.release.unwrap())(h.ctx);
}
}
#[test]
fn guard_handle_maps_err_and_panic_to_null() {
let h = guard_handle(|| Err(crate::error::Error::NoMem));
assert!(h.is_null());
let h = guard_handle(|| -> crate::error::Result<CHandle> { panic!("kaboom") });
assert!(h.is_null());
}
#[test]
fn guard_void_runs_closure_and_swallows_panic() {
let ran = Arc::new(AtomicUsize::new(0));
let r = Arc::clone(&ran);
guard_void(move || {
r.fetch_add(1, AtomicOrdering::SeqCst);
});
assert_eq!(ran.load(AtomicOrdering::SeqCst), 1);
// A panicking closure must not unwind across the FFI boundary.
guard_void(|| panic!("kaboom"));
}
}
+62
View File
@@ -0,0 +1,62 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! # oakcommon — shared utilities (Rust). Leaf module.
//!
//! Implements `include/common/*.h` verbatim. See README.md.
#![deny(unsafe_op_in_unsafe_fn)]
#![warn(missing_docs)]
pub mod colortransform;
pub mod commandlineparser;
pub mod configstore;
pub mod debug;
pub mod error;
pub mod ffmpegutils;
pub mod ffi;
pub mod filefunctions;
pub mod handle;
pub mod miscutils;
pub mod ocioutils;
pub mod oiioutils;
pub mod qtutils;
pub mod subtitleparams;
pub mod videoparams;
pub mod xmlutils;
/// Test-only helpers shared across unit-test modules.
///
/// Several domain test modules (e.g. `configstore`, `filefunctions`) mutate
/// process-global state — notably the `OAK_CONFIG_DIR` environment variable
/// and shared temp paths — while exercising configuration-location logic.
/// Rust runs tests in parallel, so all such tests must serialize on a single
/// process-wide lock to avoid racing each other across module boundaries.
#[cfg(test)]
#[doc(hidden)]
pub mod test_support {
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
/// Process-wide lock guarding tests that mutate global config/env state.
///
/// Hold this for the duration of any test (or helper) that sets/removes
/// `OAK_CONFIG_DIR` or touches the shared configuration temp path.
pub fn env_lock() -> &'static Mutex<()> {
&ENV_LOCK
}
}
+514
View File
@@ -0,0 +1,514 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Miscellaneous helpers, folding together several small `include/common`
//! headers that share no handle: `miscutils.h` (decibel/lerp),
//! `loopmode.h`, `dropworkflowbehavior.h`, `power.h`, `current.h`. Each
//! public header still gets its own submodule in `crate::ffi` for C ABI
//! completeness; this file holds all their domain types.
use std::ffi::c_void;
use std::sync::Mutex;
use std::sync::OnceLock;
use crate::error::Result;
/// Minimum decibel value used by the editor (`-200.0` dB).
pub const DECIBEL_MINIMUM: f64 = -200.0;
/// Constant `-ln(0.01)` (`lo_g100`), shared by the logarithmic slider
/// conversions in `olive::Decibel` (`src/common/src/decibel.h`).
// CPP-PARITY: value copied verbatim from `olive::Decibel::lo_g100`.
const DECIBEL_LO_G100: f64 = 4.60517018599;
/// Convert a linear amplitude to decibels (0.0 or infinite results yield
/// [`DECIBEL_MINIMUM`]).
pub fn decibel_from_linear(linear: f64) -> Result<f64> {
// CPP-PARITY: `20.0 * std::log10(linear)`, returning `minimum` when the
// result is infinite. The C++ never fails, so this always yields Ok.
let v = 20.0 * linear.log10();
if v.is_infinite() {
Ok(DECIBEL_MINIMUM)
} else {
Ok(v)
}
}
/// Convert decibels to a linear amplitude (results below `1e-6` clamp to
/// 0.0).
pub fn decibel_to_linear(db: f64) -> Result<f64> {
// CPP-PARITY: `std::pow(10.0, db / 20.0)`, clamping < 1e-6 to 0.
let v = 10.0_f64.powf(db / 20.0);
if v < 0.000001 {
Ok(0.0)
} else {
Ok(v)
}
}
/// Convert a logarithmic slider position (0..1) to decibels.
pub fn decibel_from_logarithmic(logarithmic: f64) -> Result<f64> {
// CPP-PARITY: matches `olive::Decibel::from_logarithmic` (branch
// thresholds and `20.0*log10(-log(1-x)/lo_g100)`).
if logarithmic < 0.001 {
Ok(DECIBEL_MINIMUM)
} else if logarithmic > 0.99 {
Ok(0.0)
} else {
Ok(20.0 * (-(1.0 - logarithmic).ln() / DECIBEL_LO_G100).log10())
}
}
/// Convert decibels to a logarithmic slider position (0..1).
pub fn decibel_to_logarithmic(db: f64) -> Result<f64> {
// CPP-PARITY: `1 - exp(-pow(10, db/20) * lo_g100)`, short-circuiting
// `|db| <= 1e-12` to 1.
if db.abs() <= 1e-12 {
Ok(1.0)
} else {
Ok(1.0 - (-(10.0_f64.powf(db / 20.0)) * DECIBEL_LO_G100).exp())
}
}
/// Convert a linear amplitude directly to a logarithmic position.
pub fn decibel_linear_to_logarithmic(linear: f64) -> Result<f64> {
// CPP-PARITY: `1 - exp(-linear * lo_g100)`.
Ok(1.0 - (-linear * DECIBEL_LO_G100).exp())
}
/// Convert a logarithmic position directly to a linear amplitude.
pub fn decibel_logarithmic_to_linear(logarithmic: f64) -> Result<f64> {
// CPP-PARITY: `> 0.99 -> 1`, else `-log(1-x)/lo_g100`.
if logarithmic > 0.99 {
Ok(1.0)
} else {
Ok(-(1.0 - logarithmic).ln() / DECIBEL_LO_G100)
}
}
/// Linearly interpolate between `a` and `b` using `t` (`0.0` -> `a`,
/// `1.0` -> `b`).
pub fn lerp(a: f64, b: f64, t: f64) -> Result<f64> {
// CPP-PARITY: `lerp` template `(a*(1.0 - t)) + (b*t)`.
Ok(a * (1.0 - t) + b * t)
}
/// Playback loop mode (`OakLoopMode`), mirroring `olive::LoopMode`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LoopMode {
/// Looping disabled.
Off = 0,
/// Loop playback.
Loop = 1,
/// Clamp at the end.
Clamp = 2,
}
/// Behavior when media is dropped onto a timeline without a sequence
/// (`OakDropWorkflowBehavior`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DropWorkflowBehavior {
/// Ask the user every time.
Ask = 0,
/// Automatically create a sequence.
Auto = 1,
/// Never create; import manually.
Manual = 2,
/// Disable dropping entirely.
Disable = 3,
}
impl DropWorkflowBehavior {
/// Whether `value` is a valid behavior.
pub fn is_valid(value: i32) -> bool {
// CPP-PARITY: the C++ switch matches the four enumerators.
matches!(value, 0..=3)
}
/// Printable name ("UNKNOWN" for invalid values).
pub fn name(value: i32) -> &'static str {
// CPP-PARITY: exact strings from the c_api `behavior_name()`.
match value {
0 => "ASK",
1 => "AUTO",
2 => "MANUAL",
3 => "DISABLE",
_ => "UNKNOWN",
}
}
}
/// Round `value` up to the next power of two.
pub fn power_ceil_to_power_of_2(value: u32) -> Result<u32> {
// CPP-PARITY: bit-blast from `olive::ceil_to_power_of_2`. Uses wrapping
// arithmetic so the decrement of 0 wraps to `u32::MAX` (and the final
// increment of `u32::MAX` wraps to 0), exactly like C++ unsigned wraps.
let mut v = value.wrapping_sub(1);
v |= v >> 1;
v |= v >> 2;
v |= v >> 4;
v |= v >> 8;
v |= v >> 16;
Ok(v.wrapping_add(1))
}
/// Round `value` down to the nearest power of two.
pub fn power_floor_to_power_of_2(value: u32) -> Result<u32> {
// CPP-PARITY: bit-blast from `olive::floor_to_power_of_2`.
let mut x = value;
x = x | (x >> 1);
x = x | (x >> 2);
x = x | (x >> 4);
x = x | (x >> 8);
x = x | (x >> 16);
Ok(x - (x >> 1))
}
/// Destructor callback for objects handed to Current slots.
pub type DestroyFn = Option<unsafe extern "C" fn(*mut c_void)>;
/// One opaque slot value plus its destructor, mirroring a C++
/// `std::shared_ptr<void>` held by `Current`.
struct Slot {
/// Opaque external object pointer (may be null = empty slot).
ptr: *mut c_void,
/// Optional destructor invoked when the slot is replaced or cleared.
destroy: DestroyFn,
}
impl Slot {
/// An empty slot.
fn empty() -> Self {
Self {
ptr: std::ptr::null_mut(),
destroy: None,
}
}
}
// `*mut c_void` is neither Send nor Sync; the singleton serialises all
// slot access behind a `Mutex`, so promising Send+Sync for the guarded
// `Slot` is sound.
// CPP-PARITY: the C++ `Current` uses `std::shared_ptr` (which is
// thread-safe) but does not itself lock; Rust guards each slot with a
// `Mutex` for sound `Send`/`Sync`.
unsafe impl Send for Slot {}
unsafe impl Sync for Slot {}
/// The process-wide `Current` singleton (see `include/common/current.h`).
/// `ctx` points to a statically allocated object that lives until process
/// exit; addref/release are no-ops. Slots hold opaque external objects with
/// optional destructors.
pub struct Current {
/// Video params slot.
video_params: Mutex<Slot>,
/// Audio params slot.
audio_params: Mutex<Slot>,
/// Plugin host slot.
plugin_host: Mutex<Slot>,
/// Plugin cache slot.
plugin_cache: Mutex<Slot>,
/// Whether the session is interactive.
is_interactive: bool,
}
impl Current {
/// The process-wide singleton.
pub fn instance() -> &'static Current {
// CPP-PARITY: mirrors `Current::get_instance()` returning a
// process-wide static. `OnceLock` gives the same lazy,
// thread-safe single-instance guarantee.
static INSTANCE: OnceLock<Current> = OnceLock::new();
INSTANCE.get_or_init(|| Current {
video_params: Mutex::new(Slot::empty()),
audio_params: Mutex::new(Slot::empty()),
plugin_host: Mutex::new(Slot::empty()),
plugin_cache: Mutex::new(Slot::empty()),
is_interactive: true,
})
}
/// Replace a slot's occupant, destroying the previous one if it had a
/// destructor.
fn set_slot(slot: &Mutex<Slot>, obj: *mut c_void, destroy: DestroyFn) -> Result<()> {
// Recover the guard if a previous panic poisoned the mutex; the
// slot contents remain valid.
let mut s = slot.lock().unwrap_or_else(|e| e.into_inner());
let old = std::mem::replace(&mut *s, Slot { ptr: obj, destroy });
// CPP-PARITY: the old `shared_ptr`'s refcount drops to zero when it
// is replaced, invoking its deleter (the stored `destroy`). A
// previously stored NULL destroy was a no-op deleter, so nothing
// runs then either.
if !old.ptr.is_null() {
if let Some(d) = old.destroy {
// Safety: the destructor was supplied by the caller of the
// matching `set_*` and owns the pointer it is given.
unsafe { d(old.ptr) };
}
}
Ok(())
}
/// Fetch a slot's raw occupant pointer (borrowed).
fn get_slot(slot: &Mutex<Slot>) -> Result<*mut c_void> {
let s = slot.lock().unwrap_or_else(|e| e.into_inner());
Ok(s.ptr)
}
/// Store a pointer in the video-params slot, taking over destruction.
pub fn set_video_params(&self, obj: *mut c_void, destroy: DestroyFn) -> Result<()> {
Self::set_slot(&self.video_params, obj, destroy)
}
/// Store a pointer in the audio-params slot.
pub fn set_audio_params(&self, obj: *mut c_void, destroy: DestroyFn) -> Result<()> {
Self::set_slot(&self.audio_params, obj, destroy)
}
/// Store a pointer in the plugin-host slot.
pub fn set_plugin_host(&self, obj: *mut c_void, destroy: DestroyFn) -> Result<()> {
Self::set_slot(&self.plugin_host, obj, destroy)
}
/// Store a pointer in the plugin-cache slot.
pub fn set_plugin_cache(&self, obj: *mut c_void, destroy: DestroyFn) -> Result<()> {
Self::set_slot(&self.plugin_cache, obj, destroy)
}
/// Fetch the video-params slot.
pub fn get_video_params(&self) -> Result<*mut c_void> {
Self::get_slot(&self.video_params)
}
/// Fetch the audio-params slot.
pub fn get_audio_params(&self) -> Result<*mut c_void> {
Self::get_slot(&self.audio_params)
}
/// Fetch the plugin-host slot.
pub fn get_plugin_host(&self) -> Result<*mut c_void> {
Self::get_slot(&self.plugin_host)
}
/// Fetch the plugin-cache slot.
pub fn get_plugin_cache(&self) -> Result<*mut c_void> {
Self::get_slot(&self.plugin_cache)
}
/// Whether the session is interactive.
pub fn is_interactive(&self) -> Result<bool> {
// CPP-PARITY: `Current::interactive()` is hardcoded to `true` and
// is never mutated, so the stored flag stays true.
Ok(self.is_interactive)
}
}
#[cfg(test)]
mod tests {
use std::ffi::c_void;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use super::*;
#[test]
fn decibel_from_linear_known_values() {
assert_eq!(decibel_from_linear(1.0).unwrap(), 0.0);
assert!((decibel_from_linear(10.0).unwrap() - 20.0).abs() < 1e-12);
assert!((decibel_from_linear(100.0).unwrap() - 40.0).abs() < 1e-12);
// Zero / negative-infinite log10 clamps to minimum.
assert_eq!(decibel_from_linear(0.0).unwrap(), DECIBEL_MINIMUM);
// Negative input yields NaN in both C++ and Rust (no clamp).
assert!(decibel_from_linear(-1.0).unwrap().is_nan());
}
#[test]
fn decibel_to_linear_known_values() {
assert_eq!(decibel_to_linear(0.0).unwrap(), 1.0);
assert!((decibel_to_linear(20.0).unwrap() - 10.0).abs() < 1e-12);
// Well below -120 dB clamps to 0; exactly 1e-6 does not.
assert_eq!(decibel_to_linear(-200.0).unwrap(), 0.0);
assert_eq!(decibel_to_linear(-120.0).unwrap(), 0.000001);
}
#[test]
fn decibel_logarithmic_branch_thresholds() {
// Very small positions clamp to minimum; >0.99 clamp to 0 dB.
assert_eq!(decibel_from_logarithmic(0.0).unwrap(), DECIBEL_MINIMUM);
assert_eq!(decibel_from_logarithmic(1.0).unwrap(), 0.0);
// Mid-range is a real value.
let db = decibel_from_logarithmic(0.5).unwrap();
assert!(db.is_finite());
// to_logarithmic clamps |db| <= 1e-12 to 1.0.
assert_eq!(decibel_to_logarithmic(0.0).unwrap(), 1.0);
}
#[test]
fn decibel_round_trips() {
// linear <-> logarithmic round trip (within float tolerance).
for linear in [0.001, 0.01, 0.1, 0.5, 0.9, 0.99] {
let log = decibel_linear_to_logarithmic(linear).unwrap();
let back = decibel_logarithmic_to_linear(log).unwrap();
assert!((back - linear).abs() < 1e-6, "linear {} -> {} -> {}", linear, log, back);
}
// db <-> logarithmic round trip. Only non-positive db are reversible:
// a positive db pushes the logarithmic position past 0.99, which the
// C++ `from_logarithmic` intentionally clamps back to 0 dB.
for db in [-60.0, -30.0, -12.0, -6.0, -3.0, -1.0] {
let log = decibel_to_logarithmic(db).unwrap();
let back = decibel_from_logarithmic(log).unwrap();
assert!((back - db).abs() < 1e-3, "db {} -> {} -> {}", db, log, back);
}
// A positive db saturates the logarithmic position > 0.99 and comes
// back as 0 dB (faithful to the C++ clamp).
let log6 = decibel_to_logarithmic(6.0).unwrap();
assert!(log6 > 0.99);
assert_eq!(decibel_from_logarithmic(log6).unwrap(), 0.0);
// logarithmic_to_linear clamps >0.99 to 1.
assert_eq!(decibel_logarithmic_to_linear(0.999).unwrap(), 1.0);
}
#[test]
fn lerp_matches_cpp() {
assert_eq!(lerp(0.0, 10.0, 0.0).unwrap(), 0.0);
assert_eq!(lerp(0.0, 10.0, 1.0).unwrap(), 10.0);
assert_eq!(lerp(0.0, 10.0, 0.5).unwrap(), 5.0);
assert_eq!(lerp(2.0, 4.0, 0.25).unwrap(), 2.5);
}
#[test]
fn loop_mode_discriminants_match_cpp() {
// Values are load-bearing across the C ABI (include/common/loopmode.h
// and src/common/src/loopmode.h).
assert_eq!(LoopMode::Off as i32, 0);
assert_eq!(LoopMode::Loop as i32, 1);
assert_eq!(LoopMode::Clamp as i32, 2);
// Copy/Clone/Eq semantics of a plain enum.
let a = LoopMode::Loop;
let b = a;
assert_eq!(a, b);
assert_ne!(LoopMode::Off, LoopMode::Clamp);
}
#[test]
fn drop_workflow_behavior_discriminants_match_cpp() {
// The config layer persists these as ints (enum OakDropWorkflowBehavior).
assert_eq!(DropWorkflowBehavior::Ask as i32, 0);
assert_eq!(DropWorkflowBehavior::Auto as i32, 1);
assert_eq!(DropWorkflowBehavior::Manual as i32, 2);
assert_eq!(DropWorkflowBehavior::Disable as i32, 3);
}
#[test]
fn drop_workflow_behavior() {
for (v, expected_valid) in [(0, true), (1, true), (2, true), (3, true)] {
assert_eq!(DropWorkflowBehavior::is_valid(v), expected_valid);
}
assert!(!DropWorkflowBehavior::is_valid(-1));
assert!(!DropWorkflowBehavior::is_valid(4));
assert_eq!(DropWorkflowBehavior::name(0), "ASK");
assert_eq!(DropWorkflowBehavior::name(1), "AUTO");
assert_eq!(DropWorkflowBehavior::name(2), "MANUAL");
assert_eq!(DropWorkflowBehavior::name(3), "DISABLE");
assert_eq!(DropWorkflowBehavior::name(99), "UNKNOWN");
assert_eq!(DropWorkflowBehavior::name(-5), "UNKNOWN");
}
#[test]
fn power_of_two() {
// ceil
assert_eq!(power_ceil_to_power_of_2(1).unwrap(), 1);
assert_eq!(power_ceil_to_power_of_2(2).unwrap(), 2);
assert_eq!(power_ceil_to_power_of_2(3).unwrap(), 4);
assert_eq!(power_ceil_to_power_of_2(5).unwrap(), 8);
assert_eq!(power_ceil_to_power_of_2(8).unwrap(), 8);
assert_eq!(power_ceil_to_power_of_2(0).unwrap(), 0);
// floor
assert_eq!(power_floor_to_power_of_2(1).unwrap(), 1);
assert_eq!(power_floor_to_power_of_2(2).unwrap(), 2);
assert_eq!(power_floor_to_power_of_2(5).unwrap(), 4);
assert_eq!(power_floor_to_power_of_2(9).unwrap(), 8);
assert_eq!(power_floor_to_power_of_2(8).unwrap(), 8);
assert_eq!(power_floor_to_power_of_2(0).unwrap(), 0);
// large value: 0x8000_0001 saturates the bit-blast to u32::MAX, then
// the final increment wraps to 0 (matching C++ unsigned overflow).
assert_eq!(power_ceil_to_power_of_2(0x8000_0001).unwrap(), 0u32);
assert_eq!(power_floor_to_power_of_2(0x8000_0000).unwrap(), 0x8000_0000);
}
/// Per-process destroy counter used by the Current singleton test.
static DESTROY_COUNT: AtomicUsize = AtomicUsize::new(0);
/// Serialises tests that touch the process-wide `Current` singleton
/// (cargo runs tests on threads).
static CURRENT_LOCK: Mutex<()> = Mutex::new(());
/// A `DestroyFn` that bumps [`DESTROY_COUNT`].
unsafe extern "C" fn count_destroy(_p: *mut c_void) {
DESTROY_COUNT.fetch_add(1, Ordering::SeqCst);
}
#[test]
fn current_slot_semantics() {
let _guard = CURRENT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
// Only the video-params slot is used here; the singleton is shared
// across tests, so keep each test on its own slot to avoid races.
let cur = Current::instance();
assert!(cur.is_interactive().unwrap());
DESTROY_COUNT.store(0, Ordering::SeqCst);
// Empty initially.
assert!(cur.get_video_params().unwrap().is_null());
// Store with a destructor.
let p1 = 0x1 as *mut c_void;
assert!(cur.set_video_params(p1, Some(count_destroy)).is_ok());
assert_eq!(cur.get_video_params().unwrap(), p1);
// Replacing destroys the previous occupant.
let p2 = 0x2 as *mut c_void;
assert!(cur.set_video_params(p2, Some(count_destroy)).is_ok());
assert_eq!(cur.get_video_params().unwrap(), p2);
assert_eq!(DESTROY_COUNT.load(Ordering::SeqCst), 1);
// Storing NULL clears and destroys the prior occupant.
assert!(cur.set_video_params(std::ptr::null_mut(), None).is_ok());
assert!(cur.get_video_params().unwrap().is_null());
assert_eq!(DESTROY_COUNT.load(Ordering::SeqCst), 2);
// Clearing when the slot is already empty does nothing.
assert!(cur.set_video_params(std::ptr::null_mut(), None).is_ok());
assert_eq!(DESTROY_COUNT.load(Ordering::SeqCst), 2);
}
#[test]
fn current_slots_independent() {
let _guard = CURRENT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let cur = Current::instance();
let pa = 0x10 as *mut c_void;
let ph = 0x20 as *mut c_void;
let pc = 0x30 as *mut c_void;
// Each slot is independent; no cross-slot interference.
assert!(cur.set_audio_params(pa, None).is_ok());
assert!(cur.set_plugin_host(ph, None).is_ok());
assert!(cur.set_plugin_cache(pc, None).is_ok());
assert_eq!(cur.get_audio_params().unwrap(), pa);
assert_eq!(cur.get_plugin_host().unwrap(), ph);
assert_eq!(cur.get_plugin_cache().unwrap(), pc);
}
}
+361
View File
@@ -0,0 +1,361 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! OpenColorIO utility queries, mirroring `src/common/src/ocioutils.h`
//! and `include/common/ocioutils.h`. Also the canonical home of
//! [`PixelFormat`], which other modules reuse (mirrors
//! `olive::core::PixelFormat`). The object is stateless; the handle only
//! satisfies the C API lifetime contract.
//!
//! Real OCIO access — config loading, color-space/role enumeration,
//! display/view resolution and RGBA CPU transforms — is provided by
//! [`OcioConfig`] / [`OcioProcessor`], thin wrappers over the crates.io
//! `ocio-rs` bindings. The bit-depth mapping in
//! [`OCIOUtils::get_ocio_bit_depth_from_pixel_format`] reads from the real
//! `ocio_rs::BitDepth` enum rather than hard-coded constants.
use crate::error::{Error, Result};
use ocio_rs::TransformDirection;
/// Native pixel format codes, mirroring `olive::core::PixelFormat`. Values
/// are load-bearing (they cross the C ABI as ints) and must stay in sync
/// with `olive/core/render/pixelformat.h`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PixelFormat {
/// Invalid/unknown format.
Invalid = -1,
/// 8-bit unsigned integer.
U8 = 0,
/// 10-bit unsigned integer.
U10 = 1,
/// 16-bit unsigned integer.
U16 = 2,
/// 16-bit float (half).
F16 = 3,
/// 32-bit float.
F32 = 4,
/// Sentinel, not a valid format.
Count = 5,
}
impl PixelFormat {
/// One of [`PixelFormat`] for an integer code (invalid/unknown codes map
/// to `Invalid`).
pub fn from_code(code: i32) -> PixelFormat {
match code {
-1 => PixelFormat::Invalid,
0 => PixelFormat::U8,
1 => PixelFormat::U10,
2 => PixelFormat::U16,
3 => PixelFormat::F16,
4 => PixelFormat::F32,
5 => PixelFormat::Count,
_ => PixelFormat::Invalid,
}
}
/// The integer code.
pub fn code(self) -> i32 {
self as i32
}
}
/// The OCIO utils family (stateless).
pub struct OCIOUtils;
impl OCIOUtils {
/// Creates the OCIOUtils object.
pub fn new() -> Self {
Self
}
/// Map a native pixel format to an OCIO bit depth code (`0` unknown,
/// `1` uint8, `2` uint10, `3` uint12, `4` uint14, `5` uint16,
/// `6` uint32, `7` f16, `8` f32).
///
/// The return type is `Result` to match the C ABI surface, but the
/// mapping is total (never fails); invalid/out-of-range formats yield
/// `BIT_DEPTH_UNKNOWN = 0`.
pub fn get_ocio_bit_depth_from_pixel_format(&self, pixel_format: PixelFormat) -> Result<i32> {
// The codes are the real `ocio_rs::BitDepth` discriminants
// (`#[repr(i32)]`): `Uint8=1`, `Uint10=2`, `Uint16=5`, `F16=7`,
// `F32=8`, `Unknown=0`. CPP-PARITY: matches
// `OCIOUtils::get_ocio_bit_depth_from_pixel_format` in
// src/common/src/ocioutils.cpp, where these are the OCIO `BitDepth`
// constants `BIT_DEPTH_UINT8` … `BIT_DEPTH_F32`. `u10` maps to uint10
// (not uint12) and `u16` maps to uint16 (not uint14/uint32), exactly
// as C++.
let depth = match pixel_format {
PixelFormat::U8 => ocio_rs::BitDepth::Uint8,
PixelFormat::U10 => ocio_rs::BitDepth::Uint10,
PixelFormat::U16 => ocio_rs::BitDepth::Uint16,
PixelFormat::F16 => ocio_rs::BitDepth::F16,
PixelFormat::F32 => ocio_rs::BitDepth::F32,
PixelFormat::Invalid | PixelFormat::Count => ocio_rs::BitDepth::Unknown,
};
Ok(depth as i32)
}
}
/// An OCIO color-config file loaded from disk (see [`OcioConfig::from_file`]) or
/// the built-in raw config (see [`OcioConfig::raw`]).
pub struct OcioConfig {
inner: ocio_rs::Config,
}
/// An OCIO CPU processor that transforms a single RGBA pixel.
pub struct OcioProcessor {
inner: ocio_rs::CPUProcessor,
}
// The wrapped `ocio-rs` objects own OCIO rcptrs that are immutable for the
// lifetime of the objects; OCIO documents its config/processor objects as
// safe to share for read-only use, and `ocio-rs` keeps all mutable error
// state in thread-local storage. `ocio-rs` itself marks `Processor` and
// `CPUProcessor` `Send`; `Config` holds a raw handle, so we claim `Send` and
// `Sync` here for both wrapper types.
unsafe impl Send for OcioConfig {}
unsafe impl Sync for OcioConfig {}
unsafe impl Send for OcioProcessor {}
unsafe impl Sync for OcioProcessor {}
impl OcioConfig {
/// Loads a config from an OCIO `.ocio` file.
pub fn from_file(path: &str) -> Result<Self> {
let inner = ocio_rs::Config::from_file(path)?;
Ok(OcioConfig { inner })
}
/// The built-in raw (identity) config — the OCIO 2.x replacement for the
/// removed `CreateDefault`.
pub fn raw() -> Result<Self> {
let inner = ocio_rs::Config::raw()?;
Ok(OcioConfig { inner })
}
/// Number of color spaces registered in the config.
pub fn colorspace_count(&self) -> Result<i32> {
Ok(self.inner.num_color_spaces())
}
/// Name of the color space at `index` (0-based).
pub fn colorspace_name(&self, index: i32) -> Result<String> {
self.inner.color_space_name_by_index(index).ok_or_else(|| {
Error::new(format!(
"OcioConfig::colorspace_name: no color space at index {index}"
))
})
}
/// Names of all color spaces in config order.
pub fn colorspaces(&self) -> Result<Vec<String>> {
let count = self.colorspace_count()?;
let mut out = Vec::with_capacity(count as usize);
for i in 0..count {
out.push(self.colorspace_name(i)?);
}
Ok(out)
}
/// Number of roles registered in the config.
pub fn role_count(&self) -> Result<i32> {
Ok(self.inner.num_roles())
}
/// Name of the role at `index` (0-based).
pub fn role_name(&self, index: i32) -> Result<String> {
self.inner.role_name(index).ok_or_else(|| {
Error::new(format!("OcioConfig::role_name: no role at index {index}"))
})
}
/// Names of all roles in config order.
pub fn roles(&self) -> Result<Vec<String>> {
let count = self.role_count()?;
let mut out = Vec::with_capacity(count as usize);
for i in 0..count {
out.push(self.role_name(i)?);
}
Ok(out)
}
/// Whether the config defines `role`.
pub fn has_role(&self, role: &str) -> Result<bool> {
Ok(self.inner.has_role(role))
}
/// Canonical name for a color space or role. Unknown names come back
/// unchanged (OCIO's documented behavior).
pub fn canonical_name(&self, name: &str) -> Result<String> {
Ok(self
.inner
.canonical_name(name)
.unwrap_or_else(|| name.to_string()))
}
/// The config's default display name.
pub fn default_display(&self) -> Result<String> {
self.inner.default_display().ok_or_else(|| {
Error::new("OcioConfig::default_display: config defines no displays")
})
}
/// The default view for `display`.
pub fn default_view(&self, display: &str) -> Result<String> {
self.inner.default_view(display).ok_or_else(|| {
Error::new(format!(
"OcioConfig::default_view: no default view for display '{display}'"
))
})
}
/// Builds a processor transforming `src` to `dst` (either may be a role
/// name, an alias, or a color space name).
pub fn processor(&self, src: &str, dst: &str) -> Result<OcioProcessor> {
let processor = self.inner.processor(src, dst)?;
Ok(OcioProcessor {
inner: processor.default_cpu_processor()?,
})
}
/// Builds a processor applying `src` through the display transform for
/// `display`/`view` in the forward direction.
pub fn display_processor(&self, src: &str, display: &str, view: &str) -> Result<OcioProcessor> {
let processor = self
.inner
.processor_display(src, display, view, TransformDirection::Forward)?;
Ok(OcioProcessor {
inner: processor.default_cpu_processor()?,
})
}
}
impl OcioProcessor {
/// Applies the transform to one RGBA pixel in place (0..=1 floats).
///
/// OCIO applies the curve to all four channels; the alpha channel is not
/// preserved verbatim, so callers must not assume `px[3]` is unchanged.
pub fn apply_rgba(&self, pixel: &mut [f32; 4]) -> Result<()> {
self.inner.try_apply_rgba(pixel)?;
Ok(())
}
}
impl std::fmt::Debug for OcioConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OcioConfig").finish_non_exhaustive()
}
}
impl std::fmt::Debug for OcioProcessor {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OcioProcessor").finish_non_exhaustive()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pixel_format_from_code_maps_known_codes() {
assert_eq!(PixelFormat::from_code(-1), PixelFormat::Invalid);
assert_eq!(PixelFormat::from_code(0), PixelFormat::U8);
assert_eq!(PixelFormat::from_code(1), PixelFormat::U10);
assert_eq!(PixelFormat::from_code(2), PixelFormat::U16);
assert_eq!(PixelFormat::from_code(3), PixelFormat::F16);
assert_eq!(PixelFormat::from_code(4), PixelFormat::F32);
assert_eq!(PixelFormat::from_code(5), PixelFormat::Count);
}
#[test]
fn pixel_format_from_code_maps_unknown_codes_to_invalid() {
assert_eq!(PixelFormat::from_code(-2), PixelFormat::Invalid);
assert_eq!(PixelFormat::from_code(6), PixelFormat::Invalid);
assert_eq!(PixelFormat::from_code(100), PixelFormat::Invalid);
assert_eq!(PixelFormat::from_code(i32::MIN), PixelFormat::Invalid);
assert_eq!(PixelFormat::from_code(i32::MAX), PixelFormat::Invalid);
}
#[test]
fn pixel_format_code_round_trips() {
for fmt in [
PixelFormat::Invalid,
PixelFormat::U8,
PixelFormat::U10,
PixelFormat::U16,
PixelFormat::F16,
PixelFormat::F32,
PixelFormat::Count,
] {
assert_eq!(PixelFormat::from_code(fmt.code()), fmt);
}
}
#[test]
fn pixel_format_codes_have_load_bearing_values() {
assert_eq!(PixelFormat::Invalid.code(), -1);
assert_eq!(PixelFormat::U8.code(), 0);
assert_eq!(PixelFormat::U10.code(), 1);
assert_eq!(PixelFormat::U16.code(), 2);
assert_eq!(PixelFormat::F16.code(), 3);
assert_eq!(PixelFormat::F32.code(), 4);
assert_eq!(PixelFormat::Count.code(), 5);
}
#[test]
fn new_returns_a_value() {
let utils = OCIOUtils::new();
// Stateless; just confirm construction works and stays usable.
assert_eq!(
utils.get_ocio_bit_depth_from_pixel_format(PixelFormat::U8).unwrap(),
1
);
}
#[test]
fn ocio_bit_depth_maps_supported_formats() {
let utils = OCIOUtils::new();
assert_eq!(utils.get_ocio_bit_depth_from_pixel_format(PixelFormat::U8).unwrap(), 1);
assert_eq!(utils.get_ocio_bit_depth_from_pixel_format(PixelFormat::U10).unwrap(), 2);
assert_eq!(utils.get_ocio_bit_depth_from_pixel_format(PixelFormat::U16).unwrap(), 5);
assert_eq!(utils.get_ocio_bit_depth_from_pixel_format(PixelFormat::F16).unwrap(), 7);
assert_eq!(utils.get_ocio_bit_depth_from_pixel_format(PixelFormat::F32).unwrap(), 8);
}
#[test]
fn ocio_bit_depth_maps_invalid_and_count_to_unknown() {
let utils = OCIOUtils::new();
assert_eq!(utils.get_ocio_bit_depth_from_pixel_format(PixelFormat::Invalid).unwrap(), 0);
assert_eq!(utils.get_ocio_bit_depth_from_pixel_format(PixelFormat::Count).unwrap(), 0);
}
#[test]
fn ocio_config_raw_round_trips() {
// A real OCIO library call through the ocio-sys bridge that needs no
// config file and no OCIO env var: the built-in raw (identity) config
// always defines at least the "default" role, whose canonical color
// space is named "raw".
let config = OcioConfig::raw().expect("raw config should load");
assert!(config.colorspace_count().expect("count should work") > 0);
assert!(
config.has_role("default").expect("has_role should work"),
"raw config should define the default role"
);
let canonical = config.canonical_name("default").expect("canonical should work");
assert_eq!(canonical, "raw");
}
}
+540
View File
@@ -0,0 +1,540 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! OpenImageIO utility queries, mirroring `src/common/src/oiioutils.h`
//! and `include/common/oiioutils.h`. Reuses [`crate::ocioutils::PixelFormat`]
//! rather than redefining the pixel format codes. The object is stateless;
//! the handle only satisfies the C API lifetime contract.
//!
//! The base-type mapping is derived from the `image` crate's own color-type
//! tables (per-channel bit depth → OIIO `TypeDesc::BASETYPE` code), with the
//! half-float case pinned from the frozen OIIO table since `image` 0.25 has
//! no f16 sample type. Aspect-ratio conversion uses
//! `oakcore_rs::Rational::from_double`, the C++ `Rational::from_double`
//! port of FFmpeg's `av_d2q` (kept as a hand-written port rather than
//! pulling in `ffmpeg-next` — see README decision 6). 32-bit float image I/O
//! is provided by [`F32Image`] / [`read_image_f32`] / [`write_image_f32`],
//! built on the `image` crate.
use crate::error::{Error, Result};
use crate::ocioutils::PixelFormat;
use oakcore_rs::Rational;
use image::{ExtendedColorType, ImageBuffer, Rgb, Rgba};
/// OIIO base type codes, matching `OIIO::TypeDesc::BASETYPE`.
///
/// Values cross the C ABI as plain ints. See the doc comment on
/// `OakOIIOUtils` in `include/common/oiioutils.h`: 0 = UNKNOWN, 1 = NONE,
/// 2 = UINT8, 3 = INT8, 4 = UINT16, 5 = INT16, 6 = UINT32, 7 = INT32,
/// 8 = UINT64, 9 = INT64, 10 = HALF, 11 = FLOAT, 12 = DOUBLE, 13 = STRING,
/// 14 = PTR (OIIO >= 2.5 additionally has 15 = USTRINGHASH).
mod basetype {
/// Unknown / unmappable base type.
pub(crate) const UNKNOWN: i32 = 0;
/// 8-bit unsigned integer.
pub(crate) const UINT8: i32 = 2;
/// 16-bit unsigned integer.
pub(crate) const UINT16: i32 = 4;
/// 16-bit float (half).
pub(crate) const HALF: i32 = 10;
/// 32-bit float.
pub(crate) const FLOAT: i32 = 11;
}
/// The OIIO utils family (stateless).
pub struct OIIOUtils;
impl OIIOUtils {
/// Creates the OIIOUtils object.
pub fn new() -> Self {
Self
}
/// Map a native pixel format to an OIIO base type code. On invalid or
/// unmappable formats returns `Ok(TypeDesc::UNKNOWN = 0)`.
///
/// CPP-PARITY: matches `OIIOUtils::get_oiio_base_type_from_format`. The
/// per-channel bit depth comes from the `image` crate's own color-type
/// tables (see [`image_color_type_for`]); the bit depth is then mapped to
/// the OIIO base-type codes frozen in `include/common/oiioutils.h`.
/// `u10` has no image/OIIO representation and maps to UNKNOWN;
/// `invalid`/`count` also fall through to UNKNOWN (the C++ `break`s out
/// of the switch and returns UNKNOWN). `f16` is a documented exception:
/// `image` 0.25 has no half-float sample type, so HALF is pinned from the
/// frozen OIIO table.
pub fn get_oiio_base_type_from_format(&self, pixel_format: PixelFormat) -> Result<i32> {
let base_type = match image_color_type_for(pixel_format) {
Some(color_type) => match bits_per_channel(color_type) {
8 => basetype::UINT8,
16 => basetype::UINT16,
32 => basetype::FLOAT,
// Unreachable for the mapped types (L8=8, L16=16, Rgb32F=32);
// kept as a fallback for future mappings.
_ => basetype::UNKNOWN,
},
None => match pixel_format {
// image 0.25 has no f16 sample type (see the `// TODO f16
// types?` note in image's color.rs), so HALF is pinned from
// the frozen OIIO base-type table (CPP-PARITY).
PixelFormat::F16 => basetype::HALF,
_ => basetype::UNKNOWN,
},
};
Ok(base_type)
}
/// Map an OIIO base type code to a native pixel format. On unknown or
/// unmappable base types returns `Ok(PixelFormat::Invalid)`; a negative
/// base type is an error.
///
/// CPP-PARITY: matches `OIIOUtils::get_format_from_oiio_basetype`. The
/// known-but-unmappable types (INT8/INT16/INT32/UINT32/INT64/UINT64/
/// STRING/PTR/DOUBLE/LASTBASE) print to stderr in C++ and return
/// `invalid`; here they all fall to `Ok(PixelFormat::Invalid)`. The
/// `base_type < 0` error mirrors the `oakcommon_oiioutils_get_format_from_oiio_basetype`
/// c_api guard; the `>= LASTBASE` upper-bound guard is likewise a c_api
/// concern and is not replicated in the domain function.
pub fn get_format_from_oiio_basetype(&self, base_type: i32) -> Result<PixelFormat> {
if base_type < 0 {
return Err(Error::Invalid);
}
Ok(match base_type {
basetype::UINT8 => PixelFormat::U8,
basetype::UINT16 => PixelFormat::U16,
basetype::HALF => PixelFormat::F16,
basetype::FLOAT => PixelFormat::F32,
_ => PixelFormat::Invalid,
})
}
/// Convert a `PixelAspectRatio` attribute value to a reduced
/// numerator/denominator pair.
///
/// CPP-PARITY: `olive::core::Rational::from_double`
/// (`core/src/util/rational.cpp:39`) via oakcore-rs
/// [`Rational::from_double`]. NaN and `|x| > INT_MAX + 3` yield the
/// oracle's NaN rational `(0, 0)`; `0.0` reduces to `(0, 1)`. The
/// c_api wrapper never fails for a valid `pixel_aspect_ratio`, so
/// this always returns `Ok`.
pub fn get_pixel_aspect_ratio(&self, pixel_aspect_ratio: f64) -> Result<(i32, i32)> {
let r = Rational::from_double(pixel_aspect_ratio);
// from_double caps the reduction at i32::MAX, so the cast is lossless.
Ok((r.numerator() as i32, r.denominator() as i32))
}
}
/// Map a native pixel format to the `image` crate's extended color type, or
/// `None` when the format has no image representation.
///
/// CPP-PARITY: the per-channel bit depth drives
/// [`OIIOUtils::get_oiio_base_type_from_format`]; this is the "which OIIO
/// base type would `image` emit" pivot. `u8`/`u16` map to `L8`/`L16`, `f32` to
/// `Rgb32F` (the `image` crate's only 32-bit float RGB layout; channel
/// count is derived separately via [`bits_per_channel`]). `u10` has no
/// `image`/OIIO representation, `f16` has no `image` sample type (the crate
/// has no half float; see the `// TODO f16 types?` note in its color tables),
/// and `invalid`/`count` fall through — all `None`.
fn image_color_type_for(pixel_format: PixelFormat) -> Option<ExtendedColorType> {
match pixel_format {
PixelFormat::U8 => Some(ExtendedColorType::L8),
PixelFormat::U10 => None,
PixelFormat::U16 => Some(ExtendedColorType::L16),
PixelFormat::F16 => None,
PixelFormat::F32 => Some(ExtendedColorType::Rgb32F),
PixelFormat::Invalid | PixelFormat::Count => None,
}
}
/// Per-channel bit depth of an `ExtendedColorType` (derived from the crate's
/// own tables rather than a hand-maintained list).
fn bits_per_channel(color_type: ExtendedColorType) -> u32 {
// `ExtendedColorType` is `#[non_exhaustive]`; the mapped types are the
// only ones fed in, so any new variant the crate adds would surface as a
// `0` here and fall through to UNKNOWN upstream.
let bits_per_pixel = color_type.bits_per_pixel();
let channel_count = color_type.channel_count();
if channel_count == 0 {
return 0;
}
bits_per_pixel as u32 / channel_count as u32
}
/// A decoded image stored as 32-bit float, packed row-major with `channels`
/// interleaved values per pixel.
#[derive(Debug, Clone, PartialEq)]
pub struct F32Image {
/// Image width in pixels.
pub width: i32,
/// Image height in pixels.
pub height: i32,
/// Interleaved values per pixel (1 = luma, 2 = luma+alpha, 3 = RGB,
/// 4 = RGBA).
pub channels: i32,
/// The interleaved float pixel data.
pub pixels: Vec<f32>,
}
impl F32Image {
/// Total number of float values.
pub fn len(&self) -> usize {
self.pixels.len()
}
/// Whether the image contains no pixels.
pub fn is_empty(&self) -> bool {
self.pixels.is_empty()
}
/// Access to the interleaved pixel data.
pub fn as_slice(&self) -> &[f32] {
&self.pixels
}
}
/// Reads a TIFF image as 32-bit float.
///
/// The channel count comes from the file itself; RGBA images yield
/// `channels == 4`, RGB `3`, gray+alpha `2`, grayscale `1`. Lower bit depths
/// are upscaled to float. Only the TIFF format is enabled in this crate's
/// `image` dependency; other formats fail with an error.
pub fn read_image_f32(path: &str) -> Result<F32Image> {
let img =
image::open(path).map_err(|e| Error::new(format!("image::read_image_f32: {e}")))?;
let width = img.width() as i32;
let height = img.height() as i32;
let channels = img.color().channel_count() as i32;
let pixels = match channels {
1 => img.to_luma32f().into_raw(),
2 => img.to_luma_alpha32f().into_raw(),
3 => img.to_rgb32f().into_raw(),
4 => img.to_rgba32f().into_raw(),
n => {
return Err(Error::new(format!(
"image::read_image_f32: unsupported channel count {n}"
)))
}
};
Ok(F32Image {
width,
height,
channels,
pixels,
})
}
/// Writes 32-bit float pixels to `path` as a TIFF (format inferred from the
/// `.tif`/`.tiff` extension).
///
/// `pixels` must hold exactly `width * height * channels` values, interleaved
/// row-major. `channels` must be 3 (RGB) or 4 (RGBA): the TIFF encoder in the
/// `image` crate supports 32-bit float only for those two layouts, so 1- and
/// 2-channel writes are rejected with an error.
pub fn write_image_f32(
path: &str,
width: i32,
height: i32,
channels: i32,
pixels: &[f32],
) -> Result<()> {
if width <= 0 || height <= 0 {
return Err(Error::new("image::write_image_f32: invalid dimensions"));
}
if channels != 3 && channels != 4 {
return Err(Error::new(format!(
"image::write_image_f32: unsupported channel count {channels} (TIFF float writes support RGB=3 or RGBA=4 only)"
)));
}
let expected = (width as i64) * (height as i64) * (channels as i64);
if expected != pixels.len() as i64 {
return Err(Error::new(format!(
"image::write_image_f32: pixel buffer length {} does not match {width}x{height}x{channels} = {expected}",
pixels.len()
)));
}
// Only the TIFF feature is enabled; anything else is a caller mistake.
let format = image::ImageFormat::from_path(path)
.map_err(|e| Error::new(format!("image::write_image_f32: {e}")))?;
if format != image::ImageFormat::Tiff {
return Err(Error::new(format!(
"image::write_image_f32: unsupported image format for '{path}' (only TIFF is enabled)"
)));
}
let (w, h) = (width as u32, height as u32);
let result = if channels == 3 {
let buf = ImageBuffer::<Rgb<f32>, Vec<f32>>::from_raw(w, h, pixels.to_vec()).ok_or_else(
|| {
Error::new(format!(
"image::write_image_f32: pixel buffer does not match {width}x{height}x{channels}"
))
},
)?;
buf.save_with_format(path, image::ImageFormat::Tiff)
} else {
let buf = ImageBuffer::<Rgba<f32>, Vec<f32>>::from_raw(w, h, pixels.to_vec()).ok_or_else(
|| {
Error::new(format!(
"image::write_image_f32: pixel buffer does not match {width}x{height}x{channels}"
))
},
)?;
buf.save_with_format(path, image::ImageFormat::Tiff)
};
result.map_err(|e| Error::new(format!("image::write_image_f32: {e}")))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn utils() -> OIIOUtils {
OIIOUtils::new()
}
#[test]
fn base_type_from_format_mapping() {
let u = utils();
assert_eq!(u.get_oiio_base_type_from_format(PixelFormat::U8).unwrap(), 2); // UINT8
assert_eq!(u.get_oiio_base_type_from_format(PixelFormat::U10).unwrap(), 0); // UNKNOWN
assert_eq!(u.get_oiio_base_type_from_format(PixelFormat::U16).unwrap(), 4); // UINT16
assert_eq!(u.get_oiio_base_type_from_format(PixelFormat::F16).unwrap(), 10); // HALF
assert_eq!(u.get_oiio_base_type_from_format(PixelFormat::F32).unwrap(), 11); // FLOAT
// Invalid / count fall through to UNKNOWN.
assert_eq!(u.get_oiio_base_type_from_format(PixelFormat::Invalid).unwrap(), 0);
assert_eq!(u.get_oiio_base_type_from_format(PixelFormat::Count).unwrap(), 0);
}
#[test]
fn format_from_oiio_basetype_mapping() {
let u = utils();
assert_eq!(u.get_format_from_oiio_basetype(2).unwrap(), PixelFormat::U8);
assert_eq!(u.get_format_from_oiio_basetype(4).unwrap(), PixelFormat::U16);
assert_eq!(u.get_format_from_oiio_basetype(10).unwrap(), PixelFormat::F16);
assert_eq!(u.get_format_from_oiio_basetype(11).unwrap(), PixelFormat::F32);
// Unknown / unmappable base types map to Invalid.
assert_eq!(u.get_format_from_oiio_basetype(0).unwrap(), PixelFormat::Invalid); // UNKNOWN
assert_eq!(u.get_format_from_oiio_basetype(1).unwrap(), PixelFormat::Invalid); // NONE
assert_eq!(u.get_format_from_oiio_basetype(3).unwrap(), PixelFormat::Invalid); // INT8
assert_eq!(u.get_format_from_oiio_basetype(12).unwrap(), PixelFormat::Invalid); // DOUBLE
assert_eq!(u.get_format_from_oiio_basetype(15).unwrap(), PixelFormat::Invalid); // USTRINGHASH
assert_eq!(u.get_format_from_oiio_basetype(99).unwrap(), PixelFormat::Invalid);
}
#[test]
fn format_from_oiio_basetype_negative_is_error() {
let u = utils();
assert!(u.get_format_from_oiio_basetype(-1).is_err());
}
#[test]
fn pixel_aspect_ratio_common_values() {
let u = utils();
// 1:1
assert_eq!(u.get_pixel_aspect_ratio(1.0).unwrap(), (1, 1));
// 16:9
assert_eq!(u.get_pixel_aspect_ratio(16.0 / 9.0).unwrap(), (16, 9));
// 4:3
assert_eq!(u.get_pixel_aspect_ratio(4.0 / 3.0).unwrap(), (4, 3));
// 2:1
assert_eq!(u.get_pixel_aspect_ratio(2.0).unwrap(), (2, 1));
// 1:2
assert_eq!(u.get_pixel_aspect_ratio(0.5).unwrap(), (1, 2));
// 1:1.5 = 2:3
assert_eq!(u.get_pixel_aspect_ratio(2.0 / 3.0).unwrap(), (2, 3));
}
#[test]
fn pixel_aspect_ratio_zero() {
let u = utils();
assert_eq!(u.get_pixel_aspect_ratio(0.0).unwrap(), (0, 1));
}
#[test]
fn pixel_aspect_ratio_nan_returns_nan_rational() {
let u = utils();
assert_eq!(u.get_pixel_aspect_ratio(f64::NAN).unwrap(), (0, 0));
}
#[test]
fn pixel_aspect_ratio_out_of_range_returns_nan_rational() {
let u = utils();
let too_big = i32::MAX as f64 + 4.0;
assert_eq!(u.get_pixel_aspect_ratio(too_big).unwrap(), (0, 0));
assert_eq!(u.get_pixel_aspect_ratio(-too_big).unwrap(), (0, 0));
}
#[test]
fn pixel_aspect_ratio_reduction() {
let u = utils();
// 4.0 / 6.0 reduces to 2:3.
assert_eq!(u.get_pixel_aspect_ratio(4.0 / 6.0).unwrap(), (2, 3));
// 0.25 = 1:4.
assert_eq!(u.get_pixel_aspect_ratio(0.25).unwrap(), (1, 4));
// 1.5 = 3:2.
assert_eq!(u.get_pixel_aspect_ratio(1.5).unwrap(), (3, 2));
}
#[test]
fn pixel_aspect_ratio_round_trip() {
let u = utils();
// The recovered rational should reproduce the input within the
// precision of a reduced fraction.
for input in [0.5, 1.0, 1.333_333_333_333_333_3, 1.777_777_777_777_777_7, 2.0, 2.35] {
let (n, d) = u.get_pixel_aspect_ratio(input).unwrap();
if d == 0 {
continue;
}
let recovered = n as f64 / d as f64;
let err = (recovered - input).abs();
assert!(err < 1e-9, "input={input} recovered={recovered} ({n}/{d}) err={err}");
}
}
#[test]
fn oiioutils_new_is_stateless() {
// The object is a stateless unit; construction must succeed and be
// cheap to repeat.
let _a = OIIOUtils::new();
let _b = OIIOUtils::new();
}
#[test]
fn image_color_type_maps_formats() {
assert_eq!(image_color_type_for(PixelFormat::U8), Some(ExtendedColorType::L8));
assert_eq!(image_color_type_for(PixelFormat::U16), Some(ExtendedColorType::L16));
assert_eq!(image_color_type_for(PixelFormat::F32), Some(ExtendedColorType::Rgb32F));
// No image representation.
assert_eq!(image_color_type_for(PixelFormat::U10), None);
assert_eq!(image_color_type_for(PixelFormat::F16), None);
assert_eq!(image_color_type_for(PixelFormat::Invalid), None);
assert_eq!(image_color_type_for(PixelFormat::Count), None);
}
#[test]
fn bits_per_channel_matches_crate_tables() {
// Derived from the image crate's own bits-per-pixel / channel tables.
assert_eq!(bits_per_channel(ExtendedColorType::L8), 8);
assert_eq!(bits_per_channel(ExtendedColorType::L16), 16);
assert_eq!(bits_per_channel(ExtendedColorType::Rgb32F), 32);
assert_eq!(bits_per_channel(ExtendedColorType::Rgba32F), 32);
// A channel-less type yields 0 (the upstream fallback to UNKNOWN).
assert_eq!(bits_per_channel(ExtendedColorType::Unknown(0)), 0);
}
#[test]
fn pixel_aspect_ratio_tiny_value_stays_representable() {
// Very small magnitudes must not panic or produce a NaN rational
// (av_d2q rescales internally).
let u = utils();
let (n, d) = u.get_pixel_aspect_ratio(1e-9).unwrap();
assert_ne!(d, 0);
let recovered = n as f64 / d as f64;
assert!((recovered - 1e-9).abs() / 1e-9 < 1e-6, "got {recovered}");
}
fn temp_tiff_path(name: &str) -> (std::path::PathBuf, String) {
let dir = std::env::temp_dir().join("oakcommon-oiioutils");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(name);
let path_str = path.to_str().unwrap().to_string();
(path, path_str)
}
#[test]
fn image_f32_write_read_round_trip() {
// 2x2 RGBA float image through a temp TIFF.
let w = 2;
let h = 2;
let c = 4;
let pixels: Vec<f32> = vec![
0.0, 0.25, 0.5, 1.0,
0.75, 0.5, 0.25, 1.0,
1.0, 0.0, 0.5, 0.0,
0.125, 0.625, 0.875, 1.0,
];
let (path, path_str) = temp_tiff_path("roundtrip.tif");
write_image_f32(&path_str, w, h, c, &pixels).expect("write should succeed");
let img = read_image_f32(&path_str).expect("read should succeed");
assert_eq!(img.width, w);
assert_eq!(img.height, h);
assert_eq!(img.channels, c);
assert_eq!(img.pixels.len(), (w * h * c) as usize);
assert_eq!(img.len(), img.pixels.len());
assert!(!img.is_empty());
assert_eq!(img.as_slice(), img.pixels.as_slice());
for (i, (a, b)) in img.pixels.iter().zip(pixels.iter()).enumerate() {
let diff = (a - b).abs();
assert!(diff < 1e-6, "pixel {i}: wrote {b}, read back {a}");
}
std::fs::remove_file(&path).ok();
}
#[test]
fn image_f32_write_rgb_round_trip() {
// RGB (3-channel) writes are supported too; the read reports the
// channel count from the file.
let pixels: Vec<f32> = vec![0.1, 0.2, 0.3, 0.4, 0.5, 0.6];
let (path, path_str) = temp_tiff_path("roundtrip_rgb.tif");
write_image_f32(&path_str, 2, 1, 3, &pixels).expect("write should succeed");
let img = read_image_f32(&path_str).expect("read should succeed");
assert_eq!((img.width, img.height, img.channels), (2, 1, 3));
for (a, b) in img.pixels.iter().zip(pixels.iter()) {
assert!((a - b).abs() < 1e-6, "wrote {b}, read back {a}");
}
std::fs::remove_file(&path).ok();
}
#[test]
fn image_f32_write_rejects_invalid_dimensions() {
let err = write_image_f32("/unused.tif", 0, 1, 4, &[]).unwrap_err();
assert!(matches!(err, Error::Failed(_)));
let err = write_image_f32("/unused.tif", 1, -2, 4, &[]).unwrap_err();
assert!(matches!(err, Error::Failed(_)));
}
#[test]
fn image_f32_write_rejects_unsupported_channels() {
let pixels = vec![0.0f32; 2];
let err = write_image_f32("/unused.tif", 1, 1, 1, &pixels).unwrap_err();
assert!(matches!(err, Error::Failed(_)));
let err = write_image_f32("/unused.tif", 1, 1, 2, &pixels).unwrap_err();
assert!(matches!(err, Error::Failed(_)));
}
#[test]
fn image_f32_write_rejects_length_mismatch() {
let err = write_image_f32("/unused.tif", 2, 2, 4, &[0.0f32; 3]).unwrap_err();
assert!(matches!(err, Error::Failed(_)));
}
#[test]
fn image_f32_write_rejects_non_tiff_extension() {
let pixels = vec![0.0f32; 4];
let err = write_image_f32("/unused.png", 1, 1, 4, &pixels).unwrap_err();
assert!(matches!(err, Error::Failed(_)));
}
#[test]
fn image_f32_read_missing_file_errors() {
let err = read_image_f32("/nonexistent/oakcommon-oiioutils.tif").unwrap_err();
assert!(matches!(err, Error::Failed(_)));
}
}
+276
View File
@@ -0,0 +1,276 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Small helpers for crossing the Qt <-> C boundary, mirroring
//! `include/common/qtutils.h`. No state and no handle.
//!
//! Port of `src/common/src/qtutils.{h,cpp}` and the thin wrapper in
//! `src/common/c_api/qtutils.cpp`.
use std::ffi::c_void;
#[cfg(any(target_os = "macos", target_os = "freebsd"))]
use std::ffi::{c_char, c_int, CString};
use crate::error::{Error, Result};
/// Convert an opaque pointer to its numeric representation.
///
/// A null pointer yields `0`. Never fails; the `Result` mirrors the C ABI
/// shape (the c_api only rejects a null `out_value`).
pub fn ptr_to_value(ptr: *mut c_void) -> Result<u64> {
// CPP-PARITY: `reinterpret_cast<uintptr_t>(ptr)`; zero-extended to 64 bits.
Ok(ptr as usize as u64)
}
/// Convert a numeric representation back to an opaque pointer.
///
/// `0` yields a null pointer. Never fails; the `Result` mirrors the C ABI
/// shape (the c_api only rejects a null `out_ptr`).
pub fn value_to_ptr(value: u64) -> Result<*mut c_void> {
// CPP-PARITY: `reinterpret_cast<T*>(value)`, truncating to the pointer
// width like a 32-bit `uintptr_t` cast would.
Ok(value as usize as *mut c_void)
}
/// macOS/FreeBSD `struct timespec` mirror (`sys/types.h`).
#[cfg(any(target_os = "macos", target_os = "freebsd"))]
#[repr(C)]
struct Timespec {
tv_sec: i64,
tv_nsec: i64,
}
/// macOS/FreeBSD `struct stat` mirror (`sys/stat.h`), laid out field-for-field
/// so the raw `stat()` FFI below reads `st_birthtimespec` / `st_ctimespec`.
#[cfg(any(target_os = "macos", target_os = "freebsd"))]
#[repr(C)]
struct Stat {
st_dev: i32,
st_mode: u16,
st_nlink: u16,
st_ino: u64,
st_uid: u32,
st_gid: u32,
st_rdev: i32,
_st_pad: u32,
st_atimespec: Timespec,
st_mtimespec: Timespec,
st_ctimespec: Timespec,
st_birthtimespec: Timespec,
st_size: i64,
st_blocks: i64,
st_blksize: i32,
st_flags: u32,
st_gen: u32,
st_lspare: i32,
st_qspare: [i64; 2],
}
#[cfg(any(target_os = "macos", target_os = "freebsd"))]
extern "C" {
fn stat(path: *const c_char, buf: *mut Stat) -> c_int;
}
/// File creation time as seconds since the Unix epoch.
///
/// On filesystems that record a birth time that value is used; otherwise the
/// metadata change time is returned. Mirrors `olive::QtUtils::get_creation_date`
/// plus the c_api wrapper, which turns a zero (epoch) result into
/// `E_NOT_FOUND`.
pub fn get_creation_date(path: &str) -> Result<i64> {
#[cfg(any(target_os = "macos", target_os = "freebsd"))]
{
let cpath = CString::new(path).map_err(|_| Error::Invalid)?;
let mut st = Stat {
st_dev: 0,
st_mode: 0,
st_nlink: 0,
st_ino: 0,
st_uid: 0,
st_gid: 0,
st_rdev: 0,
_st_pad: 0,
st_atimespec: Timespec { tv_sec: 0, tv_nsec: 0 },
st_mtimespec: Timespec { tv_sec: 0, tv_nsec: 0 },
st_ctimespec: Timespec { tv_sec: 0, tv_nsec: 0 },
st_birthtimespec: Timespec { tv_sec: 0, tv_nsec: 0 },
st_size: 0,
st_blocks: 0,
st_blksize: 0,
st_flags: 0,
st_gen: 0,
st_lspare: 0,
st_qspare: [0, 0],
};
if unsafe { stat(cpath.as_ptr(), &mut st) } != 0 {
return Err(Error::NotFound);
}
// CPP-PARITY: birth time, falling back to the metadata change time when
// the birth time is unset (0 / -1) — qtutils.cpp on macOS/FreeBSD.
let mut secs = st.st_birthtimespec.tv_sec;
if secs == 0 || secs == -1 {
secs = st.st_ctimespec.tv_sec;
}
// CPP-PARITY: the c_api maps a zero (epoch) result to E_NOT_FOUND.
if secs == 0 {
return Err(Error::NotFound);
}
Ok(secs)
}
#[cfg(not(any(target_os = "macos", target_os = "freebsd")))]
{
let meta = std::fs::metadata(path).map_err(|_| Error::NotFound)?;
// CPP-PARITY: `st_ctime` (metadata change time) has no std::fs
// equivalent; fall back to creation then modification time.
let t = meta
.created()
.or_else(|_| meta.modified())
.map_err(|_| Error::NotFound)?;
let secs = t
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
if secs == 0 {
return Err(Error::NotFound);
}
Ok(secs)
}
}
#[cfg(test)]
mod tests {
use std::ffi::c_void;
use std::sync::atomic::{AtomicU64, Ordering};
use super::*;
use crate::error::{Error, Result};
#[test]
fn ptr_to_value_null_is_zero() {
assert_eq!(ptr_to_value(std::ptr::null_mut()).unwrap(), 0);
}
#[test]
fn ptr_value_round_trip() {
let value: u64 = 0x1234_5678_9abc_def0;
let ptr = value_to_ptr(value).unwrap();
assert_eq!(ptr_to_value(ptr).unwrap(), value);
assert!(!ptr.is_null());
}
#[test]
fn value_to_ptr_null_is_null() {
assert!(value_to_ptr(0).unwrap().is_null());
}
#[test]
fn value_to_ptr_accepts_any_u64() {
// Mirrors `reinterpret_cast<void*>` which never rejects an input.
assert!(value_to_ptr(u64::MAX).is_ok());
assert!(value_to_ptr(1).is_ok());
}
#[test]
fn ptr_to_value_of_real_pointer_round_trips() {
let data = 42i32;
let raw = &data as *const i32 as *mut c_void;
let n = ptr_to_value(raw).unwrap();
let back = value_to_ptr(n).unwrap();
assert_eq!(back as *const i32 as *const i32, &data as *const i32);
}
// Stable temp-file helper: unique path under the system temp dir.
fn temp_file_path(tag: &str) -> std::path::PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!(
"oakcommon_qtutils_{}_{}_{}.tmp",
std::process::id(),
tag,
n
))
}
#[test]
fn creation_date_of_existing_file() {
let p = temp_file_path("exists");
std::fs::write(&p, b"hello").unwrap();
let secs = get_creation_date(p.to_str().unwrap());
// On macOS both paths read the same birth time; just assert success
// and plausibility rather than exact equality across fs providers.
match secs {
Ok(s) => assert!(s > 0, "creation time should be in the past"),
Err(e) => panic!("expected Ok, got {e:?}"),
}
// Cross-check against the std metadata creation time when available.
if let Ok(meta) = std::fs::metadata(&p) {
if let Ok(created) = meta.created() {
let expected = created
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
assert_eq!(secs.unwrap(), expected);
}
}
let _ = std::fs::remove_file(&p);
}
#[test]
fn creation_date_matches_std_for_new_file() {
let p = temp_file_path("match");
std::fs::write(&p, b"data").unwrap();
let ours = get_creation_date(p.to_str().unwrap()).unwrap();
let expected = std::fs::metadata(&p)
.and_then(|m| m.created())
.map(|t| t.duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64)
.unwrap_or(0);
assert!(expected > 0);
assert_eq!(ours, expected);
let _ = std::fs::remove_file(&p);
}
#[test]
fn creation_date_missing_file_is_not_found() {
let p = temp_file_path("missing");
let _ = std::fs::remove_file(&p);
let res = get_creation_date(p.to_str().unwrap());
assert!(matches!(res, Err(Error::NotFound)));
}
#[test]
fn creation_date_empty_path_is_not_found() {
assert!(matches!(get_creation_date(""), Err(Error::NotFound)));
}
#[test]
fn creation_date_returns_result_ok_type() {
let p = temp_file_path("typetest");
std::fs::write(&p, b"x").unwrap();
let res: Result<i64> = get_creation_date(p.to_str().unwrap());
assert!(res.is_ok());
let _ = std::fs::remove_file(&p);
}
#[test]
fn value_to_ptr_is_injective_over_u64() {
// Distinct values that survive the pointer-width cast map back distinctly.
let a = value_to_ptr(1).unwrap();
let b = value_to_ptr(2).unwrap();
assert_ne!(a, b);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+153
View File
@@ -0,0 +1,153 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! C ABI contract tests. These assert the load-bearing constants, enum
//! discriminants, and handle layout that the C headers rely on. They do
//! NOT call any crate function, only public constants/types: these checks
//! are compile-time/constant-only and document the ABI surface that must
//! not drift from the headers.
use std::mem::{align_of, size_of};
use oakcommon::error::{
OAKCOMMON_E_FAILED, OAKCOMMON_E_INVALID, OAKCOMMON_E_NOMEM, OAKCOMMON_E_NOT_FOUND,
OAKCOMMON_E_STATE, OAKCOMMON_OK,
};
use oakcommon::ffmpegutils::{RGB_CHANNEL_COUNT, RGBA_CHANNEL_COUNT};
use oakcommon::handle::{CHandle, OAKCOMMON_ABI_VERSION};
use oakcommon::miscutils::{
DECIBEL_MINIMUM, DropWorkflowBehavior, LoopMode,
};
use oakcommon::ocioutils::PixelFormat;
use oakcommon::videoparams::{ColorRange, Interlacing, VideoType};
/// Error codes must match `include/common/error.h`.
#[test]
fn error_codes_match_header() {
assert_eq!(OAKCOMMON_OK, 0);
assert_eq!(OAKCOMMON_E_INVALID, -10001);
assert_eq!(OAKCOMMON_E_STATE, -10002);
assert_eq!(OAKCOMMON_E_FAILED, -10003);
assert_eq!(OAKCOMMON_E_NOT_FOUND, -10004);
assert_eq!(OAKCOMMON_E_NOMEM, -10005);
}
/// Handle ABI version must match `include/common/handle.h`.
#[test]
fn handle_abi_version() {
assert_eq!(OAKCOMMON_ABI_VERSION, 1);
}
/// The handle struct must be a plain `{ctx, addref, release, abi_version}`
/// `#[repr(C)]` record: 3 pointers + a u32, padded to pointer alignment.
#[test]
fn handle_layout() {
let ptr = size_of::<*const ()>();
let align = align_of::<*const ()>();
let expected = (3 * ptr + size_of::<u32>()).div_ceil(align) * align;
assert_eq!(size_of::<CHandle>(), expected);
assert_eq!(align_of::<CHandle>(), align);
}
/// Pixel-format codes must match `olive::core::PixelFormat`.
#[test]
fn pixel_format_discriminants() {
assert_eq!(PixelFormat::Invalid as i32, -1);
assert_eq!(PixelFormat::U8 as i32, 0);
assert_eq!(PixelFormat::U10 as i32, 1);
assert_eq!(PixelFormat::U16 as i32, 2);
assert_eq!(PixelFormat::F16 as i32, 3);
assert_eq!(PixelFormat::F32 as i32, 4);
assert_eq!(PixelFormat::Count as i32, 5);
}
/// Decibel minimum must match `include/common/miscutils.h`.
#[test]
fn decibel_minimum() {
assert_eq!(DECIBEL_MINIMUM, -200.0);
}
/// Channel-count constants must match `include/common/ffmpegutils.h`.
#[test]
fn channel_count_constants() {
assert_eq!(RGB_CHANNEL_COUNT, 3);
assert_eq!(RGBA_CHANNEL_COUNT, 4);
}
/// Loop-mode codes must match `include/common/loopmode.h`.
#[test]
fn loop_mode_discriminants() {
assert_eq!(LoopMode::Off as i32, 0);
assert_eq!(LoopMode::Loop as i32, 1);
assert_eq!(LoopMode::Clamp as i32, 2);
}
/// Drop-workflow behavior codes must match `include/common/dropworkflowbehavior.h`.
#[test]
fn drop_workflow_behavior_discriminants() {
assert_eq!(DropWorkflowBehavior::Ask as i32, 0);
assert_eq!(DropWorkflowBehavior::Auto as i32, 1);
assert_eq!(DropWorkflowBehavior::Manual as i32, 2);
assert_eq!(DropWorkflowBehavior::Disable as i32, 3);
}
/// Interlacing codes must match `include/common/videoparams.h`.
#[test]
fn interlacing_discriminants() {
assert_eq!(Interlacing::None as i32, 0);
assert_eq!(Interlacing::TopFirst as i32, 1);
assert_eq!(Interlacing::BottomFirst as i32, 2);
}
/// Video-type codes must match `include/common/videoparams.h`.
#[test]
fn video_type_discriminants() {
assert_eq!(VideoType::Video as i32, 0);
assert_eq!(VideoType::Still as i32, 1);
assert_eq!(VideoType::ImageSequence as i32, 2);
}
/// Color-range codes must match `include/common/videoparams.h`.
#[test]
fn color_range_discriminants() {
assert_eq!(ColorRange::Limited as i32, 0);
assert_eq!(ColorRange::Full as i32, 1);
}
/// The public type names must exist and be usable at their intended ABI
/// shape (compile-time contract).
#[test]
fn public_types_exist() {
// Enums are plain C-like int enums.
let _ = PixelFormat::U8;
let _ = Interlacing::TopFirst;
let _ = VideoType::Still;
let _ = ColorRange::Full;
let _ = LoopMode::Loop;
let _ = DropWorkflowBehavior::Ask;
// The handle is a plain struct constructible without a panic.
let h = CHandle {
ctx: std::ptr::null_mut(),
addref: None,
release: None,
abi_version: 0,
};
assert!(h.ctx.is_null());
assert!(h.addref.is_none());
assert!(h.release.is_none());
assert_eq!(h.abi_version, 0);
}
+333
View File
@@ -0,0 +1,333 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! FFI-level integration tests for the C-ABI `colortransform` exports in
//! `oakcommon::ffi::colortransform`, asserted against the C++ oracle
//! `src/common/c_api/colortransform.cpp` and the Rust domain
//! `src/common/rust/src/colortransform.rs`.
//!
//! The crate exports the two constructors, `free`, `is_display`, and the
//! four two-stage getters; only the C++-typed `init_from_native` /
//! `get_native` adapters stay with the C++ adapter layer. The tests
//! cover:
//! - constructor success: a stamped (`OAKCOMMON_ABI_VERSION`), non-null
//! handle with live `addref`/`release` callbacks, whose boxed
//! [`ColorTransform`] carries the right fields (peeked via
//! `oakcommon::handle::get`);
//! - constructor failure: a null string argument yields a null handle
//! (guard semantics: any error or panic also collapses to a null
//! handle);
//! - the free contract (release once, nullify, no-op on null);
//! - `is_display` / `get_display` / `get_output` / `get_view` /
//! `get_look` happy paths, two-stage short-buffer behavior, and
//! empty-handle error codes;
//! - distinct handles plus the ownership contract (init yields reference
//! count 1; addref/release adjust it atomically; both free cleanly).
use std::ffi::{c_char, CString};
use std::sync::atomic::Ordering;
use oakcommon::colortransform::ColorTransform;
use oakcommon::ffi::colortransform::*;
use oakcommon::handle::{get, RefBox, CHandle, OAKCOMMON_ABI_VERSION};
/// Convert a string slice to a NUL-terminated C string for FFI inputs.
fn to_cstring(s: &str) -> CString {
CString::new(s).expect("test string must not contain NUL")
}
/// Cheap struct copy: `CHandle` is neither `Clone` nor `Copy`; rebuilding
/// from the same fields duplicates only the handle value — the box stays
/// alive as long as the original handle lives.
fn dup(h: &CHandle) -> CHandle {
CHandle {
ctx: h.ctx,
addref: h.addref,
release: h.release,
abi_version: h.abi_version,
}
}
/// Release one reference and nullify the handle. `oakcommon_colortransform_free`
/// is declared in the header but not exported by this crate, so the free
/// contract is driven through this exact replica of the crate-private
/// `free_handle` (and the C++ oracle): release once, write `CHandle::null()`.
fn free(h: &mut CHandle) {
if let Some(rel) = h.release {
unsafe {
rel(h.ctx);
}
}
*h = CHandle::null();
}
/// Pointer form of [`free`], mirroring `free_handle(*mut CHandle)`: a null
/// pointer is a no-op.
fn free_ptr(p: *mut CHandle) {
if p.is_null() {
return;
}
unsafe {
if let Some(h) = p.as_ref() {
if let Some(rel) = h.release {
rel(h.ctx);
}
}
p.write(CHandle::null());
}
}
/// Read the reference count behind an owned handle (test-only peek into
/// the crate-public `RefBox` layout; the box is guaranteed alive here).
unsafe fn refs_of(h: &CHandle) -> u32 {
unsafe { (*(h.ctx as *const RefBox<ColorTransform>)).refs.load(Ordering::Relaxed) }
}
/// A successful `init_output` yields a stamped, non-null handle whose boxed
/// transform is an output transform with the requested name; `free`
/// nullifies it.
#[test]
fn init_output_success() {
let mut h = oakcommon_colortransform_init_output(to_cstring("sRGB").as_ptr());
assert!(!h.is_null());
assert_eq!(h.abi_version, OAKCOMMON_ABI_VERSION);
assert!(h.addref.is_some());
assert!(h.release.is_some());
let t: &ColorTransform = unsafe { get::<ColorTransform>(&h) }.expect("boxed transform");
assert!(!t.is_display());
assert_eq!(t.output(), "sRGB");
assert_eq!(t.display(), "");
assert_eq!(t.view(), "");
assert_eq!(t.look(), "");
free(&mut h);
assert!(h.is_null());
}
/// A null output string yields a null handle with no callbacks.
#[test]
fn init_output_null_yields_null_handle() {
let h = oakcommon_colortransform_init_output(std::ptr::null::<c_char>());
assert!(h.is_null());
assert!(h.ctx.is_null());
assert!(h.addref.is_none());
assert!(h.release.is_none());
assert_eq!(h.abi_version, OAKCOMMON_ABI_VERSION);
}
/// An empty output name is a valid value: the transform is still an output
/// transform (C++ default-constructor parity).
#[test]
fn init_output_empty_string_succeeds() {
let mut h = oakcommon_colortransform_init_output(to_cstring("").as_ptr());
assert!(!h.is_null());
let t: &ColorTransform = unsafe { get::<ColorTransform>(&h) }.expect("boxed transform");
assert!(!t.is_display());
assert_eq!(t.output(), "");
free(&mut h);
assert!(h.is_null());
}
/// A successful `init_display` yields a stamped, non-null handle whose
/// boxed transform carries the display/view/look names.
#[test]
fn init_display_success() {
let mut h = oakcommon_colortransform_init_display(
to_cstring("DCI-P3").as_ptr(),
to_cstring("standard").as_ptr(),
to_cstring("soft").as_ptr(),
);
assert!(!h.is_null());
assert_eq!(h.abi_version, OAKCOMMON_ABI_VERSION);
assert!(h.addref.is_some());
assert!(h.release.is_some());
let t: &ColorTransform = unsafe { get::<ColorTransform>(&h) }.expect("boxed transform");
assert!(t.is_display());
assert_eq!(t.display(), "DCI-P3");
assert_eq!(t.view(), "standard");
assert_eq!(t.look(), "soft");
assert_eq!(t.output(), "");
free(&mut h);
assert!(h.is_null());
}
/// Any single null argument — and all-null — yields a null handle.
#[test]
fn init_display_null_any_arg_yields_null_handle() {
let d = to_cstring("DCI-P3");
let v = to_cstring("standard");
let l = to_cstring("soft");
let cases = [
(d.as_ptr(), v.as_ptr(), std::ptr::null::<c_char>()),
(d.as_ptr(), std::ptr::null::<c_char>(), l.as_ptr()),
(std::ptr::null::<c_char>(), v.as_ptr(), l.as_ptr()),
(
std::ptr::null::<c_char>(),
std::ptr::null::<c_char>(),
std::ptr::null::<c_char>(),
),
];
for (display, view, look) in cases {
let h = oakcommon_colortransform_init_display(display, view, look);
assert!(h.is_null());
assert!(h.ctx.is_null());
assert!(h.addref.is_none());
assert!(h.release.is_none());
assert_eq!(h.abi_version, OAKCOMMON_ABI_VERSION);
}
}
/// Empty display/view/look strings are valid; the explicit `is_display`
/// flag keeps the transform identifiable as a display transform.
#[test]
fn init_display_empty_strings_succeed() {
let mut h = oakcommon_colortransform_init_display(
to_cstring("").as_ptr(),
to_cstring("").as_ptr(),
to_cstring("").as_ptr(),
);
assert!(!h.is_null());
let t: &ColorTransform = unsafe { get::<ColorTransform>(&h) }.expect("boxed transform");
assert!(t.is_display());
assert_eq!(t.display(), "");
assert_eq!(t.view(), "");
assert_eq!(t.look(), "");
free(&mut h);
assert!(h.is_null());
}
/// The free contract: nullify, idempotent, and no-op on a null handle or a
/// null pointer.
#[test]
fn free_contract() {
// A real handle: free nullifies it and a second free is safe.
let mut h = oakcommon_colortransform_init_output(to_cstring("sRGB").as_ptr());
assert!(!h.is_null());
free(&mut h);
assert!(h.is_null());
assert!(h.ctx.is_null());
assert!(h.release.is_none());
free(&mut h);
assert!(h.is_null());
// Freeing an already-null handle is a no-op.
free(&mut CHandle::null());
// Freeing a null pointer is a no-op.
free_ptr(std::ptr::null_mut());
}
/// Two output transforms are distinct handles (different boxes); init
/// yields reference count 1, addref/release adjust it atomically, and both
/// free cleanly. `dup` copies the handle value but shares the same box.
#[test]
fn init_outputs_are_distinct_and_release_cleanly() {
let mut a = oakcommon_colortransform_init_output(to_cstring("sRGB").as_ptr());
let mut b = oakcommon_colortransform_init_output(to_cstring("Display P3").as_ptr());
assert!(!a.is_null());
assert!(!b.is_null());
assert_ne!(a.ctx, b.ctx);
// dup copies the handle value and shares the same underlying box.
let c = dup(&a);
assert_eq!(c.ctx, a.ctx);
// Ownership contract: count 1 at init, atomic addref/release.
unsafe {
assert_eq!(refs_of(&a), 1);
assert_eq!(refs_of(&b), 1);
(a.addref.unwrap())(a.ctx);
assert_eq!(refs_of(&a), 2);
(a.release.unwrap())(a.ctx);
assert_eq!(refs_of(&a), 1);
}
// Both release cleanly (single release + nullify, no double-free).
free(&mut a);
assert!(a.is_null());
free(&mut b);
assert!(b.is_null());
}
/// `is_display` distinguishes display transforms from output transforms
/// and reports OAKCOMMON_E_INVALID on an empty handle.
#[test]
fn is_display_paths() {
let mut d = oakcommon_colortransform_init_display(
to_cstring("sRGB").as_ptr(),
to_cstring("standard").as_ptr(),
to_cstring("").as_ptr(),
);
let mut o = oakcommon_colortransform_init_output(to_cstring("sRGB").as_ptr());
assert_eq!(oakcommon_colortransform_is_display(dup(&d)), 1);
assert_eq!(oakcommon_colortransform_is_display(dup(&o)), 0);
assert_eq!(
oakcommon_colortransform_is_display(CHandle::null()),
oakcommon::error::OAKCOMMON_E_INVALID
);
free(&mut d);
free(&mut o);
}
/// Two-stage getters: exact-fit returns content, short buffer leaves the
/// buffer untouched but reports the required size, empty handle yields
/// OAKCOMMON_E_INVALID.
#[test]
fn getters_two_stage_and_empty_handle() {
let mut d = oakcommon_colortransform_init_display(
to_cstring("P3").as_ptr(),
to_cstring("std").as_ptr(),
to_cstring("soft").as_ptr(),
);
let mut buf = [0i8; 32];
let need = oakcommon_colortransform_get_display(dup(&d), buf.as_mut_ptr(), 32);
assert_eq!(need, 3); // "P3" + NUL
assert_eq!(unsafe { std::ffi::CStr::from_ptr(buf.as_ptr()) }.to_str().unwrap(), "P3");
let need = oakcommon_colortransform_get_view(dup(&d), buf.as_mut_ptr(), 2);
assert_eq!(need, 4); // "std" + NUL, too small: nothing written
let need = oakcommon_colortransform_get_look(dup(&d), buf.as_mut_ptr(), 32);
assert_eq!(need, 5);
assert_eq!(unsafe { std::ffi::CStr::from_ptr(buf.as_ptr()) }.to_str().unwrap(), "soft");
let o = oakcommon_colortransform_init_output(to_cstring("sRGB").as_ptr());
let need = oakcommon_colortransform_get_output(o, buf.as_mut_ptr(), 32);
assert_eq!(need, 5);
assert_eq!(unsafe { std::ffi::CStr::from_ptr(buf.as_ptr()) }.to_str().unwrap(), "sRGB");
assert_eq!(
oakcommon_colortransform_get_display(CHandle::null(), buf.as_mut_ptr(), 32),
oakcommon::error::OAKCOMMON_E_INVALID
);
free(&mut d);
}
/// The real `oakcommon_colortransform_free` export: releases once,
/// nullifies, no-ops on a null pointer.
#[test]
fn free_export_contract() {
let mut h = oakcommon_colortransform_init_output(to_cstring("sRGB").as_ptr());
assert!(!h.is_null());
oakcommon_colortransform_free(&mut h);
assert!(h.is_null());
oakcommon_colortransform_free(&mut h); // already null: no-op
oakcommon_colortransform_free(std::ptr::null_mut()); // null pointer: no-op
}
@@ -0,0 +1,867 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Integration tests for the C ABI surface of
//! `oakcommon::ffi::commandlineparser`, which implements
//! `include/common/commandlineparser.h` (oracle: `src/common/c_api/commandlineparser.cpp`).
//!
//! Every exported function gets a success-path test and a failure-path test.
//! The failure paths cover the empty (NULL-ctx) handle plus the documented
//! invalid-argument codes; the string getters are TRUNCATING (see the C++
//! `copy_setting`), so all three two-stage buffer cases are exercised.
//!
//! These tests are pure: they create their own parser/option/argument
//! handles and touch no config files, so they need no `OAK_CONFIG_DIR`
//! locking and run independently of the config tests.
use std::ffi::{c_char, CStr, CString};
use std::ptr::null_mut;
use oakcommon::error::{OAKCOMMON_E_INVALID, OAKCOMMON_OK};
use oakcommon::ffi::commandlineparser::*;
use oakcommon::handle::{CHandle, OAKCOMMON_ABI_VERSION};
/// A fresh parser handle (reference count 1, non-null ctx).
fn new_parser() -> CHandle {
let h = oakcommon_commandlineparser_init();
assert!(!h.ctx.is_null());
h
}
/// Shallow-copy a handle, mirroring C's pass-by-value semantics. The FFI
/// exports take handles by value (a `CHandle` is a plain
/// `{ctx, addref, release, abi_version}` record), but the Rust type
/// deliberately does not implement `Copy`, so each call consumes its
/// argument. `dup` copies the four scalar fields; the exports never
/// release the handles they receive, so the reference count is untouched
/// and no `addref` is needed.
fn dup(h: &CHandle) -> CHandle {
CHandle {
ctx: h.ctx,
addref: h.addref,
release: h.release,
abi_version: h.abi_version,
}
}
/// Build a NUL-terminated C string.
fn c_str(s: &str) -> CString {
CString::new(s).unwrap()
}
/// Build a `char *const *`-style array: returns the CStrings (kept alive
/// for the caller) plus their pointer array.
fn c_strings(values: &[&str]) -> (Vec<CString>, Vec<*const c_char>) {
let cs: Vec<CString> = values.iter().map(|s| c_str(s)).collect();
let ptrs = cs.iter().map(|s| s.as_ptr()).collect();
(cs, ptrs)
}
/// Register a single `takes_arg` option and return its borrowed handle.
fn register_option(parser: CHandle, name: &str) -> CHandle {
let mut out = CHandle::null();
let (_names, ptrs) = c_strings(&[name]);
let r = oakcommon_commandlineparser_add_option(
parser,
ptrs.as_ptr(),
1,
c_str("desc").as_ptr(),
1,
c_str("ARG").as_ptr(),
0,
&mut out,
);
assert_eq!(r, OAKCOMMON_OK);
assert!(!out.ctx.is_null());
out
}
/// Register a single positional argument and return its borrowed handle.
fn register_positional(parser: CHandle, name: &str) -> CHandle {
let mut out = CHandle::null();
let r = oakcommon_commandlineparser_add_positional_argument(
parser,
c_str(name).as_ptr(),
c_str("desc").as_ptr(),
1,
&mut out,
);
assert_eq!(r, OAKCOMMON_OK);
assert!(!out.ctx.is_null());
out
}
// ---------------------------------------------------------------------------
// oakcommon_commandlineparser_init
// ---------------------------------------------------------------------------
/// init returns a live handle with the standard vtable and ABI stamp, and
/// free() releases it cleanly. There is no testable failure path: the only
/// failure mode is allocation failure / panic inside `guard_handle`, both
/// of which return a NULL-ctx handle and cannot be triggered deterministically.
#[test]
fn init_returns_live_handle() {
let mut h = oakcommon_commandlineparser_init();
assert!(!h.ctx.is_null());
assert!(h.addref.is_some());
assert!(h.release.is_some());
assert_eq!(h.abi_version, OAKCOMMON_ABI_VERSION);
oakcommon_commandlineparser_free(&mut h);
assert!(h.ctx.is_null());
}
// ---------------------------------------------------------------------------
// oakcommon_commandlineparser_free
// ---------------------------------------------------------------------------
#[test]
fn parser_free_null_pointer_is_noop() {
oakcommon_commandlineparser_free(null_mut());
}
#[test]
fn parser_free_twice_is_safe_and_nulls_ctx() {
let mut h = oakcommon_commandlineparser_init();
assert!(!h.ctx.is_null());
oakcommon_commandlineparser_free(&mut h);
assert!(h.ctx.is_null());
// Second free on the same (now empty) handle must be a no-op.
oakcommon_commandlineparser_free(&mut h);
assert!(h.ctx.is_null());
}
#[test]
fn parser_free_null_handle_struct_is_noop() {
let mut h = CHandle::null();
oakcommon_commandlineparser_free(&mut h);
assert!(h.ctx.is_null());
}
// ---------------------------------------------------------------------------
// oakcommon_commandlineparser_set_app_info
// ---------------------------------------------------------------------------
#[test]
fn set_app_info_success() {
let mut p = new_parser();
let r = oakcommon_commandlineparser_set_app_info(dup(&p), c_str("myapp").as_ptr(), c_str("1.0").as_ptr());
assert_eq!(r, OAKCOMMON_OK);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn set_app_info_empty_handle_is_invalid() {
let e = CHandle::null();
let r = oakcommon_commandlineparser_set_app_info(e, c_str("n").as_ptr(), c_str("v").as_ptr());
assert_eq!(r, OAKCOMMON_E_INVALID);
}
#[test]
fn set_app_info_null_name_is_invalid() {
let mut p = new_parser();
let r = oakcommon_commandlineparser_set_app_info(dup(&p), null_mut(), c_str("v").as_ptr());
assert_eq!(r, OAKCOMMON_E_INVALID);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn set_app_info_null_version_is_invalid() {
let mut p = new_parser();
// CPP-PARITY: the C++ oracle tolerates a NULL version (treated as ""),
// but the Rust export rejects it with E_INVALID.
let r = oakcommon_commandlineparser_set_app_info(dup(&p), c_str("n").as_ptr(), null_mut());
assert_eq!(r, OAKCOMMON_E_INVALID);
oakcommon_commandlineparser_free(&mut p);
}
// ---------------------------------------------------------------------------
// oakcommon_commandlineparser_add_option
// ---------------------------------------------------------------------------
#[test]
fn add_option_success() {
let mut p = new_parser();
let mut out = CHandle::null();
let (_names, ptrs) = c_strings(&["o", "output"]);
let r = oakcommon_commandlineparser_add_option(
dup(&p),
ptrs.as_ptr(),
2,
c_str("Output file.").as_ptr(),
1,
c_str("FILE").as_ptr(),
0,
&mut out,
);
assert_eq!(r, OAKCOMMON_OK);
assert!(!out.ctx.is_null());
assert!(out.addref.is_some());
assert!(out.release.is_some());
oakcommon_commandlineoption_free(&mut out);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn add_option_empty_parser_is_invalid() {
let e = CHandle::null();
let mut out = CHandle::null();
let (_names, ptrs) = c_strings(&["o"]);
let r = oakcommon_commandlineparser_add_option(
e, ptrs.as_ptr(), 1, c_str("d").as_ptr(), 0, c_str("A").as_ptr(), 0, &mut out);
assert_eq!(r, OAKCOMMON_E_INVALID);
}
#[test]
fn add_option_null_names_is_invalid() {
let mut p = new_parser();
let mut out = CHandle::null();
let r = oakcommon_commandlineparser_add_option(
dup(&p), null_mut(), 1, c_str("d").as_ptr(), 0, c_str("A").as_ptr(), 0, &mut out);
assert_eq!(r, OAKCOMMON_E_INVALID);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn add_option_non_positive_name_count_is_invalid() {
let mut p = new_parser();
let mut out = CHandle::null();
let (_names, ptrs) = c_strings(&["o"]);
let r0 = oakcommon_commandlineparser_add_option(
dup(&p), ptrs.as_ptr(), 0, c_str("d").as_ptr(), 0, c_str("A").as_ptr(), 0, &mut out);
assert_eq!(r0, OAKCOMMON_E_INVALID);
let rn = oakcommon_commandlineparser_add_option(
dup(&p), ptrs.as_ptr(), -1, c_str("d").as_ptr(), 0, c_str("A").as_ptr(), 0, &mut out);
assert_eq!(rn, OAKCOMMON_E_INVALID);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn add_option_null_description_is_invalid() {
// CPP-PARITY: the header documents description as "may be NULL" (C++ maps
// it to ""), but the Rust export rejects a NULL description with E_INVALID.
let mut p = new_parser();
let mut out = CHandle::null();
let (_names, ptrs) = c_strings(&["o"]);
let r = oakcommon_commandlineparser_add_option(
dup(&p), ptrs.as_ptr(), 1, null_mut(), 0, c_str("A").as_ptr(), 0, &mut out);
assert_eq!(r, OAKCOMMON_E_INVALID);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn add_option_null_arg_placeholder_is_invalid() {
// CPP-PARITY: the header documents arg_placeholder as "may be NULL" (C++
// maps it to ""), but the Rust export rejects it with E_INVALID.
let mut p = new_parser();
let mut out = CHandle::null();
let (_names, ptrs) = c_strings(&["o"]);
let r = oakcommon_commandlineparser_add_option(
dup(&p), ptrs.as_ptr(), 1, c_str("d").as_ptr(), 0, null_mut(), 0, &mut out);
assert_eq!(r, OAKCOMMON_E_INVALID);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn add_option_null_out_option_is_invalid() {
// CPP-PARITY: the header says out_option "may be NULL if unused"; the Rust
// export requires it.
let mut p = new_parser();
let (_names, ptrs) = c_strings(&["o"]);
let r = oakcommon_commandlineparser_add_option(
dup(&p), ptrs.as_ptr(), 1, c_str("d").as_ptr(), 0, c_str("A").as_ptr(), 0, null_mut());
assert_eq!(r, OAKCOMMON_E_INVALID);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn add_option_non_utf8_name_is_invalid() {
let mut p = new_parser();
let mut out = CHandle::null();
// 0xFF is not valid UTF-8; the export rejects it with E_INVALID before
// the option is registered.
let bad = CString::new(vec![b'x', 0xFF]).unwrap();
let ptrs = [bad.as_ptr()];
let r = oakcommon_commandlineparser_add_option(
dup(&p), ptrs.as_ptr(), 1, c_str("d").as_ptr(), 0, c_str("A").as_ptr(), 0, &mut out);
assert_eq!(r, OAKCOMMON_E_INVALID);
// The failed registration must not leave a partial option behind: a
// subsequent valid registration still works and lands at index 0.
let mut out2 = CHandle::null();
let (_names, ptrs2) = c_strings(&["ok"]);
let r2 = oakcommon_commandlineparser_add_option(
dup(&p), ptrs2.as_ptr(), 1, c_str("d").as_ptr(), 0, c_str("A").as_ptr(), 0, &mut out2);
assert_eq!(r2, OAKCOMMON_OK);
assert!(!out2.ctx.is_null());
oakcommon_commandlineoption_free(&mut out2);
oakcommon_commandlineparser_free(&mut p);
}
// ---------------------------------------------------------------------------
// oakcommon_commandlineparser_add_positional_argument
// ---------------------------------------------------------------------------
#[test]
fn add_positional_argument_success() {
let mut p = new_parser();
let mut out = CHandle::null();
let r = oakcommon_commandlineparser_add_positional_argument(
dup(&p), c_str("input").as_ptr(), c_str("Input file").as_ptr(), 1, &mut out);
assert_eq!(r, OAKCOMMON_OK);
assert!(!out.ctx.is_null());
assert!(out.release.is_some());
oakcommon_commandlinepositionalargument_free(&mut out);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn add_positional_argument_empty_parser_is_invalid() {
let e = CHandle::null();
let mut out = CHandle::null();
let r = oakcommon_commandlineparser_add_positional_argument(
e, c_str("in").as_ptr(), c_str("d").as_ptr(), 1, &mut out);
assert_eq!(r, OAKCOMMON_E_INVALID);
}
#[test]
fn add_positional_argument_null_name_is_invalid() {
let mut p = new_parser();
let mut out = CHandle::null();
let r = oakcommon_commandlineparser_add_positional_argument(
dup(&p), null_mut(), c_str("d").as_ptr(), 1, &mut out);
assert_eq!(r, OAKCOMMON_E_INVALID);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn add_positional_argument_null_description_is_invalid() {
// CPP-PARITY: C++ maps a NULL description to ""; the Rust export rejects
// it with E_INVALID.
let mut p = new_parser();
let mut out = CHandle::null();
let r = oakcommon_commandlineparser_add_positional_argument(
dup(&p), c_str("in").as_ptr(), null_mut(), 1, &mut out);
assert_eq!(r, OAKCOMMON_E_INVALID);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn add_positional_argument_null_out_is_invalid() {
// CPP-PARITY: C++ allows a NULL out_argument; the Rust export requires it.
let mut p = new_parser();
let r = oakcommon_commandlineparser_add_positional_argument(
dup(&p), c_str("in").as_ptr(), c_str("d").as_ptr(), 1, null_mut());
assert_eq!(r, OAKCOMMON_E_INVALID);
oakcommon_commandlineparser_free(&mut p);
}
// ---------------------------------------------------------------------------
// oakcommon_commandlineparser_process
// ---------------------------------------------------------------------------
#[test]
fn process_success() {
let mut p = new_parser();
let mut opt = register_option(dup(&p), "o");
let mut pos = register_positional(dup(&p), "input");
let (_argv, argv_ptrs) = c_strings(&["prog", "-o", "out.mov", "in.mp4"]);
let r = oakcommon_commandlineparser_process(dup(&p), argv_ptrs.as_ptr(), 4);
assert_eq!(r, OAKCOMMON_OK);
oakcommon_commandlineoption_free(&mut opt);
oakcommon_commandlinepositionalargument_free(&mut pos);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn process_argc_zero_success() {
let mut p = new_parser();
// Non-null argv with argc == 0: the loop never runs, returns OK.
let (_argv, argv_ptrs) = c_strings(&["prog"]);
let r = oakcommon_commandlineparser_process(dup(&p), argv_ptrs.as_ptr(), 0);
assert_eq!(r, OAKCOMMON_OK);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn process_empty_parser_is_invalid() {
let e = CHandle::null();
let (_argv, argv_ptrs) = c_strings(&["prog"]);
let r = oakcommon_commandlineparser_process(e, argv_ptrs.as_ptr(), 1);
assert_eq!(r, OAKCOMMON_E_INVALID);
}
#[test]
fn process_null_argv_is_invalid() {
let mut p = new_parser();
let r = oakcommon_commandlineparser_process(dup(&p), null_mut(), 1);
assert_eq!(r, OAKCOMMON_E_INVALID);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn process_negative_argc_is_invalid() {
let mut p = new_parser();
let (_argv, argv_ptrs) = c_strings(&["prog"]);
let r = oakcommon_commandlineparser_process(dup(&p), argv_ptrs.as_ptr(), -1);
assert_eq!(r, OAKCOMMON_E_INVALID);
oakcommon_commandlineparser_free(&mut p);
}
// ---------------------------------------------------------------------------
// oakcommon_commandlineparser_print_help
// ---------------------------------------------------------------------------
#[test]
fn print_help_success() {
let mut p = new_parser();
let r = oakcommon_commandlineparser_print_help(dup(&p), c_str("oak").as_ptr());
assert_eq!(r, OAKCOMMON_OK);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn print_help_empty_parser_is_invalid() {
let e = CHandle::null();
let r = oakcommon_commandlineparser_print_help(e, c_str("oak").as_ptr());
assert_eq!(r, OAKCOMMON_E_INVALID);
}
#[test]
fn print_help_null_filename_is_invalid() {
let mut p = new_parser();
let r = oakcommon_commandlineparser_print_help(dup(&p), null_mut());
assert_eq!(r, OAKCOMMON_E_INVALID);
oakcommon_commandlineparser_free(&mut p);
}
// ---------------------------------------------------------------------------
// oakcommon_commandlineoption_is_set / _free
// ---------------------------------------------------------------------------
#[test]
fn option_is_set_success() {
let mut p = new_parser();
let mut opt = register_option(dup(&p), "o");
let mut is_set = true;
let r = oakcommon_commandlineoption_is_set(dup(&opt), &mut is_set);
assert_eq!(r, OAKCOMMON_OK);
// CPP-PARITY: the Rust borrowed handle is a registration-time snapshot
// (make_borrowed bit-copies), so is_set is always false on the handle;
// the C++ oracle stores a pointer to the live option and would report the
// parser's real state.
assert!(!is_set);
oakcommon_commandlineoption_free(&mut opt);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn option_is_set_empty_handle_is_invalid() {
let e = CHandle::null();
let mut is_set = true;
let r = oakcommon_commandlineoption_is_set(e, &mut is_set);
assert_eq!(r, OAKCOMMON_E_INVALID);
}
#[test]
fn option_is_set_null_out_is_invalid() {
let mut p = new_parser();
let mut opt = register_option(dup(&p), "o");
let r = oakcommon_commandlineoption_is_set(dup(&opt), null_mut());
assert_eq!(r, OAKCOMMON_E_INVALID);
oakcommon_commandlineoption_free(&mut opt);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn option_free_null_pointer_is_noop() {
oakcommon_commandlineoption_free(null_mut());
}
#[test]
fn option_free_twice_is_safe_and_nulls_ctx() {
let mut p = new_parser();
let mut opt = register_option(dup(&p), "o");
assert!(!opt.ctx.is_null());
oakcommon_commandlineoption_free(&mut opt);
assert!(opt.ctx.is_null());
// Second free on the same (now empty) handle must be a no-op.
oakcommon_commandlineoption_free(&mut opt);
assert!(opt.ctx.is_null());
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn option_free_null_handle_struct_is_noop() {
let mut h = CHandle::null();
oakcommon_commandlineoption_free(&mut h);
assert!(h.ctx.is_null());
}
// ---------------------------------------------------------------------------
// oakcommon_commandlineoption_get_setting (TRUNCATING two-stage getter)
// ---------------------------------------------------------------------------
#[test]
fn option_get_setting_size_query() {
let mut p = new_parser();
let mut opt = register_option(dup(&p), "o");
assert_eq!(
oakcommon_commandlineoption_set_setting(dup(&opt), c_str("abcdef").as_ptr()),
OAKCOMMON_OK
);
// Stage 1: null buffer, size 0 -> required size len+1, nothing written.
let r = oakcommon_commandlineoption_get_setting(dup(&opt), null_mut(), 0);
assert_eq!(r, 7); // len("abcdef") + NUL
oakcommon_commandlineoption_free(&mut opt);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn option_get_setting_short_buffer_truncates_and_returns_required() {
let mut p = new_parser();
let mut opt = register_option(dup(&p), "o");
assert_eq!(
oakcommon_commandlineoption_set_setting(dup(&opt), c_str("abcdef").as_ptr()),
OAKCOMMON_OK
);
// Stage 2: buf_size 4 < 7 -> writes "abc" + NUL, still returns 7.
let mut buf = [0xFFu8; 4];
let r = oakcommon_commandlineoption_get_setting(dup(&opt), buf.as_mut_ptr() as *mut c_char, 4);
assert_eq!(r, 7);
assert_eq!(&buf[..3], b"abc");
assert_eq!(buf[3], 0);
// buf_size 1 -> writes only the NUL terminator.
let mut tiny = [0xFFu8; 1];
let r = oakcommon_commandlineoption_get_setting(dup(&opt), tiny.as_mut_ptr() as *mut c_char, 1);
assert_eq!(r, 7);
assert_eq!(tiny[0], 0);
oakcommon_commandlineoption_free(&mut opt);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn option_get_setting_exact_fit_writes_full_string() {
let mut p = new_parser();
let mut opt = register_option(dup(&p), "o");
assert_eq!(
oakcommon_commandlineoption_set_setting(dup(&opt), c_str("abcdef").as_ptr()),
OAKCOMMON_OK
);
// Stage 3: buf_size == len+1 -> full string plus NUL, returns len+1.
let mut buf = [0xFFu8; 7];
let r = oakcommon_commandlineoption_get_setting(dup(&opt), buf.as_mut_ptr() as *mut c_char, 7);
assert_eq!(r, 7);
assert_eq!(buf[6], 0);
let s = unsafe { CStr::from_ptr(buf.as_ptr() as *const c_char) };
assert_eq!(s.to_str().unwrap(), "abcdef");
oakcommon_commandlineoption_free(&mut opt);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn option_get_setting_non_null_buf_size_zero_writes_nothing() {
let mut p = new_parser();
let mut opt = register_option(dup(&p), "o");
assert_eq!(
oakcommon_commandlineoption_set_setting(dup(&opt), c_str("abcdef").as_ptr()),
OAKCOMMON_OK
);
// A non-null buffer with size 0 is a valid size query: required returned,
// buffer untouched.
let mut buf = [0xFFu8; 8];
let r = oakcommon_commandlineoption_get_setting(dup(&opt), buf.as_mut_ptr() as *mut c_char, 0);
assert_eq!(r, 7);
assert_eq!(buf, [0xFFu8; 8]);
oakcommon_commandlineoption_free(&mut opt);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn option_get_setting_unset_returns_empty_string() {
let mut p = new_parser();
let mut opt = register_option(dup(&p), "o");
// Never set: get_setting returns the empty string (required size 1).
let mut buf = [0xFFu8; 1];
let r = oakcommon_commandlineoption_get_setting(dup(&opt), buf.as_mut_ptr() as *mut c_char, 1);
assert_eq!(r, 1);
assert_eq!(buf[0], 0);
oakcommon_commandlineoption_free(&mut opt);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn option_get_setting_empty_handle_is_invalid() {
let e = CHandle::null();
let r = oakcommon_commandlineoption_get_setting(e, null_mut(), 0);
assert_eq!(r, OAKCOMMON_E_INVALID);
}
#[test]
fn option_get_setting_negative_buf_size_is_invalid() {
let mut p = new_parser();
let mut opt = register_option(dup(&p), "o");
let mut buf = [0u8; 4];
let r = oakcommon_commandlineoption_get_setting(dup(&opt), buf.as_mut_ptr() as *mut c_char, -1);
assert_eq!(r, OAKCOMMON_E_INVALID);
let r2 = oakcommon_commandlineoption_get_setting(dup(&opt), null_mut(), -1);
assert_eq!(r2, OAKCOMMON_E_INVALID);
oakcommon_commandlineoption_free(&mut opt);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn option_get_setting_null_buf_with_size_is_invalid() {
let mut p = new_parser();
let mut opt = register_option(dup(&p), "o");
let r = oakcommon_commandlineoption_get_setting(dup(&opt), null_mut(), 1);
assert_eq!(r, OAKCOMMON_E_INVALID);
oakcommon_commandlineoption_free(&mut opt);
oakcommon_commandlineparser_free(&mut p);
}
// ---------------------------------------------------------------------------
// oakcommon_commandlineoption_set_setting
// ---------------------------------------------------------------------------
#[test]
fn option_set_setting_success() {
let mut p = new_parser();
let mut opt = register_option(dup(&p), "o");
let r = oakcommon_commandlineoption_set_setting(dup(&opt), c_str("value").as_ptr());
assert_eq!(r, OAKCOMMON_OK);
// Round-trip through the two-stage getter.
let mut buf = vec![0u8; 6];
let r = oakcommon_commandlineoption_get_setting(dup(&opt), buf.as_mut_ptr() as *mut c_char, 6);
assert_eq!(r, 6);
let s = unsafe { CStr::from_ptr(buf.as_ptr() as *const c_char) };
assert_eq!(s.to_str().unwrap(), "value");
oakcommon_commandlineoption_free(&mut opt);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn option_set_setting_empty_handle_is_invalid() {
let e = CHandle::null();
let r = oakcommon_commandlineoption_set_setting(e, c_str("v").as_ptr());
assert_eq!(r, OAKCOMMON_E_INVALID);
}
#[test]
fn option_set_setting_null_value_is_invalid() {
let mut p = new_parser();
let mut opt = register_option(dup(&p), "o");
let r = oakcommon_commandlineoption_set_setting(dup(&opt), null_mut());
assert_eq!(r, OAKCOMMON_E_INVALID);
oakcommon_commandlineoption_free(&mut opt);
oakcommon_commandlineparser_free(&mut p);
}
/// CPP-PARITY: `process()` mutates the parser-owned option; the option handle
/// returned by `add_option` is a registration-time snapshot in Rust (C++
/// holds a pointer to the live option), so the handle does not observe the
/// parse: is_set stays false and get_setting stays empty.
#[test]
fn option_handle_does_not_observe_process() {
let mut p = new_parser();
let mut opt = register_option(dup(&p), "o");
let (_argv, argv_ptrs) = c_strings(&["prog", "-o", "out.mov"]);
let r = oakcommon_commandlineparser_process(dup(&p), argv_ptrs.as_ptr(), 3);
assert_eq!(r, OAKCOMMON_OK);
// The snapshot handle still reports the empty setting.
let r = oakcommon_commandlineoption_get_setting(dup(&opt), null_mut(), 0);
assert_eq!(r, 1); // empty string
let mut is_set = true;
assert_eq!(oakcommon_commandlineoption_is_set(dup(&opt), &mut is_set), OAKCOMMON_OK);
assert!(!is_set);
oakcommon_commandlineoption_free(&mut opt);
oakcommon_commandlineparser_free(&mut p);
}
// ---------------------------------------------------------------------------
// oakcommon_commandlinepositionalargument_get_setting (TRUNCATING)
// ---------------------------------------------------------------------------
#[test]
fn positional_get_setting_size_query() {
let mut p = new_parser();
let mut pos = register_positional(dup(&p), "input");
assert_eq!(
oakcommon_commandlinepositionalargument_set_setting(dup(&pos), c_str("abcdef").as_ptr()),
OAKCOMMON_OK
);
let r = oakcommon_commandlinepositionalargument_get_setting(dup(&pos), null_mut(), 0);
assert_eq!(r, 7); // len("abcdef") + NUL
oakcommon_commandlinepositionalargument_free(&mut pos);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn positional_get_setting_short_buffer_truncates_and_returns_required() {
let mut p = new_parser();
let mut pos = register_positional(dup(&p), "input");
assert_eq!(
oakcommon_commandlinepositionalargument_set_setting(dup(&pos), c_str("abcdef").as_ptr()),
OAKCOMMON_OK
);
let mut buf = [0xFFu8; 4];
let r = oakcommon_commandlinepositionalargument_get_setting(dup(&pos), buf.as_mut_ptr() as *mut c_char, 4);
assert_eq!(r, 7);
assert_eq!(&buf[..3], b"abc");
assert_eq!(buf[3], 0);
let mut tiny = [0xFFu8; 1];
let r = oakcommon_commandlinepositionalargument_get_setting(dup(&pos), tiny.as_mut_ptr() as *mut c_char, 1);
assert_eq!(r, 7);
assert_eq!(tiny[0], 0);
oakcommon_commandlinepositionalargument_free(&mut pos);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn positional_get_setting_exact_fit_writes_full_string() {
let mut p = new_parser();
let mut pos = register_positional(dup(&p), "input");
assert_eq!(
oakcommon_commandlinepositionalargument_set_setting(dup(&pos), c_str("abcdef").as_ptr()),
OAKCOMMON_OK
);
let mut buf = [0xFFu8; 7];
let r = oakcommon_commandlinepositionalargument_get_setting(dup(&pos), buf.as_mut_ptr() as *mut c_char, 7);
assert_eq!(r, 7);
assert_eq!(buf[6], 0);
let s = unsafe { CStr::from_ptr(buf.as_ptr() as *const c_char) };
assert_eq!(s.to_str().unwrap(), "abcdef");
oakcommon_commandlinepositionalargument_free(&mut pos);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn positional_get_setting_non_null_buf_size_zero_writes_nothing() {
let mut p = new_parser();
let mut pos = register_positional(dup(&p), "input");
assert_eq!(
oakcommon_commandlinepositionalargument_set_setting(dup(&pos), c_str("abcdef").as_ptr()),
OAKCOMMON_OK
);
let mut buf = [0xFFu8; 8];
let r = oakcommon_commandlinepositionalargument_get_setting(dup(&pos), buf.as_mut_ptr() as *mut c_char, 0);
assert_eq!(r, 7);
assert_eq!(buf, [0xFFu8; 8]);
oakcommon_commandlinepositionalargument_free(&mut pos);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn positional_get_setting_unset_returns_empty_string() {
let mut p = new_parser();
let mut pos = register_positional(dup(&p), "input");
let mut buf = [0xFFu8; 1];
let r = oakcommon_commandlinepositionalargument_get_setting(dup(&pos), buf.as_mut_ptr() as *mut c_char, 1);
assert_eq!(r, 1);
assert_eq!(buf[0], 0);
oakcommon_commandlinepositionalargument_free(&mut pos);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn positional_get_setting_empty_handle_is_invalid() {
let e = CHandle::null();
let r = oakcommon_commandlinepositionalargument_get_setting(e, null_mut(), 0);
assert_eq!(r, OAKCOMMON_E_INVALID);
}
#[test]
fn positional_get_setting_negative_buf_size_is_invalid() {
let mut p = new_parser();
let mut pos = register_positional(dup(&p), "input");
let mut buf = [0u8; 4];
let r = oakcommon_commandlinepositionalargument_get_setting(dup(&pos), buf.as_mut_ptr() as *mut c_char, -1);
assert_eq!(r, OAKCOMMON_E_INVALID);
let r2 = oakcommon_commandlinepositionalargument_get_setting(dup(&pos), null_mut(), -1);
assert_eq!(r2, OAKCOMMON_E_INVALID);
oakcommon_commandlinepositionalargument_free(&mut pos);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn positional_get_setting_null_buf_with_size_is_invalid() {
let mut p = new_parser();
let mut pos = register_positional(dup(&p), "input");
let r = oakcommon_commandlinepositionalargument_get_setting(dup(&pos), null_mut(), 1);
assert_eq!(r, OAKCOMMON_E_INVALID);
oakcommon_commandlinepositionalargument_free(&mut pos);
oakcommon_commandlineparser_free(&mut p);
}
// ---------------------------------------------------------------------------
// oakcommon_commandlinepositionalargument_set_setting / _free
// ---------------------------------------------------------------------------
#[test]
fn positional_set_setting_success() {
let mut p = new_parser();
let mut pos = register_positional(dup(&p), "input");
let r = oakcommon_commandlinepositionalargument_set_setting(dup(&pos), c_str("file.mp4").as_ptr());
assert_eq!(r, OAKCOMMON_OK);
// Round-trip through the two-stage getter.
let mut buf = vec![0u8; 9];
let r = oakcommon_commandlinepositionalargument_get_setting(dup(&pos), buf.as_mut_ptr() as *mut c_char, 9);
assert_eq!(r, 9);
let s = unsafe { CStr::from_ptr(buf.as_ptr() as *const c_char) };
assert_eq!(s.to_str().unwrap(), "file.mp4");
oakcommon_commandlinepositionalargument_free(&mut pos);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn positional_set_setting_empty_handle_is_invalid() {
let e = CHandle::null();
let r = oakcommon_commandlinepositionalargument_set_setting(e, c_str("v").as_ptr());
assert_eq!(r, OAKCOMMON_E_INVALID);
}
#[test]
fn positional_set_setting_null_value_is_invalid() {
let mut p = new_parser();
let mut pos = register_positional(dup(&p), "input");
let r = oakcommon_commandlinepositionalargument_set_setting(dup(&pos), null_mut());
assert_eq!(r, OAKCOMMON_E_INVALID);
oakcommon_commandlinepositionalargument_free(&mut pos);
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn positional_free_null_pointer_is_noop() {
oakcommon_commandlinepositionalargument_free(null_mut());
}
#[test]
fn positional_free_twice_is_safe_and_nulls_ctx() {
let mut p = new_parser();
let mut pos = register_positional(dup(&p), "input");
assert!(!pos.ctx.is_null());
oakcommon_commandlinepositionalargument_free(&mut pos);
assert!(pos.ctx.is_null());
// Second free on the same (now empty) handle must be a no-op.
oakcommon_commandlinepositionalargument_free(&mut pos);
assert!(pos.ctx.is_null());
oakcommon_commandlineparser_free(&mut p);
}
#[test]
fn positional_free_null_handle_struct_is_noop() {
let mut h = CHandle::null();
oakcommon_commandlinepositionalargument_free(&mut h);
assert!(h.ctx.is_null());
}
+489
View File
@@ -0,0 +1,489 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! FFI integration tests for the `config` submodule of the C ABI layer
//! (`oakcommon::ffi::config`, backed by `include/common/config.h`).
//!
//! Every exported symbol is exercised with both a success and a failure
//! path. The store is a process-wide singleton and the C-ABI `config`
//! functions have no handle, so the failure paths are the documented
//! behaviors: null/empty keys, invalid buffers, and absent entries.
//!
//! Tests use per-test unique keys so they stay independent of each other
//! and of the domain unit tests, and every test that mutates the singleton
//! (or the `OAK_CONFIG_DIR` env override) serializes on a local mutex. The
//! crate's `test_support::env_lock` is `#[cfg(test)]` and therefore not
//! visible to this integration-test crate, and the domain unit tests run in
//! a separate process anyway, so a local lock is sufficient.
use std::ffi::{c_char, c_void, CStr, CString};
use std::path::{Path, PathBuf};
use std::ptr::{null, null_mut};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Mutex, MutexGuard};
use oakcommon::error::{
OAKCOMMON_E_FAILED, OAKCOMMON_E_INVALID, OAKCOMMON_E_NOT_FOUND, OAKCOMMON_OK,
};
use oakcommon::ffi::config::*;
// ---- Test infrastructure --------------------------------------------------
/// Serializes tests that mutate the process-wide singleton store or the
/// `OAK_CONFIG_DIR` env override. Without this, a `reset_defaults`/`load`
/// running between another test's `set` and `get` would clear its key.
static LOCK: Mutex<()> = Mutex::new(());
fn lock() -> MutexGuard<'static, ()> {
LOCK.lock().unwrap()
}
static KEY_SEQ: AtomicUsize = AtomicUsize::new(0);
/// A per-test unique key, so parallel tests cannot collide on the shared
/// store.
fn unique_key(name: &str) -> CString {
let n = KEY_SEQ.fetch_add(1, Ordering::Relaxed);
CString::new(format!("itest_{}_{}_{}", name, std::process::id(), n)).unwrap()
}
fn cstr(s: &str) -> CString {
CString::new(s).unwrap()
}
/// Cleans up the `OAK_CONFIG_DIR` override and its temp dir even when the
/// test body panics.
struct TempConfigDir(PathBuf);
impl Drop for TempConfigDir {
fn drop(&mut self) {
std::env::remove_var("OAK_CONFIG_DIR");
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// Clears the process-global error handler on drop so a panicking test
/// cannot leak it to the rest of the suite.
struct HandlerGuard;
impl Drop for HandlerGuard {
fn drop(&mut self) {
oakcommon_config_set_error_handler(None, std::ptr::null_mut());
}
}
/// Point `OAK_CONFIG_DIR` at an isolated temp dir, run `f`, then clean up.
/// Mirrors the `with_temp_config` pattern of the domain unit tests.
fn with_temp_config<T>(f: impl FnOnce(&Path) -> T) -> T {
let _guard = lock();
let dir = std::env::temp_dir().join(format!(
"oakcommon_ffi_config_test_{}",
std::process::id()
));
let _ = std::fs::create_dir_all(&dir);
std::env::set_var("OAK_CONFIG_DIR", &dir);
let _cleanup = TempConfigDir(dir.clone());
f(&dir)
}
// ---- Error handler recording ---------------------------------------------
static HANDLER_HITS: AtomicUsize = AtomicUsize::new(0);
static HANDLER_USERDATA: AtomicUsize = AtomicUsize::new(0);
static HANDLER_TITLE: Mutex<Option<String>> = Mutex::new(None);
unsafe extern "C" fn record_handler(
title: *const c_char,
_message: *const c_char,
userdata: *mut c_void,
) {
HANDLER_HITS.fetch_add(1, Ordering::SeqCst);
HANDLER_USERDATA.store(userdata as usize, Ordering::SeqCst);
if !title.is_null() {
let s = unsafe { CStr::from_ptr(title) }.to_string_lossy().into_owned();
*HANDLER_TITLE.lock().unwrap() = Some(s);
}
}
// ---- load / save ----------------------------------------------------------
/// `oakcommon_config_load` treats a missing `config.ini` as a non-error.
#[test]
fn config_load_missing_file_is_ok() {
with_temp_config(|_| {
assert_eq!(oakcommon_config_load(), OAKCOMMON_OK);
});
}
/// `save` persists the store; `load` re-reads it (resetting to defaults
/// first), so the round trip restores the custom key.
#[test]
fn config_save_load_roundtrip() {
with_temp_config(|_| {
let key = unique_key("save_roundtrip");
oakcommon_config_set(null(), key.as_ptr(), cstr("persisted").as_ptr());
assert_eq!(oakcommon_config_save(), OAKCOMMON_OK);
assert_eq!(oakcommon_config_load(), OAKCOMMON_OK);
let mut buf = [0i8; 32];
let n = oakcommon_config_get(null(), key.as_ptr(), buf.as_mut_ptr(), 32);
assert_eq!(n, 10); // "persisted" + NUL
assert_eq!(unsafe { CStr::from_ptr(buf.as_ptr()) }.to_str().unwrap(), "persisted");
});
}
/// A save that cannot write reports through the registered error handler
/// and returns `OAKCOMMON_E_FAILED`. `OAK_CONFIG_DIR` points at a regular
/// file so `"<dir>/config.ini.tmp"` cannot be created (mirrors the domain
/// unit test).
#[test]
fn config_save_failure_reports_and_returns_failed() {
let _guard = lock();
let dir = std::env::temp_dir().join(format!(
"oakcommon_ffi_config_blocked_{}",
std::process::id()
));
let _ = std::fs::create_dir_all(&dir);
let blocker = dir.join("not_a_dir");
std::fs::write(&blocker, b"x").unwrap();
std::env::set_var("OAK_CONFIG_DIR", &blocker);
let _cleanup = TempConfigDir(dir);
HANDLER_HITS.store(0, Ordering::SeqCst);
HANDLER_USERDATA.store(0, Ordering::SeqCst);
*HANDLER_TITLE.lock().unwrap() = None;
let userdata: usize = 0xdead_beef;
let _handler_guard = HandlerGuard;
assert_eq!(
oakcommon_config_set_error_handler(Some(record_handler), userdata as *mut c_void),
OAKCOMMON_OK
);
assert_eq!(oakcommon_config_save(), OAKCOMMON_E_FAILED);
assert_eq!(HANDLER_HITS.load(Ordering::SeqCst), 1);
assert_eq!(HANDLER_USERDATA.load(Ordering::SeqCst), userdata);
assert_eq!(
HANDLER_TITLE.lock().unwrap().as_deref(),
Some("Error saving settings")
);
}
// ---- reset_defaults -------------------------------------------------------
/// `reset_defaults` drops custom keys and restores the compiled-in
/// defaults.
#[test]
fn config_reset_defaults_restores_defaults() {
let _guard = lock();
let custom = unique_key("reset_custom");
oakcommon_config_set_int(null(), custom.as_ptr(), 1);
assert_eq!(oakcommon_config_get_int(null(), custom.as_ptr(), -1), 1);
assert_eq!(oakcommon_config_reset_defaults(), OAKCOMMON_OK);
// The custom key is gone...
assert_eq!(oakcommon_config_get_int(null(), custom.as_ptr(), -1), -1);
assert_eq!(
oakcommon_config_entry_type(null(), custom.as_ptr()),
OAKCOMMON_E_NOT_FOUND
);
// ...and the compiled-in defaults are back.
assert_eq!(
oakcommon_config_get_int(null(), cstr("DefaultSequenceWidth").as_ptr(), -1),
1920
);
assert_eq!(
oakcommon_config_get_bool(null(), cstr("UseProxyMedia").as_ptr(), -1),
1
);
}
// ---- string set / get -----------------------------------------------------
/// Null keys/values are silently ignored by `oakcommon_config_set`.
#[test]
fn config_set_null_key_is_noop() {
let _guard = lock();
oakcommon_config_set(null(), null(), cstr("v").as_ptr());
oakcommon_config_set(null(), cstr("k").as_ptr(), null());
}
/// Two-stage getter, stage 1: a null buffer with size 0 returns the
/// required size (length + NUL) without writing.
#[test]
fn config_get_size_query_returns_required() {
let _guard = lock();
let key = unique_key("size_query");
let value = "hello";
oakcommon_config_set(null(), key.as_ptr(), cstr(value).as_ptr());
let required = oakcommon_config_get(null(), key.as_ptr(), null_mut(), 0);
assert_eq!(required, value.len() as i32 + 1);
}
/// Two-stage getter, short buffer: the full required size is returned but
/// the buffer is NOT touched (non-truncating, unlike `copy_setting`).
#[test]
fn config_get_short_buffer_is_non_truncating() {
let _guard = lock();
let key = unique_key("short_buf");
let value = "hello world"; // required = 12
oakcommon_config_set(null(), key.as_ptr(), cstr(value).as_ptr());
let mut buf = [0x7a as c_char; 8];
let n = oakcommon_config_get(null(), key.as_ptr(), buf.as_mut_ptr(), buf.len() as i32);
assert_eq!(n, value.len() as i32 + 1);
assert_eq!(buf, [0x7a as c_char; 8]); // sentinel untouched
}
/// Two-stage getter, exact fit: a buffer of exactly `len + 1` receives the
/// string plus NUL terminator.
#[test]
fn config_get_exact_fit_buffer() {
let _guard = lock();
let key = unique_key("exact_fit");
let value = "hello";
oakcommon_config_set(null(), key.as_ptr(), cstr(value).as_ptr());
let required = value.len() as i32 + 1;
let mut buf = vec![0i8; required as usize];
let n = oakcommon_config_get(null(), key.as_ptr(), buf.as_mut_ptr(), required);
assert_eq!(n, required);
assert_eq!(unsafe { CStr::from_ptr(buf.as_ptr()) }.to_str().unwrap(), value);
}
/// A missing key yields `OAKCOMMON_E_NOT_FOUND`.
#[test]
fn config_get_missing_key_returns_not_found() {
let _guard = lock();
let key = unique_key("missing");
let mut buf = [0i8; 16];
let n = oakcommon_config_get(null(), key.as_ptr(), buf.as_mut_ptr(), buf.len() as i32);
assert_eq!(n, OAKCOMMON_E_NOT_FOUND);
}
/// A null key yields `OAKCOMMON_E_INVALID`.
#[test]
fn config_get_null_key_returns_invalid() {
let n = oakcommon_config_get(null(), null(), null_mut(), 0);
assert_eq!(n, OAKCOMMON_E_INVALID);
}
/// An empty key is invalid (matches the C++ `is_valid_key` check, handled
/// here by the domain layer).
#[test]
fn config_get_empty_key_returns_invalid() {
let mut buf = [0i8; 8];
let n = oakcommon_config_get(null(), cstr("").as_ptr(), buf.as_mut_ptr(), 8);
assert_eq!(n, OAKCOMMON_E_INVALID);
}
/// A negative buffer size is an invalid output buffer.
#[test]
fn config_get_invalid_buffer_returns_invalid() {
let _guard = lock();
let key = unique_key("bad_buf");
oakcommon_config_set(null(), key.as_ptr(), cstr("v").as_ptr());
let n = oakcommon_config_get(null(), key.as_ptr(), null_mut(), -1);
assert_eq!(n, OAKCOMMON_E_INVALID);
}
// ---- int / int64 ----------------------------------------------------------
/// INT set/get round trip; the string getter formats the int.
#[test]
fn config_set_get_int_roundtrip() {
let _guard = lock();
let key = unique_key("int");
oakcommon_config_set_int(null(), key.as_ptr(), 42);
assert_eq!(oakcommon_config_get_int(null(), key.as_ptr(), -1), 42);
assert_eq!(oakcommon_config_entry_type(null(), key.as_ptr()), 2);
let mut buf = [0i8; 16];
let n = oakcommon_config_get(null(), key.as_ptr(), buf.as_mut_ptr(), 16);
assert_eq!(n, 3); // "42" + NUL
assert_eq!(unsafe { CStr::from_ptr(buf.as_ptr()) }.to_str().unwrap(), "42");
}
/// INT getter fallback paths: absent key, wrong type, null key.
#[test]
fn config_get_int_fallback_paths() {
let _guard = lock();
let missing = unique_key("int_missing");
let wrong = unique_key("int_wrongtype");
oakcommon_config_set(null(), wrong.as_ptr(), cstr("abc").as_ptr());
assert_eq!(oakcommon_config_get_int(null(), missing.as_ptr(), 7), 7);
assert_eq!(oakcommon_config_get_int(null(), wrong.as_ptr(), 7), 7);
assert_eq!(oakcommon_config_get_int(null(), null(), 9), 9);
}
/// Setting a string onto an existing typed entry parses it into that type;
/// an unparseable string leaves the entry unchanged (CPP-PARITY with
/// `config.cpp`).
#[test]
fn config_set_string_parses_into_typed_entry() {
let _guard = lock();
let key = unique_key("typed_parse");
oakcommon_config_set_int(null(), key.as_ptr(), 42);
oakcommon_config_set(null(), key.as_ptr(), cstr("7").as_ptr());
assert_eq!(oakcommon_config_get_int(null(), key.as_ptr(), -1), 7);
oakcommon_config_set(null(), key.as_ptr(), cstr("notanumber").as_ptr());
assert_eq!(oakcommon_config_get_int(null(), key.as_ptr(), -1), 7);
}
/// INT64 set/get round trip and fallback paths.
#[test]
fn config_set_get_int64_roundtrip() {
let _guard = lock();
let key = unique_key("int64");
let big: i64 = 3_000_000_000; // exceeds i32 range
oakcommon_config_set_int64(null(), key.as_ptr(), big);
assert_eq!(oakcommon_config_get_int64(null(), key.as_ptr(), -1), big);
let missing = unique_key("int64_missing");
assert_eq!(oakcommon_config_get_int64(null(), missing.as_ptr(), -99), -99);
assert_eq!(oakcommon_config_get_int64(null(), null(), -99), -99);
}
// ---- double ---------------------------------------------------------------
/// DOUBLE set/get round trip and fallback paths.
#[test]
fn config_set_get_double_roundtrip() {
let _guard = lock();
let key = unique_key("double");
oakcommon_config_set_double(null(), key.as_ptr(), 3.5);
assert_eq!(oakcommon_config_get_double(null(), key.as_ptr(), -1.0), 3.5);
assert_eq!(oakcommon_config_entry_type(null(), key.as_ptr()), 3);
let missing = unique_key("double_missing");
let wrong = unique_key("double_wrongtype");
oakcommon_config_set_int(null(), wrong.as_ptr(), 1);
assert_eq!(oakcommon_config_get_double(null(), missing.as_ptr(), 2.5), 2.5);
assert_eq!(oakcommon_config_get_double(null(), wrong.as_ptr(), 2.5), 2.5);
assert_eq!(oakcommon_config_get_double(null(), null(), 2.5), 2.5);
}
// ---- bool -----------------------------------------------------------------
/// BOOL set/get round trip (0/1) and string formatting as "true"/"false".
#[test]
fn config_set_get_bool_roundtrip() {
let _guard = lock();
let key = unique_key("bool");
oakcommon_config_set_bool(null(), key.as_ptr(), 1);
assert_eq!(oakcommon_config_get_bool(null(), key.as_ptr(), -1), 1);
assert_eq!(oakcommon_config_entry_type(null(), key.as_ptr()), 4);
let mut buf = [0i8; 8];
let n = oakcommon_config_get(null(), key.as_ptr(), buf.as_mut_ptr(), 8);
assert_eq!(n, 5); // "true" + NUL
assert_eq!(unsafe { CStr::from_ptr(buf.as_ptr()) }.to_str().unwrap(), "true");
oakcommon_config_set_bool(null(), key.as_ptr(), 0);
assert_eq!(oakcommon_config_get_bool(null(), key.as_ptr(), -1), 0);
let n = oakcommon_config_get(null(), key.as_ptr(), buf.as_mut_ptr(), 8);
assert_eq!(n, 6); // "false" + NUL
assert_eq!(unsafe { CStr::from_ptr(buf.as_ptr()) }.to_str().unwrap(), "false");
}
/// BOOL getter fallback paths: absent key, wrong type, null key.
#[test]
fn config_get_bool_fallback_paths() {
let _guard = lock();
let missing = unique_key("bool_missing");
let wrong = unique_key("bool_wrongtype");
oakcommon_config_set_int(null(), wrong.as_ptr(), 1);
assert_eq!(oakcommon_config_get_bool(null(), missing.as_ptr(), 1), 1);
assert_eq!(oakcommon_config_get_bool(null(), wrong.as_ptr(), 0), 0);
assert_eq!(oakcommon_config_get_bool(null(), null(), 1), 1);
}
/// Null keys are silently ignored by every typed setter.
#[test]
fn config_typed_setters_null_key_are_noop() {
let _guard = lock();
oakcommon_config_set_int(null(), null(), 1);
oakcommon_config_set_int64(null(), null(), 1);
oakcommon_config_set_bool(null(), null(), 1);
oakcommon_config_set_double(null(), null(), 1.0);
}
// ---- groups ---------------------------------------------------------------
/// A non-empty group prefixes the stored key (`group/key`), so the flat
/// lookup must not find it.
#[test]
fn config_group_prefixes_keys() {
let _guard = lock();
let key = unique_key("grouped");
let group = cstr("ffi_test_group");
oakcommon_config_set_int(group.as_ptr(), key.as_ptr(), 5);
assert_eq!(oakcommon_config_get_int(null(), key.as_ptr(), -1), -1);
assert_eq!(oakcommon_config_get_int(group.as_ptr(), key.as_ptr(), -1), 5);
assert_eq!(oakcommon_config_entry_type(group.as_ptr(), key.as_ptr()), 2);
}
// ---- entry_type -----------------------------------------------------------
/// Entry type codes: 1 = String, 2 = Int, 3 = Double, 4 = Bool.
#[test]
fn config_entry_type_codes() {
let _guard = lock();
let s = unique_key("et_str");
let i = unique_key("et_int");
let d = unique_key("et_double");
let b = unique_key("et_bool");
oakcommon_config_set(null(), s.as_ptr(), cstr("x").as_ptr());
oakcommon_config_set_int(null(), i.as_ptr(), 1);
oakcommon_config_set_double(null(), d.as_ptr(), 1.0);
oakcommon_config_set_bool(null(), b.as_ptr(), 1);
assert_eq!(oakcommon_config_entry_type(null(), s.as_ptr()), 1);
assert_eq!(oakcommon_config_entry_type(null(), i.as_ptr()), 2);
assert_eq!(oakcommon_config_entry_type(null(), d.as_ptr()), 3);
assert_eq!(oakcommon_config_entry_type(null(), b.as_ptr()), 4);
}
/// Missing, null, and empty keys for `entry_type`.
#[test]
fn config_entry_type_missing_and_invalid() {
let _guard = lock();
let missing = unique_key("et_missing");
assert_eq!(
oakcommon_config_entry_type(null(), missing.as_ptr()),
OAKCOMMON_E_NOT_FOUND
);
assert_eq!(oakcommon_config_entry_type(null(), null()), OAKCOMMON_E_INVALID);
assert_eq!(
oakcommon_config_entry_type(null(), cstr("").as_ptr()),
OAKCOMMON_E_INVALID
);
}
// ---- error handler --------------------------------------------------------
/// Registering and clearing the error handler both succeed; the handler
/// itself is exercised by `config_save_failure_reports_and_returns_failed`.
#[test]
fn config_set_error_handler_roundtrip() {
let _guard = lock();
let userdata = 0x1234 as *mut c_void;
let _handler_guard = HandlerGuard;
assert_eq!(
oakcommon_config_set_error_handler(Some(record_handler), userdata),
OAKCOMMON_OK
);
assert_eq!(
oakcommon_config_set_error_handler(None, std::ptr::null_mut()),
OAKCOMMON_OK
);
}
+175
View File
@@ -0,0 +1,175 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Integration tests for the C ABI surface of `oakcommon::ffi::ffmpegutils`,
//! which implements `include/common/ffmpegutils.h` (oracle:
//! `src/common/src/ffmpegutils.cpp`).
//!
//! MUST be run with `--features test-stubs`: unlike every other ffi module,
//! `oakcommon_ffmpegutils_get_compatible_bridge_pixel_format` reaches the
//! real `fb_find_best_pix_fmt_of_list` symbol (ffmpeg_bridge C ABI) in
//! non-test builds, and the integration-test binary cannot link it. The
//! `test-stubs` feature substitutes the in-crate stub (see
//! `src/ffmpegutils.rs::find_best_pix_fmt_of_list`), mirroring the
//! oakplugin/oaktimeline convention. `cargo test --lib` needs no flag: the
//! stub is active under `cfg(test)` there.
//!
//! The pure mappings are already unit-tested in `src/ffmpegutils.rs`; these
//! tests pin the exported wrapper contract: a value written to a non-null
//! out-param with `OAKCOMMON_OK`, or `OAKCOMMON_E_INVALID` for a null
//! out-param, for every export.
#[cfg(feature = "test-stubs")]
mod ffi_ffmpegutils_tests {
use std::ptr::null_mut;
use oakcommon::error::{OAKCOMMON_E_INVALID, OAKCOMMON_OK};
use oakcommon::ffi::ffmpegutils::*;
/// Every out-param wrapper must reject a null pointer without writing.
#[test]
fn null_out_param_returns_invalid() {
let mut out: i32 = -999;
assert_eq!(
oakcommon_ffmpegutils_get_compatible_bridge_pixel_format(26, -1, null_mut()),
OAKCOMMON_E_INVALID
);
assert_eq!(oakcommon_ffmpegutils_get_compatible_pixel_format(0, null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(
oakcommon_ffmpegutils_get_ffmpeg_pixel_format(0, 4, null_mut()),
OAKCOMMON_E_INVALID
);
assert_eq!(oakcommon_ffmpegutils_get_native_sample_format(0, null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_ffmpegutils_get_ffmpeg_sample_format(6, null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(
oakcommon_ffmpegutils_convert_jpeg_space_to_regular_space(12, null_mut()),
OAKCOMMON_E_INVALID
);
// The out-param is untouched on the failure path.
assert_eq!(out, -999);
}
/// `get_compatible_bridge_pixel_format` picks an exact candidate from the
/// clamped list (values match `FB_PIX_FMT_*` / `PixelFormat`).
#[test]
fn compatible_bridge_pixel_format_exact_candidate() {
let mut out: i32 = -999;
// pix_fmt = RGBA (26), maximum = invalid (-1): no limit, RGBA is
// first in the candidate list.
assert_eq!(
oakcommon_ffmpegutils_get_compatible_bridge_pixel_format(26, -1, &mut out),
OAKCOMMON_OK
);
assert_eq!(out, 26);
// pix_fmt = RGBA_F32_LE (220), maximum = F32 (4): the f32 clamp adds
// RGBA_F32_LE, so it matches exactly.
assert_eq!(
oakcommon_ffmpegutils_get_compatible_bridge_pixel_format(220, 4, &mut out),
OAKCOMMON_OK
);
assert_eq!(out, 220);
}
/// `get_compatible_bridge_pixel_format` falls back to the first candidate
/// for an unknown source format (bridge loss-metric behaviour).
#[test]
fn compatible_bridge_pixel_format_unknown_falls_back() {
let mut out: i32 = -999;
// YUV420P (0) is not in the RGBA-oriented candidate list -> first
// candidate RGBA (26).
assert_eq!(
oakcommon_ffmpegutils_get_compatible_bridge_pixel_format(0, -1, &mut out),
OAKCOMMON_OK
);
assert_eq!(out, 26);
}
/// `get_compatible_pixel_format` maps native formats to the least-lossy
/// native format.
#[test]
fn compatible_pixel_format_maps_native() {
let mut out: i32 = -999;
assert_eq!(oakcommon_ffmpegutils_get_compatible_pixel_format(0, &mut out), OAKCOMMON_OK); // U8
assert_eq!(out, 0);
assert_eq!(oakcommon_ffmpegutils_get_compatible_pixel_format(3, &mut out), OAKCOMMON_OK); // F16
assert_eq!(out, 2); // -> U16
assert_eq!(oakcommon_ffmpegutils_get_compatible_pixel_format(-1, &mut out), OAKCOMMON_OK); // invalid
assert_eq!(out, -1);
}
/// `get_ffmpeg_pixel_format` maps native format + channel count to a
/// bridge pixel format.
#[test]
fn ffmpeg_pixel_format_maps_native_to_bridge() {
let mut out: i32 = -999;
assert_eq!(oakcommon_ffmpegutils_get_ffmpeg_pixel_format(0, 3, &mut out), OAKCOMMON_OK); // U8 RGB
assert_eq!(out, 2); // RGB24
assert_eq!(oakcommon_ffmpegutils_get_ffmpeg_pixel_format(0, 4, &mut out), OAKCOMMON_OK); // U8 RGBA
assert_eq!(out, 26); // RGBA
assert_eq!(oakcommon_ffmpegutils_get_ffmpeg_pixel_format(2, 4, &mut out), OAKCOMMON_OK); // U16 RGBA
assert_eq!(out, 105); // RGBA64LE
assert_eq!(oakcommon_ffmpegutils_get_ffmpeg_pixel_format(1, 3, &mut out), OAKCOMMON_OK); // U10 RGB
assert_eq!(out, -1); // no bridge format
}
/// `get_native_sample_format` maps bridge sample formats to native.
#[test]
fn native_sample_format_maps_bridge_to_native() {
let mut out: i32 = -999;
assert_eq!(oakcommon_ffmpegutils_get_native_sample_format(0, &mut out), OAKCOMMON_OK); // U8
assert_eq!(out, 6); // SMP_FMT_U8
assert_eq!(oakcommon_ffmpegutils_get_native_sample_format(1, &mut out), OAKCOMMON_OK); // S16
assert_eq!(out, 7);
assert_eq!(oakcommon_ffmpegutils_get_native_sample_format(8, &mut out), OAKCOMMON_OK); // FLTP
assert_eq!(out, 4); // SMP_FMT_F32_P
assert_eq!(oakcommon_ffmpegutils_get_native_sample_format(999, &mut out), OAKCOMMON_OK);
assert_eq!(out, -1); // unknown -> invalid
}
/// `get_ffmpeg_sample_format` maps native sample formats to bridge.
#[test]
fn ffmpeg_sample_format_maps_native_to_bridge() {
let mut out: i32 = -999;
assert_eq!(oakcommon_ffmpegutils_get_ffmpeg_sample_format(6, &mut out), OAKCOMMON_OK); // SMP_FMT_U8
assert_eq!(out, 0); // U8
assert_eq!(oakcommon_ffmpegutils_get_ffmpeg_sample_format(10, &mut out), OAKCOMMON_OK); // SMP_FMT_F32
assert_eq!(out, 3); // FLT
assert_eq!(oakcommon_ffmpegutils_get_ffmpeg_sample_format(-1, &mut out), OAKCOMMON_OK); // invalid
assert_eq!(out, -1);
}
/// JPEG-range bridge formats convert to their regular counterparts;
/// everything else passes through unchanged.
#[test]
fn jpeg_space_converts_to_regular_space() {
let mut out: i32 = -999;
assert_eq!(oakcommon_ffmpegutils_convert_jpeg_space_to_regular_space(12, &mut out), OAKCOMMON_OK); // YUVJ420P
assert_eq!(out, 0); // YUV420P
assert_eq!(oakcommon_ffmpegutils_convert_jpeg_space_to_regular_space(13, &mut out), OAKCOMMON_OK); // YUVJ422P
assert_eq!(out, 4); // YUV422P
assert_eq!(oakcommon_ffmpegutils_convert_jpeg_space_to_regular_space(14, &mut out), OAKCOMMON_OK); // YUVJ444P
assert_eq!(out, 5); // YUV444P
assert_eq!(oakcommon_ffmpegutils_convert_jpeg_space_to_regular_space(32, &mut out), OAKCOMMON_OK); // YUVJ440P
assert_eq!(out, 31); // YUV440P
assert_eq!(oakcommon_ffmpegutils_convert_jpeg_space_to_regular_space(138, &mut out), OAKCOMMON_OK); // YUVJ411P
assert_eq!(out, 7); // YUV411P
// Non-JPEG formats pass through unchanged.
assert_eq!(oakcommon_ffmpegutils_convert_jpeg_space_to_regular_space(26, &mut out), OAKCOMMON_OK); // RGBA
assert_eq!(out, 26);
assert_eq!(oakcommon_ffmpegutils_convert_jpeg_space_to_regular_space(-1, &mut out), OAKCOMMON_OK); // none
assert_eq!(out, -1);
}
}
+498
View File
@@ -0,0 +1,498 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! FFI-level integration tests for the C-ABI `misc`, `debug`, and `current`
//! exports in `oakcommon::ffi`, asserted against the C++ oracle
//! (`src/common/c_api/*.cpp`) and the Rust domain modules
//! (`src/common/rust/src/{miscutils,debug}.rs`).
//!
//! The contract under test (each point matches the implementations):
//! - decibel/lerp/power helpers write into a non-null out-param and reject a
//! null one with `E_INVALID`; domain helpers never fail, so the only
//! failure path is the null out-param;
//! - `drop_workflow_behavior_is_valid` returns 1/0 without error codes, and
//! `drop_workflow_behavior_name` is a non-truncating two-stage getter
//! ("UNKNOWN" for out-of-range codes);
//! - `debug` exports: null messages are `E_INVALID`, the level-name getter
//! is a non-truncating two-stage getter, level codes outside 0..=4 are
//! rejected by `log_set_level` with `E_INVALID`, and the get-level export
//! rejects a null out-param;
//! - `current` exports: the singleton handle is stamped and `free` is
//! idempotent, setters/getters reject a null handle (getters also reject a
//! null out-param), empty slots read back as null, and replacing an
//! occupied slot runs the previous owner's destructor exactly once.
use std::ffi::{c_char, c_void, CString};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Mutex;
use oakcommon::error::{OAKCOMMON_E_INVALID, OAKCOMMON_OK};
use oakcommon::ffi::current::*;
use oakcommon::ffi::debug::*;
use oakcommon::ffi::misc::*;
use oakcommon::handle::{CHandle, OAKCOMMON_ABI_VERSION};
/// Serialises tests that mutate the process-wide `Current` singleton (cargo
/// runs tests on threads).
static CURRENT_LOCK: Mutex<()> = Mutex::new(());
/// Serialises tests that mutate the process-wide logging level.
static DEBUG_LOCK: Mutex<()> = Mutex::new(());
/// Destroy tally for the slot-replacement test.
static DESTROY_COUNT: AtomicUsize = AtomicUsize::new(0);
/// A `DestroyFn` that bumps [`DESTROY_COUNT`].
unsafe extern "C" fn count_destroy(_p: *mut c_void) {
DESTROY_COUNT.fetch_add(1, Ordering::SeqCst);
}
/// Convert a string slice to a NUL-terminated C string for FFI inputs.
fn to_cstring(s: &str) -> CString {
CString::new(s).expect("test string must not contain NUL")
}
/// Cheap struct copy: `CHandle` is neither `Clone` nor `Copy`, but every
/// getter takes it by value. Rebuilding from the same fields duplicates
/// only the handle value — the box stays alive as long as the original
/// handle lives, and the getters never release.
fn dup(h: &CHandle) -> CHandle {
CHandle {
ctx: h.ctx,
addref: h.addref,
release: h.release,
abi_version: h.abi_version,
}
}
/// Assert two f64 values agree within `tol`.
fn assert_close(actual: f64, expected: f64, tol: f64) {
assert!(
(actual - expected).abs() <= tol,
"expected {expected} ± {tol}, got {actual}"
);
}
/// Drive a two-stage string getter against the standard `copy_string`
/// convention: a null-buffer size query, a short buffer that must stay
/// untouched (no truncation), an exact-fit copy with its NUL, and an
/// oversized copy with the tail untouched.
fn assert_two_stage_getter(getter: impl Fn(*mut c_char, i32) -> i32, expected: &str) {
let required = (expected.len() + 1) as i32;
// Size query: a null buffer returns the required size, NUL included.
assert_eq!(getter(std::ptr::null_mut(), 0), required);
// Short buffer: too small, so nothing is written to it.
let short_size = (required - 1).max(0);
let mut short = vec![0xABu8; short_size as usize];
assert_eq!(getter(short.as_mut_ptr() as *mut c_char, short_size), required);
assert!(short.iter().all(|&b| b == 0xAB), "short buffer must stay untouched");
// Exact fit: payload followed by a NUL.
let mut exact = vec![0xCDu8; required as usize];
assert_eq!(getter(exact.as_mut_ptr() as *mut c_char, required), required);
assert_eq!(&exact[..expected.len()], expected.as_bytes());
assert_eq!(exact[expected.len()], 0);
// Oversized: payload and NUL written, tail left as initialized.
let mut big = vec![0u8; (required + 8) as usize];
assert_eq!(getter(big.as_mut_ptr() as *mut c_char, required + 8), required);
assert_eq!(&big[..expected.len()], expected.as_bytes());
assert_eq!(big[expected.len()], 0);
assert!(big[(required + 1) as usize..].iter().all(|&b| b == 0));
}
// ---- debug ----
/// A null message is `E_INVALID`.
#[test]
fn debug_log_null_msg_is_invalid() {
assert_eq!(oakcommon_debug_log(0, std::ptr::null()), OAKCOMMON_E_INVALID);
}
/// Any non-null message returns `OK`, including out-of-range level codes
/// (`log_raw` never fails on the level itself).
#[test]
fn debug_log_returns_ok_for_any_level() {
let msg = to_cstring("ffi_misc debug_log probe");
assert_eq!(oakcommon_debug_log(0, msg.as_ptr()), OAKCOMMON_OK);
let raw = to_cstring("out-of-range level");
assert_eq!(oakcommon_debug_log(99, raw.as_ptr()), OAKCOMMON_OK);
}
/// Level-name getter is a non-truncating two-stage getter; out-of-range
/// codes yield "UNKNOWN".
#[test]
fn debug_level_name_two_stage() {
assert_two_stage_getter(|buf, size| oakcommon_debug_level_name(0, buf, size), "DEBUG");
assert_two_stage_getter(|buf, size| oakcommon_debug_level_name(2, buf, size), "WARNING");
assert_two_stage_getter(|buf, size| oakcommon_debug_level_name(4, buf, size), "FATAL");
assert_two_stage_getter(|buf, size| oakcommon_debug_level_name(5, buf, size), "UNKNOWN");
assert_two_stage_getter(|buf, size| oakcommon_debug_level_name(-1, buf, size), "UNKNOWN");
}
/// `log_set_level` accepts 0..=4, rejects everything else with `E_INVALID`
/// without changing the stored level, and `log_get_level` reads it back.
#[test]
fn log_set_get_level_roundtrip_and_invalid() {
let _guard = DEBUG_LOCK.lock().unwrap_or_else(|e| e.into_inner());
assert_eq!(oakcommon_log_set_level(0), OAKCOMMON_OK);
let mut level = -1;
assert_eq!(oakcommon_log_get_level(&mut level), OAKCOMMON_OK);
assert_eq!(level, 0);
assert_eq!(oakcommon_log_set_level(4), OAKCOMMON_OK);
assert_eq!(oakcommon_log_get_level(&mut level), OAKCOMMON_OK);
assert_eq!(level, 4);
assert_eq!(oakcommon_log_set_level(5), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_log_set_level(-1), OAKCOMMON_E_INVALID);
// The rejected set leaves the stored level untouched.
assert_eq!(oakcommon_log_get_level(&mut level), OAKCOMMON_OK);
assert_eq!(level, 4);
// Restore the default so other tests see a clean level.
assert_eq!(oakcommon_log_set_level(1), OAKCOMMON_OK);
assert_eq!(oakcommon_log_get_level(&mut level), OAKCOMMON_OK);
assert_eq!(level, 1);
}
/// The get-level export rejects a null out-param.
#[test]
fn log_get_level_null_out_is_invalid() {
assert_eq!(oakcommon_log_get_level(std::ptr::null_mut()), OAKCOMMON_E_INVALID);
}
// ---- misc: decibel / lerp ----
/// Linear -> decibels for exact and mid-range inputs; zero (log10 = -inf)
/// collapses to the decibel minimum, negative inputs yield NaN.
#[test]
fn decibel_from_linear_known_values() {
let mut out = -1.0;
assert_eq!(oakcommon_decibel_from_linear(1.0, &mut out), OAKCOMMON_OK);
assert_close(out, 0.0, 1e-9);
assert_eq!(oakcommon_decibel_from_linear(10.0, &mut out), OAKCOMMON_OK);
assert_close(out, 20.0, 1e-9);
assert_eq!(oakcommon_decibel_from_linear(0.5, &mut out), OAKCOMMON_OK);
assert_close(out, -6.020599913, 1e-6);
assert_eq!(oakcommon_decibel_from_linear(0.0, &mut out), OAKCOMMON_OK);
assert_eq!(out, -200.0);
assert_eq!(oakcommon_decibel_from_linear(-1.0, &mut out), OAKCOMMON_OK);
assert!(out.is_nan(), "negative linear input must produce NaN, got {out}");
}
/// Decibels -> linear for exact inputs; results below 1e-6 clamp to 0.0.
#[test]
fn decibel_to_linear_known_values() {
let mut out = -1.0;
assert_eq!(oakcommon_decibel_to_linear(0.0, &mut out), OAKCOMMON_OK);
assert_close(out, 1.0, 1e-9);
assert_eq!(oakcommon_decibel_to_linear(20.0, &mut out), OAKCOMMON_OK);
assert_close(out, 10.0, 1e-9);
assert_eq!(oakcommon_decibel_to_linear(-100.0, &mut out), OAKCOMMON_OK);
assert_close(out, 1e-5, 1e-12);
assert_eq!(oakcommon_decibel_to_linear(-200.0, &mut out), OAKCOMMON_OK);
assert_eq!(out, 0.0);
}
/// Logarithmic position -> decibels: saturates to the minimum below 0.001,
/// to 0 dB above 0.99, and follows the linearization formula in between.
#[test]
fn decibel_from_logarithmic_known_values() {
let mut out = -1.0;
assert_eq!(oakcommon_decibel_from_logarithmic(0.0, &mut out), OAKCOMMON_OK);
assert_eq!(out, -200.0);
assert_eq!(oakcommon_decibel_from_logarithmic(1.0, &mut out), OAKCOMMON_OK);
assert_eq!(out, 0.0);
assert_eq!(oakcommon_decibel_from_logarithmic(0.99, &mut out), OAKCOMMON_OK);
assert_close(out, 0.0, 1e-6);
assert_eq!(oakcommon_decibel_from_logarithmic(0.5, &mut out), OAKCOMMON_OK);
assert_close(out, -16.45, 0.05);
}
/// Decibels -> logarithmic position; `|db| <= 1e-12` snaps to 1.0.
#[test]
fn decibel_to_logarithmic_known_values() {
let mut out = -1.0;
assert_eq!(oakcommon_decibel_to_logarithmic(0.0, &mut out), OAKCOMMON_OK);
assert_eq!(out, 1.0);
assert_eq!(oakcommon_decibel_to_logarithmic(20.0, &mut out), OAKCOMMON_OK);
assert_close(out, 1.0, 1e-9);
assert_eq!(oakcommon_decibel_to_logarithmic(-120.0, &mut out), OAKCOMMON_OK);
assert_close(out, 4.605e-6, 1e-9);
}
/// Linear amplitude -> logarithmic position (`1 - exp(-linear * lo_g100)`).
#[test]
fn decibel_linear_to_logarithmic_known_values() {
let mut out = -1.0;
assert_eq!(oakcommon_decibel_linear_to_logarithmic(0.0, &mut out), OAKCOMMON_OK);
assert_eq!(out, 0.0);
assert_eq!(oakcommon_decibel_linear_to_logarithmic(1.0, &mut out), OAKCOMMON_OK);
assert_close(out, 0.99, 1e-9);
}
/// Logarithmic position -> linear amplitude; values above 0.99 snap to 1.0.
#[test]
fn decibel_logarithmic_to_linear_known_values() {
let mut out = -1.0;
assert_eq!(oakcommon_decibel_logarithmic_to_linear(0.0, &mut out), OAKCOMMON_OK);
assert_eq!(out, 0.0);
assert_eq!(oakcommon_decibel_logarithmic_to_linear(1.0, &mut out), OAKCOMMON_OK);
assert_eq!(out, 1.0);
assert_eq!(oakcommon_decibel_logarithmic_to_linear(0.99, &mut out), OAKCOMMON_OK);
assert_close(out, 1.0, 1e-6);
}
/// `lerp(a, b, t) = a*(1-t) + b*t` at the endpoints and midpoints.
#[test]
fn lerp_known_values() {
let mut out = -1.0;
assert_eq!(oakcommon_lerp(0.0, 10.0, 0.5, &mut out), OAKCOMMON_OK);
assert_close(out, 5.0, 1e-9);
assert_eq!(oakcommon_lerp(0.0, 10.0, 0.0, &mut out), OAKCOMMON_OK);
assert_close(out, 0.0, 1e-9);
assert_eq!(oakcommon_lerp(0.0, 10.0, 1.0, &mut out), OAKCOMMON_OK);
assert_close(out, 10.0, 1e-9);
assert_eq!(oakcommon_lerp(2.0, 4.0, 0.25, &mut out), OAKCOMMON_OK);
assert_close(out, 2.5, 1e-9);
}
/// Every decibel/lerp export rejects a null out-param with `E_INVALID`.
#[test]
fn decibel_lerp_reject_null_out() {
assert_eq!(oakcommon_decibel_from_linear(1.0, std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_decibel_to_linear(0.0, std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_decibel_from_logarithmic(0.5, std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_decibel_to_logarithmic(0.0, std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_decibel_linear_to_logarithmic(0.5, std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_decibel_logarithmic_to_linear(0.5, std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_lerp(0.0, 1.0, 0.5, std::ptr::null_mut()), OAKCOMMON_E_INVALID);
}
// ---- misc: drop-workflow behavior / power ----
/// Codes 0..=3 are valid; everything else returns 0.
#[test]
fn drop_workflow_behavior_is_valid() {
for value in 0..=3 {
assert_eq!(oakcommon_drop_workflow_behavior_is_valid(value), 1);
}
assert_eq!(oakcommon_drop_workflow_behavior_is_valid(4), 0);
assert_eq!(oakcommon_drop_workflow_behavior_is_valid(-1), 0);
assert_eq!(oakcommon_drop_workflow_behavior_is_valid(i32::MAX), 0);
}
/// Behavior-name getter is a non-truncating two-stage getter; out-of-range
/// codes yield "UNKNOWN".
#[test]
fn drop_workflow_behavior_name_two_stage() {
assert_two_stage_getter(|buf, size| oakcommon_drop_workflow_behavior_name(0, buf, size), "ASK");
assert_two_stage_getter(|buf, size| oakcommon_drop_workflow_behavior_name(1, buf, size), "AUTO");
assert_two_stage_getter(|buf, size| oakcommon_drop_workflow_behavior_name(2, buf, size), "MANUAL");
assert_two_stage_getter(|buf, size| oakcommon_drop_workflow_behavior_name(3, buf, size), "DISABLE");
assert_two_stage_getter(|buf, size| oakcommon_drop_workflow_behavior_name(4, buf, size), "UNKNOWN");
assert_two_stage_getter(|buf, size| oakcommon_drop_workflow_behavior_name(-1, buf, size), "UNKNOWN");
}
/// Round `value` up to a power of two (wrapping overflow -> 0); a null
/// out-param is `E_INVALID`.
#[test]
fn power_ceil_to_power_of_2() {
let mut out = 0u32;
for (input, expected) in [
(0u32, 0u32),
(1, 1),
(2, 2),
(3, 4),
(5, 8),
(9, 16),
(0x8000_0001, 0),
] {
assert_eq!(oakcommon_power_ceil_to_power_of_2(input, &mut out), OAKCOMMON_OK);
assert_eq!(out, expected, "ceil({input})");
}
assert_eq!(
oakcommon_power_ceil_to_power_of_2(7, std::ptr::null_mut()),
OAKCOMMON_E_INVALID
);
}
/// Round `value` down to a power of two; a null out-param is `E_INVALID`.
#[test]
fn power_floor_to_power_of_2() {
let mut out = 0u32;
for (input, expected) in [
(0u32, 0u32),
(1, 1),
(4, 4),
(5, 4),
(9, 8),
(0x8000_0000, 0x8000_0000),
] {
assert_eq!(oakcommon_power_floor_to_power_of_2(input, &mut out), OAKCOMMON_OK);
assert_eq!(out, expected, "floor({input})");
}
assert_eq!(
oakcommon_power_floor_to_power_of_2(7, std::ptr::null_mut()),
OAKCOMMON_E_INVALID
);
}
// ---- current ----
/// The singleton handle is stamped; `free` nullifies it, is idempotent, and
/// tolerates a null pointer or an explicit null handle.
#[test]
fn current_instance_and_free_lifecycle() {
let mut h = oakcommon_current_instance();
assert!(!h.is_null());
assert_eq!(h.abi_version, OAKCOMMON_ABI_VERSION);
assert!(h.addref.is_some());
assert!(h.release.is_some());
oakcommon_current_free(&mut h);
assert!(h.is_null());
// A second free of the now-empty handle is safe.
oakcommon_current_free(&mut h);
assert!(h.is_null());
// Freeing a null pointer is safe.
oakcommon_current_free(std::ptr::null_mut());
// Freeing an explicit null handle is safe.
let mut null_h = CHandle::null();
oakcommon_current_free(&mut null_h);
assert!(null_h.is_null());
}
/// All four slots round-trip set -> get; null handles and null out-params
/// are `E_INVALID`, and clearing a slot makes it read back as null.
#[test]
fn current_set_get_all_slots_roundtrip() {
let _guard = CURRENT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let h = oakcommon_current_instance();
let video = 0x1000usize as *mut c_void;
let audio = 0x2000usize as *mut c_void;
let host = 0x3000usize as *mut c_void;
let cache = 0x4000usize as *mut c_void;
// Empty slots read back as null before anything is stored.
let mut got: *mut c_void = std::ptr::null_mut();
assert_eq!(oakcommon_current_get_video_params(dup(&h), &mut got), OAKCOMMON_OK);
assert!(got.is_null());
assert_eq!(oakcommon_current_set_video_params(dup(&h), video, None), OAKCOMMON_OK);
assert_eq!(oakcommon_current_set_audio_params(dup(&h), audio, None), OAKCOMMON_OK);
assert_eq!(oakcommon_current_set_plugin_host(dup(&h), host, None), OAKCOMMON_OK);
assert_eq!(oakcommon_current_set_plugin_cache(dup(&h), cache, None), OAKCOMMON_OK);
assert_eq!(oakcommon_current_get_video_params(dup(&h), &mut got), OAKCOMMON_OK);
assert_eq!(got, video);
assert_eq!(oakcommon_current_get_audio_params(dup(&h), &mut got), OAKCOMMON_OK);
assert_eq!(got, audio);
assert_eq!(oakcommon_current_get_plugin_host(dup(&h), &mut got), OAKCOMMON_OK);
assert_eq!(got, host);
assert_eq!(oakcommon_current_get_plugin_cache(dup(&h), &mut got), OAKCOMMON_OK);
assert_eq!(got, cache);
// Getters reject a null out-param and a null handle.
assert_eq!(
oakcommon_current_get_video_params(dup(&h), std::ptr::null_mut()),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_current_get_video_params(CHandle::null(), &mut got),
OAKCOMMON_E_INVALID
);
// Setters reject a null handle.
assert_eq!(
oakcommon_current_set_video_params(CHandle::null(), video, None),
OAKCOMMON_E_INVALID
);
// Clear every slot so other tests see a clean singleton.
assert_eq!(oakcommon_current_set_video_params(dup(&h), std::ptr::null_mut(), None), OAKCOMMON_OK);
assert_eq!(oakcommon_current_set_audio_params(dup(&h), std::ptr::null_mut(), None), OAKCOMMON_OK);
assert_eq!(oakcommon_current_set_plugin_host(dup(&h), std::ptr::null_mut(), None), OAKCOMMON_OK);
assert_eq!(oakcommon_current_set_plugin_cache(dup(&h), std::ptr::null_mut(), None), OAKCOMMON_OK);
assert_eq!(oakcommon_current_get_video_params(dup(&h), &mut got), OAKCOMMON_OK);
assert!(got.is_null());
}
/// Replacing an occupied slot runs the previous owner's destructor exactly
/// once; clearing a slot whose occupant has no destructor runs nothing.
#[test]
fn current_set_destroys_replaced_pointer() {
let _guard = CURRENT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let h = oakcommon_current_instance();
let base = DESTROY_COUNT.load(Ordering::SeqCst);
assert_eq!(
oakcommon_current_set_video_params(dup(&h), 0xAAAAusize as *mut c_void, Some(count_destroy)),
OAKCOMMON_OK
);
assert_eq!(DESTROY_COUNT.load(Ordering::SeqCst), base);
// Replacing the slot invokes the stored destructor exactly once.
assert_eq!(
oakcommon_current_set_video_params(dup(&h), 0xBBBBusize as *mut c_void, None),
OAKCOMMON_OK
);
assert_eq!(DESTROY_COUNT.load(Ordering::SeqCst), base + 1);
let mut got: *mut c_void = std::ptr::null_mut();
assert_eq!(oakcommon_current_get_video_params(dup(&h), &mut got), OAKCOMMON_OK);
assert_eq!(got, 0xBBBBusize as *mut c_void);
// Clearing a slot with no destructor invokes nothing.
assert_eq!(
oakcommon_current_set_video_params(dup(&h), std::ptr::null_mut(), None),
OAKCOMMON_OK
);
assert_eq!(DESTROY_COUNT.load(Ordering::SeqCst), base + 1);
assert_eq!(oakcommon_current_get_video_params(dup(&h), &mut got), OAKCOMMON_OK);
assert!(got.is_null());
}
/// `is_interactive` writes 1; null handle/out-param are `E_INVALID`.
#[test]
fn current_is_interactive_writes_one() {
let _guard = CURRENT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let h = oakcommon_current_instance();
let mut out = 0i32;
assert_eq!(oakcommon_current_is_interactive(dup(&h), &mut out), OAKCOMMON_OK);
assert_eq!(out, 1);
assert_eq!(
oakcommon_current_is_interactive(dup(&h), std::ptr::null_mut()),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_current_is_interactive(CHandle::null(), &mut out),
OAKCOMMON_E_INVALID
);
}
+539
View File
@@ -0,0 +1,539 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! FFI-level integration tests for the C-ABI `subtitleparams` exports in
//! `oakcommon::ffi::subtitleparams`, asserted against the C++ oracle
//! `src/common/c_api/subtitleparams.cpp` and the Rust domain
//! `src/common/rust/src/subtitleparams.rs`.
//!
//! The contract under test (each point matches the C++ oracle):
//! - exports take a `CHandle` by value and never release it, so callers
//! keep the handle alive and `free` it exactly once;
//! - `free` nullifies the handle, is idempotent, and tolerates a null
//! pointer;
//! - two-stage string getters return the required size (NUL included) and
//! only copy when the buffer is large enough — they never truncate;
//! - getters reject a null handle or null out-param with `E_INVALID`,
//! setters reject a null handle, and string inputs reject null pointers;
//! - an out-of-range subtitle index yields `E_NOT_FOUND`; malformed XML
//! yields `E_FAILED`; the empty set is invalid with `count` 0 and
//! `duration` 0/1.
use std::ffi::{c_char, CString};
use oakcommon::error::{
OAKCOMMON_E_FAILED, OAKCOMMON_E_INVALID, OAKCOMMON_E_NOT_FOUND, OAKCOMMON_OK,
};
use oakcommon::ffi::subtitleparams::*;
use oakcommon::handle::{CHandle, OAKCOMMON_ABI_VERSION};
/// Create an empty subtitle parameter set.
fn make() -> CHandle {
oakcommon_subtitleparams_init()
}
/// A populated set: stream index 2, disabled, two subtitles.
fn make_populated() -> CHandle {
let h = make();
assert_eq!(
oakcommon_subtitleparams_add_subtitle(dup(&h), 0, 1, 25, 1, to_cstring("hello").as_ptr()),
OAKCOMMON_OK
);
assert_eq!(
oakcommon_subtitleparams_add_subtitle(dup(&h), 25, 1, 50, 1, to_cstring("world").as_ptr()),
OAKCOMMON_OK
);
assert_eq!(oakcommon_subtitleparams_set_stream_index(dup(&h), 2), OAKCOMMON_OK);
assert_eq!(oakcommon_subtitleparams_set_enabled(dup(&h), 0), OAKCOMMON_OK);
h
}
/// Convert a string slice to a NUL-terminated C string for FFI inputs.
fn to_cstring(s: &str) -> CString {
CString::new(s).expect("test string must not contain NUL")
}
/// Cheap struct copy: `CHandle` is neither `Clone` nor `Copy`, but every
/// getter takes it by value. Rebuilding from the same fields duplicates
/// only the handle value — the box stays alive as long as the original
/// handle lives, and the getters never release.
fn dup(h: &CHandle) -> CHandle {
CHandle {
ctx: h.ctx,
addref: h.addref,
release: h.release,
abi_version: h.abi_version,
}
}
/// Drive a two-stage string getter against the C++ `copy_string`
/// convention: a null-buffer size query, a short buffer that must stay
/// untouched (no truncation), an exact-fit copy with its NUL, and an
/// oversized copy with the tail untouched.
fn assert_two_stage_getter(getter: impl Fn(*mut c_char, i32) -> i32, expected: &str) {
let required = (expected.len() + 1) as i32;
// Size query: a null buffer returns the required size, NUL included.
assert_eq!(getter(std::ptr::null_mut(), 0), required);
// Short buffer: too small, so nothing is written to it.
let short_size = (required - 1).max(0);
let mut short = vec![0xABu8; short_size as usize];
assert_eq!(getter(short.as_mut_ptr() as *mut c_char, short_size), required);
assert!(short.iter().all(|&b| b == 0xAB), "short buffer must stay untouched");
// Exact fit: payload followed by a NUL.
let mut exact = vec![0xCDu8; required as usize];
assert_eq!(getter(exact.as_mut_ptr() as *mut c_char, required), required);
assert_eq!(&exact[..expected.len()], expected.as_bytes());
assert_eq!(exact[expected.len()], 0);
// Oversized: payload and NUL written, tail left as initialized.
let mut big = vec![0u8; (required + 8) as usize];
assert_eq!(getter(big.as_mut_ptr() as *mut c_char, required + 8), required);
assert_eq!(&big[..expected.len()], expected.as_bytes());
assert_eq!(big[expected.len()], 0);
assert!(big[(required + 1) as usize..].iter().all(|&b| b == 0));
}
// ---- Handle lifecycle ----
/// `init` yields a stamped, non-empty handle; `free` nullifies it, is
/// idempotent, and tolerates a null pointer.
#[test]
fn init_free_lifecycle() {
let h = oakcommon_subtitleparams_init();
assert!(!h.is_null());
assert_eq!(h.abi_version, OAKCOMMON_ABI_VERSION);
assert!(h.addref.is_some());
assert!(h.release.is_some());
let mut hf = make();
assert!(!hf.is_null());
oakcommon_subtitleparams_free(&mut hf);
assert!(hf.is_null());
// A second free of the now-empty handle is safe.
oakcommon_subtitleparams_free(&mut hf);
assert!(hf.is_null());
// Freeing a null pointer is safe.
oakcommon_subtitleparams_free(std::ptr::null_mut());
// Freeing a handle struct that is already null (by value) is safe too.
let mut empty = CHandle::null();
oakcommon_subtitleparams_free(&mut empty);
assert!(empty.is_null());
}
// ---- Core field round-trips ----
/// Stream index defaults to 0 and round-trips; a null handle or null
/// out-param is `E_INVALID`.
#[test]
fn stream_index_roundtrip() {
let h = make();
let mut si = -1i32;
assert_eq!(oakcommon_subtitleparams_get_stream_index(dup(&h), &mut si), OAKCOMMON_OK);
assert_eq!(si, 0);
assert_eq!(oakcommon_subtitleparams_set_stream_index(dup(&h), 3), OAKCOMMON_OK);
assert_eq!(oakcommon_subtitleparams_get_stream_index(dup(&h), &mut si), OAKCOMMON_OK);
assert_eq!(si, 3);
assert_eq!(oakcommon_subtitleparams_set_stream_index(CHandle::null(), 1), OAKCOMMON_E_INVALID);
assert_eq!(
oakcommon_subtitleparams_get_stream_index(CHandle::null(), &mut si),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_subtitleparams_get_stream_index(dup(&h), std::ptr::null_mut()),
OAKCOMMON_E_INVALID
);
}
/// `enabled` defaults to 1 (CPP-PARITY: the C++ constructor sets
/// `enabled_ = true`) and round-trips; any non-zero setter value enables.
#[test]
fn enabled_roundtrip() {
let h = make();
let mut en = -1i32;
assert_eq!(oakcommon_subtitleparams_get_enabled(dup(&h), &mut en), OAKCOMMON_OK);
assert_eq!(en, 1);
assert_eq!(oakcommon_subtitleparams_set_enabled(dup(&h), 0), OAKCOMMON_OK);
assert_eq!(oakcommon_subtitleparams_get_enabled(dup(&h), &mut en), OAKCOMMON_OK);
assert_eq!(en, 0);
// A non-zero code (even 5) enables the stream.
assert_eq!(oakcommon_subtitleparams_set_enabled(dup(&h), 5), OAKCOMMON_OK);
assert_eq!(oakcommon_subtitleparams_get_enabled(dup(&h), &mut en), OAKCOMMON_OK);
assert_eq!(en, 1);
assert_eq!(oakcommon_subtitleparams_set_enabled(CHandle::null(), 1), OAKCOMMON_E_INVALID);
assert_eq!(
oakcommon_subtitleparams_get_enabled(CHandle::null(), &mut en),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_subtitleparams_get_enabled(dup(&h), std::ptr::null_mut()),
OAKCOMMON_E_INVALID
);
}
// ---- Empty-set behavior ----
/// An empty set is invalid, has count 0, and duration 0/1; null handle or
/// null out-param is `E_INVALID`.
#[test]
fn empty_set_defaults() {
let h = make();
let mut v = -1i32;
let mut c = -1i32;
let mut n = -1i32;
let mut d = -1i32;
assert_eq!(oakcommon_subtitleparams_is_valid(dup(&h), &mut v), OAKCOMMON_OK);
assert_eq!(v, 0);
assert_eq!(oakcommon_subtitleparams_count(dup(&h), &mut c), OAKCOMMON_OK);
assert_eq!(c, 0);
assert_eq!(oakcommon_subtitleparams_duration(dup(&h), &mut n, &mut d), OAKCOMMON_OK);
assert_eq!((n, d), (0, 1));
assert_eq!(oakcommon_subtitleparams_is_valid(CHandle::null(), &mut v), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_subtitleparams_count(CHandle::null(), &mut c), OAKCOMMON_E_INVALID);
assert_eq!(
oakcommon_subtitleparams_duration(CHandle::null(), &mut n, &mut d),
OAKCOMMON_E_INVALID
);
assert_eq!(oakcommon_subtitleparams_is_valid(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_subtitleparams_count(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(
oakcommon_subtitleparams_duration(dup(&h), std::ptr::null_mut(), &mut d),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_subtitleparams_duration(dup(&h), &mut n, std::ptr::null_mut()),
OAKCOMMON_E_INVALID
);
}
// ---- Subtitles ----
/// `add_subtitle` appends entries; `is_valid`/`count`/`duration`/
/// `get_subtitle` reflect the populated set. Rationals are reduced
/// (CPP-PARITY with the C++ `Rational` constructor).
#[test]
fn add_subtitle_and_query() {
let h = make_populated();
let mut v = -1i32;
let mut c = -1i32;
let mut n = -1i32;
let mut d = -1i32;
assert_eq!(oakcommon_subtitleparams_is_valid(dup(&h), &mut v), OAKCOMMON_OK);
assert_eq!(v, 1);
assert_eq!(oakcommon_subtitleparams_count(dup(&h), &mut c), OAKCOMMON_OK);
assert_eq!(c, 2);
assert_eq!(oakcommon_subtitleparams_duration(dup(&h), &mut n, &mut d), OAKCOMMON_OK);
assert_eq!((n, d), (50, 1));
assert_eq!(oakcommon_subtitleparams_get_subtitle(dup(&h), 0, &mut n, &mut d, &mut v, &mut c), OAKCOMMON_OK);
assert_eq!((n, d), (0, 1));
assert_eq!((v, c), (25, 1));
assert_eq!(oakcommon_subtitleparams_get_subtitle(dup(&h), 1, &mut n, &mut d, &mut v, &mut c), OAKCOMMON_OK);
assert_eq!((n, d), (25, 1));
assert_eq!((v, c), (50, 1));
// Reduction: Rational(2,4) -> 1/2, Rational(9,3) -> 3/1.
assert_eq!(
oakcommon_subtitleparams_add_subtitle(dup(&h), 2, 4, 9, 3, to_cstring("t").as_ptr()),
OAKCOMMON_OK
);
assert_eq!(oakcommon_subtitleparams_get_subtitle(dup(&h), 2, &mut n, &mut d, &mut v, &mut c), OAKCOMMON_OK);
assert_eq!((n, d), (1, 2));
assert_eq!((v, c), (3, 1));
}
/// `add_subtitle` rejects a null handle or null text; an empty text string
/// is a valid subtitle.
#[test]
fn add_subtitle_failures() {
let h = make();
assert_eq!(
oakcommon_subtitleparams_add_subtitle(CHandle::null(), 0, 1, 1, 1, to_cstring("x").as_ptr()),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_subtitleparams_add_subtitle(dup(&h), 0, 1, 1, 1, std::ptr::null()),
OAKCOMMON_E_INVALID
);
// Empty text is fine and counts as a subtitle.
assert_eq!(
oakcommon_subtitleparams_add_subtitle(dup(&h), 0, 1, 1, 1, to_cstring("").as_ptr()),
OAKCOMMON_OK
);
let mut c = -1i32;
assert_eq!(oakcommon_subtitleparams_count(dup(&h), &mut c), OAKCOMMON_OK);
assert_eq!(c, 1);
}
/// `get_subtitle` on an out-of-range index is `E_NOT_FOUND`; a null handle
/// or any null out-param is `E_INVALID`.
#[test]
fn get_subtitle_out_of_range() {
let h = make_populated();
let mut n = -1i32;
let mut d = -1i32;
let mut v = -1i32;
let mut c = -1i32;
assert_eq!(oakcommon_subtitleparams_get_subtitle(dup(&h), 2, &mut n, &mut d, &mut v, &mut c), OAKCOMMON_E_NOT_FOUND);
assert_eq!(oakcommon_subtitleparams_get_subtitle(dup(&h), -1, &mut n, &mut d, &mut v, &mut c), OAKCOMMON_E_NOT_FOUND);
// On an empty set even index 0 is out of range.
let e = make();
assert_eq!(oakcommon_subtitleparams_get_subtitle(dup(&e), 0, &mut n, &mut d, &mut v, &mut c), OAKCOMMON_E_NOT_FOUND);
assert_eq!(
oakcommon_subtitleparams_get_subtitle(CHandle::null(), 0, &mut n, &mut d, &mut v, &mut c),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_subtitleparams_get_subtitle(dup(&h), 0, std::ptr::null_mut(), &mut d, &mut v, &mut c),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_subtitleparams_get_subtitle(dup(&h), 0, &mut n, std::ptr::null_mut(), &mut v, &mut c),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_subtitleparams_get_subtitle(dup(&h), 0, &mut n, &mut d, std::ptr::null_mut(), &mut c),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_subtitleparams_get_subtitle(dup(&h), 0, &mut n, &mut d, &mut v, std::ptr::null_mut()),
OAKCOMMON_E_INVALID
);
}
/// `clear` removes every subtitle (count 0, invalid, duration 0/1) and
/// rejects a null handle.
#[test]
fn clear() {
let h = make_populated();
assert_eq!(oakcommon_subtitleparams_clear(dup(&h)), OAKCOMMON_OK);
let mut v = -1i32;
let mut c = -1i32;
let mut n = -1i32;
let mut d = -1i32;
assert_eq!(oakcommon_subtitleparams_count(dup(&h), &mut c), OAKCOMMON_OK);
assert_eq!(c, 0);
assert_eq!(oakcommon_subtitleparams_is_valid(dup(&h), &mut v), OAKCOMMON_OK);
assert_eq!(v, 0);
assert_eq!(oakcommon_subtitleparams_duration(dup(&h), &mut n, &mut d), OAKCOMMON_OK);
assert_eq!((n, d), (0, 1));
// Clearing again is a no-op success.
assert_eq!(oakcommon_subtitleparams_clear(dup(&h)), OAKCOMMON_OK);
assert_eq!(oakcommon_subtitleparams_clear(CHandle::null()), OAKCOMMON_E_INVALID);
}
// ---- String getters ----
/// `get_subtitle_text` is a two-stage string getter; an out-of-range index
/// is `E_NOT_FOUND`, and a null handle or invalid out-buffer is
/// `E_INVALID`.
#[test]
fn get_subtitle_text_two_stage() {
let h = make_populated();
assert_two_stage_getter(
|buf, size| oakcommon_subtitleparams_get_subtitle_text(dup(&h), 0, buf, size),
"hello",
);
assert_two_stage_getter(
|buf, size| oakcommon_subtitleparams_get_subtitle_text(dup(&h), 1, buf, size),
"world",
);
// Out-of-range index is E_NOT_FOUND (even on a size query).
assert_eq!(
oakcommon_subtitleparams_get_subtitle_text(dup(&h), 2, std::ptr::null_mut(), 0),
OAKCOMMON_E_NOT_FOUND
);
assert_eq!(
oakcommon_subtitleparams_get_subtitle_text(dup(&h), -1, std::ptr::null_mut(), 0),
OAKCOMMON_E_NOT_FOUND
);
assert_eq!(
oakcommon_subtitleparams_get_subtitle_text(CHandle::null(), 0, std::ptr::null_mut(), 0),
OAKCOMMON_E_INVALID
);
// A null buffer with a positive size is an invalid string out-param.
assert_eq!(
oakcommon_subtitleparams_get_subtitle_text(dup(&h), 0, std::ptr::null_mut(), 5),
OAKCOMMON_E_INVALID
);
// A negative size is invalid too.
assert_eq!(
oakcommon_subtitleparams_get_subtitle_text(dup(&h), 0, std::ptr::null_mut(), -1),
OAKCOMMON_E_INVALID
);
}
/// `generate_ass_header` is a static two-stage string getter (no handle).
/// The payload matches the C++ header verbatim (CRLF line endings).
#[test]
fn generate_ass_header_two_stage() {
let expected = "[Script Info]\r\n\
; Script generated by Oak\r\n\
ScriptType: v4.00+\r\n\
PlayResX: 384\r\n\
PlayResY: 288\r\n\
ScaledBorderAndShadow: yes\r\n\
\r\n\
[V4+ Styles]\r\n\
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, \
BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, \
BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\r\n\
Style: Default,Arial,16,&HFFFFFF,&HFFFFFF,&H000000,&H000000,0,0,0,0,100,100,0,0,1,1,0,2,10,10,10,0\r\n\
\r\n\
[Events]\r\n\
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\r\n";
assert_two_stage_getter(
|buf, size| oakcommon_subtitleparams_generate_ass_header(buf, size),
expected,
);
// An invalid out-buffer (null with a positive size) is E_INVALID.
assert_eq!(
oakcommon_subtitleparams_generate_ass_header(std::ptr::null_mut(), 5),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_subtitleparams_generate_ass_header(std::ptr::null_mut(), -1),
OAKCOMMON_E_INVALID
);
}
// ---- XML ----
/// `load_xml` parses a fragment and applies stream index, enabled, and
/// subtitles; malformed input or a missing root is `E_FAILED`; a null
/// handle or null xml is `E_INVALID`.
#[test]
fn load_xml() {
let h = make();
let xml = "<subtitleparams><streamindex>7</streamindex><enabled>0</enabled>\
<subtitles><subtitle in=\"0/1\" out=\"25/1\">hello</subtitle>\
<subtitle in=\"25/1\" out=\"50/1\">world</subtitle></subtitles></subtitleparams>";
assert_eq!(oakcommon_subtitleparams_load_xml(dup(&h), to_cstring(xml).as_ptr()), OAKCOMMON_OK);
let mut si = -1i32;
let mut en = -1i32;
let mut c = -1i32;
assert_eq!(oakcommon_subtitleparams_get_stream_index(dup(&h), &mut si), OAKCOMMON_OK);
assert_eq!(si, 7);
assert_eq!(oakcommon_subtitleparams_get_enabled(dup(&h), &mut en), OAKCOMMON_OK);
assert_eq!(en, 0);
assert_eq!(oakcommon_subtitleparams_count(dup(&h), &mut c), OAKCOMMON_OK);
assert_eq!(c, 2);
assert_eq!(oakcommon_subtitleparams_get_subtitle_text(dup(&h), 0, std::ptr::null_mut(), 0), 6);
assert_two_stage_getter(
|buf, size| oakcommon_subtitleparams_get_subtitle_text(dup(&h), 1, buf, size),
"world",
);
// Malformed / missing-root fragments fail with E_FAILED.
assert_eq!(
oakcommon_subtitleparams_load_xml(dup(&h), to_cstring("<subtitleparams><streamindex>").as_ptr()),
OAKCOMMON_E_FAILED
);
assert_eq!(
oakcommon_subtitleparams_load_xml(dup(&h), to_cstring("not xml").as_ptr()),
OAKCOMMON_E_FAILED
);
assert_eq!(
oakcommon_subtitleparams_load_xml(dup(&h), to_cstring("").as_ptr()),
OAKCOMMON_E_FAILED
);
assert_eq!(
oakcommon_subtitleparams_load_xml(CHandle::null(), to_cstring(xml).as_ptr()),
OAKCOMMON_E_INVALID
);
assert_eq!(oakcommon_subtitleparams_load_xml(dup(&h), std::ptr::null()), OAKCOMMON_E_INVALID);
}
/// `save_xml` is a two-stage string getter; the output matches the C++
/// `XmlStreamWriter` format exactly (no indentation, escaped text).
#[test]
fn save_xml_two_stage() {
// The empty set serializes to streamindex 0 / enabled 1 / no subtitles.
let e = make();
assert_two_stage_getter(
|buf, size| oakcommon_subtitleparams_save_xml(dup(&e), buf, size),
"<subtitleparams><streamindex>0</streamindex><enabled>1</enabled><subtitles></subtitles></subtitleparams>",
);
// The populated set round-trips through load_xml.
let h = make_populated();
let expected = "<subtitleparams><streamindex>2</streamindex><enabled>0</enabled>\
<subtitles><subtitle in=\"0/1\" out=\"25/1\">hello</subtitle>\
<subtitle in=\"25/1\" out=\"50/1\">world</subtitle></subtitles></subtitleparams>";
assert_two_stage_getter(
|buf, size| oakcommon_subtitleparams_save_xml(dup(&h), buf, size),
expected,
);
// A loaded fragment round-trips byte-for-byte.
let xml = "<subtitleparams><streamindex>9</streamindex><enabled>1</enabled>\
<subtitles><subtitle in=\"3/2\" out=\"5/1\">hi</subtitle></subtitles></subtitleparams>";
assert_eq!(oakcommon_subtitleparams_load_xml(dup(&h), to_cstring(xml).as_ptr()), OAKCOMMON_OK);
assert_two_stage_getter(
|buf, size| oakcommon_subtitleparams_save_xml(dup(&h), buf, size),
xml,
);
assert_eq!(
oakcommon_subtitleparams_save_xml(CHandle::null(), std::ptr::null_mut(), 0),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_subtitleparams_save_xml(dup(&h), std::ptr::null_mut(), 5),
OAKCOMMON_E_INVALID
);
// The saved XML parses back to the same data via the C API.
let mut si = -1i32;
let mut en = -1i32;
let mut c = -1i32;
let mut n = -1i32;
let mut d = -1i32;
assert_eq!(oakcommon_subtitleparams_get_stream_index(dup(&h), &mut si), OAKCOMMON_OK);
assert_eq!(si, 9);
assert_eq!(oakcommon_subtitleparams_get_enabled(dup(&h), &mut en), OAKCOMMON_OK);
assert_eq!(en, 1);
assert_eq!(oakcommon_subtitleparams_count(dup(&h), &mut c), OAKCOMMON_OK);
assert_eq!(c, 1);
assert_eq!(oakcommon_subtitleparams_get_subtitle(dup(&h), 0, &mut n, &mut d, &mut si, &mut en), OAKCOMMON_OK);
assert_eq!((n, d), (3, 2));
assert_eq!((si, en), (5, 1));
}
+775
View File
@@ -0,0 +1,775 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! FFI-level integration tests for the C-ABI `videoparams` exports in
//! `oakcommon::ffi::videoparams`, asserted against the C++ oracle
//! `src/common/c_api/videoparams.cpp` and the Rust domain
//! `src/common/rust/src/videoparams.rs`.
//!
//! The contract under test (each point matches the C++ oracle):
//! - exports take a `CHandle` by value and never release it, so callers
//! keep the handle alive and `free` it exactly once;
//! - `free` nullifies the handle, is idempotent, and tolerates a null
//! pointer;
//! - two-stage string getters return the required size (NUL included) and
//! only copy when the buffer is large enough — they never truncate;
//! - getters reject a null handle or null out-param with `E_INVALID`,
//! setters reject a null handle, and string inputs reject null pointers;
//! - out-of-range enum-like setter codes clamp to a documented fallback
//! (CPP-PARITY with the C++ `static_cast` semantics).
use std::ffi::{c_char, CStr, CString};
use oakcommon::error::{OAKCOMMON_E_FAILED, OAKCOMMON_E_INVALID, OAKCOMMON_OK};
use oakcommon::ffi::videoparams::*;
use oakcommon::handle::{CHandle, OAKCOMMON_ABI_VERSION};
/// `init_basic` arguments: 1920x1080 U8 RGBA, square pixels, progressive,
/// divider 1.
fn make() -> CHandle {
oakcommon_videoparams_init_basic(1920, 1080, 0, 4, 1, 1, 0, 1)
}
/// `init_with_time_base` arguments: 1920x1080 U8 RGBA, time base 1001/30000,
/// square pixels, progressive, divider 1.
fn make_tb() -> CHandle {
oakcommon_videoparams_init_with_time_base(1920, 1080, 1001, 30000, 0, 4, 1, 1, 0, 1)
}
/// Convert a string slice to a NUL-terminated C string for FFI inputs.
fn to_cstring(s: &str) -> CString {
CString::new(s).expect("test string must not contain NUL")
}
/// Cheap struct copy: `CHandle` is neither `Clone` nor `Copy`, but every
/// getter takes it by value. Rebuilding from the same fields duplicates
/// only the handle value — the box stays alive as long as the original
/// handle lives, and the getters never release.
fn dup(h: &CHandle) -> CHandle {
CHandle {
ctx: h.ctx,
addref: h.addref,
release: h.release,
abi_version: h.abi_version,
}
}
/// Drive a two-stage string getter against the C++ `copy_string`
/// convention: a null-buffer size query, a short buffer that must stay
/// untouched (no truncation), an exact-fit copy with its NUL, and an
/// oversized copy with the tail untouched.
fn assert_two_stage_getter(getter: impl Fn(*mut c_char, i32) -> i32, expected: &str) {
let required = (expected.len() + 1) as i32;
// Size query: a null buffer returns the required size, NUL included.
assert_eq!(getter(std::ptr::null_mut(), 0), required);
// Short buffer: too small, so nothing is written to it.
let short_size = (required - 1).max(0);
let mut short = vec![0xABu8; short_size as usize];
assert_eq!(getter(short.as_mut_ptr() as *mut c_char, short_size), required);
assert!(short.iter().all(|&b| b == 0xAB), "short buffer must stay untouched");
// Exact fit: payload followed by a NUL.
let mut exact = vec![0xCDu8; required as usize];
assert_eq!(getter(exact.as_mut_ptr() as *mut c_char, required), required);
assert_eq!(&exact[..expected.len()], expected.as_bytes());
assert_eq!(exact[expected.len()], 0);
// Oversized: payload and NUL written, tail left as initialized.
let mut big = vec![0u8; (required + 8) as usize];
assert_eq!(getter(big.as_mut_ptr() as *mut c_char, required + 8), required);
assert_eq!(&big[..expected.len()], expected.as_bytes());
assert_eq!(big[expected.len()], 0);
assert!(big[(required + 1) as usize..].iter().all(|&b| b == 0));
}
// ---- Handle lifecycle ----
/// All three constructors yield a stamped, non-empty handle; `free`
/// nullifies it, is idempotent, and tolerates a null pointer.
#[test]
fn init_free_lifecycle() {
let h = oakcommon_videoparams_init();
assert!(!h.is_null());
assert_eq!(h.abi_version, OAKCOMMON_ABI_VERSION);
assert!(h.addref.is_some());
assert!(h.release.is_some());
let hb = make();
assert!(!hb.is_null());
assert_eq!(hb.abi_version, OAKCOMMON_ABI_VERSION);
let ht = make_tb();
assert!(!ht.is_null());
assert_eq!(ht.abi_version, OAKCOMMON_ABI_VERSION);
let mut hf = oakcommon_videoparams_init_basic(1, 1, 4, 4, 1, 1, 0, 1);
assert!(!hf.is_null());
oakcommon_videoparams_free(&mut hf);
assert!(hf.is_null());
// A second free of the now-empty handle is safe.
oakcommon_videoparams_free(&mut hf);
assert!(hf.is_null());
// Freeing a null pointer is safe.
oakcommon_videoparams_free(std::ptr::null_mut());
}
// ---- Core field round-trips ----
/// Width, height, and depth round-trip; a null handle or null out-param is
/// `E_INVALID`.
#[test]
fn width_height_depth_roundtrip() {
let h = make();
let mut w = -1i32;
let mut ht = -1i32;
let mut d = -1i32;
assert_eq!(oakcommon_videoparams_get_width(dup(&h), &mut w), OAKCOMMON_OK);
assert_eq!(w, 1920);
assert_eq!(oakcommon_videoparams_get_height(dup(&h), &mut ht), OAKCOMMON_OK);
assert_eq!(ht, 1080);
assert_eq!(oakcommon_videoparams_get_depth(dup(&h), &mut d), OAKCOMMON_OK);
assert_eq!(d, 1);
assert_eq!(oakcommon_videoparams_set_width(dup(&h), 640), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_set_height(dup(&h), 480), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_set_depth(dup(&h), 2), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_width(dup(&h), &mut w), OAKCOMMON_OK);
assert_eq!(w, 640);
assert_eq!(oakcommon_videoparams_get_height(dup(&h), &mut ht), OAKCOMMON_OK);
assert_eq!(ht, 480);
assert_eq!(oakcommon_videoparams_get_depth(dup(&h), &mut d), OAKCOMMON_OK);
assert_eq!(d, 2);
assert_eq!(oakcommon_videoparams_set_width(CHandle::null(), 1), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_width(CHandle::null(), &mut w), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_width(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_height(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_depth(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
}
/// `is_3d` is derived from depth (CPP-PARITY: C++ has no `set_is_3d`;
/// the getter reads `depth > 1`).
#[test]
fn is_3d_roundtrip() {
let h = make();
let mut is3d = -1i32;
let mut d = -1i32;
assert_eq!(oakcommon_videoparams_get_is_3d(dup(&h), &mut is3d), OAKCOMMON_OK);
assert_eq!(is3d, 0);
assert_eq!(oakcommon_videoparams_set_depth(dup(&h), 2), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_is_3d(dup(&h), &mut is3d), OAKCOMMON_OK);
assert_eq!(is3d, 1);
assert_eq!(oakcommon_videoparams_get_depth(dup(&h), &mut d), OAKCOMMON_OK);
assert_eq!(d, 2);
assert_eq!(oakcommon_videoparams_set_depth(dup(&h), 1), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_is_3d(dup(&h), &mut is3d), OAKCOMMON_OK);
assert_eq!(is3d, 0);
assert_eq!(oakcommon_videoparams_get_depth(dup(&h), &mut d), OAKCOMMON_OK);
assert_eq!(d, 1);
assert_eq!(oakcommon_videoparams_set_depth(CHandle::null(), 1), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_is_3d(CHandle::null(), &mut is3d), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_is_3d(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
}
/// Time base and frame rate round-trip independently: `set_time_base` does
/// not touch the frame rate and `set_frame_rate` does not touch the time
/// base (CPP-PARITY with the C++ setters). The frame rate expressed as a
/// time base is always the flipped frame rate.
#[test]
fn time_base_frame_rate_roundtrip() {
let h = make_tb();
let mut n = -1i32;
let mut d = -1i32;
// Derived from the constructor: frame rate = flipped time base.
assert_eq!(oakcommon_videoparams_get_time_base(dup(&h), &mut n, &mut d), OAKCOMMON_OK);
assert_eq!((n, d), (1001, 30000));
assert_eq!(oakcommon_videoparams_get_frame_rate(dup(&h), &mut n, &mut d), OAKCOMMON_OK);
assert_eq!((n, d), (30000, 1001));
assert_eq!(oakcommon_videoparams_frame_rate_as_time_base(dup(&h), &mut n, &mut d), OAKCOMMON_OK);
assert_eq!((n, d), (1001, 30000));
assert_eq!(oakcommon_videoparams_set_time_base(dup(&h), 1, 50), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_time_base(dup(&h), &mut n, &mut d), OAKCOMMON_OK);
assert_eq!((n, d), (1, 50));
// The frame rate is untouched by `set_time_base`.
assert_eq!(oakcommon_videoparams_get_frame_rate(dup(&h), &mut n, &mut d), OAKCOMMON_OK);
assert_eq!((n, d), (30000, 1001));
assert_eq!(oakcommon_videoparams_set_frame_rate(dup(&h), 60, 1), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_frame_rate(dup(&h), &mut n, &mut d), OAKCOMMON_OK);
assert_eq!((n, d), (60, 1));
// The time base is untouched by `set_frame_rate`.
assert_eq!(oakcommon_videoparams_get_time_base(dup(&h), &mut n, &mut d), OAKCOMMON_OK);
assert_eq!((n, d), (1, 50));
assert_eq!(oakcommon_videoparams_frame_rate_as_time_base(dup(&h), &mut n, &mut d), OAKCOMMON_OK);
assert_eq!((n, d), (1, 60));
assert_eq!(oakcommon_videoparams_set_time_base(CHandle::null(), 1, 50), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_set_frame_rate(CHandle::null(), 60, 1), OAKCOMMON_E_INVALID);
for getter in [
|buf1, buf2| oakcommon_videoparams_get_time_base(CHandle::null(), buf1, buf2),
|buf1, buf2| oakcommon_videoparams_get_frame_rate(CHandle::null(), buf1, buf2),
|buf1, buf2| oakcommon_videoparams_frame_rate_as_time_base(CHandle::null(), buf1, buf2),
] {
assert_eq!(getter(&mut n, &mut d), OAKCOMMON_E_INVALID);
}
// A null out-param is `E_INVALID` even with a valid handle.
assert_eq!(
oakcommon_videoparams_get_time_base(dup(&h), std::ptr::null_mut(), &mut d),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_videoparams_get_frame_rate(dup(&h), &mut n, std::ptr::null_mut()),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_videoparams_frame_rate_as_time_base(dup(&h), std::ptr::null_mut(), std::ptr::null_mut()),
OAKCOMMON_E_INVALID
);
}
/// Pixel aspect ratio, format, and channel count round-trip. The default
/// handle has no format (code -1) and zero channels; an out-of-range format
/// code maps back to `Invalid` (code -1, CPP-PARITY with the C++ enum
/// `static_cast`).
#[test]
fn par_format_channel_roundtrip() {
let h = oakcommon_videoparams_init();
let mut n = -1i32;
let mut d = -1i32;
let mut f = -1i32;
let mut c = -1i32;
assert_eq!(oakcommon_videoparams_get_pixel_aspect_ratio(dup(&h), &mut n, &mut d), OAKCOMMON_OK);
assert_eq!((n, d), (1, 1));
assert_eq!(oakcommon_videoparams_get_format(dup(&h), &mut f), OAKCOMMON_OK);
assert_eq!(f, -1);
assert_eq!(oakcommon_videoparams_get_channel_count(dup(&h), &mut c), OAKCOMMON_OK);
assert_eq!(c, 0);
assert_eq!(oakcommon_videoparams_set_pixel_aspect_ratio(dup(&h), 16, 9), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_set_format(dup(&h), 0), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_set_channel_count(dup(&h), 2), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_pixel_aspect_ratio(dup(&h), &mut n, &mut d), OAKCOMMON_OK);
assert_eq!((n, d), (16, 9));
assert_eq!(oakcommon_videoparams_get_format(dup(&h), &mut f), OAKCOMMON_OK);
assert_eq!(f, 0);
assert_eq!(oakcommon_videoparams_get_channel_count(dup(&h), &mut c), OAKCOMMON_OK);
assert_eq!(c, 2);
// A null (0/5) pixel aspect ratio falls back to square pixels.
assert_eq!(oakcommon_videoparams_set_pixel_aspect_ratio(dup(&h), 0, 5), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_pixel_aspect_ratio(dup(&h), &mut n, &mut d), OAKCOMMON_OK);
assert_eq!((n, d), (1, 1));
// Out-of-range format codes map to `Invalid`, read back as -1.
assert_eq!(oakcommon_videoparams_set_format(dup(&h), 999), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_format(dup(&h), &mut f), OAKCOMMON_OK);
assert_eq!(f, -1);
assert_eq!(oakcommon_videoparams_set_pixel_aspect_ratio(CHandle::null(), 1, 1), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_set_format(CHandle::null(), 0), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_set_channel_count(CHandle::null(), 2), OAKCOMMON_E_INVALID);
assert_eq!(
oakcommon_videoparams_get_pixel_aspect_ratio(CHandle::null(), &mut n, &mut d),
OAKCOMMON_E_INVALID
);
assert_eq!(oakcommon_videoparams_get_format(CHandle::null(), &mut f), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_channel_count(CHandle::null(), &mut c), OAKCOMMON_E_INVALID);
assert_eq!(
oakcommon_videoparams_get_pixel_aspect_ratio(dup(&h), std::ptr::null_mut(), &mut d),
OAKCOMMON_E_INVALID
);
assert_eq!(oakcommon_videoparams_get_format(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_channel_count(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
}
/// Interlacing, divider, and enabled round-trip. Interlacing codes clamp:
/// 1 -> TopFirst, 2 -> BottomFirst, anything else -> None (0).
#[test]
fn interlacing_divider_enabled_roundtrip() {
let h = make();
let mut il = -1i32;
let mut dv = -1i32;
let mut en = -1i32;
assert_eq!(oakcommon_videoparams_get_interlacing(dup(&h), &mut il), OAKCOMMON_OK);
assert_eq!(il, 0);
assert_eq!(oakcommon_videoparams_get_divider(dup(&h), &mut dv), OAKCOMMON_OK);
assert_eq!(dv, 1);
assert_eq!(oakcommon_videoparams_get_enabled(dup(&h), &mut en), OAKCOMMON_OK);
assert_eq!(en, 1);
assert_eq!(oakcommon_videoparams_set_interlacing(dup(&h), 1), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_interlacing(dup(&h), &mut il), OAKCOMMON_OK);
assert_eq!(il, 1);
assert_eq!(oakcommon_videoparams_set_interlacing(dup(&h), 2), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_interlacing(dup(&h), &mut il), OAKCOMMON_OK);
assert_eq!(il, 2);
assert_eq!(oakcommon_videoparams_set_interlacing(dup(&h), 3), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_interlacing(dup(&h), &mut il), OAKCOMMON_OK);
assert_eq!(il, 0);
assert_eq!(oakcommon_videoparams_set_divider(dup(&h), 4), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_divider(dup(&h), &mut dv), OAKCOMMON_OK);
assert_eq!(dv, 4);
assert_eq!(oakcommon_videoparams_set_enabled(dup(&h), 0), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_enabled(dup(&h), &mut en), OAKCOMMON_OK);
assert_eq!(en, 0);
assert_eq!(oakcommon_videoparams_set_interlacing(CHandle::null(), 1), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_set_divider(CHandle::null(), 2), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_set_enabled(CHandle::null(), 1), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_interlacing(CHandle::null(), &mut il), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_divider(CHandle::null(), &mut dv), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_enabled(CHandle::null(), &mut en), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_interlacing(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_divider(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_enabled(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
}
/// Video type, x/y offset, stream index, start time, and duration
/// round-trip. Video-type codes clamp: 1 -> Still, 2 -> ImageSequence,
/// anything else -> Video (0).
#[test]
fn video_type_position_stream_roundtrip() {
let h = make();
let mut vt = -1i32;
let mut x = -1f32;
let mut y = -1f32;
let mut si = -1i32;
let mut st = -1i64;
let mut du = -1i64;
assert_eq!(oakcommon_videoparams_get_video_type(dup(&h), &mut vt), OAKCOMMON_OK);
assert_eq!(vt, 0);
assert_eq!(oakcommon_videoparams_get_x(dup(&h), &mut x), OAKCOMMON_OK);
assert_eq!(x, 0.0);
assert_eq!(oakcommon_videoparams_get_y(dup(&h), &mut y), OAKCOMMON_OK);
assert_eq!(y, 0.0);
assert_eq!(oakcommon_videoparams_get_stream_index(dup(&h), &mut si), OAKCOMMON_OK);
assert_eq!(si, 0);
assert_eq!(oakcommon_videoparams_get_start_time(dup(&h), &mut st), OAKCOMMON_OK);
assert_eq!(st, 0);
assert_eq!(oakcommon_videoparams_get_duration(dup(&h), &mut du), OAKCOMMON_OK);
assert_eq!(du, 0);
assert_eq!(oakcommon_videoparams_set_video_type(dup(&h), 1), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_video_type(dup(&h), &mut vt), OAKCOMMON_OK);
assert_eq!(vt, 1);
assert_eq!(oakcommon_videoparams_set_video_type(dup(&h), 2), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_video_type(dup(&h), &mut vt), OAKCOMMON_OK);
assert_eq!(vt, 2);
assert_eq!(oakcommon_videoparams_set_video_type(dup(&h), 9), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_video_type(dup(&h), &mut vt), OAKCOMMON_OK);
assert_eq!(vt, 0);
assert_eq!(oakcommon_videoparams_set_x(dup(&h), 1.5), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_set_y(dup(&h), -2.5), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_set_stream_index(dup(&h), 3), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_set_start_time(dup(&h), 12345i64), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_set_duration(dup(&h), 67890i64), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_x(dup(&h), &mut x), OAKCOMMON_OK);
assert_eq!(x, 1.5);
assert_eq!(oakcommon_videoparams_get_y(dup(&h), &mut y), OAKCOMMON_OK);
assert_eq!(y, -2.5);
assert_eq!(oakcommon_videoparams_get_stream_index(dup(&h), &mut si), OAKCOMMON_OK);
assert_eq!(si, 3);
assert_eq!(oakcommon_videoparams_get_start_time(dup(&h), &mut st), OAKCOMMON_OK);
assert_eq!(st, 12345);
assert_eq!(oakcommon_videoparams_get_duration(dup(&h), &mut du), OAKCOMMON_OK);
assert_eq!(du, 67890);
assert_eq!(oakcommon_videoparams_set_video_type(CHandle::null(), 1), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_set_x(CHandle::null(), 1.0), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_set_y(CHandle::null(), 1.0), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_set_stream_index(CHandle::null(), 1), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_set_start_time(CHandle::null(), 1), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_set_duration(CHandle::null(), 1), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_video_type(CHandle::null(), &mut vt), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_x(CHandle::null(), &mut x), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_y(CHandle::null(), &mut y), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_stream_index(CHandle::null(), &mut si), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_start_time(CHandle::null(), &mut st), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_duration(CHandle::null(), &mut du), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_video_type(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_x(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_y(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_stream_index(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_start_time(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_duration(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
}
/// Premultiplied alpha, color range, primaries, and transfer round-trip.
/// Color-range codes clamp: 1 -> Full, anything else -> Limited (0).
#[test]
fn color_fields_roundtrip() {
let h = make();
let mut pa = -1i32;
let mut cr = -1i32;
let mut cp = -1i32;
let mut ct = -1i32;
assert_eq!(oakcommon_videoparams_get_premultiplied_alpha(dup(&h), &mut pa), OAKCOMMON_OK);
assert_eq!(pa, 0);
assert_eq!(oakcommon_videoparams_get_color_range(dup(&h), &mut cr), OAKCOMMON_OK);
assert_eq!(cr, 0);
assert_eq!(oakcommon_videoparams_get_color_primaries(dup(&h), &mut cp), OAKCOMMON_OK);
assert_eq!(cp, 0);
assert_eq!(oakcommon_videoparams_get_color_transfer(dup(&h), &mut ct), OAKCOMMON_OK);
assert_eq!(ct, 0);
assert_eq!(oakcommon_videoparams_set_premultiplied_alpha(dup(&h), 1), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_set_color_range(dup(&h), 1), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_set_color_primaries(dup(&h), 2), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_set_color_transfer(dup(&h), 3), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_premultiplied_alpha(dup(&h), &mut pa), OAKCOMMON_OK);
assert_eq!(pa, 1);
assert_eq!(oakcommon_videoparams_get_color_range(dup(&h), &mut cr), OAKCOMMON_OK);
assert_eq!(cr, 1);
assert_eq!(oakcommon_videoparams_get_color_primaries(dup(&h), &mut cp), OAKCOMMON_OK);
assert_eq!(cp, 2);
assert_eq!(oakcommon_videoparams_get_color_transfer(dup(&h), &mut ct), OAKCOMMON_OK);
assert_eq!(ct, 3);
// Out-of-range color range clamps to Limited.
assert_eq!(oakcommon_videoparams_set_color_range(dup(&h), 5), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_color_range(dup(&h), &mut cr), OAKCOMMON_OK);
assert_eq!(cr, 0);
assert_eq!(oakcommon_videoparams_set_premultiplied_alpha(CHandle::null(), 1), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_set_color_range(CHandle::null(), 1), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_set_color_primaries(CHandle::null(), 1), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_set_color_transfer(CHandle::null(), 1), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_premultiplied_alpha(CHandle::null(), &mut pa), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_color_range(CHandle::null(), &mut cr), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_color_primaries(CHandle::null(), &mut cp), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_color_transfer(CHandle::null(), &mut ct), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_premultiplied_alpha(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_color_range(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_color_primaries(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_color_transfer(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
}
// ---- String fields ----
/// `colorspace` round-trips through a two-stage getter; the empty string is
/// a valid value (required size 1). Null handle or null string is
/// `E_INVALID`.
#[test]
fn colorspace_two_stage() {
let h = make();
assert_eq!(oakcommon_videoparams_set_colorspace(dup(&h), to_cstring("sRGB").as_ptr()), OAKCOMMON_OK);
assert_two_stage_getter(|buf, size| oakcommon_videoparams_get_colorspace(dup(&h), buf, size), "sRGB");
// The empty colorspace is a valid value.
assert_eq!(oakcommon_videoparams_set_colorspace(dup(&h), to_cstring("").as_ptr()), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_colorspace(dup(&h), std::ptr::null_mut(), 0), 1);
assert_eq!(oakcommon_videoparams_set_colorspace(CHandle::null(), to_cstring("sRGB").as_ptr()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_set_colorspace(dup(&h), std::ptr::null()), OAKCOMMON_E_INVALID);
assert_eq!(
oakcommon_videoparams_get_colorspace(CHandle::null(), std::ptr::null_mut(), 0),
OAKCOMMON_E_INVALID
);
}
// ---- Derived values ----
/// Square-pixel width, effective dimensions/depth, validity, bytes per
/// channel/pixel, and buffer size, computed from the stored fields.
#[test]
fn derived_dimensions_and_buffer() {
let h = make();
let mut v = -1i32;
let mut w = -1i32;
let mut ht = -1i32;
let mut d = -1i32;
assert_eq!(oakcommon_videoparams_get_square_pixel_width(dup(&h), &mut v), OAKCOMMON_OK);
assert_eq!(v, 1920);
assert_eq!(oakcommon_videoparams_get_effective_width(dup(&h), &mut v), OAKCOMMON_OK);
assert_eq!(v, 1920);
assert_eq!(oakcommon_videoparams_get_effective_height(dup(&h), &mut v), OAKCOMMON_OK);
assert_eq!(v, 1080);
assert_eq!(oakcommon_videoparams_get_effective_depth(dup(&h), &mut v), OAKCOMMON_OK);
assert_eq!(v, 1);
assert_eq!(oakcommon_videoparams_get_is_valid(dup(&h), &mut v), OAKCOMMON_OK);
assert_eq!(v, 1);
assert_eq!(oakcommon_videoparams_get_bytes_per_channel(dup(&h), &mut v), OAKCOMMON_OK);
assert_eq!(v, 1);
assert_eq!(oakcommon_videoparams_get_bytes_per_pixel(dup(&h), &mut v), OAKCOMMON_OK);
assert_eq!(v, 4);
assert_eq!(oakcommon_videoparams_get_buffer_size(dup(&h), &mut v), OAKCOMMON_OK);
assert_eq!(v, 1920 * 1080 * 4);
// Square-pixel width follows the pixel aspect ratio (lround(1920*16/9)
// == 3413) and the effective width follows the divider.
assert_eq!(oakcommon_videoparams_set_pixel_aspect_ratio(dup(&h), 16, 9), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_square_pixel_width(dup(&h), &mut v), OAKCOMMON_OK);
assert_eq!(v, 3413);
assert_eq!(oakcommon_videoparams_set_divider(dup(&h), 2), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_effective_width(dup(&h), &mut v), OAKCOMMON_OK);
assert_eq!(v, 960);
assert_eq!(oakcommon_videoparams_get_effective_height(dup(&h), &mut v), OAKCOMMON_OK);
assert_eq!(v, 540);
// An invalid format makes the parameter set invalid.
assert_eq!(oakcommon_videoparams_set_format(dup(&h), 999), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_is_valid(dup(&h), &mut v), OAKCOMMON_OK);
assert_eq!(v, 0);
assert_eq!(oakcommon_videoparams_get_square_pixel_width(CHandle::null(), &mut v), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_effective_width(CHandle::null(), &mut v), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_effective_height(CHandle::null(), &mut v), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_effective_depth(CHandle::null(), &mut v), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_is_valid(CHandle::null(), &mut v), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_bytes_per_channel(CHandle::null(), &mut v), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_bytes_per_pixel(CHandle::null(), &mut v), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_buffer_size(CHandle::null(), &mut v), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_square_pixel_width(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_effective_width(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_effective_height(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_effective_depth(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_is_valid(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_bytes_per_channel(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_bytes_per_pixel(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_buffer_size(dup(&h), std::ptr::null_mut()), OAKCOMMON_E_INVALID);
// Clean up the width/height out-params used above to keep clippy quiet.
assert_eq!(w, -1);
assert_eq!(ht, -1);
assert_eq!(d, -1);
}
/// `get_time_in_timebase_units` converts a time into time-base units plus
/// the start time. With no time base set it writes `i64::MIN`
/// (CPP-PARITY: C++ returns `INT64_MIN` / `AV_NOPTS_VALUE`) but still
/// succeeds.
#[test]
fn time_in_timebase_units() {
let h = make_tb();
let mut ts = -1i64;
// Time base 1001/30000; 1001/30000 seconds == 1 unit.
assert_eq!(oakcommon_videoparams_get_time_in_timebase_units(dup(&h), 1001, 30000, &mut ts), OAKCOMMON_OK);
assert_eq!(ts, 1);
assert_eq!(oakcommon_videoparams_set_start_time(dup(&h), 5i64), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_get_time_in_timebase_units(dup(&h), 1001, 30000, &mut ts), OAKCOMMON_OK);
assert_eq!(ts, 6);
// A time base of 1001/30000 (29.97 fps): 1 second == 30 units.
assert_eq!(oakcommon_videoparams_get_time_in_timebase_units(dup(&h), 1, 1, &mut ts), OAKCOMMON_OK);
assert_eq!(ts, 35);
// `init_basic` has no time base: i64::MIN is written, call succeeds.
let nb = make();
assert_eq!(oakcommon_videoparams_get_time_in_timebase_units(dup(&nb), 1, 1, &mut ts), OAKCOMMON_OK);
assert_eq!(ts, i64::MIN);
assert_eq!(
oakcommon_videoparams_get_time_in_timebase_units(CHandle::null(), 1, 1, &mut ts),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_videoparams_get_time_in_timebase_units(dup(&h), 1, 1, std::ptr::null_mut()),
OAKCOMMON_E_INVALID
);
}
/// `equals` compares the nine parameter-set fields; the same fields yield 1
/// and any difference yields 0. A null handle, null `other`, or null
/// out-param is `E_INVALID`.
#[test]
fn equals() {
let a = make();
let b = make();
let mut eq = -1i32;
assert_eq!(oakcommon_videoparams_equals(dup(&a), dup(&b), &mut eq), OAKCOMMON_OK);
assert_eq!(eq, 1);
assert_eq!(oakcommon_videoparams_set_width(dup(&b), 640), OAKCOMMON_OK);
assert_eq!(oakcommon_videoparams_equals(dup(&a), dup(&b), &mut eq), OAKCOMMON_OK);
assert_eq!(eq, 0);
assert_eq!(
oakcommon_videoparams_equals(CHandle::null(), dup(&b), &mut eq),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_videoparams_equals(dup(&a), CHandle::null(), &mut eq),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_videoparams_equals(dup(&a), dup(&b), std::ptr::null_mut()),
OAKCOMMON_E_INVALID
);
}
// ---- XML ----
/// `load_xml` parses a fragment and applies the fields; malformed input is
/// `E_FAILED`. `save_xml` is a two-stage getter whose output starts with
/// the `<videoparams>` root.
#[test]
fn load_save_xml() {
let h = make();
// Round-trip through XML.
let xml = "<videoparams><width>640</width><height>480</height></videoparams>";
assert_eq!(oakcommon_videoparams_load_xml(dup(&h), to_cstring(xml).as_ptr()), OAKCOMMON_OK);
let mut w = -1i32;
let mut ht = -1i32;
assert_eq!(oakcommon_videoparams_get_width(dup(&h), &mut w), OAKCOMMON_OK);
assert_eq!(w, 640);
assert_eq!(oakcommon_videoparams_get_height(dup(&h), &mut ht), OAKCOMMON_OK);
assert_eq!(ht, 480);
// save_xml is a two-stage getter (CPP-PARITY: copy_string semantics).
let required = oakcommon_videoparams_save_xml(dup(&h), std::ptr::null_mut(), 0);
assert!(required > 0);
let mut out = vec![0u8; required as usize];
assert_eq!(
oakcommon_videoparams_save_xml(dup(&h), out.as_mut_ptr() as *mut c_char, required),
required
);
let s = unsafe { CStr::from_ptr(out.as_ptr() as *const c_char) }.to_str().unwrap();
assert!(s.starts_with("<videoparams>"));
assert!(s.contains("<width>640</width>"));
// Malformed fragments fail with E_FAILED.
assert_eq!(oakcommon_videoparams_load_xml(dup(&h), to_cstring("<width>").as_ptr()), OAKCOMMON_E_FAILED);
assert_eq!(oakcommon_videoparams_load_xml(dup(&h), to_cstring("not xml").as_ptr()), OAKCOMMON_E_FAILED);
assert_eq!(oakcommon_videoparams_load_xml(dup(&h), to_cstring("").as_ptr()), OAKCOMMON_E_FAILED);
assert_eq!(
oakcommon_videoparams_load_xml(CHandle::null(), to_cstring(xml).as_ptr()),
OAKCOMMON_E_INVALID
);
assert_eq!(oakcommon_videoparams_load_xml(dup(&h), std::ptr::null()), OAKCOMMON_E_INVALID);
assert_eq!(
oakcommon_videoparams_save_xml(CHandle::null(), std::ptr::null_mut(), 0),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_videoparams_save_xml(dup(&h), std::ptr::null_mut(), 5),
OAKCOMMON_E_INVALID
);
}
// ---- Static helpers ----
/// Pure static arithmetic helpers. These take no handle and have no failure
/// path by design (out-of-range formats map to `Invalid`, which yields 0
/// bytes).
#[test]
fn static_arithmetic() {
// bytes per channel: U8=1, U10=0, U16/F16=2, F32=4, Invalid/Count=0.
assert_eq!(oakcommon_videoparams_get_bytes_per_channel_for_format(0), 1);
assert_eq!(oakcommon_videoparams_get_bytes_per_channel_for_format(1), 0);
assert_eq!(oakcommon_videoparams_get_bytes_per_channel_for_format(2), 2);
assert_eq!(oakcommon_videoparams_get_bytes_per_channel_for_format(4), 4);
assert_eq!(oakcommon_videoparams_get_bytes_per_channel_for_format(999), 0);
assert_eq!(oakcommon_videoparams_static_get_bytes_per_channel(0), 1);
assert_eq!(oakcommon_videoparams_static_get_bytes_per_channel(4), 4);
// bytes per pixel: U10 is packed 4 bytes only for 4 channels.
assert_eq!(oakcommon_videoparams_get_bytes_per_pixel_for_format(0, 4), 4);
assert_eq!(oakcommon_videoparams_get_bytes_per_pixel_for_format(1, 4), 4);
assert_eq!(oakcommon_videoparams_get_bytes_per_pixel_for_format(1, 3), 0);
assert_eq!(oakcommon_videoparams_get_bytes_per_pixel_for_format(2, 3), 6);
assert_eq!(oakcommon_videoparams_get_bytes_per_pixel_for_format(4, 4), 16);
assert_eq!(oakcommon_videoparams_get_bytes_per_pixel_for_format(999, 4), 0);
assert_eq!(oakcommon_videoparams_static_get_bytes_per_pixel(0, 4), 4);
assert_eq!(oakcommon_videoparams_static_get_bytes_per_pixel(1, 4), 4);
// buffer size = w * h * bpp (U8 RGBA).
assert_eq!(oakcommon_videoparams_calculate_buffer_size(1920, 1080, 0, 4), 1920 * 1080 * 4);
// float formats are F16/F32 only.
assert_eq!(oakcommon_videoparams_format_is_float(3), 1);
assert_eq!(oakcommon_videoparams_format_is_float(4), 1);
assert_eq!(oakcommon_videoparams_format_is_float(0), 0);
assert_eq!(oakcommon_videoparams_format_is_float(999), 0);
// auto divider: 1920x1080 -> 2 (of 8, 12, 16 ... capped).
assert_eq!(oakcommon_videoparams_generate_auto_divider(100, 100), 1);
assert_eq!(oakcommon_videoparams_generate_auto_divider(1920, 1080), 2);
assert_eq!(oakcommon_videoparams_generate_auto_divider(3840, 2160), 3);
// scaled dimension is floor(dim/divider).
assert_eq!(oakcommon_videoparams_get_scaled_dimension(1920, 2), 960);
assert_eq!(oakcommon_videoparams_get_scaled_dimension(1080, 2), 540);
// divider for a target resolution.
assert_eq!(
oakcommon_videoparams_get_divider_for_target_resolution(3840, 2160, 1920, 1080),
2
);
assert_eq!(
oakcommon_videoparams_get_divider_for_target_resolution(1920, 1080, 1920, 1080),
1
);
assert_eq!(
oakcommon_videoparams_get_divider_for_target_resolution(1921, 1081, 959, 540),
3
);
}
/// Static two-stage string getters: divider names, format names, and
/// frame-rate strings. An invalid output buffer is `E_INVALID`.
#[test]
fn static_string_getters() {
assert_two_stage_getter(|buf, size| oakcommon_videoparams_get_name_for_divider(1, buf, size), "Full");
assert_two_stage_getter(|buf, size| oakcommon_videoparams_get_name_for_divider(2, buf, size), "1/2");
assert_two_stage_getter(|buf, size| oakcommon_videoparams_get_name_for_divider(8, buf, size), "1/8");
assert_two_stage_getter(|buf, size| oakcommon_videoparams_get_format_name(0, buf, size), "8-bit");
assert_two_stage_getter(|buf, size| oakcommon_videoparams_get_format_name(1, buf, size), "10-bit Packed");
assert_two_stage_getter(|buf, size| oakcommon_videoparams_get_format_name(2, buf, size), "16-bit Integer");
assert_two_stage_getter(|buf, size| oakcommon_videoparams_get_format_name(3, buf, size), "Half-Float (16-bit)");
assert_two_stage_getter(|buf, size| oakcommon_videoparams_get_format_name(4, buf, size), "Full-Float (32-bit)");
assert_two_stage_getter(|buf, size| oakcommon_videoparams_get_format_name(5, buf, size), "Unknown (0x5)");
assert_two_stage_getter(|buf, size| oakcommon_videoparams_get_format_name(-1, buf, size), "Unknown (0xFFFFFFFF)");
assert_two_stage_getter(
|buf, size| oakcommon_videoparams_frame_rate_to_string(24000, 1001, buf, size),
"23.976 FPS",
);
assert_two_stage_getter(
|buf, size| oakcommon_videoparams_frame_rate_to_string(24, 1, buf, size),
"24 FPS",
);
assert_two_stage_getter(
|buf, size| oakcommon_videoparams_frame_rate_to_string(0, 0, buf, size),
"nan FPS",
);
// An invalid output buffer (null with a positive size) is E_INVALID.
assert_eq!(oakcommon_videoparams_get_name_for_divider(1, std::ptr::null_mut(), 5), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_get_format_name(0, std::ptr::null_mut(), 5), OAKCOMMON_E_INVALID);
assert_eq!(oakcommon_videoparams_frame_rate_to_string(24, 1, std::ptr::null_mut(), 5), OAKCOMMON_E_INVALID);
}
+666
View File
@@ -0,0 +1,666 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! FFI-level integration tests for the C-ABI XML streaming exports in
//! `oakcommon::ffi::xmlutils`, asserted against the C++ oracle
//! `src/common/c_api/xmlutils.cpp`.
//!
//! `ffi::error` and `ffi::error_abi` are documented placeholder modules
//! (`include/common/error.h` exposes no functions; the `OAKCOMMON_OK` /
//! `OAKCOMMON_E_*` constants live in `crate::error`), so the only callable
//! surface here is `xmlutils`. The error constants its exports return are
//! pinned in `error_constants_returned_by_xmlutils_match_header`; the full
//! set is already asserted in `contract.rs`.
//!
//! The contract under test (each point matches the C++ oracle):
//! - exports take a `CHandle` by value and never release it;
//! - two-stage string getters return the required size (NUL included) and
//! only copy when the buffer is large enough — they never truncate;
//! - out-of-range attribute indexes report `OAKCOMMON_E_NOT_FOUND`;
//! - a malformed document still yields a usable handle whose `has_error`
//! flag reads back 1 and whose navigation reports no start elements.
use std::ffi::{c_char, CString};
use oakcommon::error::{OAKCOMMON_E_INVALID, OAKCOMMON_E_NOT_FOUND, OAKCOMMON_OK};
use oakcommon::ffi::xmlutils::*;
use oakcommon::handle::{CHandle, OAKCOMMON_ABI_VERSION};
/// Shared sample document: two attributes on the root plus a nested text
/// element. The writer test sequence reproduces this exact document.
const DOC: &str = r#"<root a="1" b="two"><child>text here</child></root>"#;
/// Convert a string slice to a NUL-terminated C string for FFI inputs.
fn to_cstring(s: &str) -> CString {
CString::new(s).expect("test string must not contain NUL")
}
/// Cheap struct copy: `CHandle` is neither `Clone` nor `Copy`, but every
/// getter takes it by value. Rebuilding from the same fields duplicates
/// only the handle value — the box stays alive as long as the original
/// handle lives, and the getters never release.
fn dup(h: &CHandle) -> CHandle {
CHandle {
ctx: h.ctx,
addref: h.addref,
release: h.release,
abi_version: h.abi_version,
}
}
/// Drive a two-stage string getter against the C++ `copy_string`
/// convention: a null-buffer size query, a short buffer that must stay
/// untouched (no truncation), an exact-fit copy with its NUL, and an
/// oversized copy with the tail untouched.
fn assert_two_stage_getter(getter: impl Fn(*mut c_char, i32) -> i32, expected: &str) {
let required = (expected.len() + 1) as i32;
// Size query: a null buffer returns the required size, NUL included.
assert_eq!(getter(std::ptr::null_mut(), 0), required);
// Short buffer: too small, so nothing is written to it.
let short_size = (required - 1).max(0);
let mut short = vec![0xABu8; short_size as usize];
assert_eq!(getter(short.as_mut_ptr() as *mut c_char, short_size), required);
assert!(short.iter().all(|&b| b == 0xAB), "short buffer must stay untouched");
// Exact fit: payload followed by a NUL.
let mut exact = vec![0xCDu8; required as usize];
assert_eq!(getter(exact.as_mut_ptr() as *mut c_char, required), required);
assert_eq!(&exact[..expected.len()], expected.as_bytes());
assert_eq!(exact[expected.len()], 0);
// Oversized: payload and NUL written, tail left as initialized.
let mut big = vec![0u8; (required + 8) as usize];
assert_eq!(getter(big.as_mut_ptr() as *mut c_char, required + 8), required);
assert_eq!(&big[..expected.len()], expected.as_bytes());
assert_eq!(big[expected.len()], 0);
assert!(big[(required + 1) as usize..].iter().all(|&b| b == 0));
}
// ---- Reader: handle lifecycle ----
/// `init` with a null data pointer yields an empty handle.
#[test]
fn init_returns_null_handle_for_null_data() {
let h = oakcommon_xml_reader_init(std::ptr::null());
assert!(h.is_null());
assert!(h.ctx.is_null());
assert!(h.addref.is_none());
assert!(h.release.is_none());
}
/// `init` over valid data yields a stamped, non-empty handle.
#[test]
fn init_creates_stamped_handle() {
let h = oakcommon_xml_reader_init(to_cstring(DOC).as_ptr());
assert!(!h.is_null());
assert_eq!(h.abi_version, OAKCOMMON_ABI_VERSION);
assert!(h.addref.is_some());
assert!(h.release.is_some());
}
/// `free` nullifies the handle, is idempotent, and tolerates a null
/// pointer.
#[test]
fn free_nullifies_and_is_idempotent() {
let mut h = oakcommon_xml_reader_init(to_cstring(DOC).as_ptr());
assert!(!h.is_null());
oakcommon_xml_reader_free(&mut h);
assert!(h.is_null());
// A second free of the now-empty handle is safe.
oakcommon_xml_reader_free(&mut h);
assert!(h.is_null());
// Freeing a null pointer is safe.
oakcommon_xml_reader_free(std::ptr::null_mut());
}
// ---- Reader: navigation ----
/// `read_next_start_element` writes 1 while a start element is found and
/// 0 once the document is exhausted (CPP-PARITY: an end element and the
/// end of the document both report 0, not an error).
#[test]
fn read_next_start_element_reports_found() {
let r = oakcommon_xml_reader_init(to_cstring(DOC).as_ptr());
let mut found = -1i32;
assert_eq!(
oakcommon_xml_reader_read_next_start_element(dup(&r), &mut found),
OAKCOMMON_OK
);
assert_eq!(found, 1); // root
assert_eq!(
oakcommon_xml_reader_read_next_start_element(dup(&r), &mut found),
OAKCOMMON_OK
);
assert_eq!(found, 1); // child
assert_eq!(
oakcommon_xml_reader_read_next_start_element(dup(&r), &mut found),
OAKCOMMON_OK
);
assert_eq!(found, 0); // child's end element
assert_eq!(
oakcommon_xml_reader_read_next_start_element(dup(&r), &mut found),
OAKCOMMON_OK
);
assert_eq!(found, 0); // root's end element
}
/// A null reader handle or a null `found` out-param is `E_INVALID`.
#[test]
fn read_next_start_element_rejects_null_args() {
let r = oakcommon_xml_reader_init(to_cstring(DOC).as_ptr());
let mut found = 0i32;
assert_eq!(
oakcommon_xml_reader_read_next_start_element(CHandle::null(), &mut found),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_xml_reader_read_next_start_element(dup(&r), std::ptr::null_mut()),
OAKCOMMON_E_INVALID
);
}
// ---- Reader: two-stage string getters ----
/// `name` is a two-stage getter over the current element's name; a null
/// handle is `E_INVALID`.
#[test]
fn name_two_stage_getter() {
let r = oakcommon_xml_reader_init(to_cstring(DOC).as_ptr());
let mut found = 0i32;
assert_eq!(
oakcommon_xml_reader_read_next_start_element(dup(&r), &mut found),
OAKCOMMON_OK
);
assert_eq!(found, 1);
assert_two_stage_getter(|buf, size| oakcommon_xml_reader_name(dup(&r), buf, size), "root");
assert_eq!(
oakcommon_xml_reader_name(CHandle::null(), std::ptr::null_mut(), 0),
OAKCOMMON_E_INVALID
);
}
/// Before any token is read the name is the empty string (size 1).
#[test]
fn name_is_empty_before_any_read() {
let r = oakcommon_xml_reader_init(to_cstring(DOC).as_ptr());
assert_eq!(oakcommon_xml_reader_name(dup(&r), std::ptr::null_mut(), 0), 1);
}
/// `read_element_text` is a two-stage getter and caches its result, so a
/// second read returns the same text even though the stream was consumed
/// by the first (CPP-PARITY with the C++ `XmlReaderState::cached_text`).
#[test]
fn read_element_text_two_stage_with_cache() {
let r = oakcommon_xml_reader_init(to_cstring(DOC).as_ptr());
let mut found = 0i32;
assert_eq!(
oakcommon_xml_reader_read_next_start_element(dup(&r), &mut found),
OAKCOMMON_OK
);
assert_eq!(
oakcommon_xml_reader_read_next_start_element(dup(&r), &mut found),
OAKCOMMON_OK
);
assert_eq!(found, 1);
assert_two_stage_getter(
|buf, size| oakcommon_xml_reader_read_element_text(dup(&r), buf, size),
"text here",
);
assert_two_stage_getter(
|buf, size| oakcommon_xml_reader_read_element_text(dup(&r), buf, size),
"text here",
);
}
/// When not on a start element the text is empty (CPP-PARITY: the C++
/// reader returns an empty string).
#[test]
fn read_element_text_not_on_start_element_is_empty() {
let r = oakcommon_xml_reader_init(to_cstring(DOC).as_ptr());
let mut found = 0i32;
assert_eq!(
oakcommon_xml_reader_read_next_start_element(dup(&r), &mut found),
OAKCOMMON_OK
);
assert_eq!(
oakcommon_xml_reader_read_next_start_element(dup(&r), &mut found),
OAKCOMMON_OK
);
assert_eq!(
oakcommon_xml_reader_read_next_start_element(dup(&r), &mut found),
OAKCOMMON_OK
);
assert_eq!(found, 0);
assert_eq!(
oakcommon_xml_reader_read_element_text(dup(&r), std::ptr::null_mut(), 0),
1
);
assert_eq!(
oakcommon_xml_reader_read_element_text(CHandle::null(), std::ptr::null_mut(), 0),
OAKCOMMON_E_INVALID
);
}
/// `skip_current_element` consumes the current element and its subtree; a
/// null handle is `E_INVALID`.
#[test]
fn skip_current_element_skips_subtree() {
let r = oakcommon_xml_reader_init(to_cstring(DOC).as_ptr());
let mut found = 0i32;
assert_eq!(
oakcommon_xml_reader_read_next_start_element(dup(&r), &mut found),
OAKCOMMON_OK
);
assert_eq!(found, 1);
assert_eq!(oakcommon_xml_reader_skip_current_element(dup(&r)), OAKCOMMON_OK);
assert_eq!(
oakcommon_xml_reader_read_next_start_element(dup(&r), &mut found),
OAKCOMMON_OK
);
assert_eq!(found, 0);
assert_eq!(
oakcommon_xml_reader_skip_current_element(CHandle::null()),
OAKCOMMON_E_INVALID
);
}
// ---- Reader: attributes ----
/// `attribute_count` reports 0 before any token and the real count on a
/// start element; a null handle or a null count out-param is `E_INVALID`.
#[test]
fn attribute_count_reports_attributes() {
let r = oakcommon_xml_reader_init(to_cstring(DOC).as_ptr());
let mut count = -1i32;
assert_eq!(
oakcommon_xml_reader_attribute_count(dup(&r), &mut count),
OAKCOMMON_OK
);
assert_eq!(count, 0);
let mut found = 0i32;
assert_eq!(
oakcommon_xml_reader_read_next_start_element(dup(&r), &mut found),
OAKCOMMON_OK
);
assert_eq!(found, 1);
assert_eq!(
oakcommon_xml_reader_attribute_count(dup(&r), &mut count),
OAKCOMMON_OK
);
assert_eq!(count, 2);
assert_eq!(
oakcommon_xml_reader_attribute_count(CHandle::null(), &mut count),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_xml_reader_attribute_count(dup(&r), std::ptr::null_mut()),
OAKCOMMON_E_INVALID
);
}
/// `attribute_name` / `attribute_value` are two-stage getters over the
/// attributes of the current start element, in document order.
#[test]
fn attribute_name_and_value_two_stage() {
let r = oakcommon_xml_reader_init(to_cstring(DOC).as_ptr());
let mut found = 0i32;
assert_eq!(
oakcommon_xml_reader_read_next_start_element(dup(&r), &mut found),
OAKCOMMON_OK
);
assert_eq!(found, 1);
assert_two_stage_getter(
|buf, size| oakcommon_xml_reader_attribute_name(dup(&r), 0, buf, size),
"a",
);
assert_two_stage_getter(
|buf, size| oakcommon_xml_reader_attribute_value(dup(&r), 0, buf, size),
"1",
);
assert_two_stage_getter(
|buf, size| oakcommon_xml_reader_attribute_name(dup(&r), 1, buf, size),
"b",
);
assert_two_stage_getter(
|buf, size| oakcommon_xml_reader_attribute_value(dup(&r), 1, buf, size),
"two",
);
}
/// An out-of-range attribute index is `E_NOT_FOUND` (CPP-PARITY), while a
/// null handle is `E_INVALID`.
#[test]
fn attribute_out_of_range_is_not_found() {
let r = oakcommon_xml_reader_init(to_cstring(DOC).as_ptr());
let mut found = 0i32;
assert_eq!(
oakcommon_xml_reader_read_next_start_element(dup(&r), &mut found),
OAKCOMMON_OK
);
assert_eq!(found, 1);
assert_eq!(
oakcommon_xml_reader_attribute_name(dup(&r), 2, std::ptr::null_mut(), 0),
OAKCOMMON_E_NOT_FOUND
);
assert_eq!(
oakcommon_xml_reader_attribute_name(dup(&r), -1, std::ptr::null_mut(), 0),
OAKCOMMON_E_NOT_FOUND
);
assert_eq!(
oakcommon_xml_reader_attribute_value(dup(&r), 2, std::ptr::null_mut(), 0),
OAKCOMMON_E_NOT_FOUND
);
assert_eq!(
oakcommon_xml_reader_attribute_name(CHandle::null(), 0, std::ptr::null_mut(), 0),
OAKCOMMON_E_INVALID
);
}
// ---- Reader: error reporting ----
/// `has_error` is 0 for a well-formed document and 1 for a malformed one;
/// a malformed document still navigates safely and reports no start
/// elements (CPP-PARITY: parse failures surface lazily through
/// `has_error`, never at init time).
#[test]
fn has_error_flags_malformed_documents() {
let mut err = -1i32;
let r = oakcommon_xml_reader_init(to_cstring(DOC).as_ptr());
assert_eq!(oakcommon_xml_reader_has_error(dup(&r), &mut err), OAKCOMMON_OK);
assert_eq!(err, 0);
let bad = oakcommon_xml_reader_init(to_cstring("<a></b>").as_ptr());
assert!(!bad.is_null());
assert_eq!(oakcommon_xml_reader_has_error(dup(&bad), &mut err), OAKCOMMON_OK);
assert_eq!(err, 1);
let mut found = -1i32;
assert_eq!(
oakcommon_xml_reader_read_next_start_element(dup(&bad), &mut found),
OAKCOMMON_OK
);
assert_eq!(found, 0);
let empty = oakcommon_xml_reader_init(to_cstring("").as_ptr());
assert!(!empty.is_null());
assert_eq!(oakcommon_xml_reader_has_error(dup(&empty), &mut err), OAKCOMMON_OK);
assert_eq!(err, 1);
assert_eq!(
oakcommon_xml_reader_has_error(CHandle::null(), &mut err),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_xml_reader_has_error(dup(&r), std::ptr::null_mut()),
OAKCOMMON_E_INVALID
);
}
// ---- Writer: handle lifecycle ----
/// `writer_init` yields a stamped handle; `free` nullifies it, is
/// idempotent, and tolerates a null pointer.
#[test]
fn writer_init_free_and_idempotent() {
let mut w = oakcommon_xml_writer_init();
assert!(!w.is_null());
assert_eq!(w.abi_version, OAKCOMMON_ABI_VERSION);
assert!(w.addref.is_some());
assert!(w.release.is_some());
oakcommon_xml_writer_free(&mut w);
assert!(w.is_null());
oakcommon_xml_writer_free(&mut w);
assert!(w.is_null());
oakcommon_xml_writer_free(std::ptr::null_mut());
}
// ---- Writer: operations ----
/// Every writer export rejects a null handle with `E_INVALID`, and the
/// string-taking exports reject null strings.
#[test]
fn writer_rejects_null_arguments() {
let w = oakcommon_xml_writer_init();
assert_eq!(
oakcommon_xml_writer_write_start_element(CHandle::null(), to_cstring("a").as_ptr()),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_xml_writer_write_attribute(
CHandle::null(),
to_cstring("a").as_ptr(),
to_cstring("b").as_ptr(),
),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_xml_writer_write_characters(CHandle::null(), to_cstring("x").as_ptr()),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_xml_writer_write_text_element(
CHandle::null(),
to_cstring("a").as_ptr(),
to_cstring("b").as_ptr(),
),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_xml_writer_write_end_element(CHandle::null()),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_xml_writer_write_end_document(CHandle::null()),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_xml_writer_output(CHandle::null(), std::ptr::null_mut(), 0),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_xml_writer_write_start_element(dup(&w), std::ptr::null()),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_xml_writer_write_attribute(dup(&w), std::ptr::null(), to_cstring("b").as_ptr()),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_xml_writer_write_attribute(dup(&w), to_cstring("a").as_ptr(), std::ptr::null()),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_xml_writer_write_characters(dup(&w), std::ptr::null()),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_xml_writer_write_text_element(dup(&w), std::ptr::null(), to_cstring("b").as_ptr()),
OAKCOMMON_E_INVALID
);
assert_eq!(
oakcommon_xml_writer_write_text_element(dup(&w), to_cstring("a").as_ptr(), std::ptr::null()),
OAKCOMMON_E_INVALID
);
}
/// The writer sequence builds the exact sample document, and `output` is a
/// two-stage getter over it.
#[test]
fn writer_builds_document_and_output_two_stage() {
let w = oakcommon_xml_writer_init();
assert_eq!(
oakcommon_xml_writer_write_start_element(dup(&w), to_cstring("root").as_ptr()),
OAKCOMMON_OK
);
assert_eq!(
oakcommon_xml_writer_write_attribute(dup(&w), to_cstring("a").as_ptr(), to_cstring("1").as_ptr()),
OAKCOMMON_OK
);
assert_eq!(
oakcommon_xml_writer_write_attribute(dup(&w), to_cstring("b").as_ptr(), to_cstring("two").as_ptr()),
OAKCOMMON_OK
);
assert_eq!(
oakcommon_xml_writer_write_text_element(
dup(&w),
to_cstring("child").as_ptr(),
to_cstring("text here").as_ptr(),
),
OAKCOMMON_OK
);
assert_eq!(oakcommon_xml_writer_write_end_element(dup(&w)), OAKCOMMON_OK);
assert_eq!(oakcommon_xml_writer_write_end_document(dup(&w)), OAKCOMMON_OK);
assert_two_stage_getter(|buf, size| oakcommon_xml_writer_output(dup(&w), buf, size), DOC);
}
/// The writer's output round-trips through the reader.
#[test]
fn writer_output_round_trips_through_reader() {
let w = oakcommon_xml_writer_init();
assert_eq!(
oakcommon_xml_writer_write_start_element(dup(&w), to_cstring("root").as_ptr()),
OAKCOMMON_OK
);
assert_eq!(
oakcommon_xml_writer_write_attribute(dup(&w), to_cstring("a").as_ptr(), to_cstring("1").as_ptr()),
OAKCOMMON_OK
);
assert_eq!(
oakcommon_xml_writer_write_attribute(dup(&w), to_cstring("b").as_ptr(), to_cstring("two").as_ptr()),
OAKCOMMON_OK
);
assert_eq!(
oakcommon_xml_writer_write_text_element(
dup(&w),
to_cstring("child").as_ptr(),
to_cstring("text here").as_ptr(),
),
OAKCOMMON_OK
);
assert_eq!(oakcommon_xml_writer_write_end_element(dup(&w)), OAKCOMMON_OK);
let mut out = vec![0u8; 64];
let needed = oakcommon_xml_writer_output(dup(&w), out.as_mut_ptr() as *mut c_char, 64);
assert_eq!(needed, (DOC.len() + 1) as i32);
assert_eq!(&out[..DOC.len()], DOC.as_bytes());
assert_eq!(out[DOC.len()], 0);
let r = oakcommon_xml_reader_init(out.as_ptr() as *const c_char);
assert!(!r.is_null());
let mut found = 0i32;
assert_eq!(
oakcommon_xml_reader_read_next_start_element(dup(&r), &mut found),
OAKCOMMON_OK
);
assert_eq!(found, 1);
assert_two_stage_getter(|buf, size| oakcommon_xml_reader_name(dup(&r), buf, size), "root");
assert_eq!(
oakcommon_xml_reader_read_next_start_element(dup(&r), &mut found),
OAKCOMMON_OK
);
assert_eq!(found, 1);
assert_two_stage_getter(
|buf, size| oakcommon_xml_reader_read_element_text(dup(&r), buf, size),
"text here",
);
assert_eq!(
oakcommon_xml_reader_read_next_start_element(dup(&r), &mut found),
OAKCOMMON_OK
);
assert_eq!(found, 0);
}
/// Documented no-op writer operations (attribute without an open start
/// tag, end element on an empty stack, end document with nothing open)
/// still return `OK`.
#[test]
fn writer_noop_operations_return_ok() {
let w = oakcommon_xml_writer_init();
assert_eq!(
oakcommon_xml_writer_write_attribute(dup(&w), to_cstring("a").as_ptr(), to_cstring("b").as_ptr()),
OAKCOMMON_OK
);
assert_eq!(oakcommon_xml_writer_write_end_element(dup(&w)), OAKCOMMON_OK);
assert_eq!(oakcommon_xml_writer_write_end_document(dup(&w)), OAKCOMMON_OK);
assert_eq!(
oakcommon_xml_writer_write_characters(dup(&w), to_cstring("x").as_ptr()),
OAKCOMMON_OK
);
assert_two_stage_getter(|buf, size| oakcommon_xml_writer_output(dup(&w), buf, size), "x");
}
/// An empty element with attributes serializes as `<a k="v"/>`.
#[test]
fn writer_self_closing_empty_element() {
let w = oakcommon_xml_writer_init();
assert_eq!(
oakcommon_xml_writer_write_start_element(dup(&w), to_cstring("a").as_ptr()),
OAKCOMMON_OK
);
assert_eq!(
oakcommon_xml_writer_write_attribute(dup(&w), to_cstring("k").as_ptr(), to_cstring("v").as_ptr()),
OAKCOMMON_OK
);
assert_eq!(oakcommon_xml_writer_write_end_element(dup(&w)), OAKCOMMON_OK);
assert_two_stage_getter(|buf, size| oakcommon_xml_writer_output(dup(&w), buf, size), r#"<a k="v"/>"#);
}
/// Text and attribute values are escaped for the five predefined XML
/// entities.
#[test]
fn writer_escapes_text_and_attributes() {
let w = oakcommon_xml_writer_init();
assert_eq!(
oakcommon_xml_writer_write_text_element(
dup(&w),
to_cstring("e").as_ptr(),
to_cstring("hi & bye <there>").as_ptr(),
),
OAKCOMMON_OK
);
assert_eq!(
oakcommon_xml_writer_write_start_element(dup(&w), to_cstring("a").as_ptr()),
OAKCOMMON_OK
);
assert_eq!(
oakcommon_xml_writer_write_attribute(
dup(&w),
to_cstring("q").as_ptr(),
to_cstring("x\"y&z").as_ptr(),
),
OAKCOMMON_OK
);
assert_eq!(oakcommon_xml_writer_write_end_element(dup(&w)), OAKCOMMON_OK);
assert_two_stage_getter(
|buf, size| oakcommon_xml_writer_output(dup(&w), buf, size),
r#"<e>hi &amp; bye &lt;there&gt;</e><a q="x&quot;y&amp;z"/>"#,
);
}
// ---- error / error_abi ----
/// The constants the xmlutils exports return are defined in `crate::error`
/// and match `include/common/error.h` (the `ffi::error` / `ffi::error_abi`
/// modules only point here). The full set is pinned in `contract.rs`.
#[test]
fn error_constants_returned_by_xmlutils_match_header() {
assert_eq!(OAKCOMMON_OK, 0);
assert_eq!(OAKCOMMON_E_INVALID, -10001);
assert_eq!(OAKCOMMON_E_NOT_FOUND, -10004);
}
+168
View File
@@ -0,0 +1,168 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Real-library smoke tests for the OCIO/image-backed utilities.
//!
//! These exercise the real OpenColorIO library through `ocio-rs`/`ocio-sys`
//! (compiled with `OCIO_RS_ENABLE_REAL=1`, see `.cargo/config.toml`). Each
//! OCIO test loads the project's own config at
//! `engine/render/ocioconf/config.ocio` and sets the `OCIO` environment
//! variable first (the library reads it at config-load time). The image tests
//! round-trip a small float TIFF through a temp file via the `image` crate.
use oakcommon::error::Error;
use oakcommon::ocioutils::OcioConfig;
use oakcommon::oiioutils::{read_image_f32, write_image_f32};
/// Path to the project's OCIO config, relative to this crate's manifest dir.
fn config_path() -> String {
let manifest = env!("CARGO_MANIFEST_DIR");
// src/common/rust -> repository root
format!("{}/../../../engine/render/ocioconf/config.ocio", manifest)
}
/// The `OCIO` env var is consumed by the library at config-load time; setting
/// it here keeps the test hermetic regardless of the host environment.
fn set_ocio_env() -> String {
let path = config_path();
std::env::set_var("OCIO", &path);
path
}
#[test]
fn ocio_load_and_list_colorspaces() {
let path = set_ocio_env();
let config = OcioConfig::from_file(&path).expect("config.ocio should load");
let count = config.colorspace_count().expect("count should work");
assert!(count > 0, "config should define at least one color space");
let colorspaces = config.colorspaces().expect("listing should work");
assert_eq!(colorspaces.len(), count as usize);
assert!(colorspaces.contains(&"Linear".to_string()));
assert!(colorspaces.contains(&"sRGB OETF".to_string()));
eprintln!("color spaces ({}): {:?}", colorspaces.len(), colorspaces);
}
#[test]
fn ocio_roles_and_canonical_names() {
let path = set_ocio_env();
let config = OcioConfig::from_file(&path).unwrap();
let roles = config.roles().unwrap();
assert!(!roles.is_empty(), "config should define roles");
eprintln!("roles: {:?}", roles);
assert!(config.has_role("scene_linear").unwrap(), "scene_linear role should exist");
assert!(config.has_role("default").unwrap());
assert!(!config.has_role("no_such_role").unwrap());
// scene_linear maps to the Linear color space.
assert_eq!(config.canonical_name("scene_linear").unwrap(), "Linear");
// Role/name equivalence: role name resolves to its canonical color space.
assert_eq!(config.canonical_name("Linear").unwrap(), "Linear");
}
#[test]
fn ocio_displays_and_views() {
let path = set_ocio_env();
let config = OcioConfig::from_file(&path).unwrap();
let display = config.default_display().unwrap();
assert_eq!(display, "sRGB");
let view = config.default_view(&display).unwrap();
assert_eq!(view, "sRGB OETF");
}
#[test]
fn ocio_processor_apply_rgba() {
let path = set_ocio_env();
let config = OcioConfig::from_file(&path).unwrap();
// Linear -> sRGB OETF: a mid-gray 0.18 (a common linear display-referred
// midpoint) should map well above itself and stay finite.
let processor = config.processor("Linear", "sRGB OETF").unwrap();
let mut px = [0.18f32, 0.18f32, 0.18f32, 1.0f32];
processor.apply_rgba(&mut px).unwrap();
assert!(px[0] > 0.18f32, "sRGB OETF should lift 0.18 linear, got {}", px[0]);
assert!(px[0] < 1.0f32 + 1e-6, "sRGB OETF output should be <= 1.0, got {}", px[0]);
assert!(px.iter().all(|v| v.is_finite()));
// Display-referred path: scene_linear -> default sRGB view.
let processor = config
.display_processor("scene_linear", "sRGB", "sRGB OETF")
.unwrap();
let mut px = [0.18f32, 0.18f32, 0.18f32, 1.0f32];
processor.apply_rgba(&mut px).unwrap();
assert!(px.iter().all(|v| v.is_finite()));
assert!(px[0] > 0.18f32, "display processor should also lift 0.18, got {}", px[0]);
}
#[test]
fn ocio_error_paths() {
let path = set_ocio_env();
// Nonexistent config file.
let err = OcioConfig::from_file("/nonexistent/oakcommon-real-ocio.ocio").unwrap_err();
eprintln!("nonexistent config error: {err:?}");
assert!(matches!(err, Error::Failed(_)));
let config = OcioConfig::from_file(&path).unwrap();
// Unknown destination color space.
let err = config.processor("Linear", "No Such Color Space").unwrap_err();
eprintln!("unknown colorspace error: {err:?}");
assert!(matches!(err, Error::Failed(_)));
// Unknown display/view.
let err = config.display_processor("Linear", "No Such Display", "No View").unwrap_err();
eprintln!("unknown display error: {err:?}");
assert!(matches!(err, Error::Failed(_)));
}
#[test]
fn image_f32_round_trip() {
let dir = std::env::temp_dir().join("oakcommon-real-ocio");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("roundtrip.tif");
let path_str = path.to_str().unwrap().to_string();
// 2x2 RGBA float image.
let w = 2;
let h = 2;
let c = 4;
let pixels: Vec<f32> = vec![
0.0, 0.25, 0.5, 1.0,
0.75, 0.5, 0.25, 1.0,
1.0, 0.0, 0.5, 0.0,
0.125, 0.625, 0.875, 1.0,
];
write_image_f32(&path_str, w, h, c, &pixels).expect("write should succeed");
let img = read_image_f32(&path_str).expect("read should succeed");
assert_eq!(img.width, w);
assert_eq!(img.height, h);
assert_eq!(img.channels, c);
assert_eq!(img.pixels.len(), (w * h * c) as usize);
for (i, (a, b)) in img.pixels.iter().zip(pixels.iter()).enumerate() {
let diff = (a - b).abs();
assert!(diff < 1e-6, "pixel {i}: wrote {b}, read back {a}");
}
std::fs::remove_file(&path).ok();
}
+2
View File
@@ -32,6 +32,8 @@ add_library(oakcommon SHARED
loopmode.h
subtitleparams.cpp
subtitleparams.h
variant.cpp
variant.h
videoparams.cpp
videoparams.h
ffmpegutils.cpp
+7
View File
@@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "oakcore-rs"
version = "0.1.0"
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "oakcore-rs"
version = "0.1.0"
edition = "2021"
description = "Rust value types mirroring oakcore (Rational, TimeRange, format enums)"
license = "GPL-3.0-or-later"
[lib]
crate-type = ["rlib"]
[dependencies]
+20
View File
@@ -0,0 +1,20 @@
# oakcore-rs — Rust value-type foundation for the oak Rust modules
> Status: **declaration draft for review** (no implementation, not wired
> into any build). Companion to `src/node/rust` and `src/render/rust`.
Rust reimplementation of the oakcore C++ value types that cross every
module boundary: `Rational`, `TimeRange`, `TimeRangeList`, pixel/sample
format enums. These are pure data types with value semantics — the one
place where duplicating the C++ layout discipline in plain Rust is both
safe and required (a Rust crate cannot hold C++ objects by value).
Rules:
- Bit-exact arithmetic compatibility with `olive::core::Rational`
(reduction, overflow behavior, comparison) — the golden rule is the
C++ test-suite semantics, not "ideal" rational math.
- `#[repr(C)]` only where a type crosses the C ABI; everything else is
plain Rust with `Copy + Clone + Eq + Hash`.
- No I/O, no allocation in arithmetic paths, no panics on degenerate
input (denominator zero follows the C++ sentinel semantics).
+30
View File
@@ -0,0 +1,30 @@
// 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/>.
//! oakcore-rs: Rust value types mirroring the oakcore C++ library.
//!
//! Bit-exact behavioral compatibility with `olive::core` is the design
//! constraint; see README.md.
#![warn(missing_docs)]
mod rational;
mod samplefmt;
mod timerange;
pub use rational::Rational;
pub use samplefmt::{PixelFormat, SampleFormat};
pub use timerange::{TimeRange, TimeRangeList};
+481
View File
@@ -0,0 +1,481 @@
// 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/>.
//! Rational numbers with oakcore-compatible semantics.
/// The cap used by the C++ `reduce_fraction` oracle: it reduces against
/// `INT_MAX` regardless of the wider integer width. We keep the same cap so
/// that every value C++ can represent round-trips bit-for-bit.
const REDUCE_MAX: i128 = i32::MAX as i128;
/// `RATIONAL_MIN` as produced by the C++ `Rational(INT_MIN)` constructor:
/// because the reduce cap is `INT_MAX`, `INT_MIN` reduces to `-2147483647/1`
/// (not `-2147483648/1`). Arithmetic treats this value (and its positive
/// counterpart) as a sentinel that propagates NaN.
const RATIONAL_MIN: Rational = Rational { num: -2147483647, den: 1 };
/// `RATIONAL_MAX` (`Rational(INT_MAX)`, i.e. `2147483647/1`).
const RATIONAL_MAX: Rational = Rational { num: 2147483647, den: 1 };
/// A rational number, always kept reduced with a non-negative
/// denominator (mirrors `olive::core::Rational`).
///
/// Compatibility notes (these are load-bearing, project files depend
/// on them):
/// - `0/0` is the "null/invalid" sentinel (`Rational()` in C++).
/// - Arithmetic follows the C++ overflow behavior: intermediate
/// products are 128-bit where the C++ uses wider temporaries; where
/// C++ truncates, we truncate identically.
/// - `from_string`/`to_string` round-trip the exact C++ text format
/// (e.g. "30000/1001"), including the sentinel spellings used in
/// project XML ("0/0", RATIONAL_MIN/MAX sentinels).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
pub struct Rational {
num: i64,
den: i64,
}
/// Signed Euclidean GCD on absolute values (mirrors the C++
/// `i64_gcd`). Computed in `i128` so that `i64::MIN`-class inputs
/// cannot overflow when negated.
fn i64_gcd(mut a: i128, mut b: i128) -> i128 {
if a < 0 {
a = -a;
}
if b < 0 {
b = -b;
}
while b != 0 {
let t = a % b;
a = b;
b = t;
}
a
}
/// Reduce `num`/`den` in place so that `|num| <= max` and `den <= max`,
/// using the exact C++ algorithm (`core/src/util/fractionutils.cpp`,
/// ported from FFmpeg's `av_reduce`). Implemented in `i128` so the
/// intermediate products never overflow for any `i64` input.
fn reduce_fraction(num: &mut i128, den: &mut i128, max: i128) {
if *den == 0 {
*num = 0;
return;
}
let sign = (*num < 0) != (*den < 0);
let gcd = i64_gcd(*num, *den);
if gcd != 0 {
*num = if *num < 0 { -*num } else { *num } / gcd;
*den = if *den < 0 { -*den } else { *den } / gcd;
}
if *num <= max && *den <= max {
*num = if sign { -*num } else { *num };
return;
}
// Continued fraction approximation (FFmpeg's av_reduce).
let mut a0n: i128 = 0;
let mut a0d: i128 = 1;
let mut a1n: i128 = 1;
let mut a1d: i128 = 0;
let mut n = *num;
let mut d = *den;
while d != 0 {
let x = n / d;
let next_den = n - d * x;
let a2n = x * a1n + a0n;
let a2d = x * a1d + a0d;
if a2n > max || a2d > max {
let mut x = x;
if a1n != 0 {
x = (max - a0n) / a1n;
}
if a1d != 0 && (max - a0d) / a1d < x {
x = (max - a0d) / a1d;
}
if d * (2 * x * a1d + a0d) > n * a1d {
a1n = x * a1n + a0n;
a1d = x * a1d + a0d;
}
break;
}
a0n = a1n;
a0d = a1d;
a1n = a2n;
a1d = a2d;
n = d;
d = next_den;
}
*num = if sign { -a1n } else { a1n };
*den = a1d;
}
/// C `frexp`: split into mantissa in [0.5, 1) and base-2 exponent.
/// Only used by `from_double`; NaN/inf/zero pass through with exp 0.
fn frexp(x: f64, exp: &mut i32) -> f64 {
if x == 0.0 || x.is_nan() || x.is_infinite() {
*exp = 0;
return x;
}
let bits = x.to_bits();
let raw = ((bits >> 52) & 0x7ff) as i32;
if raw == 0 {
// Subnormal: scale up into the normal range first.
let scaled = x * 9007199254740992.0; // 2^53
let mut e = 0;
let m = frexp(scaled, &mut e);
*exp = e - 53;
return m;
}
*exp = raw - 1022;
f64::from_bits((bits & !(0x7ffu64 << 52)) | (1022u64 << 52))
}
/// Apply C++ `fix_signs`: negative denominators are normalized by
/// flipping both signs; `0/0` stays as the NaN sentinel; a zero
/// numerator becomes `0/1`.
fn fix_signs(num: &mut i64, den: &mut i64) {
if *den < 0 {
*den = -*den;
*num = -*num;
} else if *den == 0 {
*num = 0;
} else if *num == 0 {
*den = 1;
}
}
/// Build a rational from already-reduced `i128` values, applying
/// `fix_signs` and narrowing to `i64` (safe: `reduce_fraction` caps at
/// `i32::MAX`).
fn from_reduced(num: i128, den: i128) -> Rational {
let mut num = num as i64;
let mut den = den as i64;
fix_signs(&mut num, &mut den);
Rational { num, den }
}
/// Compare two fractions exactly (C++ `compare_fractions`). Non-NaN
/// inputs yield `-1`/`0`/`1`; the `0/0` cases return `i32::MIN`
/// (meaningless, never used for total ordering).
fn compare_fractions(an: i64, ad: i64, bn: i64, bd: i64) -> i32 {
let tmp = an as i128 * bd as i128 - bn as i128 * ad as i128;
if tmp != 0 {
// C++: `((tmp ^ ad ^ bd) >> 63) | 1` == sign of tmp (dens are >= 0).
if tmp > 0 {
1
} else {
-1
}
} else if bd != 0 && ad != 0 {
0
} else if an != 0 && bn != 0 {
((an >> 31) - (bn >> 31)) as i32
} else {
i32::MIN
}
}
/// Parse a single C++ `strtol`-style integer (base 10); garbage or
/// empty input yields 0.
fn to_int(s: &str) -> i64 {
s.trim().parse::<i64>().unwrap_or(0)
}
/// Rounding modes for the C++ `Timecode` conversion helpers.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum Rounding {
Round,
Floor,
}
/// C++ `Rational::flipped` as a free function: swap numerator and
/// denominator, then `fix_signs`. A null rational (0/0 or 0/n) is left
/// unchanged.
fn flipped(r: Rational) -> Rational {
if r.num == 0 {
return r;
}
let mut num = r.den;
let mut den = r.num;
fix_signs(&mut num, &mut den);
Rational { num, den }
}
/// C++ `Timecode::timestamp_to_time`: `timebase.num * ts / timebase.den`,
/// reduced against `INT_MAX`.
fn timestamp_to_time(ts: i64, timebase: Rational) -> Rational {
let mut num = timebase.num as i128 * ts as i128;
let mut den = timebase.den as i128;
reduce_fraction(&mut num, &mut den, REDUCE_MAX);
from_reduced(num, den)
}
/// C++ `Timecode::time_to_timestamp` (any rounding mode), given an
/// explicit timebase.
pub(crate) fn time_to_timestamp_rnd(time: Rational, timebase: Rational, rnd: Rounding) -> i64 {
let d = time.to_f64() * flipped(timebase).to_f64();
if d.is_nan() {
return 0;
}
let eps = 0.000000000001;
match rnd {
Rounding::Round => d.round() as i64,
Rounding::Floor => {
if d > d.ceil() - eps {
d.ceil() as i64
} else {
d.floor() as i64
}
}
}
}
/// C++ `Timecode::snap_time_to_timebase` with the `k_floor` rounding
/// used by `TimeRangeListFrameIterator`.
pub(crate) fn snap_time_to_timebase(time: Rational, timebase: Rational) -> Rational {
let ts = time_to_timestamp_rnd(time, timebase, Rounding::Floor);
timestamp_to_time(ts, timebase)
}
impl Rational {
/// The invalid sentinel (C++ `Rational()`, i.e. 0/0).
pub const NULL: Rational = Rational { num: 0, den: 0 };
/// Construct reduced; `new(0, 0)` yields [`Rational::NULL`].
pub fn new(num: i64, den: i64) -> Rational {
let mut num = num;
let mut den = den;
fix_signs(&mut num, &mut den);
let mut num = num as i128;
let mut den = den as i128;
reduce_fraction(&mut num, &mut den, REDUCE_MAX);
Rational {
num: num as i64,
den: den as i64,
}
}
/// Numerator of the reduced form.
pub fn numerator(self) -> i64 {
self.num
}
/// Denominator of the reduced form (0 for the null sentinel).
pub fn denominator(self) -> i64 {
self.den
}
/// True for the null sentinel (`num == 0`, so 0/0 and 0/1).
pub fn is_null(self) -> bool {
self.num == 0
}
/// True for the NaN sentinel (`den == 0`, only 0/0 after
/// normalization). C++ `isNaN()`.
pub fn is_nan(self) -> bool {
self.den == 0
}
/// True when this value equals `RATIONAL_MIN` or `RATIONAL_MAX`
/// (the sentinels that propagate NaN through arithmetic in C++).
fn is_minmax(self) -> bool {
self == RATIONAL_MIN || self == RATIONAL_MAX
}
/// Parse the C++ text format; invalid input yields the null
/// sentinel (C++ `fromString` behavior).
pub fn from_string(s: &str) -> Rational {
let elements: Vec<&str> = s.split('/').collect();
match elements.len() {
1 => Rational::new(to_int(elements[0]), 1),
2 => Rational::new(to_int(elements[0]), to_int(elements[1])),
_ => Rational::NULL,
}
}
/// Format identical to C++ `toString()`.
pub fn to_display_string(self) -> String {
format!("{}/{}", self.num, self.den)
}
/// Truncating conversion to f64 (C++ `toDouble`).
pub fn to_f64(self) -> f64 {
if self.den != 0 {
self.num as f64 / self.den as f64
} else {
f64::NAN
}
}
/// f64 → Rational (C++ `Rational::from_double`, continued-fraction
/// port of FFmpeg's `av_d2q`; NaN and |v| > INT_MAX+3 yield the
/// 0/0 NaN sentinel).
/// `// CPP-PARITY: core/src/util/rational.cpp:39`
pub fn from_double(value: f64) -> Rational {
if value.is_nan() || value.abs() > i32::MAX as f64 + 3.0 {
return Rational::NULL;
}
let mut exponent = 0;
let _ = frexp(value, &mut exponent);
exponent = (exponent - 1).max(0);
let den: i64 = 1i64 << (62 - exponent);
let num: i64 = (value * den as f64 + 0.5).floor() as i64;
let mut rnum = num as i128;
let mut rden = den as i128;
reduce_fraction(&mut rnum, &mut rden, i32::MAX as i128);
if (rnum == 0 || rden == 0) && value != 0.0 {
// Too small to represent above; retry at maximum precision.
rnum = (value * i64::MAX as f64) as i64 as i128;
rden = i64::MAX as i128;
reduce_fraction(&mut rnum, &mut rden, i32::MAX as i128);
}
from_reduced(rnum, rden)
}
/// Frame-number conversion using this value as a timebase
/// (C++ `Timecode::time_to_timestamp` semantics, rounding mode
/// included).
pub fn time_to_timestamp(self, time: Rational) -> i64 {
time_to_timestamp_rnd(time, self, Rounding::Round)
}
/// Inverse of [`Rational::time_to_timestamp`]
/// (C++ `Timecode::timestamp_to_time`).
pub fn timestamp_to_time(self, ts: i64) -> Rational {
timestamp_to_time(ts, self)
}
}
impl std::ops::Add for Rational {
type Output = Rational;
fn add(self, rhs: Rational) -> Rational {
if self.is_minmax() || rhs.is_minmax() {
return Rational::NULL;
}
if self.is_nan() {
return self;
}
if rhs.is_nan() {
return Rational::NULL;
}
let mut n = self.num as i128 * rhs.den as i128 + rhs.num as i128 * self.den as i128;
let mut d = self.den as i128 * rhs.den as i128;
reduce_fraction(&mut n, &mut d, REDUCE_MAX);
from_reduced(n, d)
}
}
impl std::ops::Sub for Rational {
type Output = Rational;
fn sub(self, rhs: Rational) -> Rational {
if self.is_minmax() || rhs.is_minmax() {
return Rational::NULL;
}
if self.is_nan() {
return self;
}
if rhs.is_nan() {
return Rational::NULL;
}
let mut n = self.num as i128 * rhs.den as i128 - rhs.num as i128 * self.den as i128;
let mut d = self.den as i128 * rhs.den as i128;
reduce_fraction(&mut n, &mut d, REDUCE_MAX);
from_reduced(n, d)
}
}
impl std::ops::Mul for Rational {
type Output = Rational;
fn mul(self, rhs: Rational) -> Rational {
if self.is_minmax() || rhs.is_minmax() {
return Rational::NULL;
}
if self.is_nan() {
return self;
}
if rhs.is_nan() {
return Rational::NULL;
}
let mut n = self.num as i128 * rhs.num as i128;
let mut d = self.den as i128 * rhs.den as i128;
reduce_fraction(&mut n, &mut d, REDUCE_MAX);
from_reduced(n, d)
}
}
impl std::ops::Div for Rational {
type Output = Rational;
fn div(self, rhs: Rational) -> Rational {
if self.is_minmax() || rhs.is_minmax() {
return Rational::NULL;
}
if self.is_nan() {
return self;
}
if rhs.is_nan() {
return Rational::NULL;
}
let mut n = self.num as i128 * rhs.den as i128;
let mut d = self.den as i128 * rhs.num as i128;
reduce_fraction(&mut n, &mut d, REDUCE_MAX);
from_reduced(n, d)
}
}
impl PartialOrd for Rational {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Rational {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
use std::cmp::Ordering;
// NaN (0/0) orders before everything and equals itself, keeping
// `Ord` consistent with the derived structural `Eq` (C++ makes
// 0/0 == 0/0 false, but this crate deliberately keeps Eq).
match (self.den == 0, other.den == 0) {
(true, true) => Ordering::Equal,
(true, false) => Ordering::Less,
(false, true) => Ordering::Greater,
(false, false) => match compare_fractions(self.num, self.den, other.num, other.den) {
0 => Ordering::Equal,
1 => Ordering::Greater,
_ => Ordering::Less,
},
}
}
}
+145
View File
@@ -0,0 +1,145 @@
// 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/>.
//! Pixel and sample format enums (oakcore `PixelFormat` /
//! `SampleFormat` equivalents). Integer values MUST match the C++
//! enums (`core/include/olive/core/render/pixelformat.h`,
//! `sampleformat.h`) — they cross the C ABI as `int`.
/// Pixel format (values identical to `olive::core::PixelFormat::Format`:
/// invalid=-1, u8=0, u10=1, u16=2, f16=3, f32=4).
#[repr(i32)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum PixelFormat {
/// Invalid/unspecified.
Invalid = -1,
/// 8-bit unsigned per channel.
U8 = 0,
/// 10-bit unsigned per channel (packed).
U10 = 1,
/// 16-bit unsigned per channel.
U16 = 2,
/// 16-bit half float.
F16 = 3,
/// 32-bit float (primary pipeline format).
F32 = 4,
}
impl PixelFormat {
/// Bytes per channel (C++ `byte_count`; Invalid -> 0, U10 -> 4
/// since it is packed RGBA10A2 stored as 4 bytes per pixel).
pub fn bytes_per_channel(self) -> usize {
match self {
PixelFormat::Invalid => 0,
PixelFormat::U8 => 1,
PixelFormat::U10 => 4,
PixelFormat::U16 | PixelFormat::F16 => 2,
PixelFormat::F32 => 4,
}
}
/// Bytes per pixel for `channels` (C++ `bytes_per_pixel`).
pub fn bytes_per_pixel(self, channels: usize) -> usize {
self.bytes_per_channel() * channels
}
}
/// Audio sample format (values identical to
/// `olive::core::SampleFormat::Format`: PLANAR first — u8_p=0, s16_p=1,
/// s32_p=2, s64_p=3, f32_p=4, f64_p=5, then packed u8=6, s16=7, s32=8,
/// s64=9, f32=10, f64=11). This order is load-bearing: oakaudio's
/// default format constant (f32_p) is 4.
#[repr(i32)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum SampleFormat {
/// Invalid/unspecified.
Invalid = -1,
/// Unsigned 8-bit planar.
U8Planar = 0,
/// Signed 16-bit planar.
S16Planar = 1,
/// Signed 32-bit planar.
S32Planar = 2,
/// Signed 64-bit planar.
S64Planar = 3,
/// 32-bit float planar.
F32Planar = 4,
/// 64-bit float planar.
F64Planar = 5,
/// Unsigned 8-bit packed.
U8 = 6,
/// Signed 16-bit packed.
S16 = 7,
/// Signed 32-bit packed.
S32 = 8,
/// Signed 64-bit packed.
S64 = 9,
/// 32-bit float packed.
F32 = 10,
/// 64-bit float packed.
F64 = 11,
}
impl SampleFormat {
/// Bytes per sample (C++ `byte_count`; Invalid -> 0).
pub fn bytes_per_sample(self) -> usize {
match self {
SampleFormat::Invalid => 0,
SampleFormat::U8Planar | SampleFormat::U8 => 1,
SampleFormat::S16Planar | SampleFormat::S16 => 2,
SampleFormat::S32Planar
| SampleFormat::S32
| SampleFormat::F32Planar
| SampleFormat::F32 => 4,
SampleFormat::S64Planar
| SampleFormat::S64
| SampleFormat::F64Planar
| SampleFormat::F64 => 8,
}
}
/// True for planar layouts (C++ `is_planar`).
pub fn is_planar(self) -> bool {
(self as i32) >= 0 && (self as i32) < 6
}
/// Packed counterpart of a planar format (and vice versa;
/// C++ `to_packed`/`to_planar`).
pub fn to_packed(self) -> SampleFormat {
match self {
SampleFormat::U8Planar => SampleFormat::U8,
SampleFormat::S16Planar => SampleFormat::S16,
SampleFormat::S32Planar => SampleFormat::S32,
SampleFormat::S64Planar => SampleFormat::S64,
SampleFormat::F32Planar => SampleFormat::F32,
SampleFormat::F64Planar => SampleFormat::F64,
other => other,
}
}
/// See [`SampleFormat::to_packed`].
pub fn to_planar(self) -> SampleFormat {
match self {
SampleFormat::U8 => SampleFormat::U8Planar,
SampleFormat::S16 => SampleFormat::S16Planar,
SampleFormat::S32 => SampleFormat::S32Planar,
SampleFormat::S64 => SampleFormat::S64Planar,
SampleFormat::F32 => SampleFormat::F32Planar,
SampleFormat::F64 => SampleFormat::F64Planar,
other => other,
}
}
}
+254
View File
@@ -0,0 +1,254 @@
// 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/>.
//! Time ranges and normalized range lists (oakcore `TimeRange` /
//! `TimeRangeList` equivalents).
use crate::rational::{self, Rational};
/// Half-open time range [in, out) — mirrors `olive::core::TimeRange`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
pub struct TimeRange {
in_: Rational,
out: Rational,
}
impl TimeRange {
/// Construct and normalize (C++ ctor calls `normalize()`: if `out <
/// in` the two are swapped). The doc comment on the skeleton claimed
/// normalization is not performed; matching C++ takes precedence.
pub fn new(in_: Rational, out: Rational) -> TimeRange {
let mut r = TimeRange { in_, out };
r.normalize();
r
}
/// Inclusive start.
pub fn in_(&self) -> Rational {
self.in_
}
/// Exclusive end.
pub fn out(&self) -> Rational {
self.out
}
/// `out - in`. When either endpoint is a `RATIONAL_MIN/MAX`
/// sentinel the subtraction propagates NaN, matching C++ which
/// stores the same sentinel value for `length_`.
pub fn length(&self) -> Rational {
self.out - self.in_
}
/// True when `t` lies in [in, out).
pub fn contains(&self, t: Rational) -> bool {
t >= self.in_ && t < self.out
}
/// True when `self` contains `compare`, honoring inclusivity of the
/// in/out edges (C++ `TimeRange::contains(TimeRange)`).
fn contains_range(
&self,
compare: &TimeRange,
in_inclusive: bool,
out_inclusive: bool,
) -> bool {
let contains_in = if in_inclusive {
compare.in_ >= self.in_
} else {
compare.in_ > self.in_
};
let contains_out = if out_inclusive {
compare.out <= self.out
} else {
compare.out < self.out
};
contains_in && contains_out
}
/// True when `self` and `a` overlap, honoring edge inclusivity
/// (C++ `TimeRange::overlaps_with`).
fn overlaps_with(&self, a: &TimeRange, in_inclusive: bool, out_inclusive: bool) -> bool {
let does_not_overlap_in = if in_inclusive {
a.out < self.in_
} else {
a.out <= self.in_
};
let does_not_overlap_out = if out_inclusive {
a.in_ > self.out
} else {
a.in_ >= self.out
};
!does_not_overlap_in && !does_not_overlap_out
}
/// Intersection; empty when disjoint (C++ `intersected`).
///
/// Note: C++ normalizes the result, so disjoint inputs produce a
/// swapped (in > out) range rather than an "empty" marker; we match
/// that bit-for-bit.
pub fn intersected(&self, other: &TimeRange) -> TimeRange {
TimeRange::new(
std::cmp::max(self.in_, other.in_),
std::cmp::min(self.out, other.out),
)
}
/// Union that also merges touching ranges (C++ `combined`).
pub fn combined(&self, other: &TimeRange) -> TimeRange {
TimeRange::new(
std::cmp::min(self.in_, other.in_),
std::cmp::max(self.out, other.out),
)
}
/// C++ `set_in` + `normalize`.
fn set_in(&mut self, in_: Rational) {
self.in_ = in_;
self.normalize();
}
/// C++ `set_out` + `normalize`.
fn set_out(&mut self, out: Rational) {
self.out = out;
self.normalize();
}
/// C++ `normalize`: swap if `out < in`.
fn normalize(&mut self) {
if self.out < self.in_ {
std::mem::swap(&mut self.out, &mut self.in_);
}
}
}
/// Normalized (sorted, non-overlapping) list of ranges — mirrors
/// `olive::core::TimeRangeList` including its merge-on-insert and
/// subtraction semantics.
#[derive(Clone, Debug, Default)]
pub struct TimeRangeList {
ranges: Vec<TimeRange>,
}
impl TimeRangeList {
/// Empty list.
pub fn new() -> Self {
TimeRangeList { ranges: Vec::new() }
}
/// True when any element fully contains `range` (C++
/// `TimeRangeList::contains`, inclusive edges).
fn contains_range(&self, range: &TimeRange) -> bool {
self.ranges
.iter()
.any(|r| r.contains_range(range, true, true))
}
/// Insert a range, merging overlaps and touching neighbors
/// (C++ `insert(TimeRange)`).
pub fn insert(&mut self, range: TimeRange) {
// If the list already fully contains this range, nothing to do.
if self.contains_range(&range) {
return;
}
let mut range = range;
let mut i = 0;
while i < self.ranges.len() {
let compare = self.ranges[i];
if compare.overlaps_with(&range, true, true) {
range = compare.combined(&range);
self.ranges.remove(i);
} else {
i += 1;
}
}
self.ranges.push(range);
}
/// Subtract a range (C++ `remove`, via `util_remove`).
pub fn remove(&mut self, range: TimeRange) {
let mut additions: Vec<TimeRange> = Vec::new();
let mut i = 0;
while i < self.ranges.len() {
let compare = self.ranges[i];
if range.contains_range(&compare, true, true) {
// The removal range entirely encompasses this element.
self.ranges.remove(i);
} else if compare.contains_range(&range, false, false) {
// The removal range is strictly inside this element:
// split it into two.
let mut new_range = compare;
new_range.set_in(range.out);
let mut trimmed = compare;
trimmed.set_out(range.in_);
self.ranges[i] = trimmed;
additions.push(new_range);
break;
} else {
if compare.in_ < range.in_ && compare.out > range.in_ {
// This element's out overlaps the range's in: trim it.
self.ranges[i].set_out(range.in_);
} else if compare.in_ < range.out && compare.out > range.out {
// This element's in overlaps the range's out: trim it.
self.ranges[i].set_in(range.out);
}
i += 1;
}
}
self.ranges.extend(additions);
}
/// Sorted ranges view.
pub fn ranges(&self) -> &[TimeRange] {
&self.ranges
}
/// True when the list has no ranges.
pub fn is_empty(&self) -> bool {
self.ranges.is_empty()
}
/// Total covered duration (sum of each range's length).
pub fn total_length(&self) -> Rational {
let mut total = Rational::new(0, 1);
for r in &self.ranges {
total = total + r.length();
}
total
}
/// First time covered by any range (C++ `in()` on the first range);
/// null rational when empty.
pub fn first(&self) -> Rational {
match self.ranges.first() {
Some(r) => r.in_(),
None => Rational::NULL,
}
}
/// Frame-accurate iteration helper: snap a time to the containing
/// frame grid of `timebase` (C++ TimeRangeListFrameIterator snap,
/// `k_floor` rounding).
pub fn snap(&self, time: Rational, timebase: Rational) -> Rational {
let _ = self; // self carries no state relevant to a single snap
rational::snap_time_to_timebase(time, timebase)
}
}
+401
View File
@@ -0,0 +1,401 @@
// 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/>.
//! oakcore-rs contract tests. The oracle is the C++ oakcore behavior:
/// every case below names the C++ semantic it pins down.
use oakcore_rs::{PixelFormat, Rational, SampleFormat, TimeRange, TimeRangeList};
/// Construction reduces (2/4 -> 1/2), normalizes sign (1/-2 -> -1/2),
/// and 0/0 is the null sentinel. C++: Rational ctor + reduced().
#[test]
fn rational_reduction_and_sentinel() {
assert_eq!(Rational::new(2, 4), Rational::new(1, 2));
assert_eq!(Rational::new(2, 4).numerator(), 1);
assert_eq!(Rational::new(2, 4).denominator(), 2);
assert_eq!(Rational::new(1, -2), Rational::new(-1, 2));
assert_eq!(Rational::new(1, -2).numerator(), -1);
assert_eq!(Rational::new(1, -2).denominator(), 2);
// 0/0 is the null/invalid sentinel.
let n = Rational::new(0, 0);
assert!(n.is_null());
assert!(n.is_nan());
assert_eq!(n, Rational::NULL);
// A zero numerator normalizes the denominator to 1 (0/5 -> 0/1).
let z = Rational::new(0, 5);
assert!(z.is_null());
assert!(!z.is_nan());
assert_eq!(z, Rational::new(0, 1));
assert_eq!(Rational::NULL, Rational::new(0, 0));
}
/// Arithmetic matches C++ exactly, including 30000/1001-style video
/// rates: (1001/30000 * 30000/1001 == 1), addition across denominators,
/// division by zero yields the C++ result (null propagation).
#[test]
fn rational_arithmetic_video_rates() {
assert_eq!(
Rational::new(1001, 30000) * Rational::new(30000, 1001),
Rational::new(1, 1)
);
assert_eq!(Rational::new(1, 3) + Rational::new(1, 6), Rational::new(1, 2));
assert_eq!(Rational::new(1, 2) - Rational::new(1, 3), Rational::new(1, 6));
assert_eq!(Rational::new(2, 3) * Rational::new(3, 4), Rational::new(1, 2));
// Division by a zero-value rational yields 0/0 (NaN): the denominator
// becomes 0 and reduce_fraction forces the numerator to 0.
let d = Rational::new(1, 1) / Rational::new(0, 1);
assert!(d.is_nan());
assert!(d.is_null());
assert_eq!(d, Rational::NULL);
// 0/0 on the left propagates the (unchanged) NaN self.
assert_eq!(Rational::NULL + Rational::new(1, 2), Rational::NULL);
// 0/0 on the right yields NULL for every operator.
assert_eq!(Rational::new(1, 2) + Rational::NULL, Rational::NULL);
assert_eq!(Rational::new(1, 2) - Rational::NULL, Rational::NULL);
assert_eq!(Rational::new(1, 2) * Rational::NULL, Rational::NULL);
assert_eq!(Rational::new(1, 2) / Rational::NULL, Rational::NULL);
}
/// from_string/to_string round-trip incl. sentinel spellings used by
/// project XML ("0/0", RATIONAL_MIN/MAX); garbage input -> null.
#[test]
fn rational_string_roundtrip() {
assert_eq!(Rational::from_string("0/0"), Rational::NULL);
assert_eq!(
Rational::from_string("2147483647"),
Rational::new(2147483647, 1)
);
assert_eq!(
Rational::from_string("-2147483647/1"),
Rational::new(-2147483647, 1)
);
// Garbage single token parses as 0 -> 0/1 (null but not NaN).
let g = Rational::from_string("abc");
assert!(g.is_null());
assert!(!g.is_nan());
assert_eq!(g, Rational::new(0, 1));
assert_eq!(g.to_display_string(), "0/1");
assert_eq!(Rational::new(30000, 1001).to_display_string(), "30000/1001");
// More than two '/' elements -> null sentinel.
assert_eq!(Rational::from_string("a/b/c"), Rational::NULL);
}
/// Ordering across denominators (1/3 vs 1001/3000) and equality of
/// differently-reduced equal values.
#[test]
fn rational_ordering() {
assert!(Rational::new(1, 3) < Rational::new(1001, 3000));
assert!(Rational::new(1, 3) > Rational::new(1, 4));
assert_eq!(Rational::new(1, 3), Rational::new(2, 6));
assert!(Rational::new(1, 2) > Rational::new(1, 3));
// NaN orders before everything and equals itself.
assert!(Rational::NULL < Rational::new(1, 1));
assert!(Rational::new(1, 1) > Rational::NULL);
assert_eq!(Rational::NULL, Rational::NULL);
// Total order sorts a mixed list.
let mut v = vec![Rational::new(1, 2), Rational::new(1, 4), Rational::new(1, 3)];
v.sort();
assert_eq!(
v,
vec![Rational::new(1, 4), Rational::new(1, 3), Rational::new(1, 2)]
);
}
/// time_to_timestamp/timestamp_to_time match C++ Timecode rounding
/// (half-away-from-zero at frame boundaries), incl. negative times.
#[test]
fn timecode_rounding() {
// 29.97 fps: the timebase is seconds-per-frame = 1001/30000.
let tb = Rational::new(1001, 30000);
// 0 seconds -> 0 frames.
assert_eq!(tb.time_to_timestamp(Rational::new(0, 1)), 0);
// 1 full frame.
assert_eq!(tb.time_to_timestamp(Rational::new(1001, 30000)), 1);
// Half a frame (0.5 * 1001/30000 s) rounds half-away-from-zero -> 1.
let half = Rational::new(1001, 60000);
assert_eq!(tb.time_to_timestamp(half), 1);
// Negative half frame rounds to -1 (llround half away from zero).
assert_eq!(tb.time_to_timestamp(Rational::new(-1001, 60000)), -1);
// 30 frames round-trip to exactly 1001/1000 s.
assert_eq!(tb.timestamp_to_time(30), Rational::new(1001, 1000));
// timestamp -> time -> timestamp round trip.
assert_eq!(tb.time_to_timestamp(tb.timestamp_to_time(29)), 29);
assert_eq!(tb.time_to_timestamp(tb.timestamp_to_time(300)), 300);
// Video-rate time: 30 frames at 1001/1000 s -> 30.
assert_eq!(tb.time_to_timestamp(Rational::new(1001, 1000)), 30);
}
/// TimeRange: contains/intersected/combined, touching ranges,
/// zero-length ranges. C++: TimeRange methods.
#[test]
fn timerange_ops() {
let r = TimeRange::new(Rational::new(0, 1), Rational::new(10, 1));
assert!(r.contains(Rational::new(5, 1)));
assert!(r.contains(Rational::new(0, 1)));
assert!(!r.contains(Rational::new(10, 1)));
assert!(!r.contains(Rational::new(-1, 1)));
assert_eq!(r.length(), Rational::new(10, 1));
let a = TimeRange::new(Rational::new(0, 1), Rational::new(10, 1));
let b = TimeRange::new(Rational::new(5, 1), Rational::new(15, 1));
assert_eq!(
a.intersected(&b),
TimeRange::new(Rational::new(5, 1), Rational::new(10, 1))
);
assert_eq!(
a.combined(&b),
TimeRange::new(Rational::new(0, 1), Rational::new(15, 1))
);
// Touching ranges combined into one.
let c = TimeRange::new(Rational::new(10, 1), Rational::new(20, 1));
assert_eq!(
a.combined(&c),
TimeRange::new(Rational::new(0, 1), Rational::new(20, 1))
);
// Zero-length range.
let z = TimeRange::new(Rational::new(5, 1), Rational::new(5, 1));
assert_eq!(z.length(), Rational::new(0, 1));
assert!(!z.contains(Rational::new(5, 1)));
// out < in is normalized by swapping.
let swapped = TimeRange::new(Rational::new(10, 1), Rational::new(5, 1));
assert_eq!(
swapped,
TimeRange::new(Rational::new(5, 1), Rational::new(10, 1))
);
}
/// TimeRangeList insert merges overlapping AND touching ranges;
/// remove splits. C++: TimeRangeList.
#[test]
fn timerangelist_normalization() {
let mut list = TimeRangeList::new();
assert!(list.is_empty());
assert_eq!(list.first(), Rational::NULL);
list.insert(TimeRange::new(Rational::new(0, 1), Rational::new(10, 1)));
list.insert(TimeRange::new(Rational::new(20, 1), Rational::new(30, 1)));
// Overlaps (0,10): merges into (0,15), leaving (0,15) + (20,30).
list.insert(TimeRange::new(Rational::new(5, 1), Rational::new(15, 1)));
assert!(!list.is_empty());
assert_eq!(list.total_length(), Rational::new(25, 1));
assert_eq!(list.ranges().len(), 2);
let mut ins: Vec<_> = list.ranges().iter().map(|r| r.in_()).collect();
ins.sort();
assert_eq!(ins, vec![Rational::new(0, 1), Rational::new(20, 1)]);
// Touching inserts merge into a single range.
let mut t = TimeRangeList::new();
t.insert(TimeRange::new(Rational::new(0, 1), Rational::new(10, 1)));
t.insert(TimeRange::new(Rational::new(10, 1), Rational::new(20, 1)));
assert_eq!(t.ranges().len(), 1);
assert_eq!(
t.ranges()[0],
TimeRange::new(Rational::new(0, 1), Rational::new(20, 1))
);
// remove splits (0,20) into (0,5) + (15,20) around the removed (5,15).
let mut s = TimeRangeList::new();
s.insert(TimeRange::new(Rational::new(0, 1), Rational::new(20, 1)));
s.remove(TimeRange::new(Rational::new(5, 1), Rational::new(15, 1)));
assert_eq!(s.ranges().len(), 2);
let mut outs: Vec<_> = s.ranges().iter().map(|r| r.out()).collect();
outs.sort();
assert_eq!(outs, vec![Rational::new(5, 1), Rational::new(20, 1)]);
// first() returns the first range's in().
let mut f = TimeRangeList::new();
f.insert(TimeRange::new(Rational::new(3, 1), Rational::new(7, 1)));
assert_eq!(f.first(), Rational::new(3, 1));
}
/// Frame-grid snap semantics (C++ TimeRangeListFrameIterator::Snap).
#[test]
fn timerangelist_snap() {
let list = TimeRangeList::new();
let tb = Rational::new(1001, 30000); // 29.97 fps, seconds per frame.
// 0.5 frames floors down to frame 0.
let half = Rational::new(1001, 60000);
assert_eq!(list.snap(half, tb), Rational::new(0, 1));
// Exactly 1 frame stays put.
let one = Rational::new(1001, 30000);
assert_eq!(list.snap(one, tb), Rational::new(1001, 30000));
// 1.5 frames (1001/20000 s) floors down to 1 frame.
let one_and_half = Rational::new(1001, 20000);
assert_eq!(list.snap(one_and_half, tb), Rational::new(1001, 30000));
// 30 frames round-trip exactly.
assert_eq!(list.snap(Rational::new(1001, 1000), tb), Rational::new(1001, 1000));
}
/// Enum discriminants identical to the C++ enums (C ABI contract).
#[test]
fn format_enum_values_match_cpp() {
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::Invalid.bytes_per_channel(), 0);
assert_eq!(PixelFormat::U8.bytes_per_channel(), 1);
assert_eq!(PixelFormat::U10.bytes_per_channel(), 4); // packed RGBA10A2
assert_eq!(PixelFormat::U16.bytes_per_channel(), 2);
assert_eq!(PixelFormat::F16.bytes_per_channel(), 2);
assert_eq!(PixelFormat::F32.bytes_per_channel(), 4);
assert_eq!(PixelFormat::F32.bytes_per_pixel(4), 16);
assert_eq!(SampleFormat::Invalid as i32, -1);
assert_eq!(SampleFormat::U8Planar as i32, 0);
assert_eq!(SampleFormat::S16Planar as i32, 1);
assert_eq!(SampleFormat::S32Planar as i32, 2);
assert_eq!(SampleFormat::S64Planar as i32, 3);
assert_eq!(SampleFormat::F32Planar as i32, 4);
assert_eq!(SampleFormat::F64Planar as i32, 5);
assert_eq!(SampleFormat::U8 as i32, 6);
assert_eq!(SampleFormat::S16 as i32, 7);
assert_eq!(SampleFormat::S32 as i32, 8);
assert_eq!(SampleFormat::S64 as i32, 9);
assert_eq!(SampleFormat::F32 as i32, 10);
assert_eq!(SampleFormat::F64 as i32, 11);
assert_eq!(SampleFormat::Invalid.bytes_per_sample(), 0);
assert_eq!(SampleFormat::U8.bytes_per_sample(), 1);
assert_eq!(SampleFormat::S16.bytes_per_sample(), 2);
assert_eq!(SampleFormat::S32.bytes_per_sample(), 4);
assert_eq!(SampleFormat::F32.bytes_per_sample(), 4);
assert_eq!(SampleFormat::F64.bytes_per_sample(), 8);
assert_eq!(SampleFormat::F64Planar.bytes_per_sample(), 8);
assert!(SampleFormat::U8Planar.is_planar());
assert!(SampleFormat::F64Planar.is_planar());
assert!(!SampleFormat::U8.is_planar());
assert!(!SampleFormat::Invalid.is_planar());
assert_eq!(SampleFormat::U8Planar.to_packed(), SampleFormat::U8);
assert_eq!(SampleFormat::F64Planar.to_packed(), SampleFormat::F64);
assert_eq!(SampleFormat::U8.to_packed(), SampleFormat::U8);
assert_eq!(SampleFormat::U8.to_planar(), SampleFormat::U8Planar);
assert_eq!(SampleFormat::S16.to_planar(), SampleFormat::S16Planar);
assert_eq!(SampleFormat::F64.to_planar(), SampleFormat::F64Planar);
}
/// Extreme i64 inputs (which C++ `int` could never receive) must reduce
/// without panicking, capped to the C++ `i32::MAX` reduce cap; the
/// RATIONAL_MIN/MAX sentinels propagate NaN through arithmetic.
#[test]
fn rational_large_inputs_and_minmax() {
let mn = Rational::new(i64::MIN, 1);
assert_eq!(mn.numerator(), -2147483647);
assert_eq!(mn.denominator(), 1);
let mx = Rational::new(i64::MAX, 1);
assert_eq!(mx.numerator(), 2147483647);
assert_eq!(mx.denominator(), 1);
let mx = Rational::new(2147483647, 1);
assert!(!mx.is_null()); // 2147483647/1 is a valid value, not null
let r = mx + Rational::new(1, 1);
assert!(r.is_nan());
assert_eq!(r, Rational::NULL);
let mn = Rational::new(-2147483647, 1);
let r = Rational::new(1, 1) * mn;
assert!(r.is_nan());
}
/// Additional edge cases: full-encompassing remove erases, partial
/// removes trim the correct endpoint, NaN timebase yields 0 frames, and
/// invalid format conversions are identities.
#[test]
fn additional_edge_cases() {
// remove fully encompassing an element erases it.
let mut l = TimeRangeList::new();
l.insert(TimeRange::new(Rational::new(0, 1), Rational::new(20, 1)));
l.remove(TimeRange::new(Rational::new(-5, 1), Rational::new(25, 1)));
assert!(l.is_empty());
// Trim the element's out down to the removal's in.
let mut l2 = TimeRangeList::new();
l2.insert(TimeRange::new(Rational::new(0, 1), Rational::new(20, 1)));
l2.remove(TimeRange::new(Rational::new(5, 1), Rational::new(25, 1)));
assert_eq!(l2.ranges().len(), 1);
assert_eq!(
l2.ranges()[0],
TimeRange::new(Rational::new(0, 1), Rational::new(5, 1))
);
// Trim the element's in up to the removal's out.
let mut l3 = TimeRangeList::new();
l3.insert(TimeRange::new(Rational::new(0, 1), Rational::new(20, 1)));
l3.remove(TimeRange::new(Rational::new(-5, 1), Rational::new(5, 1)));
assert_eq!(l3.ranges().len(), 1);
assert_eq!(
l3.ranges()[0],
TimeRange::new(Rational::new(5, 1), Rational::new(20, 1))
);
// A NaN timebase yields 0 frames.
assert_eq!(Rational::NULL.time_to_timestamp(Rational::new(1, 1)), 0);
// to_f64 of the null sentinel is NaN.
assert!(Rational::NULL.to_f64().is_nan());
// Invalid format conversions are identities.
assert_eq!(SampleFormat::Invalid.to_packed(), SampleFormat::Invalid);
assert_eq!(SampleFormat::Invalid.to_planar(), SampleFormat::Invalid);
}
/// from_double: C++ Rational::from_double parity — NaN and huge values
/// yield 0/0; common values round-trip; tiny values use the
/// high-precision retry path.
#[test]
fn rational_from_double() {
use oakcore_rs::Rational;
assert!(Rational::from_double(f64::NAN).is_nan());
assert!(Rational::from_double(1e300).is_nan());
assert!(Rational::from_double(-1e300).is_nan());
let r = Rational::from_double(0.5);
assert_eq!((r.numerator(), r.denominator()), (1, 2));
let fps = Rational::from_double(29.97002997002997);
assert!((fps.to_f64() - 29.97002997002997).abs() < 1e-9);
let third = Rational::from_double(1.0 / 3.0);
assert!((third.to_f64() - 1.0 / 3.0).abs() < 1e-9);
assert_eq!(Rational::from_double(0.0).numerator(), 0);
}
+123
View File
@@ -0,0 +1,123 @@
# Timeline 类完整覆盖映射表(C++ oaktimeline → oaktimeline Rust crate)
> 逐类盘点 `src/timeline/src/*.h`。每一行标注 Rust 侧的落点:
> `common`/`marker`/`workarea`/`util` = 对应 domain 模块,
> `undo*` = 对应 undo 命令模块,`bridge` = 经 C ABI 出模块,
> `drop` = 刻意不迁移(附理由)。`// CPP-PARITY` 注释义务不变。
> 命令类统一在结构体上暴露 `prepare()/redo()/undo()`(todo!()),
> 经 `bridge::undo` 的 vtable 对外暴露(`to_command()` 工厂)。
## 1. Timeline 命名空间(timelinecommon.h)
| C++ | Rust 落点 |
|---|---|
| `Timeline::MovementMode`(k_none/k_move/k_trim_in/k_trim_out) | `common::MovementMode`(与 include/timeline/edit.h 的 `OAKTIMELINE_MOVEMENT_*` 值兼容) |
| `Timeline::ThumbnailMode` | `common::ThumbnailMode`(与 include/timeline/displaymode.h 的 `OAK_TIMELINE_THUMBNAIL_*` 值兼容) |
| `Timeline::WaveformMode` | `common::WaveformMode`(与 `OAK_TIMELINE_WAVEFORMS_*` 值兼容) |
| `Timeline::is_a_trim_mode` | `common::is_a_trim_mode` |
| `Timeline::EditToInfo` | `common::EditToInfo` |
| `PLAYHEAD_COLOR` | `drop`(UI 取色宏,facade/app 职责,不含核心逻辑) |
## 2. Marker(timelinemarker.h)
| C++ | Rust 落点 |
|---|---|
| `TimelineMarker`(time/name/color/parent,de-Qt) | `marker::TimelineMarker` |
| `TimelineMarker::time()/set_time()` | `marker` 查询/编辑(set_time 触发 list resort,CPP-PARITY) |
| `TimelineMarker::has_sibling_at_time` | `marker` |
| `TimelineMarker::name()/set_name()` / `color()/set_color()` | `marker` |
| `TimelineMarker::load()/save()` | `marker`(golden XML,见 tests) |
| `TimelineMarkerList`(`markers_`,按时间排序) | `marker::TimelineMarkerList` |
| `empty/size/at/back/front` | `marker` 查询 |
| `add_marker`(排序插入)/ `remove_marker` / `get_marker_at_time` / `get_closest_marker_to_time` / `resort` | `marker`(resort 由 set_time 调用) |
| `MarkerAddCommand` / `MarkerRemoveCommand` / `MarkerChangeColorCommand` / `MarkerChangeNameCommand` / `MarkerChangeTimeCommand` | `marker` 5 个命令结构体,`to_command()` 经 vtable 暴露 |
## 3. WorkArea(timelineworkarea.h + timelineundoworkarea.h)
| C++ | Rust 落点 |
|---|---|
| `TimelineWorkArea`(enabled + range) | `workarea::TimelineWorkArea` |
| `enabled()/set_enabled()` / `in()/out()/length()/range()/set_range()` | `workarea` |
| `k_reset_in` / `k_reset_out` | `workarea::RESET_IN` / `RESET_OUT`(对应 `oaktimeline_workarea_reset`) |
| `load()/save()` | `workarea`(golden XML,见 tests) |
| `WorkareaSetEnabledCommand` / `WorkareaSetRangeCommand` | `workarea` 2 个命令结构体 |
## 4. Undo 通用助手(timelineundocommon.h)
| C++ | Rust 落点 |
|---|---|
| `node_can_be_removed(Node/Block)` | `undocommon::node_can_be_removed` |
| `create_remove_command(Node/Block)` | `undocommon::create_remove_command` |
| `create_and_run_remove_command(Node/Block)` | `undocommon::create_and_run_remove_command` |
| `free_command_handle` | `undocommon::free_command_handle` |
| `CHandleCommandWrapper` | `undocommon::CHandleCommandWrapper`(把 oakundo vtable command 当本 crate 命令暴露的封装) |
## 5. Track 命令(timelineundotrack.h)
| C++ | Rust 落点 |
|---|---|
| `TrackRippleRemoveBlockCommand` | `undotrack` |
| `TrackPrependBlockCommand` | `undotrack` |
| `TrackInsertBlockAfterCommand` | `undotrack` |
| `TrackReplaceBlockCommand` | `undotrack` |
## 6. 通用命令(timelineundogeneral.h)
| C++ | Rust 落点 |
|---|---|
| `BlockResizeCommand` | `undogeneral` |
| `BlockResizeWithMediaInCommand` | `undogeneral` |
| `BlockSetMediaInCommand` | `undogeneral` |
| `TimelineAddTrackCommand`(含 `run_immediately`) | `undogeneral` |
| `TimelineRemoveTrackCommand` | `undogeneral` |
| `TransitionRemoveCommand` | `undogeneral` |
| `TrackReplaceBlockWithGapCommand` | `undogeneral` |
| `BlockEnableDisableCommand` | `undogeneral` |
| `TrackListInsertGaps` | `undogeneral` |
| `TimelineAddDefaultTransitionCommand` | `undogeneral` |
## 7. 指针/滑动/放置命令(timelineundopointer.h)
| C++ | Rust 落点 |
|---|---|
| `BlockTrimCommand`(含 `set_trim_is_a_roll_edit` / `set_remove_zero_length_from_graph`) | `undopointer` |
| `TrackSlideCommand` | `undopointer` |
| `TrackPlaceBlockCommand` | `undopointer` |
## 8. Ripple 命令(timelineundoripple.h)
| C++ | Rust 落点 |
|---|---|
| `TrackRippleRemoveAreaCommand`(含 `get_insertion_index` / `get_spliced_block` / `set_allow_splitting_gaps`) | `undoripple` |
| `TrackListRippleRemoveAreaCommand` | `undoripple` |
| `TimelineRippleRemoveAreaCommand`(MultiUndoCommand) | `undoripple` |
| `TrackListRippleToolCommand`(`RippleInfo`/`WorkingData`) | `undoripple` |
| `TimelineRippleDeleteGapsAtRegionsCommand`(`has_commands`) | `undoripple` |
## 9. Split 命令(timelineundosplit.h)
| C++ | Rust 落点 |
|---|---|
| `BlockSplitCommand`(`new_block()`) | `undosplit` |
| `BlockSplitPreservingLinksCommand`(`get_split`) | `undosplit` |
| `TrackSplitAtTimeCommand` | `undosplit` |
## 10. 工具助手(timelineutil.h)
| C++ | Rust 落点 |
|---|---|
| `rat_nd` | `util::rat_nd` |
| `same_block/same_track/same_node` | `util` |
| `BlockHandleLess/TrackHandleLess` | `util` |
| `free_detached_handle` | `util`(取回 ownership 再释放) |
| `block_in/out/length` / `block_set_length_and_media_out/in` | `util` |
| `track_length` / `block_previous/next/track` | `util` |
| `track_project` / `block_add_to_graph` / `block_remove_from_graph` | `util` |
## 11. 刻意不迁移(drop) / 通过 C ABI(bridge)
| 项 | 理由 |
|---|---|
| `oaknode_c_api::to_native` / `oakundo_capi::make_command_handle`(C++ 内部助手) | 不复制;Rust 侧把 handle 当 opaque,命令经 vtable、裸指针经 bridge |
| `Timeline::PLAYHEAD_COLOR` | UI 取色宏,归 facade/app |
| 全部 `*_internal` 私有辅助 / `MemoryManager` 语义 | 实现细节,重组于各模块内部;内存所有权由 `CHandle` addref/release 表达 |
+14
View File
@@ -0,0 +1,14 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "oakcore-rs"
version = "0.1.0"
[[package]]
name = "oaktimeline"
version = "0.1.0"
dependencies = [
"oakcore-rs",
]
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "oaktimeline"
version = "0.1.0"
edition = "2021"
description = "Oak Video Editor timeline edit primitives (Rust)"
license = "GPL-3.0-or-later"
[lib]
crate-type = ["staticlib", "rlib"]
[profile.release]
# FFI discipline: panics must be catchable at every exported entry.
panic = "unwind"
[features]
# Compile the in-crate C ABI mocks (src/bridge/teststubs.rs) so the unit and
# integration tests can link without the oaknode/oakundo/oakcommon DLLs.
# Every test run must pass this flag (see README.md "testing").
test-stubs = []
[dependencies]
oakcore-rs = { path = "../../oakcore-rs" }
+106
View File
@@ -0,0 +1,106 @@
# oaktimeline Rust crate (declaration draft, for review)
> Status: **declaration draft**. Signatures + doc comments are the
> spec; every body is `todo!()`. Not wired into any build.
> The crate template (FFI discipline, testing layers) follows
> `src/plugin/rust/README.md` and `src/node/rust/README.md`.
## Scope
Replaces the C++ oaktimeline module (`src/timeline/src`): timeline
markers and work areas, the timeline undo-command family (add/remove
tracks, place/trim/split blocks, ripple edits, slide, gap insertion),
and the shared `Timeline` namespace / utility helpers.
Public contract: `include/timeline/*.h` (`error.h`, `displaymode.h`,
`marker.h`, `workarea.h`, `edit.h`) — frozen, implemented verbatim by
`src/ffi.rs`. The timeline value handles (`OakTimelineMarkerList`,
`OakTimelineWorkArea`) and every edit command are exported through this
ABI only; consumers (the facade/app, the oaknode crate) never see the
internal Rust types.
## Key architectural decisions (C++ → Rust mapping)
1. **Domain modules mirror the C++ header files.** The C++ module is a
flat set of headers, not a deep class hierarchy, so the Rust crate
keeps one module per C++ header family (`common`, `marker`,
`workarea`, `undocommon`, `undotrack`, `undogeneral`, `undopointer`,
`undoripple`, `undosplit`, `util`). `COVERAGE.md` maps every C++ type
to its Rust home; review that first.
2. **No C++ `UndoCommand` subclass hierarchy.** Following the oaknode
crate decision (#4), each undo command is a plain Rust struct that
exposes `prepare()` / `redo()` / `undo()` (all `todo!()` here) and
is surfaced to the world through the oakundo C ABI vtable
(`bridge::undo::oakundo_command_init`, Rust callbacks as `userdata`).
Every command struct carries a `to_command() -> CHandle` factory doc
comment describing the wiring.
3. **Value types come from `oakcore-rs`.** Markers and work areas are
built on `Rational`/`TimeRange`, so the crate depends on
`oakcore-rs` (`src/oakcore-rs`) exactly like oaknode does; no
pixel/sample formats are involved here.
4. **All cross-module access goes through the C ABI.** Per the project
rule (no cross-module C++ member calls), timeline commands touch the
node graph exclusively through the oaknode C ABI
(`bridge::node`), undo through the oakundo C ABI (`bridge::undo`),
and XML/config through the oakcommon C ABI (`bridge::common`). The
C++ internal helpers `oakundo_capi::make_command_handle` /
`oaknode_c_api::to_native` are **not** replicated in Rust — their
role is subsumed by vtable commands and by value handles treated as
opaque.
5. **Handles.** `handle.rs` provides the shared `RefBox`/`CHandle`
scaffolding (duplicated per crate, as in oaknode) with
`OAKTIMELINE_ABI_VERSION = 1`. Borrowed handles into node-owned
objects and owning handles created by `*_create` share one box
layout `{ctx, addref, release, abi_version}`.
## Layout
```
src/
lib.rs crate doc + module map
error.rs error codes (mirrors include/timeline/error.h)
handle.rs refcounted-handle scaffolding (same pattern as node)
common.rs Timeline namespace (MovementMode/ThumbnailMode/
WaveformMode, EditToInfo) — timelinecommon.h
marker.rs TimelineMarker/MarkerList + 5 marker commands
workarea.rs TimelineWorkArea + 2 workarea commands
undocommon.rs node/block remove helpers + CHandleCommandWrapper
undotrack.rs track ripple/prepend/insert-after/replace commands
undogeneral.rs resize/media-in/add/remove-track/transition/gap/
enable-disable/insert-gaps/default-transition commands
undopointer.rs BlockTrimCommand/TrackSlideCommand/TrackPlaceBlockCommand
undoripple.rs ripple remove-area / ripple-tool / delete-gaps commands
undosplit.rs BlockSplitCommand/BlockSplitPreservingLinksCommand/
TrackSplitAtTimeCommand
util.rs timelineutil.h inline helpers (rat_nd, same_*,
free_detached_handle, block/track queries)
bridge/ C ABI imports: node.rs, undo.rs, common.rs
ffi.rs include/timeline/*.h export layer
tests/ contract + golden tests (see README test section)
```
## Hard rules for the implementer
1. Every `extern "C"` body goes through `handle::guard*`; no panic
crosses FFI.
2. Timeline objects never outlive their owning node; borrowed handles
are created under a guard that owns the node reference.
3. Behavior parity with C++ is proven by the C ABI test-suite
(`src/timeline/tests`, unchanged) plus the golden tests in `tests/`
(XML save/load formats captured verbatim from
`src/timeline/src/timelinemarker.cpp` / `timelineworkarea.cpp`).
4. Where C++ behavior is genuinely load-bearing but ugly (e.g. marker
list kept sorted by time, ripple's compensation gap rules), port the
behavior, not the aesthetics; leave a `// CPP-PARITY:` comment with
the C++ file:line.
## Dependency policy
Prefer mature third-party crates (MIT/Apache-2.0/BSD, GPL-compatible)
over hand-rolling; register each addition (name + reason) here. Large
existing C++ libraries (OTIO, OCIO, OIIO, FFmpeg) are NEVER rewritten
— they are consumed through their C ABI / bridge layers.
+69
View File
@@ -0,0 +1,69 @@
// 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 C ABI imports (XML reader/writer + config), mirroring
//! `include/common/xmlutils.h` and `include/common/config.h`.
use std::ffi::{c_char, c_int};
use crate::handle::CHandle;
extern "C" {
/// `oakcommon_xml_reader_init` — create a reader over a NUL-terminated
/// document; the document must outlive the reader.
pub fn oakcommon_xml_reader_init(data: *const c_char) -> CHandle;
/// `oakcommon_xml_reader_free` — release the reader; NULL is a no-op.
pub fn oakcommon_xml_reader_free(reader: *mut CHandle);
/// `oakcommon_xml_reader_skip_current_element` — skip to the end of the
/// current element subtree.
pub fn oakcommon_xml_reader_skip_current_element(reader: CHandle) -> c_int;
/// `oakcommon_xml_reader_read_next_start_element` — advance to the next
/// start element, writing its (possibly empty) name into `name`.
pub fn oakcommon_xml_reader_read_next_start_element(reader: CHandle, name: *mut c_char, buf_size: c_int) -> c_int;
/// `oakcommon_xml_reader_name` — the current element's name.
pub fn oakcommon_xml_reader_name(reader: CHandle, name: *mut c_char, buf_size: c_int) -> c_int;
/// `oakcommon_xml_reader_read_element_text` — read the current element's
/// text content.
pub fn oakcommon_xml_reader_read_element_text(reader: CHandle, text: *mut c_char, buf_size: c_int) -> c_int;
/// `oakcommon_xml_reader_attribute_count` — number of attributes on the
/// current element.
pub fn oakcommon_xml_reader_attribute_count(reader: CHandle, count: *mut c_int) -> c_int;
/// `oakcommon_xml_reader_attribute_name` — name of attribute `i`.
pub fn oakcommon_xml_reader_attribute_name(reader: CHandle, index: c_int, name: *mut c_char, buf_size: c_int) -> c_int;
/// `oakcommon_xml_reader_attribute_value` — value of attribute `i`.
pub fn oakcommon_xml_reader_attribute_value(reader: CHandle, index: c_int, value: *mut c_char, buf_size: c_int) -> c_int;
/// `oakcommon_xml_reader_has_error` — whether the reader hit an error.
pub fn oakcommon_xml_reader_has_error(reader: CHandle, has_error: *mut c_int) -> c_int;
/// `oakcommon_xml_writer_init` — create a writer.
pub fn oakcommon_xml_writer_init() -> CHandle;
/// `oakcommon_xml_writer_free` — release the writer; NULL is a no-op.
pub fn oakcommon_xml_writer_free(writer: *mut CHandle);
/// `oakcommon_xml_writer_write_start_element` — open `<name>`.
pub fn oakcommon_xml_writer_write_start_element(writer: CHandle, name: *const c_char) -> c_int;
/// `oakcommon_xml_writer_write_end_element` — close the current element.
pub fn oakcommon_xml_writer_write_end_element(writer: CHandle) -> c_int;
/// `oakcommon_xml_writer_write_end_document` — finish the document.
pub fn oakcommon_xml_writer_write_end_document(writer: CHandle) -> c_int;
/// `oakcommon_xml_writer_write_attribute` — write `key="value"`.
pub fn oakcommon_xml_writer_write_attribute(writer: CHandle, key: *const c_char, value: *const c_char) -> c_int;
/// `oakcommon_xml_writer_write_characters` — write raw text content.
pub fn oakcommon_xml_writer_write_characters(writer: CHandle, text: *const c_char) -> c_int;
/// `oakcommon_xml_writer_write_text_element` — write `<name>text</name>`.
pub fn oakcommon_xml_writer_write_text_element(writer: CHandle, name: *const c_char, text: *const c_char) -> c_int;
/// `oakcommon_config_get_int` — read an integer config value (marker default
/// colour).
pub fn oakcommon_config_get_int(group: *const c_char, key: *const c_char, default: c_int) -> c_int;
}
+33
View File
@@ -0,0 +1,33 @@
// 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 imports from other oak modules. Signatures mirror the public
//! headers verbatim; they are resolved at link time against the oaknode,
//! oakundo and oakcommon DLLs.
//!
//! Timeline never reimplements C++ internals: every cross-module access
//! goes through these frozen C ABIs (see README.md "no replication").
pub mod common;
pub mod node;
pub mod undo;
/// In-crate C ABI mocks, compiled only for tests (feature `test-stubs`).
/// Each `#[no_mangle]` function here provides a definition for the `extern
/// "C"` symbol declared in the submodules above, so `cargo test
/// --features test-stubs` links without the real oak DLLs.
#[cfg(feature = "test-stubs")]
pub mod teststubs;
+167
View File
@@ -0,0 +1,167 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! oaknode C ABI imports (`include/node/{block,node,sequence,track,project}.h`).
//! Timeline reads and mutates the block/track/sequence graph exclusively
//! through these frozen functions — it never reimplements node internals.
//!
//! All time quantities cross the C ABI as `int` numerator/denominator pairs
//! (the ABI is `int`; the crate's internal `Rational` is `i64`). Converting
//! between the two is the bridge's responsibility.
use std::ffi::{c_char, c_int};
use crate::handle::CHandle;
/// A timeline block (`OakNodeBlock`) — a clip or a gap. Opaque handle.
pub type OakNodeBlock = CHandle;
/// A track (`OakNodeTrack`). Opaque handle.
pub type OakNodeTrack = CHandle;
/// A sequence/timeline (`OakNodeSequence`). Opaque handle.
pub type OakNodeSequence = CHandle;
/// A generic node (`OakNodeNode`). Opaque handle.
pub type OakNodeNode = CHandle;
/// A project (`OakNodeProject`). Opaque handle.
pub type OakNodeProject = CHandle;
/// A track list (`OakNodeTrackList`). Opaque handle.
pub type OakNodeTrackList = CHandle;
extern "C" {
/// `oaknode_block_clip_create` — a new clip block (count 1).
pub fn oaknode_block_clip_create() -> OakNodeBlock;
/// `oaknode_block_gap_create` — a new gap block (count 1).
pub fn oaknode_block_gap_create() -> OakNodeBlock;
/// `oaknode_block_as_node` — the block viewed as a generic node.
pub fn oaknode_block_as_node(block: OakNodeBlock) -> OakNodeNode;
/// `oaknode_block_get_in` — the block's in point as an int pair.
pub fn oaknode_block_get_in(block: OakNodeBlock, numerator: *mut c_int, denominator: *mut c_int) -> c_int;
/// `oaknode_block_get_out` — the block's out point as an int pair.
pub fn oaknode_block_get_out(block: OakNodeBlock, numerator: *mut c_int, denominator: *mut c_int) -> c_int;
/// `oaknode_block_get_length` — the block's length as an int pair.
pub fn oaknode_block_get_length(block: OakNodeBlock, numerator: *mut c_int, denominator: *mut c_int) -> c_int;
/// `oaknode_block_set_length_and_media_out` — resize keeping media-in fixed.
pub fn oaknode_block_set_length_and_media_out(block: OakNodeBlock, numerator: c_int, denominator: c_int) -> c_int;
/// `oaknode_block_set_length_and_media_in` — resize keeping the out point fixed.
pub fn oaknode_block_set_length_and_media_in(block: OakNodeBlock, numerator: c_int, denominator: c_int) -> c_int;
/// `oaknode_block_get_enabled` — the block's enabled flag.
pub fn oaknode_block_get_enabled(block: OakNodeBlock, enabled: *mut c_int) -> c_int;
/// `oaknode_block_set_enabled` — set the enabled flag.
pub fn oaknode_block_set_enabled(block: OakNodeBlock, enabled: c_int) -> c_int;
/// `oaknode_block_get_previous` — borrowed previous block (empty when none).
pub fn oaknode_block_get_previous(block: OakNodeBlock, out: *mut OakNodeBlock) -> c_int;
/// `oaknode_block_get_next` — borrowed next block (empty when none).
pub fn oaknode_block_get_next(block: OakNodeBlock, out: *mut OakNodeBlock) -> c_int;
/// `oaknode_block_get_track` — the owning track (empty when detached).
pub fn oaknode_block_get_track(block: OakNodeBlock, out: *mut OakNodeTrack) -> c_int;
/// `oaknode_block_link` — link two blocks so they move together.
pub fn oaknode_block_link(a: OakNodeBlock, b: OakNodeBlock) -> c_int;
/// `oaknode_block_unlink` — unlink two blocks.
pub fn oaknode_block_unlink(a: OakNodeBlock, b: OakNodeBlock) -> c_int;
/// `oaknode_track_get_length` — the track's length as an int pair.
pub fn oaknode_track_get_length(track: OakNodeTrack, numerator: *mut c_int, denominator: *mut c_int) -> c_int;
/// `oaknode_track_get_sequence` — the owning sequence (empty when detached).
pub fn oaknode_track_get_sequence(track: OakNodeTrack, out: *mut OakNodeSequence) -> c_int;
/// `oaknode_track_prepend_block` — prepend a block.
pub fn oaknode_track_prepend_block(track: OakNodeTrack, block: OakNodeBlock) -> c_int;
/// `oaknode_track_insert_block_after` — insert after `before`.
pub fn oaknode_track_insert_block_after(track: OakNodeTrack, block: OakNodeBlock, before: OakNodeBlock) -> c_int;
/// `oaknode_track_ripple_remove_block` — remove a block, shifting later ones
/// earlier; ownership returns to the caller.
pub fn oaknode_track_ripple_remove_block(track: OakNodeTrack, block: OakNodeBlock) -> c_int;
/// `oaknode_track_replace_block` — replace `old_block` with `new_block`
/// (equal lengths required).
pub fn oaknode_track_replace_block(track: OakNodeTrack, old_block: OakNodeBlock, new_block: OakNodeBlock) -> c_int;
/// `oaknode_sequence_as_node` — the sequence viewed as a generic node.
pub fn oaknode_sequence_as_node(sequence: OakNodeSequence) -> OakNodeNode;
/// `oaknode_sequence_from_node` — a generic node viewed as a sequence.
pub fn oaknode_sequence_from_node(node: OakNodeNode) -> OakNodeSequence;
/// `oaknode_node_get_project` — the owning project (empty when detached).
pub fn oaknode_node_get_project(node: OakNodeNode, out: *mut OakNodeProject) -> c_int;
/// `oaknode_node_output_connection_count` — number of output connections.
pub fn oaknode_node_output_connection_count(node: OakNodeNode, out_count: *mut c_int) -> c_int;
/// `oaknode_node_get_markers` — borrow the node's marker list.
pub fn oaknode_node_get_markers(node: OakNodeNode, out: *mut crate::handle::CHandle) -> c_int;
/// `oaknode_node_get_work_area` — borrow the node's work area.
pub fn oaknode_node_get_work_area(node: OakNodeNode, out: *mut crate::handle::CHandle) -> c_int;
/// `oaknode_project_add_node` — adopt a node into a project.
pub fn oaknode_project_add_node(project: OakNodeProject, node: OakNodeNode) -> c_int;
/// `oaknode_project_remove_node` — remove a node from a project.
pub fn oaknode_project_remove_node(project: OakNodeProject, node: OakNodeNode) -> c_int;
/// `oaknode_command_create_remove_node` — a command that removes a node.
pub fn oaknode_command_create_remove_node(node: OakNodeNode) -> crate::handle::CHandle;
// --- block.h: querying / building blocks -------------------------------
/// `oaknode_block_get_kind` — the block's kind (`OAKNODE_BLOCK_*`).
pub fn oaknode_block_get_kind(block: OakNodeBlock, out_kind: *mut c_int) -> c_int;
/// `oaknode_block_from_node` — a generic node viewed as a block (empty when not a block).
pub fn oaknode_block_from_node(node: OakNodeNode) -> OakNodeBlock;
/// `oaknode_block_are_linked` — whether two blocks are linked (`linked` receives 1/0).
pub fn oaknode_block_are_linked(a: OakNodeBlock, b: OakNodeBlock, linked: *mut c_int) -> c_int;
/// `oaknode_clip_add_cache_passthrough_from` — copy render-cache passthroughs from `other`.
pub fn oaknode_clip_add_cache_passthrough_from(clip: OakNodeBlock, other: OakNodeBlock) -> c_int;
/// `oaknode_clip_get_media_in` — the clip's media-in as an int pair (clip blocks only).
pub fn oaknode_clip_get_media_in(clip: OakNodeBlock, numerator: *mut c_int, denominator: *mut c_int) -> c_int;
/// `oaknode_clip_set_media_in` — set the clip's media-in as an int pair (clip blocks only).
pub fn oaknode_clip_set_media_in(clip: OakNodeBlock, numerator: c_int, denominator: c_int) -> c_int;
// --- track.h: tracks and track lists -----------------------------------
/// `oaknode_track_create` — a new detached track of the given type.
pub fn oaknode_track_create(kind: c_int) -> OakNodeTrack;
/// `oaknode_track_get_locked` — whether the track is locked (`locked` receives 1/0).
pub fn oaknode_track_get_locked(track: OakNodeTrack, locked: *mut c_int) -> c_int;
/// `oaknode_track_set_locked` — set the locked flag.
pub fn oaknode_track_set_locked(track: OakNodeTrack, locked: c_int) -> c_int;
/// `oaknode_track_get_block_count` — number of blocks on the track.
pub fn oaknode_track_get_block_count(track: OakNodeTrack, count: *mut c_int) -> c_int;
/// `oaknode_track_get_block_at` — borrowed block at `index`.
pub fn oaknode_track_get_block_at(track: OakNodeTrack, index: c_int, out: *mut OakNodeBlock) -> c_int;
/// `oaknode_track_get_block_containing_time` — block strictly containing `time` (in < t < out).
pub fn oaknode_track_get_block_containing_time(track: OakNodeTrack, numerator: c_int, denominator: c_int, out: *mut OakNodeBlock) -> c_int;
/// `oaknode_track_get_nearest_block_before_or_at` — block at or before `time`.
pub fn oaknode_track_get_nearest_block_before_or_at(track: OakNodeTrack, numerator: c_int, denominator: c_int, out: *mut OakNodeBlock) -> c_int;
/// `oaknode_track_get_nearest_block_after_or_at` — block at or after `time`.
pub fn oaknode_track_get_nearest_block_after_or_at(track: OakNodeTrack, numerator: c_int, denominator: c_int, out: *mut OakNodeBlock) -> c_int;
/// `oaknode_tracklist_get_type` — the track list's track type.
pub fn oaknode_tracklist_get_type(list: OakNodeTrackList, kind: *mut c_int) -> c_int;
/// `oaknode_tracklist_get_track_count` — number of tracks in the list.
pub fn oaknode_tracklist_get_track_count(list: OakNodeTrackList, count: *mut c_int) -> c_int;
/// `oaknode_tracklist_get_track_at` — borrowed track at `index`.
pub fn oaknode_tracklist_get_track_at(list: OakNodeTrackList, index: c_int, out: *mut OakNodeTrack) -> c_int;
/// `oaknode_tracklist_array_append` — append a track-array element on the parent sequence.
pub fn oaknode_tracklist_array_append(list: OakNodeTrackList) -> c_int;
/// `oaknode_tracklist_array_remove_last` — remove the last track-array element.
pub fn oaknode_tracklist_array_remove_last(list: OakNodeTrackList) -> c_int;
// --- sequence.h ---------------------------------------------------------
/// `oaknode_sequence_get_track_list` — borrowed per-type track list.
pub fn oaknode_sequence_get_track_list(sequence: OakNodeSequence, kind: c_int, out: *mut OakNodeTrackList) -> c_int;
/// `oaknode_sequence_get_all_track_count` — total connected tracks across all types.
pub fn oaknode_sequence_get_all_track_count(sequence: OakNodeSequence, count: *mut c_int) -> c_int;
/// `oaknode_sequence_get_all_track_at` — borrowed track at `index` (flat cache).
pub fn oaknode_sequence_get_all_track_at(sequence: OakNodeSequence, index: c_int, out: *mut OakNodeTrack) -> c_int;
// --- node.h: edges and cloning ------------------------------------------
/// `oaknode_node_connect` — connect `output_node` to `input_node`'s `input_id` (live).
pub fn oaknode_node_connect(output_node: OakNodeNode, input_node: OakNodeNode, input_id: *const c_char) -> c_int;
/// `oaknode_node_disconnect` — remove the edge feeding `input_node`'s `input_id` (live).
pub fn oaknode_node_disconnect(input_node: OakNodeNode, input_id: *const c_char) -> c_int;
/// `oaknode_node_copy_in_graph` — clone `node` in its graph; `*out_command` receives an owned undo handle.
pub fn oaknode_node_copy_in_graph(node: OakNodeNode, out_command: *mut crate::handle::CHandle) -> OakNodeNode;
}
File diff suppressed because it is too large Load Diff
+56
View File
@@ -0,0 +1,56 @@
// 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/>.
//! oakundo C ABI imports (`include/undo/undocommand.h`). Undo commands are
//! created through the C ABI vtable (`oakundo_command_init` with Rust
//! closures as `userdata`) — timeline does not subclass the C++
//! `UndoCommand`; every command struct in this crate exposes `to_command()`
//! that wraps it this way.
use std::ffi::{c_char, c_int, c_void};
use crate::handle::CHandle;
/// `OakUndoCommandVtable` — callback table backing a caller-defined undo
/// command (undocommand.h). Any callback may be NULL (a NULL redo/undo makes
/// that direction a no-op); `free_fn` runs when the command is destroyed.
#[repr(C)]
pub struct OakUndoCommandVtable {
/// `redo` callback (NULL = no-op).
pub redo: Option<unsafe extern "C" fn(*mut c_void)>,
/// `undo` callback (NULL = no-op).
pub undo: Option<unsafe extern "C" fn(*mut c_void)>,
/// `free_fn` — releases `userdata` on destruction.
pub free_fn: Option<unsafe extern "C" fn(*mut c_void)>,
}
extern "C" {
/// `oakundo_command_init` — create a command backed by C callbacks; takes
/// ownership of `userdata`, copies `vtable`.
pub fn oakundo_command_init(vtable: *const OakUndoCommandVtable, userdata: *mut c_void) -> CHandle;
/// `oakundo_command_init_multi` — an empty multi command.
pub fn oakundo_command_init_multi() -> CHandle;
/// `oakundo_command_multi_add_child` — add a child to a multi command.
pub fn oakundo_command_multi_add_child(multi: CHandle, child: CHandle) -> c_int;
/// `oakundo_command_redo_now` — run the command's redo outside a stack.
pub fn oakundo_command_redo_now(command: CHandle) -> c_int;
/// `oakundo_command_undo_now` — run the command's undo outside a stack.
pub fn oakundo_command_undo_now(command: CHandle) -> c_int;
/// `oakundo_command_free` — release one reference; NULL is a no-op.
pub fn oakundo_command_free(command: *mut CHandle);
/// `oakundo_stack_push` — push a command onto a facade-owned stack.
pub fn oakundo_stack_push(stack: CHandle, command: CHandle, text: *const c_char) -> c_int;
}
+104
View File
@@ -0,0 +1,104 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! The shared `Timeline` namespace (`src/timeline/src/timelinecommon.h`):
//! movement/thumbnail/waveform modes and the `EditToInfo` struct.
//!
//! Enum discriminants are value-compatible with both `include/timeline/edit.h`
//! (`OAKTIMELINE_MOVEMENT_*`) and `include/timeline/displaymode.h`
//! (`OAK_TIMELINE_THUMBNAIL_*` / `OAK_TIMELINE_WAVEFORMS_*`); `ffi.rs` maps
//! between them mechanically.
use oakcore_rs::Rational;
/// `Timeline::MovementMode` (timelinecommon.h).
///
/// `OAKTIMELINE_MOVEMENT_NONE = 0`, `MOVE = 1`, `TRIM_IN = 2`,
/// `TRIM_OUT = 3` (include/timeline/edit.h).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MovementMode {
/// `k_none`: no movement.
None,
/// `k_move`: slide/position move.
Move,
/// `k_trim_in`: trim the in point.
TrimIn,
/// `k_trim_out`: trim the out point.
TrimOut,
}
impl MovementMode {
/// `Timeline::is_a_trim_mode`: true for `TrimIn`/`TrimOut`.
pub fn is_a_trim_mode(self) -> bool {
matches!(self, MovementMode::TrimIn | MovementMode::TrimOut)
}
/// Map to the `OAKTIMELINE_MOVEMENT_*` C enum value.
pub fn to_c_int(self) -> i32 {
match self {
MovementMode::None => 0,
MovementMode::Move => 1,
MovementMode::TrimIn => 2,
MovementMode::TrimOut => 3,
}
}
/// Map a `OAKTIMELINE_MOVEMENT_*` C enum value back; `None` when the
/// integer is out of range.
pub fn from_c_int(v: i32) -> Option<MovementMode> {
match v {
0 => Some(MovementMode::None),
1 => Some(MovementMode::Move),
2 => Some(MovementMode::TrimIn),
3 => Some(MovementMode::TrimOut),
_ => None,
}
}
}
/// `Timeline::ThumbnailMode` (timelinecommon.h). Discriminants match
/// `OAK_TIMELINE_THUMBNAIL_OFF/IN_OUT/ON` (include/timeline/displaymode.h).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ThumbnailMode {
/// `k_thumbnail_off`: no thumbnails.
Off,
/// `k_thumbnail_in_out`: thumbnail at in/out.
InOut,
/// `k_thumbnail_on`: thumbnails on.
On,
}
/// `Timeline::WaveformMode` (timelinecommon.h). Discriminants match
/// `OAK_TIMELINE_WAVEFORMS_DISABLED/ENABLED` (include/timeline/displaymode.h).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WaveformMode {
/// `k_waveforms_disabled`.
Disabled,
/// `k_waveforms_enabled`.
Enabled,
}
/// `Timeline::EditToInfo` (timelinecommon.h): where the nearest block edge
/// falls for a pointer edit. Not `Copy`/`Clone`/`Debug` — it owns
/// [`crate::handle::CHandle`]s, which are opaque refcounted handles.
pub struct EditToInfo {
/// Owning track of the nearest block.
pub track: crate::handle::CHandle,
/// Nearest edit point time.
pub nearest_time: Rational,
/// Nearest block.
pub nearest_block: crate::handle::CHandle,
}
+66
View File
@@ -0,0 +1,66 @@
// 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/timeline/error.h` verbatim;
//! project-wide -MMCCCC scheme (module registry in include/common/error.h),
//! pass-through untranslated. Timeline is module 04 (no CANCELLED code;
//! only codec=05 and task=08 carry one).
/// ABI version stamped into every handle.
pub const OAKTIMELINE_ABI_VERSION: u32 = 1;
/// Success.
pub const OAKTIMELINE_OK: i32 = 0;
/// Null handle or invalid argument.
pub const OAKTIMELINE_E_INVALID: i32 = -40001;
/// Call not valid in the current state.
pub const OAKTIMELINE_E_STATE: i32 = -40002;
/// The underlying operation failed.
pub const OAKTIMELINE_E_FAILED: i32 = -40003;
/// Index out of range / entry not found.
pub const OAKTIMELINE_E_NOT_FOUND: i32 = -40004;
/// Allocation failed.
pub const OAKTIMELINE_E_NOMEM: i32 = -40005;
/// Crate-internal result type; the FFI layer maps it to the codes.
pub type Result<T> = std::result::Result<T, Error>;
/// Crate-internal error.
#[derive(Debug)]
pub enum Error {
/// Null handle or invalid argument.
Invalid,
/// Wrong state.
State,
/// Operation failed (context string is log-only).
Failed(String),
/// Index out of range / entry not found.
NotFound,
/// Out of memory.
NoMem,
}
impl Error {
/// Map to the public error code.
pub fn code(&self) -> i32 {
match self {
Error::Invalid => OAKTIMELINE_E_INVALID,
Error::State => OAKTIMELINE_E_STATE,
Error::Failed(_) => OAKTIMELINE_E_FAILED,
Error::NotFound => OAKTIMELINE_E_NOT_FOUND,
Error::NoMem => OAKTIMELINE_E_NOMEM,
}
}
}
File diff suppressed because it is too large Load Diff
+205
View File
@@ -0,0 +1,205 @@
// 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 oaknode crate
//! (`src/node/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). Value handles (`OakTimelineMarkerList`,
//! `OakTimelineWorkArea`) share this box.
use std::ffi::c_void;
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::sync::atomic::{AtomicU32, Ordering};
use crate::error::{OAKTIMELINE_E_FAILED, OAKTIMELINE_OK, Result};
/// ABI version stamped into every oaktimeline handle.
pub const OAKTIMELINE_ABI_VERSION: u32 = 1;
/// Heap box behind a handle's `ctx`.
pub struct RefBox<T: ?Sized> {
/// Atomic reference count.
pub refs: AtomicU32,
/// Boxed value.
pub value: T,
}
/// `#[repr(C)]` mirror of the public handle structs
/// (`{ctx, addref, release, abi_version}`).
#[repr(C)]
#[derive(Clone)]
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,
}
/// Generic `addref` implementation: increments the box's reference count.
///
/// # Safety
/// `ptr` must point to a `RefBox<T>`.
unsafe extern "C" fn addref_box<T: 'static>(ptr: *mut c_void) {
if ptr.is_null() {
return;
}
let rb = unsafe { &*(ptr as *const RefBox<T>) };
rb.refs.fetch_add(1, Ordering::SeqCst);
}
/// Generic `release` implementation: decrements the reference count and
/// destroys the box at zero.
///
/// # Safety
/// `ptr` must point to a `RefBox<T>`.
unsafe extern "C" fn release_box<T: 'static>(ptr: *mut c_void) {
if ptr.is_null() {
return;
}
let rb = ptr as *mut RefBox<T>;
let prev = unsafe { (*rb).refs.fetch_sub(1, Ordering::SeqCst) };
if prev == 1 {
// Last reference: reclaim the box.
drop(unsafe { Box::from_raw(rb) });
}
}
impl CHandle {
/// The empty handle.
pub fn null() -> Self {
CHandle {
ctx: std::ptr::null_mut(),
addref: None,
release: None,
abi_version: 0,
}
}
/// Whether this is the empty (all-null) handle.
pub fn is_null(&self) -> bool {
self.ctx.is_null()
}
}
/// Owned handle with count 1; empty on allocation failure.
pub fn make_owned<T: Send + 'static>(value: T) -> CHandle {
let boxed = Box::new(RefBox {
refs: AtomicU32::new(1),
value,
});
let ptr = Box::into_raw(boxed) as *mut c_void;
CHandle {
ctx: ptr,
addref: Some(addref_box::<T>),
release: Some(release_box::<T>),
abi_version: OAKTIMELINE_ABI_VERSION,
}
}
/// Borrowed handle for an object owned elsewhere (release frees only
/// the box).
///
/// The box holds a detached copy of `*ptr`, so the underlying object is
/// never touched by the handle's release; `get` returns the copy. The
/// caller retains ownership of `ptr`.
///
/// # Safety
/// Caller guarantees `ptr` is valid for reading for the duration of the
/// call.
pub unsafe fn make_borrowed<T: Send + 'static>(ptr: *mut T) -> CHandle {
let value = unsafe { ptr.read() };
make_owned(value)
}
/// Typed view into a handle; `None` for empty handles.
///
/// # Safety
/// `T` must be the boxed type.
pub unsafe fn get<T: 'static>(h: &CHandle) -> Option<&T> {
if h.ctx.is_null() {
return None;
}
if h.addref.is_some() {
// Owned (or borrowed-via-copy) handle: `ctx` points at a `RefBox<T>`.
let rb = unsafe { &*(h.ctx as *const RefBox<T>) };
Some(&rb.value)
} else {
// Borrowed handle wrapping a raw object pointer (e.g. test-stub
// handles): `ctx` is the object itself, not a `RefBox<T>`.
Some(unsafe { &*(h.ctx as *const T) })
}
}
/// Mutable typed view into a handle; `None` for empty handles. Used by
/// undo commands to mutate the boxed value they hold a handle to.
///
/// # Safety
/// `T` must be the boxed type, and the caller must guarantee exclusive
/// access to the boxed value for the duration of the borrow (no two
/// mutable views alive at once).
pub unsafe fn get_mut<T: 'static>(h: &CHandle) -> Option<&mut T> {
if h.ctx.is_null() {
return None;
}
if h.addref.is_some() {
// Owned (or borrowed-via-copy) handle: `ctx` points at a `RefBox<T>`.
let rb = unsafe { &mut *(h.ctx as *mut RefBox<T>) };
Some(&mut rb.value)
} else {
// Borrowed handle wrapping a raw object pointer (e.g. test-stub
// handles): `ctx` is the object itself, not a `RefBox<T>`.
Some(unsafe { &mut *(h.ctx as *mut T) })
}
}
/// Panic-catching FFI wrapper for i32-returning exports.
pub fn guard<F: FnOnce() -> Result<()>>(f: F) -> i32 {
match catch_unwind(AssertUnwindSafe(f)) {
Ok(Ok(())) => OAKTIMELINE_OK,
Ok(Err(e)) => e.code(),
Err(_) => OAKTIMELINE_E_FAILED,
}
}
/// Panic-catching FFI wrapper for handle-returning exports.
pub fn guard_handle<F: FnOnce() -> Result<CHandle>>(f: F) -> CHandle {
match catch_unwind(AssertUnwindSafe(f)) {
Ok(Ok(h)) => h,
_ => CHandle::null(),
}
}
/// Panic-catching FFI wrapper for void exports.
pub fn guard_void<F: FnOnce()>(f: F) {
let _ = catch_unwind(AssertUnwindSafe(f));
}
/// Panic-catching FFI wrapper for exports returning an `i32` value that is
/// not an error code (e.g. two-stage string lengths): a successful closure
/// returns its value verbatim, errors map through `Error::code`, and a
/// panic becomes `OAKTIMELINE_E_FAILED`.
pub fn guard_i32<F: FnOnce() -> Result<i32>>(f: F) -> i32 {
match catch_unwind(AssertUnwindSafe(f)) {
Ok(Ok(v)) => v,
Ok(Err(e)) => e.code(),
Err(_) => OAKTIMELINE_E_FAILED,
}
}
+47
View File
@@ -0,0 +1,47 @@
// 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/>.
//! # oaktimeline — the timeline edit module (Rust)
//!
//! Reimplements the C++ oaktimeline module (`src/timeline/src`) behind
//! its frozen C ABI (`include/timeline/*.h`). See README.md for the
//! architectural mapping (per-header domain modules, no UndoCommand
//! inheritance → vtable commands).
//!
//! ## FFI discipline
//!
//! Identical to the oaknode crate: every export goes through
//! [`handle::guard*`], handles are opaque refcounted boxes, and all
//! cross-module access goes through the oaknode/oakundo/oakcommon C ABIs
//! (`bridge::*`).
#![deny(unsafe_op_in_unsafe_fn)]
#![warn(missing_docs)]
pub mod bridge;
pub mod common;
pub mod error;
pub mod ffi;
pub mod handle;
pub mod marker;
pub mod undocommon;
pub mod undogeneral;
pub mod undopointer;
pub mod undoripple;
pub mod undosplit;
pub mod undotrack;
pub mod util;
pub mod workarea;
+589
View File
@@ -0,0 +1,589 @@
// 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/>.
//! Timeline markers and the marker undo-command family
//! (`src/timeline/src/timelinemarker.h`): `TimelineMarker`/`TimelineMarkerList`
//! plus `MarkerAdd`/`MarkerRemove`/`MarkerChangeColor`/`MarkerChangeName`/
//! `MarkerChangeTime` commands.
//!
//! The list keeps markers sorted by time (`// CPP-PARITY:
//! src/timeline/src/timelinemarker.h` `insert_into_list`). De-Qt: no QObject,
//! no signals — change notifications are the facade's job. `set_time` does
//! not re-sort (the De-Qt marker has no parent pointer); callers restore
//! order via `TimelineMarkerList::resort`/`resort_at`.
use oakcore_rs::{Rational, TimeRange};
use crate::handle::{get, get_mut};
use crate::undocommon::{box_command, Command};
/// `TimelineMarker` — a named, colored time range on a timeline
/// (timelinemarker.h). De-Qt; `draw()` moved to the app layer.
pub struct TimelineMarker {
/// Marker time range.
time_: TimeRange,
/// Marker name (may be empty).
name_: String,
/// Marker color index.
color_: i32,
}
impl TimelineMarker {
/// Default constructor.
pub fn new() -> Self {
Self::with_time(
0,
TimeRange::new(Rational::new(0, 1), Rational::new(0, 1)),
"",
)
}
/// Construct with color, time and optional name.
pub fn with_time(color: i32, time: TimeRange, name: &str) -> Self {
Self {
time_: time,
name_: name.to_string(),
color_: color,
}
}
/// The marker's time range.
pub fn time(&self) -> &TimeRange {
&self.time_
}
/// Set the time range. De-Qt: the marker has no parent pointer, so the
/// owning list is not re-sorted here; callers use
/// `TimelineMarkerList::resort`/`resort_at` when order must be restored.
pub fn set_time(&mut self, time: TimeRange) {
self.time_ = time;
}
/// Set the time to a zero-length range at `time`.
pub fn set_time_point(&mut self, time: Rational) {
let length = self.time_.length();
self.set_time(TimeRange::new(time, time + length));
}
/// Whether another marker in the same list shares `time` as its in point.
///
/// De-Qt simplification: the marker has no parent pointer, so this always
/// returns `false`; callers that need the answer use
/// `TimelineMarkerList::get_marker_at_time` instead.
pub fn has_sibling_at_time(&self, _t: Rational) -> bool {
false
}
/// The marker's name.
pub fn name(&self) -> &str {
&self.name_
}
/// Set the marker's name.
pub fn set_name(&mut self, name: &str) {
self.name_ = name.to_string();
}
/// The marker's color index.
pub fn color(&self) -> i32 {
self.color_
}
/// Set the marker's color index.
pub fn set_color(&mut self, c: i32) {
self.color_ = c;
}
}
/// `TimelineMarkerList` — an ordered collection of markers, sorted by time
/// (timelinemarker.h).
pub struct TimelineMarkerList {
/// Markers, kept sorted by time.
markers_: Vec<TimelineMarker>,
}
impl TimelineMarkerList {
/// A new, empty list.
pub fn new() -> Self {
Self {
markers_: Vec::new(),
}
}
/// Whether the list is empty.
pub fn empty(&self) -> bool {
self.markers_.is_empty()
}
/// Number of markers.
pub fn size(&self) -> usize {
self.markers_.len()
}
/// Marker at `i`; `None` out of range.
pub fn at(&self, i: usize) -> Option<&TimelineMarker> {
self.markers_.get(i)
}
/// The last marker; `None` when empty.
pub fn back(&self) -> Option<&TimelineMarker> {
self.markers_.last()
}
/// The first marker; `None` when empty.
pub fn front(&self) -> Option<&TimelineMarker> {
self.markers_.first()
}
/// Insert a marker, keeping the list sorted by time (takes ownership).
///
/// Equal in points insert after the existing ones (`>` comparison,
/// CPP-PARITY timelinemarker.h `insert_into_list`).
pub fn add_marker(&mut self, marker: TimelineMarker) {
for i in 0..self.markers_.len() {
if self.markers_[i].time().in_() > marker.time().in_() {
self.markers_.insert(i, marker);
return;
}
}
self.markers_.push(marker);
}
/// Detach `m` from the list, returning it; `None` if not present.
pub fn remove_marker(&mut self, m: &TimelineMarker) -> Option<TimelineMarker> {
for i in 0..self.markers_.len() {
if std::ptr::eq(&self.markers_[i], m) {
return Some(self.markers_.remove(i));
}
}
None
}
/// First marker whose in point equals `t`; `None` if none.
pub fn get_marker_at_time(&self, t: Rational) -> Option<&TimelineMarker> {
self.markers_.iter().find(|m| m.time().in_() == t)
}
/// Marker closest to `t` (early-exits once the diff increases;
/// CPP-PARITY timelinemarker.h).
pub fn get_closest_marker_to_time(&self, t: Rational) -> Option<&TimelineMarker> {
let mut closest: Option<&TimelineMarker> = None;
let mut closest_diff = Rational::new(0, 1);
for m in &self.markers_ {
let this_diff = rational_abs(m.time().in_() - t);
if closest.is_some() && this_diff > closest_diff {
// Sorted by in point, so the distance is increasing from
// here on.
break;
}
if closest.is_none() || this_diff < closest_diff {
closest = Some(m);
closest_diff = this_diff;
}
}
closest
}
/// Re-sort `m` after its time changed.
pub fn resort(&mut self, m: &mut TimelineMarker) {
if let Some(index) = self.markers_.iter().position(|x| std::ptr::eq(x, &*m)) {
let marker = self.markers_.remove(index);
self.add_marker(marker);
}
}
/// Marker at `i`, mutably; `None` out of range.
fn at_mut(&mut self, i: usize) -> Option<&mut TimelineMarker> {
self.markers_.get_mut(i)
}
/// Remove the marker at `index` and re-insert it sorted; no-op when
/// `index` is out of range. Used by the time-change command after
/// mutating a marker in place.
fn resort_at(&mut self, index: usize) {
if index < self.markers_.len() {
let marker = self.markers_.remove(index);
self.add_marker(marker);
}
}
}
/// Absolute value of a rational (oakcore-rs has no `abs`).
fn rational_abs(r: Rational) -> Rational {
if r < Rational::new(0, 1) {
Rational::new(0, 1) - r
} else {
r
}
}
// ---------------------------------------------------------------------------
// Marker undo commands. Each struct exposes prepare()/redo()/undo(); the
// FFI layer wraps it through bridge::undo's vtable (to_command()).
// ---------------------------------------------------------------------------
/// `MarkerAddCommand` (timelinemarker.h).
pub struct MarkerAddCommand {
/// Target list.
marker_list: crate::handle::CHandle,
/// Marker range.
range: TimeRange,
/// Marker name.
name: String,
/// Marker color.
color: i32,
/// Whether the marker is currently in the list.
added: bool,
}
impl MarkerAddCommand {
/// Construct from range/name/color.
pub fn new(marker_list: crate::handle::CHandle, range: TimeRange, name: &str, color: i32) -> Self {
Self {
marker_list,
range,
name: name.to_string(),
color,
added: false,
}
}
/// `redo`: add the marker, sorted.
pub fn redo(&mut self) {
if self.added {
return;
}
let marker = TimelineMarker::with_time(self.color, self.range, &self.name);
// SAFETY: the boxed value is a `TimelineMarkerList` created by
// `make_owned`, and the command holds exclusive access to it.
if let Some(list) = unsafe { get_mut::<TimelineMarkerList>(&self.marker_list) } {
list.add_marker(marker);
self.added = true;
}
}
/// `undo`: remove the added marker.
///
/// Simplification: the marker is located by its in point
/// (`get_marker_at_time`); when several markers share the in point, the
/// oldest (first in the list) is removed.
pub fn undo(&mut self) {
if !self.added {
return;
}
// SAFETY: as `redo`.
if let Some(list) = unsafe { get_mut::<TimelineMarkerList>(&self.marker_list) } {
if let Some(m) = list.get_marker_at_time(self.range.in_()) {
// SAFETY: `m` borrows from `list`; `remove_marker` uses it
// only for identity comparison before detaching.
let mptr = m as *const TimelineMarker;
let _ = list.remove_marker(unsafe { &*mptr });
}
self.added = false;
}
}
/// Wrap as an oakundo vtable command handle.
pub fn to_command(self) -> crate::handle::CHandle {
box_command(self)
}
}
impl Command for MarkerAddCommand {
/// `Command::redo` — the inherent method takes precedence.
fn redo(&mut self) {
self.redo();
}
/// `Command::undo` — the inherent method takes precedence.
fn undo(&mut self) {
self.undo();
}
}
/// `MarkerRemoveCommand` (timelinemarker.h).
pub struct MarkerRemoveCommand {
/// Target list.
marker_list: crate::handle::CHandle,
/// Index of the marker to remove.
index: usize,
/// Marker detached on `redo`, re-inserted by `undo`.
removed: Option<TimelineMarker>,
}
impl MarkerRemoveCommand {
/// Construct from list + index of the marker to remove.
pub fn new(marker_list: crate::handle::CHandle, index: usize) -> Self {
Self {
marker_list,
index,
removed: None,
}
}
/// `redo`: remove the marker.
pub fn redo(&mut self) {
if self.removed.is_some() {
return;
}
// SAFETY: the boxed value is a `TimelineMarkerList` created by
// `make_owned`, and the command holds exclusive access to it.
if let Some(list) = unsafe { get_mut::<TimelineMarkerList>(&self.marker_list) } {
if let Some(m) = list.at(self.index) {
// SAFETY: `m` borrows from `list`; `remove_marker` uses it
// only for identity comparison before detaching.
let mptr = m as *const TimelineMarker;
if let Some(removed) = list.remove_marker(unsafe { &*mptr }) {
self.removed = Some(removed);
}
}
}
}
/// `undo`: re-insert the marker, sorted.
pub fn undo(&mut self) {
// SAFETY: as `redo`.
if let Some(list) = unsafe { get_mut::<TimelineMarkerList>(&self.marker_list) } {
if let Some(marker) = self.removed.take() {
list.add_marker(marker);
}
}
}
/// Wrap as an oakundo vtable command handle.
pub fn to_command(self) -> crate::handle::CHandle {
box_command(self)
}
}
impl Command for MarkerRemoveCommand {
/// `Command::redo` — the inherent method takes precedence.
fn redo(&mut self) {
self.redo();
}
/// `Command::undo` — the inherent method takes precedence.
fn undo(&mut self) {
self.undo();
}
}
/// `MarkerChangeColorCommand` (timelinemarker.h).
pub struct MarkerChangeColorCommand {
/// Target list.
marker_list: crate::handle::CHandle,
/// Index of the marker to change.
index: usize,
/// Color before the change.
old_color: i32,
/// New color.
new_color: i32,
}
impl MarkerChangeColorCommand {
/// Construct from list + index + new color, capturing the current color
/// as old.
pub fn new(marker_list: crate::handle::CHandle, index: usize, new_color: i32) -> Self {
// SAFETY: the boxed value is a `TimelineMarkerList` created by
// `make_owned`; reading it here is the command's own handle.
let old_color = unsafe { get::<TimelineMarkerList>(&marker_list) }
.and_then(|l| l.at(index))
.map(|m| m.color())
.unwrap_or(0);
Self {
marker_list,
index,
old_color,
new_color,
}
}
/// `redo`: apply the new color.
pub fn redo(&mut self) {
// SAFETY: as `new`.
if let Some(list) = unsafe { get_mut::<TimelineMarkerList>(&self.marker_list) } {
if let Some(m) = list.at_mut(self.index) {
m.set_color(self.new_color);
}
}
}
/// `undo`: restore the old color.
pub fn undo(&mut self) {
// SAFETY: as `new`.
if let Some(list) = unsafe { get_mut::<TimelineMarkerList>(&self.marker_list) } {
if let Some(m) = list.at_mut(self.index) {
m.set_color(self.old_color);
}
}
}
/// Wrap as an oakundo vtable command handle.
pub fn to_command(self) -> crate::handle::CHandle {
box_command(self)
}
}
impl Command for MarkerChangeColorCommand {
/// `Command::redo` — the inherent method takes precedence.
fn redo(&mut self) {
self.redo();
}
/// `Command::undo` — the inherent method takes precedence.
fn undo(&mut self) {
self.undo();
}
}
/// `MarkerChangeNameCommand` (timelinemarker.h).
pub struct MarkerChangeNameCommand {
/// Target list.
marker_list: crate::handle::CHandle,
/// Index of the marker to change.
index: usize,
/// Name before the change.
old_name: String,
/// New name.
new_name: String,
}
impl MarkerChangeNameCommand {
/// Construct from list + index + new name, capturing the current name as
/// old.
pub fn new(marker_list: crate::handle::CHandle, index: usize, name: &str) -> Self {
// SAFETY: the boxed value is a `TimelineMarkerList` created by
// `make_owned`; reading it here is the command's own handle.
let old_name = unsafe { get::<TimelineMarkerList>(&marker_list) }
.and_then(|l| l.at(index))
.map(|m| m.name().to_string())
.unwrap_or_default();
Self {
marker_list,
index,
old_name,
new_name: name.to_string(),
}
}
/// `redo`: apply the new name.
pub fn redo(&mut self) {
// SAFETY: as `new`.
if let Some(list) = unsafe { get_mut::<TimelineMarkerList>(&self.marker_list) } {
if let Some(m) = list.at_mut(self.index) {
m.set_name(&self.new_name);
}
}
}
/// `undo`: restore the old name.
pub fn undo(&mut self) {
// SAFETY: as `new`.
if let Some(list) = unsafe { get_mut::<TimelineMarkerList>(&self.marker_list) } {
if let Some(m) = list.at_mut(self.index) {
m.set_name(&self.old_name);
}
}
}
/// Wrap as an oakundo vtable command handle.
pub fn to_command(self) -> crate::handle::CHandle {
box_command(self)
}
}
impl Command for MarkerChangeNameCommand {
/// `Command::redo` — the inherent method takes precedence.
fn redo(&mut self) {
self.redo();
}
/// `Command::undo` — the inherent method takes precedence.
fn undo(&mut self) {
self.undo();
}
}
/// `MarkerChangeTimeCommand` (timelinemarker.h). The old range is captured at
/// construction when not supplied.
pub struct MarkerChangeTimeCommand {
/// Target list.
marker_list: crate::handle::CHandle,
/// Index of the marker to change.
index: usize,
/// Time range before the change.
old_time: TimeRange,
/// New time range.
new_time: TimeRange,
}
impl MarkerChangeTimeCommand {
/// Construct from list + index + new time, capturing the current range as
/// old.
pub fn new(marker_list: crate::handle::CHandle, index: usize, time: TimeRange) -> Self {
// SAFETY: the boxed value is a `TimelineMarkerList` created by
// `make_owned`; reading it here is the command's own handle.
let old_time = unsafe { get::<TimelineMarkerList>(&marker_list) }
.and_then(|l| l.at(index))
.map(|m| *m.time())
.unwrap_or_else(|| TimeRange::new(Rational::new(0, 1), Rational::new(0, 1)));
Self {
marker_list,
index,
old_time,
new_time: time,
}
}
/// `redo`: apply the new time (resorts).
pub fn redo(&mut self) {
// SAFETY: as `new`.
if let Some(list) = unsafe { get_mut::<TimelineMarkerList>(&self.marker_list) } {
if let Some(m) = list.at_mut(self.index) {
m.set_time(self.new_time);
}
list.resort_at(self.index);
}
}
/// `undo`: restore the old time (resorts).
pub fn undo(&mut self) {
// SAFETY: as `new`.
if let Some(list) = unsafe { get_mut::<TimelineMarkerList>(&self.marker_list) } {
if let Some(m) = list.at_mut(self.index) {
m.set_time(self.old_time);
}
list.resort_at(self.index);
}
}
/// Wrap as an oakundo vtable command handle.
pub fn to_command(self) -> crate::handle::CHandle {
box_command(self)
}
}
impl Command for MarkerChangeTimeCommand {
/// `Command::redo` — the inherent method takes precedence.
fn redo(&mut self) {
self.redo();
}
/// `Command::undo` — the inherent method takes precedence.
fn undo(&mut self) {
self.undo();
}
}

Some files were not shown because too many files have changed in this diff Show More