Files
oak-editor/crates/oakengine/tests/it_render.rs
T
Mike-Solar 5afa95c80e feat(render,app): footage decode lands (M12 P0) + UI density pass
engine:
- oakrender eval footage hook decodes via oakcodec (JobSpec::Footage
  carries filename/stream); ticket/ffi/manager wiring, real-media
  decode test with programmatically generated MPEG-2
- oakrender bridge/codec.rs + node.rs: direct oakcodec/oaknode calls;
  the crate's dlsym module is gone (project_deep_copy/sync_copy remain
  documented always-fail stubs — never implemented in oaknode)
- oakaudio waveform/decoder path adjustments for the decode hook

app (gpui + gpui_widgets):
- menu bar scrubbing: hovering another top-level title while a menu is
  open switches to it; popup width is content-aware (CJK-aware) instead
  of fixed 160px
- density pass: window rem 16 -> 14px, menu rows 26 -> 22px, dock tabs
  32 -> 26px, viewer transport tightened
- open/import/save-as use the native platform file dialogs
  (prompt_for_paths / prompt_for_new_path; multi-select import);
  MockEngine records imported footage for tests
- project explorer Tree/Icons toggle is localized (explorer.tree /
  explorer.icons widget keys)
2026-08-13 18:16:10 +08:00

1331 lines
45 KiB
Rust

// 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 render family (`crates/oakengine/src/render.rs`,
//! the facade contract of `engine/include/oakengine/{renderer,color,lut}.h`).
//!
//! Covers all 60 exported `oakengine_*` functions of the family: the render
//! manager/cacher setters, the renderer lifecycle + real frame render, the
//! frame/audio-buffer accessors, the color-manager/config/processor surface
//! and the LUT library.
//!
//! Nothing here is mocked: the render manager is initialized through the real
//! oakrender C ABI (`oakrender_manager_init`), frames are produced by the
//! module's CPU eval pipeline, the color processor goes through the real
//! bundled OCIO config (`oakrender_color_manager_set_up_default_config`), and
//! the renderer's sequence handle comes from the real node/timeline families
//! (`oakengine_project_*` + `oakengine_sequence_new`).
//!
//! ## Global state
//!
//! The render manager singleton, the OCIO default config, the `$OCIO`
//! environment variable and the facade undo stack (cleared by
//! `oakengine_project_new`) are process-global, so every test that touches
//! them takes the single [`STATE_LOCK`]. The pure-stub tests (NULL-argument
//! accessors, LUT/audio stubs) run lock-free.
#[path = "common/mod.rs"]
mod common;
use std::ffi::{c_char, c_int, c_void};
use std::sync::{Mutex, MutexGuard};
use oakengine::handle::OakEngineAudioBuffer;
use oakengine::node::{
oakengine_footage_free, oakengine_node_factory_create_from_id, oakengine_node_free,
oakengine_project_create, oakengine_project_footage_at, oakengine_project_footage_count,
oakengine_project_free, oakengine_project_import_footage, oakengine_project_new,
};
use oakengine::render::{
oakengine_audio_channel_count, oakengine_audio_data, oakengine_audio_free,
oakengine_audio_sample_count, oakengine_audio_sample_rate,
oakengine_color_config_colorspace_at, oakengine_color_config_colorspace_count,
oakengine_color_config_free, oakengine_color_config_load_default,
oakengine_color_config_load_file, oakengine_color_last_error,
oakengine_color_manager_colorspace_at, oakengine_color_manager_colorspace_count,
oakengine_color_manager_compliant_color_space, oakengine_color_manager_compliant_transform,
oakengine_color_manager_default_display, oakengine_color_manager_default_input_color_space,
oakengine_color_manager_default_luma_coefs, oakengine_color_manager_default_view,
oakengine_color_manager_display_at, oakengine_color_manager_display_count,
oakengine_color_manager_from_project, oakengine_color_manager_get_config_filename,
oakengine_color_manager_look_at, oakengine_color_manager_look_count,
oakengine_color_manager_reference_color_space, oakengine_color_manager_set_config_filename,
oakengine_color_manager_set_default_input_color_space, oakengine_color_manager_view_at,
oakengine_color_manager_view_count, oakengine_color_processor_convert_color,
oakengine_color_processor_create, oakengine_color_processor_free, oakengine_color_processor_id,
oakengine_color_processor_is_valid, oakengine_color_transform_job_set_processor,
oakengine_frame_channel_count, oakengine_frame_data, oakengine_frame_format,
oakengine_frame_free, oakengine_frame_height, oakengine_frame_linesize_bytes,
oakengine_frame_width, oakengine_lut_directory_at, oakengine_lut_directory_count,
oakengine_lut_file_at, oakengine_lut_file_count, oakengine_lut_set_directories,
oakengine_render_cache_set_display_color_processor, oakengine_render_cache_set_multicam_node,
oakengine_render_manager_backend_to_string, oakengine_render_manager_requested_backend,
oakengine_render_manager_set_aggressive_garbage_collection, oakengine_renderer_cancel,
oakengine_renderer_create, oakengine_renderer_create_for_node, oakengine_renderer_free,
oakengine_renderer_last_error, oakengine_renderer_render_audio,
oakengine_renderer_render_frame, oakengine_renderer_set_mode, OakColorTransformPod,
};
use oakengine::timeline::oakengine_sequence_new;
/// Serializes tests that touch process-global state: the render manager
/// singleton, the OCIO default config, the `$OCIO` env var and the facade
/// undo stack (`oakengine_project_new` clears it). Every test that takes
/// this lock leaves the manager shut down and the config/env untouched, so
/// the tests are order-independent.
static STATE_LOCK: Mutex<()> = Mutex::new(());
fn state_lock() -> MutexGuard<'static, ()> {
STATE_LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
/// Read a two-stage facade string into a Rust `String`.
unsafe fn read_buf(buf: &mut [c_char]) -> String {
unsafe { std::ffi::CStr::from_ptr(buf.as_ptr()) }
.to_string_lossy()
.into_owned()
}
// ---------------------------------------------------------------------------
// Render manager / cacher (5 exports)
// ---------------------------------------------------------------------------
/// Manager state machine: STATE errors before init, legal paths after
/// `oakrender_manager_init`, STATE again after shutdown. The two cache
/// setters use their documented NULL-clears path here (real-handle paths
/// are exercised in `color_family_lifecycle`).
#[test]
fn render_manager_state_machine() {
common::force_link();
let _g = state_lock();
// Clean slate: no manager may be up when this test runs.
unsafe { oakrender::ffi::manager::oakrender_manager_shutdown() };
assert_eq!(
unsafe { oakrender::ffi::manager::oakrender_manager_available() },
0
);
// Not initialized → the module's STATE error passes through.
assert_eq!(
unsafe { oakengine_render_manager_set_aggressive_garbage_collection(1) },
-70002
);
assert_eq!(
unsafe { oakengine_render_cache_set_display_color_processor(std::ptr::null_mut()) },
-70002
);
assert_eq!(
unsafe { oakengine_render_cache_set_multicam_node(std::ptr::null_mut()) },
-70002
);
// Backend queries are stubs that ignore their arguments: requested
// backend is 0 (k_open_gl); backend_to_string is always E_FAILED even
// with garbage backend ids or NULL buffers.
assert_eq!(unsafe { oakengine_render_manager_requested_backend() }, 0);
let mut buf = [0 as c_char; 64];
assert_eq!(
unsafe { oakengine_render_manager_backend_to_string(0, std::ptr::null_mut(), 0) },
-3
);
assert_eq!(
unsafe { oakengine_render_manager_backend_to_string(2, std::ptr::null_mut(), 0) },
-3
);
assert_eq!(
unsafe { oakengine_render_manager_backend_to_string(-7, buf.as_mut_ptr(), 64) },
-3
);
// Init through the real module C ABI; double init is a state error.
assert_eq!(
unsafe { oakrender::ffi::manager::oakrender_manager_init() },
0
);
assert_eq!(
unsafe { oakrender::ffi::manager::oakrender_manager_available() },
1
);
assert_eq!(
unsafe { oakrender::ffi::manager::oakrender_manager_init() },
-70002
);
// Legal matrix for the aggressive-GC toggle: 0, 1 and any garbage
// value are all accepted (nonzero = enabled).
assert_eq!(
unsafe { oakengine_render_manager_set_aggressive_garbage_collection(0) },
0
);
assert_eq!(
unsafe { oakengine_render_manager_set_aggressive_garbage_collection(1) },
0
);
assert_eq!(
unsafe { oakengine_render_manager_set_aggressive_garbage_collection(2) },
0
);
assert_eq!(
unsafe { oakengine_render_manager_set_aggressive_garbage_collection(-1) },
0
);
// NULL-clears paths on the cacher.
assert_eq!(
unsafe { oakengine_render_cache_set_display_color_processor(std::ptr::null_mut()) },
0
);
assert_eq!(
unsafe { oakengine_render_cache_set_multicam_node(std::ptr::null_mut()) },
0
);
// Shutdown → STATE again.
unsafe { oakrender::ffi::manager::oakrender_manager_shutdown() };
assert_eq!(
unsafe { oakrender::ffi::manager::oakrender_manager_available() },
0
);
assert_eq!(
unsafe { oakengine_render_manager_set_aggressive_garbage_collection(1) },
-70002
);
}
// ---------------------------------------------------------------------------
// Renderer (7 exports)
// ---------------------------------------------------------------------------
/// Renderer lifecycle and illegal arguments. `render_frame` without a
/// render manager fails cleanly (NULL + last_error); the audio path is
/// unimplemented by the module and always fails the same way.
#[test]
fn renderer_lifecycle() {
common::force_link();
let _g = state_lock();
unsafe { oakrender::ffi::manager::oakrender_manager_shutdown() }; // render_frame below must see no manager
// NULL sequence → NULL renderer for every geometry combination.
for (w, h, pf, num, den) in [
(1920, 1080, 4, 30000, 1001),
(0, 1080, 4, 30000, 1001),
(1920, 0, 4, 30000, 1001),
(1920, 1080, 4, 0, 1001),
(1920, 1080, 4, 30000, 0),
(-1, 1080, 4, 30000, 1001),
(1920, -1, 4, 30000, 1001),
] {
let r = unsafe {
oakengine_renderer_create(std::ptr::null_mut(), w, h, pf, num, den, std::ptr::null())
};
assert!(
r.is_null(),
"NULL seq (w={w} h={h} pf={pf} {num}/{den}) must give NULL"
);
}
// NULL renderer calls are all safe.
unsafe { oakengine_renderer_free(std::ptr::null_mut()) };
unsafe { oakengine_renderer_cancel(std::ptr::null_mut()) };
let mut err = [0 as c_char; 128];
assert_eq!(
unsafe { oakengine_renderer_last_error(std::ptr::null(), err.as_mut_ptr(), 128) },
-1
);
assert_eq!(
unsafe { oakengine_renderer_set_mode(std::ptr::null_mut(), 0) },
-1
);
assert!(unsafe { oakengine_renderer_render_frame(std::ptr::null_mut(), 0) }.is_null());
assert!(unsafe { oakengine_renderer_render_audio(std::ptr::null_mut(), 0, 10) }.is_null());
// A real sequence (node family, no manager needed to create it).
let project = unsafe { oakengine_project_create() };
assert!(!project.is_null());
assert_eq!(unsafe { oakengine_project_new(project) }, 0);
let seq = unsafe { oakengine_sequence_new(project, c"it-render-seq".as_ptr()) };
assert!(!seq.is_null());
let r = unsafe { oakengine_renderer_create(seq, 1920, 1080, 4, 30000, 1001, std::ptr::null()) };
assert!(!r.is_null());
// Mode matrix: only 0 and 1 are legal; anything else is E_INVALID.
assert_eq!(unsafe { oakengine_renderer_set_mode(r, 0) }, 0);
assert_eq!(unsafe { oakengine_renderer_set_mode(r, 1) }, 0);
assert_eq!(unsafe { oakengine_renderer_set_mode(r, 2) }, -1);
assert_eq!(unsafe { oakengine_renderer_set_mode(r, -1) }, -1);
assert_eq!(unsafe { oakengine_renderer_set_mode(r, 42) }, -1);
// A fresh renderer reports an empty last_error.
assert_eq!(
unsafe { oakengine_renderer_last_error(r, err.as_mut_ptr(), 128) },
0
);
// render_frame without a manager: clean NULL + last_error.
assert!(unsafe { oakengine_renderer_render_frame(r, 0) }.is_null());
let elen = unsafe { oakengine_renderer_last_error(r, err.as_mut_ptr(), 128) };
assert!(elen > 0, "failed render must set last_error (got {elen})");
// render_audio: the facade hands the module a NULL audio-params
// pointer, so the ticket is never created (documented: the module's
// samples path is unimplemented) → NULL + last_error.
assert!(unsafe { oakengine_renderer_render_audio(r, 0, 10) }.is_null());
let elen = unsafe { oakengine_renderer_last_error(r, err.as_mut_ptr(), 128) };
assert!(
elen > 0,
"failed audio render must set last_error (got {elen})"
);
assert_eq!(
unsafe { read_buf(&mut err) },
"audio render ticket submission failed"
);
// Cancel is a documented no-op.
unsafe { oakengine_renderer_cancel(r) };
// Extreme timestamps must never crash: in debug builds the
// i64::MAX * frame_rate_den overflow panics inside the facade guard and
// yields NULL; in release it wraps and renders a frame. Either outcome
// is acceptable — the point is that the call is robust.
let f_extreme = unsafe { oakengine_renderer_render_frame(r, i64::MAX) };
if !f_extreme.is_null() {
unsafe { oakengine_frame_free(f_extreme) };
}
unsafe { oakengine_renderer_free(r) };
unsafe { oakengine_project_free(project) };
}
/// End-to-end CPU render: with the render manager up and a real sequence,
/// `render_frame` produces a real F32 frame through the module's eval
/// pipeline. The renderer's geometry (width/height) is forwarded as
/// force_width/force_height, so the frame size follows the renderer, and
/// the pixel-format validation rejects codes outside the oakcore enum.
#[test]
fn renderer_render_frame_e2e() {
common::force_link();
let _g = state_lock();
unsafe { oakrender::ffi::manager::oakrender_manager_shutdown() };
assert_eq!(
unsafe { oakrender::ffi::manager::oakrender_manager_init() },
0
);
let base = unsafe { oakrender::ffi::cache::oakrender_debug_alive_count() };
let project = unsafe { oakengine_project_create() };
assert_eq!(unsafe { oakengine_project_new(project) }, 0);
let seq = unsafe { oakengine_sequence_new(project, c"it-render-e2e".as_ptr()) };
assert!(!seq.is_null());
// 1920x1080 F32 (pixel format 4 = PixelFormat::F32).
let r = unsafe { oakengine_renderer_create(seq, 1920, 1080, 4, 30000, 1001, std::ptr::null()) };
assert!(!r.is_null());
// Legal render: a real frame comes back and every accessor reads it.
let f = unsafe { oakengine_renderer_render_frame(r, 0) };
assert!(!f.is_null(), "render_frame must produce a frame");
assert_eq!(unsafe { oakengine_frame_width(f) }, 1920);
assert_eq!(unsafe { oakengine_frame_height(f) }, 1080);
assert_eq!(unsafe { oakengine_frame_format(f) }, 4); // F32 pipeline format
assert_eq!(unsafe { oakengine_frame_linesize_bytes(f) }, 1920 * 4 * 4);
assert!(!unsafe { oakengine_frame_data(f) }.is_null());
// channel_count has no crate accessor and reports 0 (documented).
assert_eq!(unsafe { oakengine_frame_channel_count(f) }, 0);
// The renderer's last_error is cleared after a successful render.
assert_eq!(
unsafe { oakengine_renderer_last_error(r, err_buf().as_mut_ptr(), 128) },
0
);
// The produced frame is an oakrender-owned handle: alive count +1,
// back to baseline after free.
assert_eq!(
unsafe { oakrender::ffi::cache::oakrender_debug_alive_count() },
base + 1
);
unsafe { oakengine_frame_free(f) };
assert_eq!(
unsafe { oakrender::ffi::cache::oakrender_debug_alive_count() },
base
);
// A second render at a positive timestamp works too.
let f2 = unsafe { oakengine_renderer_render_frame(r, 30) };
assert!(!f2.is_null());
unsafe { oakengine_frame_free(f2) };
// The renderer's output geometry is honored: the facade forwards the
// boxed size as force_width/force_height, so a 640x360 renderer
// produces a 640x360 frame.
let r_small =
unsafe { oakengine_renderer_create(seq, 640, 360, 0, 30000, 1001, std::ptr::null()) };
assert!(!r_small.is_null());
let f3 = unsafe { oakengine_renderer_render_frame(r_small, 0) };
assert!(!f3.is_null());
assert_eq!(unsafe { oakengine_frame_width(f3) }, 640);
assert_eq!(unsafe { oakengine_frame_height(f3) }, 360);
unsafe { oakengine_frame_free(f3) };
unsafe { oakengine_renderer_free(r_small) };
// The pixel-format validation in renderer_create rejects codes outside
// the oakcore enum: garbage formats and Invalid (-1) yield NULL.
let r_garbage_pf =
unsafe { oakengine_renderer_create(seq, 64, 48, 99999, 30000, 1001, std::ptr::null()) };
assert!(
r_garbage_pf.is_null(),
"renderer_create must reject pixel_format=99999"
);
let r_neg_pf =
unsafe { oakengine_renderer_create(seq, 64, 48, -1, 30000, 1001, std::ptr::null()) };
assert!(
r_neg_pf.is_null(),
"renderer_create must reject pixel_format=-1"
);
unsafe { oakengine_renderer_free(r) };
unsafe { oakengine_project_free(project) };
assert_eq!(
unsafe { oakrender::ffi::cache::oakrender_debug_alive_count() },
base
);
unsafe { oakrender::ffi::manager::oakrender_manager_shutdown() };
assert_eq!(
unsafe { oakrender::ffi::manager::oakrender_manager_available() },
0
);
}
/// A scratch error buffer helper.
fn err_buf() -> [c_char; 128] {
[0 as c_char; 128]
}
/// End-to-end single-node CPU render: `oakengine_renderer_create_for_node`
/// binds a footage node (instead of a sequence) and `render_frame` produces
/// a real F32 frame through the module's eval pipeline. The footage node
/// comes from `oakengine_project_footage_at` (the import registered it in
/// the project graph) — the surface the source monitor renders through.
#[test]
fn renderer_render_frame_for_node_e2e() {
common::force_link();
let _g = state_lock();
unsafe { oakrender::ffi::manager::oakrender_manager_shutdown() };
assert_eq!(
unsafe { oakrender::ffi::manager::oakrender_manager_init() },
0
);
let base = unsafe { oakrender::ffi::cache::oakrender_debug_alive_count() };
let project = unsafe { oakengine_project_create() };
assert_eq!(unsafe { oakengine_project_new(project) }, 0);
// Import a footage file so the project graph owns a footage node.
// M12 P0: the renderer now DECODES footage-node renders, so the
// media must be real — generated programmatically through the facade.
let media = temp_media_file();
let media_c = std::ffi::CString::new(media.to_string_lossy().into_owned()).unwrap();
assert_eq!(
unsafe { oakengine::testmedia::oakengine_testmedia_write_clip(media_c.as_ptr(), 64, 64, 10, 10) },
0,
"generate real media for the footage-node render"
);
let footage = unsafe { oakengine_project_import_footage(project, media_c.as_ptr()) };
assert!(!footage.is_null());
unsafe { oakengine_footage_free(footage) };
assert_eq!(unsafe { oakengine_project_footage_count(project) }, 1);
// The footage node at index 0 is boxed; freed with node_free.
let node = unsafe { oakengine_project_footage_at(project, 0) };
assert!(
!node.is_null(),
"imported footage must be addressable by index"
);
// NULL project / out-of-range indices → NULL.
assert!(unsafe { oakengine_project_footage_at(std::ptr::null(), 0) }.is_null());
assert!(unsafe { oakengine_project_footage_at(project, -1) }.is_null());
assert!(unsafe { oakengine_project_footage_at(project, 5) }.is_null());
// NULL node → NULL renderer for every geometry combination.
for (w, h, pf, num, den) in [
(1920, 1080, 4, 30000, 1001),
(0, 1080, 4, 30000, 1001),
(1920, 0, 4, 30000, 1001),
(1920, 1080, 4, 0, 1001),
] {
let r = unsafe {
oakengine_renderer_create_for_node(
std::ptr::null_mut(),
w,
h,
pf,
num,
den,
std::ptr::null(),
)
};
assert!(r.is_null(), "NULL node (w={w} h={h} {num}/{den}) must give NULL");
}
// 1920x1080 F32 renderer over the footage node.
let r = unsafe {
oakengine_renderer_create_for_node(
node,
1920,
1080,
4,
30000,
1001,
std::ptr::null(),
)
};
assert!(!r.is_null());
// A legal render returns a real frame with the renderer's geometry.
let f = unsafe { oakengine_renderer_render_frame(r, 0) };
assert!(
!f.is_null(),
"render_frame over a footage node must produce a frame"
);
assert_eq!(unsafe { oakengine_frame_width(f) }, 1920);
assert_eq!(unsafe { oakengine_frame_height(f) }, 1080);
assert_eq!(unsafe { oakengine_frame_format(f) }, 4); // F32 pipeline format
assert_eq!(unsafe { oakengine_frame_linesize_bytes(f) }, 1920 * 4 * 4);
assert!(!unsafe { oakengine_frame_data(f) }.is_null());
// M12 P0: the footage node render decodes the media — the frame
// carries the test clip's known content (non-black, red-dominant
// left half).
let data = unsafe { oakengine_frame_data(f) } as *const f32;
let stride = 1920 * 4;
let px = unsafe { std::slice::from_raw_parts(data.add(540 * stride + 480 * 4), 4) };
assert!(
px[0] > 0.5 && px[1] < 0.4 && px[2] < 0.4,
"footage-node render decodes the known pattern: {px:?}"
);
assert_eq!(
unsafe { oakrender::ffi::cache::oakrender_debug_alive_count() },
base + 1
);
unsafe { oakengine_frame_free(f) };
assert_eq!(
unsafe { oakrender::ffi::cache::oakrender_debug_alive_count() },
base
);
// Illegal geometry / pixel format with a valid node → NULL.
let r_bad_geom = unsafe {
oakengine_renderer_create_for_node(
node,
0,
1080,
4,
30000,
1001,
std::ptr::null(),
)
};
assert!(r_bad_geom.is_null(), "zero width must give NULL");
let r_bad_pf = unsafe {
oakengine_renderer_create_for_node(
node,
64,
48,
99999,
30000,
1001,
std::ptr::null(),
)
};
assert!(r_bad_pf.is_null(), "garbage pixel format must give NULL");
unsafe { oakengine_renderer_free(r) };
unsafe { oakengine_node_free(node) };
unsafe { oakengine_project_free(project) };
assert_eq!(
unsafe { oakrender::ffi::cache::oakrender_debug_alive_count() },
base
);
unsafe { oakrender::ffi::manager::oakrender_manager_shutdown() };
assert_eq!(
unsafe { oakrender::ffi::manager::oakrender_manager_available() },
0
);
}
/// Fresh media file under the system temp dir with a unique name.
fn temp_media_file() -> std::path::PathBuf {
let p = std::env::temp_dir().join(format!(
"oak-it-render-media-{}.mp4",
std::process::id()
));
std::fs::write(&p, b"not-real-media").expect("write temp file");
p
}
// ---------------------------------------------------------------------------
// Frame accessors (7 exports)
// ---------------------------------------------------------------------------
/// All frame accessors are NULL-safe and report zero/NULL (the engine
/// contract: NULL is a no-op yielding zero results).
#[test]
fn frame_accessors_null_safe() {
assert_eq!(unsafe { oakengine_frame_width(std::ptr::null()) }, 0);
assert_eq!(unsafe { oakengine_frame_height(std::ptr::null()) }, 0);
assert_eq!(unsafe { oakengine_frame_format(std::ptr::null()) }, 0);
assert_eq!(
unsafe { oakengine_frame_channel_count(std::ptr::null()) },
0
);
assert_eq!(
unsafe { oakengine_frame_linesize_bytes(std::ptr::null()) },
0
);
assert!(unsafe { oakengine_frame_data(std::ptr::null()) }.is_null());
unsafe { oakengine_frame_free(std::ptr::null_mut()) };
}
// ---------------------------------------------------------------------------
// Audio buffer accessors (5 exports, documented stubs)
// ---------------------------------------------------------------------------
/// The audio buffer has no crate backing: every accessor reports the
/// documented neutral value for NULL (and any) handles.
#[test]
fn audio_buffer_stubs() {
// M12 P1: the accessors are real (they read the rendered-samples
// box); NULL stays safe and yields zero/NULL.
assert_eq!(unsafe { oakengine_audio_sample_rate(std::ptr::null()) }, 0);
assert_eq!(
unsafe { oakengine_audio_channel_count(std::ptr::null()) },
0
);
assert_eq!(unsafe { oakengine_audio_sample_count(std::ptr::null()) }, 0);
assert!(unsafe { oakengine_audio_data(std::ptr::null(), 0) }.is_null());
// The free is NULL-safe.
unsafe { oakengine_audio_free(std::ptr::null_mut()) };
}
// ---------------------------------------------------------------------------
// Color management (20 exports) + config handle (5) + processor (6)
// ---------------------------------------------------------------------------
/// The color family: documented stubs, the config-filename env-var paths,
/// the real OCIO-backed processor lifecycle, and the real-handle cache
/// setters. Runs under the state lock because the `$OCIO` env var, the
/// process-wide OCIO config and the render manager are global.
#[test]
fn color_family_lifecycle() {
common::force_link();
let _g = state_lock();
unsafe { oakrender::ffi::manager::oakrender_manager_shutdown() }; // clean slate for the tail
let mut buf = [0 as c_char; 256];
// ---- last_error: empty until a processor creation fails ----------
assert_eq!(
unsafe { oakengine_color_last_error(buf.as_mut_ptr(), 64) },
0
);
assert_eq!(
unsafe { oakengine_color_last_error(std::ptr::null_mut(), 0) },
0
);
// ---- color manager: documented stubs report their header values ---
assert!(unsafe { oakengine_color_manager_from_project(std::ptr::null_mut()) }.is_null());
assert_eq!(
unsafe {
oakengine_color_manager_set_config_filename(std::ptr::null_mut(), std::ptr::null())
},
-3
);
assert_eq!(
unsafe {
oakengine_color_manager_set_config_filename(std::ptr::null_mut(), c"x.ocio".as_ptr())
},
-3
);
assert_eq!(unsafe { oakengine_color_manager_colorspace_count() }, -3);
assert_eq!(unsafe { oakengine_color_manager_display_count() }, -3);
assert_eq!(unsafe { oakengine_color_manager_look_count() }, -3);
assert_eq!(
unsafe {
oakengine_color_manager_colorspace_at(std::ptr::null(), 0, std::ptr::null_mut(), 0)
},
-3
);
assert_eq!(
unsafe { oakengine_color_manager_display_at(std::ptr::null(), 0, std::ptr::null_mut(), 0) },
-3
);
assert_eq!(
unsafe { oakengine_color_manager_view_count(std::ptr::null(), std::ptr::null()) },
-1
);
assert_eq!(
unsafe {
oakengine_color_manager_view_at(
std::ptr::null(),
std::ptr::null(),
0,
std::ptr::null_mut(),
0,
)
},
-3
);
assert_eq!(
unsafe { oakengine_color_manager_look_at(std::ptr::null(), 0, std::ptr::null_mut(), 0) },
-3
);
assert_eq!(
unsafe {
oakengine_color_manager_default_display(std::ptr::null(), std::ptr::null_mut(), 0)
},
-3
);
assert_eq!(
unsafe {
oakengine_color_manager_default_view(
std::ptr::null(),
std::ptr::null(),
std::ptr::null_mut(),
0,
)
},
-3
);
assert_eq!(
unsafe {
oakengine_color_manager_default_input_color_space(
std::ptr::null(),
std::ptr::null_mut(),
0,
)
},
-3
);
assert_eq!(
unsafe {
oakengine_color_manager_set_default_input_color_space(
std::ptr::null_mut(),
std::ptr::null(),
)
},
-3
);
assert_eq!(
unsafe {
oakengine_color_manager_reference_color_space(std::ptr::null(), std::ptr::null_mut(), 0)
},
-3
);
assert_eq!(
unsafe {
oakengine_color_manager_default_luma_coefs(std::ptr::null(), std::ptr::null_mut())
},
-3
);
let mut rgb = [0.0f64; 3];
assert_eq!(
unsafe { oakengine_color_manager_default_luma_coefs(std::ptr::null(), rgb.as_mut_ptr()) },
-3
);
assert_eq!(
unsafe {
oakengine_color_manager_compliant_color_space(
std::ptr::null(),
std::ptr::null(),
std::ptr::null_mut(),
0,
)
},
-3
);
assert_eq!(
unsafe {
oakengine_color_manager_compliant_transform(
std::ptr::null(),
std::ptr::null(),
0,
std::ptr::null_mut(),
std::ptr::null_mut(),
0,
std::ptr::null_mut(),
0,
std::ptr::null_mut(),
0,
)
},
-3
);
// ---- standalone config handle: documented stubs -------------------
assert!(unsafe { oakengine_color_config_load_default() }.is_null());
assert!(unsafe { oakengine_color_config_load_file(std::ptr::null()) }.is_null());
assert!(unsafe { oakengine_color_config_load_file(c"no/such/config.ocio".as_ptr()) }.is_null());
unsafe { oakengine_color_config_free(std::ptr::null_mut()) };
assert_eq!(
unsafe { oakengine_color_config_colorspace_count(std::ptr::null()) },
0
);
assert_eq!(
unsafe {
oakengine_color_config_colorspace_at(std::ptr::null(), 0, std::ptr::null_mut(), 0)
},
-3
);
// ---- processor id / transform-job stubs --------------------------
assert_eq!(
unsafe { oakengine_color_processor_id(std::ptr::null(), std::ptr::null_mut(), 0) },
-3
);
assert_eq!(
unsafe {
oakengine_color_transform_job_set_processor(std::ptr::null_mut(), std::ptr::null())
},
-1
);
assert_eq!(
unsafe {
oakengine_color_transform_job_set_processor(0x1 as *mut c_void, std::ptr::null())
},
-3
);
// ---- config-filename: no $OCIO and no default config yet → STATE --
unsafe { std::env::remove_var("OCIO") };
assert_eq!(
unsafe {
oakengine_color_manager_get_config_filename(std::ptr::null(), std::ptr::null_mut(), 0)
},
-70002
);
// ---- config-filename: $OCIO set → the getter reports the path ----
// (the file itself is never opened by the getter).
let path = "/tmp/oakengine-it-render-ocio/config.ocio";
unsafe { std::env::set_var("OCIO", path) };
let rc = unsafe {
oakengine_color_manager_get_config_filename(std::ptr::null(), std::ptr::null_mut(), 0)
};
assert_eq!(rc, path.len() as c_int);
let rc = unsafe {
oakengine_color_manager_get_config_filename(std::ptr::null(), buf.as_mut_ptr(), 256)
};
assert_eq!(rc, path.len() as c_int);
assert_eq!(unsafe { read_buf(&mut buf) }, path);
// A too-small buffer still reports the full length (two-stage).
let mut small = [0 as c_char; 8];
let rc = unsafe {
oakengine_color_manager_get_config_filename(std::ptr::null(), small.as_mut_ptr(), 8)
};
assert_eq!(rc, path.len() as c_int);
// ---- default config: real OCIO setup through the module C ABI ----
unsafe { std::env::remove_var("OCIO") };
let setup_rc =
unsafe { oakrender::ffi::color::oakrender_color_manager_set_up_default_config() };
assert!(
setup_rc == 0 || setup_rc == -70003,
"set_up_default_config rc={setup_rc} (0 = bundled OCIO, -70003 = stub build)"
);
let config_ok = setup_rc == 0;
if config_ok {
// The getter now reports the extracted config path (no $OCIO).
let rc = unsafe {
oakengine_color_manager_get_config_filename(std::ptr::null(), buf.as_mut_ptr(), 256)
};
assert!(rc > 0);
let s = unsafe { read_buf(&mut buf) };
assert!(s.ends_with("config.ocio"), "config path was {s:?}");
}
// ---- processor creation ------------------------------------------
let dest = OakColorTransformPod {
is_display: 0,
output: c"ACEScg".as_ptr(),
view: std::ptr::null(),
look: std::ptr::null(),
};
// NULL input / NULL dest → NULL.
assert!(unsafe {
oakengine_color_processor_create(std::ptr::null(), std::ptr::null(), std::ptr::null(), 0)
}
.is_null());
assert!(unsafe {
oakengine_color_processor_create(std::ptr::null(), c"ACEScg".as_ptr(), std::ptr::null(), 0)
}
.is_null());
// A NULL output name in the POD is substituted with an empty string by
// the facade (not rejected); it follows the config-dependent path below.
let dest_null_out = OakColorTransformPod {
is_display: 0,
output: std::ptr::null(),
view: std::ptr::null(),
look: std::ptr::null(),
};
// Empty input string → NULL (module rejects empty names).
assert!(
unsafe { oakengine_color_processor_create(std::ptr::null(), c"".as_ptr(), &dest, 0) }
.is_null()
);
// Garbage directions → NULL.
assert!(unsafe {
oakengine_color_processor_create(std::ptr::null(), c"ACEScg".as_ptr(), &dest, 7)
}
.is_null());
assert!(unsafe {
oakengine_color_processor_create(std::ptr::null(), c"ACEScg".as_ptr(), &dest, -1)
}
.is_null());
// The failed create set the thread-local color last_error.
let elen = unsafe { oakengine_color_last_error(buf.as_mut_ptr(), 256) };
assert!(
elen > 0,
"failed processor create must set last_error (got {elen})"
);
// NULL is invalid / convert_color on a NULL processor → E_INVALID.
assert_eq!(
unsafe { oakengine_color_processor_is_valid(std::ptr::null()) },
0
);
let mut out_rgba = [0.0f64; 4];
let in_rgba = [0.18f64, 0.18, 0.18, 1.0];
assert_eq!(
unsafe {
oakengine_color_processor_convert_color(
std::ptr::null(),
in_rgba.as_ptr(),
out_rgba.as_mut_ptr(),
)
},
-1
);
unsafe { oakengine_color_processor_free(std::ptr::null_mut()) };
// Real processor (only when a config exists; the module returns NULL
// without one, which the engine contract allows). Capture the alive
// baseline before creating the first oakrender-owned handle.
let base = unsafe { oakrender::ffi::cache::oakrender_debug_alive_count() };
let proc =
unsafe { oakengine_color_processor_create(std::ptr::null(), c"ACEScg".as_ptr(), &dest, 0) };
if config_ok {
assert!(
!proc.is_null(),
"a default config must yield a processor handle"
);
assert_eq!(
unsafe { oakrender::ffi::cache::oakrender_debug_alive_count() },
base + 1
);
// A processor is either valid (real OCIO processor) or a
// documented pass-through (is_valid 0); both are legal outcomes.
let valid = unsafe { oakengine_color_processor_is_valid(proc) };
assert!(valid == 0 || valid == 1);
// Direction INVERSE is a legal path too.
let proc_inv = unsafe {
oakengine_color_processor_create(std::ptr::null(), c"ACEScg".as_ptr(), &dest, 1)
};
assert!(!proc_inv.is_null());
// A NULL output name is substituted with an empty string by the
// facade and still creates a processor.
let proc_null_out = unsafe {
oakengine_color_processor_create(
std::ptr::null(),
c"ACEScg".as_ptr(),
&dest_null_out,
0,
)
};
assert!(!proc_null_out.is_null());
// A display-transform destination is accepted as well.
let dest_disp = OakColorTransformPod {
is_display: 1,
output: c"sRGB".as_ptr(),
view: c"Filmic".as_ptr(),
look: std::ptr::null(),
};
let proc_disp = unsafe {
oakengine_color_processor_create(std::ptr::null(), c"ACEScg".as_ptr(), &dest_disp, 0)
};
assert!(!proc_disp.is_null());
// convert_color: NULL in/out → E_INVALID; legal → OK. A
// pass-through processor copies the input; a valid one converts
// (outputs stay finite and alpha is preserved).
assert_eq!(
unsafe {
oakengine_color_processor_convert_color(
proc,
std::ptr::null(),
out_rgba.as_mut_ptr(),
)
},
-1
);
assert_eq!(
unsafe {
oakengine_color_processor_convert_color(
proc,
in_rgba.as_ptr(),
std::ptr::null_mut(),
)
},
-1
);
out_rgba = [-1.0; 4];
assert_eq!(
unsafe {
oakengine_color_processor_convert_color(
proc,
in_rgba.as_ptr(),
out_rgba.as_mut_ptr(),
)
},
0
);
if valid == 0 {
assert_eq!(out_rgba, in_rgba, "pass-through processor copies the input");
} else {
assert!(out_rgba.iter().all(|v| v.is_finite()));
assert!((out_rgba[3] - 1.0).abs() < 1e-6, "alpha preserved");
}
// The successful create cleared last_error.
assert_eq!(
unsafe { oakengine_color_last_error(buf.as_mut_ptr(), 256) },
0
);
// Free contracts: each free releases the oakrender handle (alive
// count back to the pre-create baseline).
unsafe { oakengine_color_processor_free(proc_disp) };
unsafe { oakengine_color_processor_free(proc_inv) };
unsafe { oakengine_color_processor_free(proc_null_out) };
unsafe { oakengine_color_processor_free(proc) };
assert_eq!(
unsafe { oakrender::ffi::cache::oakrender_debug_alive_count() },
base
);
// The transform-job stub returns E_FAILED for a real processor too.
assert_eq!(
unsafe { oakengine_color_transform_job_set_processor(0x1 as *mut c_void, proc.cast()) },
-3
);
// Real-handle cache setter paths (manager required; proc is dead
// here, so create a fresh one for the cacher).
let proc2 = unsafe {
oakengine_color_processor_create(std::ptr::null(), c"ACEScg".as_ptr(), &dest, 0)
};
let node = unsafe {
oakengine_node_factory_create_from_id(
c"org.olivevideoeditor.Olive.solidgenerator".as_ptr(),
)
};
assert!(!node.is_null());
assert_eq!(
unsafe { oakrender::ffi::manager::oakrender_manager_init() },
0
);
assert_eq!(
unsafe { oakengine_render_cache_set_display_color_processor(proc2.cast()) },
0
);
assert_eq!(unsafe { oakengine_render_cache_set_multicam_node(node) }, 0);
unsafe { oakrender::ffi::manager::oakrender_manager_shutdown() };
unsafe { oakengine_color_processor_free(proc2) };
unsafe { oakengine_node_free(node) };
} else {
// Stub-OCIO build: creation is a clean NULL, nothing to free.
assert!(proc.is_null());
assert_eq!(
unsafe { oakrender::ffi::cache::oakrender_debug_alive_count() },
0
);
}
}
// ---------------------------------------------------------------------------
// LUT library (5 exports, documented stubs)
// ---------------------------------------------------------------------------
/// The LUT directory/file library is facade-level over FileFunctions and
/// has no module backing: the documented neutral values hold for every
/// argument (garbage indices, NULL buffers, NULL/NULL directory lists).
#[test]
fn lut_library_stubs() {
assert_eq!(unsafe { oakengine_lut_directory_count() }, 0);
assert_eq!(
unsafe { oakengine_lut_directory_at(0, std::ptr::null_mut(), 0) },
-3
);
assert_eq!(
unsafe { oakengine_lut_directory_at(-1, std::ptr::null_mut(), 0) },
-3
);
assert_eq!(
unsafe { oakengine_lut_directory_at(999, std::ptr::null_mut(), 0) },
-3
);
assert_eq!(unsafe { oakengine_lut_file_count() }, 0);
assert_eq!(
unsafe { oakengine_lut_file_at(0, std::ptr::null_mut(), 0) },
-3
);
assert_eq!(
unsafe { oakengine_lut_file_at(-1, std::ptr::null_mut(), 0) },
-3
);
assert_eq!(
unsafe { oakengine_lut_set_directories(std::ptr::null(), 0) },
-3
);
assert_eq!(
unsafe { oakengine_lut_set_directories(std::ptr::null(), 1) },
-3
);
assert_eq!(
unsafe { oakengine_lut_set_directories(std::ptr::null(), -1) },
-3
);
// A non-NULL list is never dereferenced by the stub.
let fake_dirs = 0x1 as *const *const c_char;
assert_eq!(unsafe { oakengine_lut_set_directories(fake_dirs, 1) }, -3);
}
// ---------------------------------------------------------------------------
// oakrender debug counter + free/destroy contracts
// ---------------------------------------------------------------------------
/// Module-level frame free contracts through the real oakrender C ABI:
/// free(NULL) no-op, alive count +1 on create and back to baseline on
/// free, and a double free is a safe no-op (the module nulls the handle
/// ctx after releasing). Facade box frees (`oakengine_frame_free` /
/// `oakengine_color_processor_free` / `oakengine_renderer_free`) are
/// NULL-safe and alive-balance-clean, verified in `renderer_render_frame_e2e`
/// and `color_family_lifecycle`.
#[test]
fn oakrender_debug_alive_and_double_free() {
common::force_link();
let _g = state_lock();
let base = unsafe { oakrender::ffi::cache::oakrender_debug_alive_count() };
// Create an oakrender-owned frame: +1.
let mut f = unsafe { oakrender::ffi::renderer::oakrender_codec_frame_create() };
assert!(!f.is_null());
assert_eq!(
unsafe { oakrender::ffi::cache::oakrender_debug_alive_count() },
base + 1
);
// free(NULL) is a no-op.
unsafe { oakrender::ffi::renderer::oakrender_codec_frame_free(std::ptr::null_mut()) };
assert_eq!(
unsafe { oakrender::ffi::cache::oakrender_debug_alive_count() },
base + 1
);
// Free: back to baseline.
unsafe { oakrender::ffi::renderer::oakrender_codec_frame_free(&mut f) };
assert_eq!(
unsafe { oakrender::ffi::cache::oakrender_debug_alive_count() },
base
);
// Double free: the handle ctx was nulled, so it is a safe no-op.
unsafe { oakrender::ffi::renderer::oakrender_codec_frame_free(&mut f) };
assert_eq!(
unsafe { oakrender::ffi::cache::oakrender_debug_alive_count() },
base
);
}
#[test]
fn renderer_montage_with_clip_e2e() {
common::force_link();
let _g = state_lock();
unsafe { oakrender::ffi::manager::oakrender_manager_shutdown() };
assert_eq!(unsafe { oakrender::ffi::manager::oakrender_manager_init() }, 0);
let project = unsafe { oakengine_project_create() };
assert_eq!(unsafe { oakengine_project_new(project) }, 0);
let name = std::ffi::CString::new("Montage E2E").unwrap();
let seq = unsafe { oakengine_sequence_new(project, name.as_ptr()) };
assert!(!seq.is_null());
assert_eq!(unsafe { oakengine::timeline::oakengine_sequence_add_track(seq, 0) }, 0);
// Generate real test media through the facade, import it, place a
// clip covering [0, 10) frames.
let media = std::env::temp_dir().join(format!("oak_it_montage_{}.mp4", std::process::id()));
let mc = std::ffi::CString::new(media.to_string_lossy().into_owned()).unwrap();
assert_eq!(
unsafe { oakengine::testmedia::oakengine_testmedia_write_clip(mc.as_ptr(), 64, 64, 10, 10) },
0
);
let footage = unsafe { oakengine_project_import_footage(project, mc.as_ptr()) };
assert!(!footage.is_null(), "import must succeed");
let clip = unsafe {
oakengine::timeline::oakengine_sequence_add_footage_clip_ex(seq, footage, 0, 0, 0, 10, 0)
};
assert!(!clip.is_null(), "add_footage_clip_ex must succeed");
unsafe { oakengine_footage_free(footage) };
let renderer = unsafe { oakengine_renderer_create(seq, 64, 64, 4, 10, 1, std::ptr::null()) };
assert!(!renderer.is_null());
let frame = unsafe { oakengine_renderer_render_frame(renderer, 0) };
assert!(!frame.is_null(), "montage render must produce a frame");
let linesize = unsafe { oakengine_frame_linesize_bytes(frame) };
let data = unsafe { oakengine_frame_data(frame) } as *const f32;
assert!(!data.is_null());
let stride = linesize as usize / 4;
let base = 32 * stride + 8 * 4;
let px = unsafe { std::slice::from_raw_parts(data.add(base), 4) };
assert!(px[0] > 0.5, "left half red from the decoded clip: {px:?}");
unsafe { oakengine_frame_free(frame) };
let _ = std::fs::remove_file(&media);
}
#[test]
fn decode_via_dylib_rlib_direct() {
// The same decode call the montage producer makes, invoked directly
// through the oakrender rlib — isolates the dlsym bridge vs the FFmpeg
// duplication question.
let media = std::env::temp_dir().join(format!("oak_it_direct_{}.mp4", std::process::id()));
let mc = std::ffi::CString::new(media.to_string_lossy().into_owned()).unwrap();
assert_eq!(
unsafe { oakengine::testmedia::oakengine_testmedia_write_clip(mc.as_ptr(), 64, 64, 10, 10) },
0
);
let tex = oakrender::eval::render_footage_frame(
&media.to_string_lossy(),
0,
oakcore_rs::Rational::new(0, 1),
(64, 64),
oakcore_rs::PixelFormat::F32,
)
.expect("the dlsym codec bridge must decode inside the engine binary");
let oakrender::texture::Texture::Cpu(frame) = &tex else {
panic!("non-CPU texture");
};
assert!(frame.data.iter().any(|&b| b != 0), "non-black decode");
let _ = std::fs::remove_file(&media);
}
#[test]
fn renderer_render_audio_montage_e2e() {
common::force_link();
let _g = state_lock();
unsafe { oakrender::ffi::manager::oakrender_manager_shutdown() };
assert_eq!(unsafe { oakrender::ffi::manager::oakrender_manager_init() }, 0);
let project = unsafe { oakengine_project_create() };
assert_eq!(unsafe { oakengine_project_new(project) }, 0);
let name = std::ffi::CString::new("Audio E2E").unwrap();
let seq = unsafe { oakengine_sequence_new(project, name.as_ptr()) };
assert!(!seq.is_null());
assert_eq!(unsafe { oakengine::timeline::oakengine_sequence_add_track(seq, 0) }, 0);
assert_eq!(unsafe { oakengine::timeline::oakengine_sequence_add_track(seq, 1) }, 0);
// Generated media with a 440 Hz sine audio track.
let media = std::env::temp_dir().join(format!("oak_it_audio_{}.mp4", std::process::id()));
let mc = std::ffi::CString::new(media.to_string_lossy().into_owned()).unwrap();
assert_eq!(
unsafe { oakengine::testmedia::oakengine_testmedia_write_clip(mc.as_ptr(), 64, 64, 10, 10) },
0
);
let footage = unsafe { oakengine_project_import_footage(project, mc.as_ptr()) };
assert!(!footage.is_null(), "import must succeed");
// Video clip on track 0, linked audio clip on track 1.
let vclip = unsafe {
oakengine::timeline::oakengine_sequence_add_footage_clip_ex(seq, footage, 0, 0, 0, 10, 0)
};
assert!(!vclip.is_null(), "video clip must be placed");
let aclip = unsafe {
oakengine::timeline::oakengine_sequence_add_footage_clip_ex(seq, footage, 1, 0, 0, 10, 0)
};
assert!(!aclip.is_null(), "audio clip must be placed");
unsafe { oakengine_footage_free(footage) };
// Render the audio of the first 0.1 s (2 frames at 10 fps) — the
// sine must come back as non-silent interleaved samples.
let renderer = unsafe { oakengine_renderer_create(seq, 64, 64, 4, 10, 1, std::ptr::null()) };
assert!(!renderer.is_null());
let buf = unsafe { oakengine_renderer_render_audio(renderer, 0, 1) };
assert!(!buf.is_null(), "render_audio must produce a buffer");
assert_eq!(unsafe { oakengine_audio_sample_rate(buf) }, 48000);
assert_eq!(unsafe { oakengine_audio_channel_count(buf) }, 2);
let frames = unsafe { oakengine_audio_sample_count(buf) };
assert!(frames >= 4800, "0.1 s at 48 kHz: {frames} frames");
let data = unsafe { oakengine_audio_data(buf, 0) };
assert!(!data.is_null());
let samples = unsafe { std::slice::from_raw_parts(data, frames as usize * 2) };
let peak = samples.iter().fold(0.0f32, |a, &s| a.max(s.abs()));
assert!(peak > 0.1, "the sine tone must be audible: peak={peak}");
let mut nonzero = 0usize;
for &s in samples.iter().take(960) {
if s != 0.0 {
nonzero += 1;
}
}
assert!(nonzero > 100, "samples carry the tone: {nonzero}");
unsafe { oakengine_audio_free(buf) };
let _ = std::fs::remove_file(&media);
}
#[test]
fn probe_and_waveform_extract_e2e() {
common::force_link();
let _g = state_lock();
let media = std::env::temp_dir().join(format!("oak_it_wf_{}.mp4", std::process::id()));
let mc = std::ffi::CString::new(media.to_string_lossy().into_owned()).unwrap();
assert_eq!(
unsafe { oakengine::testmedia::oakengine_testmedia_write_clip(mc.as_ptr(), 64, 64, 10, 10) },
0
);
// Probe first (the waveform extractor's first step).
let probe = unsafe { oakcodec::ffi::decoder::oakcodec_decoder_probe(mc.as_ptr()) };
assert!(!probe.is_null(), "probe must succeed");
let mut info = unsafe { std::mem::zeroed::<oakcodec::decoder::OakCodecAudioStreamInfo>() };
let r = unsafe {
oakcodec::ffi::decoder::oakcodec_decoder_probe_get_audio_stream(probe, 0, &mut info)
};
eprintln!("probe audio rc={r} rate={} ch={}", info.sample_rate, info.channel_count);
assert_eq!(r, 0);
let mut probe = probe;
unsafe { oakcodec::ffi::decoder::oakcodec_decoder_free(&mut probe) };
let _ = std::fs::remove_file(&media);
}