feat(engine,app): source-monitor frames + real audio meter

source monitor:
- new facade exports oakengine_renderer_create_for_node (render any
  node, not just sequences) and oakengine_project_footage_at (fetch a
  footage node by index); it_render covers the e2e render plus the
  NULL/illegal matrix
- RealEngine renders the selected footage's real frames to the source
  viewer (per-node renderer slot, frame cache invalidated on selection
  change, synthetic fallback kept)
- known gap (documented): pixels stay transparent black until
  oakrender's footage decode hook lands — the render surface itself is
  real end to end

audio meter:
- oakaudio manager can now report per-channel linear peaks of the
  buffered output (PreviewAudioDevice::peek_tail + levelmeter analysis
  of the newest 8192 frames, packed/planar F32)
- new facade export oakengine_audio_output_levels (negative codes pass
  through); RealEngine's AudioMeterDataSource reads it instead of
  returning hardcoded silence
- tests: module peak readback + facade validation/readback matrix
This commit is contained in:
2026-08-12 14:19:18 +08:00
parent f1f4124b18
commit 06f47235b2
12 changed files with 724 additions and 50 deletions
+18 -1
View File
@@ -22,7 +22,7 @@
//! unwrap handles, call safe Rust, and map results through
//! [`crate::handle::guard*`].
use std::ffi::{c_char, c_double, c_int, CStr};
use std::ffi::{c_char, c_double, c_float, c_int, CStr};
use oakcore_rs::Rational;
@@ -217,6 +217,23 @@ pub mod manager {
guard(|| crate::manager::stop_output(&_self))
}
/// `oakaudio_manager_output_levels` — per-channel linear peaks of the
/// buffered output into `peaks` (up to `capacity` entries). Returns
/// the channel count (0 when nothing is buffered or no output is
/// configured). OAKAUDIO_E_INVALID for NULL/zero-capacity out args.
#[no_mangle]
pub unsafe extern "C" fn oakaudio_manager_output_levels(
_self: CHandle,
peaks: *mut c_float,
capacity: c_int,
) -> c_int {
guard_int(|| {
invalid_if(peaks.is_null() || capacity <= 0)?;
let slice = unsafe { std::slice::from_raw_parts_mut(peaks, capacity as usize) };
crate::manager::output_levels(&_self, slice)
})
}
/// `oakaudio_manager_seconds`: write elapsed playback seconds into `out`.
#[no_mangle]
pub unsafe extern "C" fn oakaudio_manager_seconds(_self: CHandle, out: *mut c_double) -> c_int {
+67
View File
@@ -372,6 +372,73 @@ pub fn find_device_by_name_s(name: &std::ffi::CStr, _is_output_device: bool) ->
PA_NO_DEVICE
}
/// Peak level (linear, 0..1 and above) of each channel of the buffered,
/// not-yet-consumed output, written to `peaks` in channel order.
/// Returns the channel count (0 when no output is configured or the
/// layout is unknown). Only packed/planar F32 buffers are analyzed —
/// other formats report zeroed peaks. The analysis window is the most
/// recent 8192 frames of the queue.
///
/// There is no C++ counterpart (the Qt side metered inside the audio
/// output callback); with the output callback unbridged this is how the
/// UI reads levels.
pub fn output_levels(self_: &CHandle, peaks: &mut [f32]) -> Result<i32> {
let m = with_instance(self_)?;
let Some(params) = m.output_params else {
return Ok(0);
};
let channels = params.channel_count();
if channels <= 0 {
return Ok(0);
}
let n = (channels as usize).min(peaks.len());
for p in &mut peaks[..n] {
*p = 0.0;
}
use crate::params::SampleFormat;
let packed = params.format == SampleFormat::F32;
let planar = params.format == SampleFormat::F32Planar;
if !packed && !planar {
return Ok(channels);
}
let frames_max = 8192i64;
let bpf = channels as i64 * 4;
let mut buf = vec![0u8; (bpf * frames_max) as usize];
let got = m.output_buffer.peek_tail(&mut buf);
// Whole frames only; the tail is what we hold, so leading partial
// bytes (when the queue is not frame-aligned) are dropped.
let frames = got / bpf;
if frames == 0 {
return Ok(channels);
}
let bytes = (frames * bpf) as usize;
let buf = &buf[..bytes];
let frame_count = frames as usize;
let channel_count = channels as usize;
let mut planes: Vec<Vec<f32>> = vec![Vec::with_capacity(frame_count); channel_count];
if packed {
for frame in buf.chunks_exact(bpf as usize) {
for ch in 0..channel_count {
let b = &frame[ch * 4..ch * 4 + 4];
planes[ch].push(f32::from_le_bytes([b[0], b[1], b[2], b[3]]));
}
}
} else {
for (ch, plane) in planes.iter_mut().enumerate() {
let start = ch * frame_count * 4;
for b in buf[start..start + frame_count * 4].chunks_exact(4) {
plane.push(f32::from_le_bytes([b[0], b[1], b[2], b[3]]));
}
}
}
let views: Vec<&[f32]> = planes.iter().map(Vec::as_slice).collect();
let stats = crate::levelmeter::analyze_sample_buffer(&views);
for (i, p) in peaks[..n].iter_mut().enumerate() {
*p = stats.channels[i].peak_linear as f32;
}
Ok(channels)
}
/// Number of live oakaudio reference-counted objects (leak check).
pub fn debug_alive_count() -> i32 {
crate::handle::alive_count()
+16
View File
@@ -192,3 +192,19 @@ impl Default for PreviewAudioDevice {
PreviewAudioDevice::new()
}
}
impl PreviewAudioDevice {
/// Copy up to `data.len()` bytes from the TAIL of the queued buffer
/// without consuming them (the level meter peeks at what is about to
/// play; the oldest bytes are irrelevant for that). Returns the byte
/// count copied.
pub fn peek_tail(&self, data: &mut [u8]) -> i64 {
let inner = self.lock.lock().unwrap();
let copy = (data.len() as i64).min(inner.buffer.len() as i64);
if copy > 0 {
let start = inner.buffer.len() - copy as usize;
data[..copy as usize].copy_from_slice(&inner.buffer[start..]);
}
copy
}
}
+70 -1
View File
@@ -33,7 +33,8 @@ use oakaudio::ffi::manager::{
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_output_levels, oakaudio_manager_set_output_notify_interval,
oakaudio_manager_start_recording,
oakaudio_manager_stop_output, oakaudio_manager_stop_recording,
};
@@ -407,3 +408,71 @@ fn free_null_noop() {
unsafe { oakaudio_manager_destroy_instance() };
}
/// output_levels: validation, the no-output case, and a real peak
/// readback over pushed packed-F32 stereo samples (left ramps to 0.25,
/// right to ~1.0 — the per-channel linear peaks).
#[test]
fn output_levels_reports_buffered_peaks() {
let _guard = lock();
unsafe { oakaudio_manager_destroy_instance() };
assert_eq!(unsafe { oakaudio_manager_create_instance() }, OAKAUDIO_OK);
let m = instance();
// Invalid out args.
let mut peaks = [0.0f32; 4];
assert_eq!(
unsafe { oakaudio_manager_output_levels(m, std::ptr::null_mut(), 4) },
OAKAUDIO_E_INVALID
);
assert_eq!(unsafe { oakaudio_manager_output_levels(m, peaks.as_mut_ptr(), 0) }, OAKAUDIO_E_INVALID);
// The manager singleton retains output params across tests in this
// binary, so the "no output" case is not reachable here; instead
// verify a cleared buffer reports zeroed peaks (channel count from
// the configured layout, 0 only when nothing was ever configured).
assert_eq!(unsafe { oakaudio_manager_set_output_device(m, 42) }, OAKAUDIO_OK);
assert_eq!(unsafe { oakaudio_manager_clear_buffered_output(m) }, OAKAUDIO_OK);
let cleared = unsafe { oakaudio_manager_output_levels(m, peaks.as_mut_ptr(), 4) };
assert!(cleared >= 0);
for p in &peaks[..cleared as usize] {
assert_eq!(*p, 0.0, "cleared buffer must have silent peaks");
}
// Push 480 frames of packed F32 stereo (format 10), stereo layout 0x3.
let frames = 480usize;
let mut samples = Vec::with_capacity(frames * 2);
for i in 0..frames {
let t = i as f32 / frames as f32;
samples.push(0.25f32 * t);
samples.push(t);
}
let rc = unsafe {
oakaudio_manager_push_to_output(
m,
48000,
0x3,
10,
samples.as_ptr() as *const c_char,
(samples.len() * 4) as i64,
std::ptr::null_mut(),
0,
)
};
assert_eq!(rc, OAKAUDIO_OK);
let n = unsafe { oakaudio_manager_output_levels(m, peaks.as_mut_ptr(), 4) };
assert_eq!(n, 2);
let last = (frames - 1) as f32 / frames as f32;
assert!((peaks[0] - 0.25 * last).abs() < 1e-6, "left peak: {}", peaks[0]);
assert!((peaks[1] - last).abs() < 1e-6, "right peak: {}", peaks[1]);
// Undersized buffer truncates the write, not the reported count.
let mut one = [0.0f32; 1];
assert_eq!(unsafe { oakaudio_manager_output_levels(m, one.as_mut_ptr(), 1) }, 2);
// Leave the singleton as we found it: the push flipped
// `output_started`, which other tests' seconds() assertions depend on.
assert_eq!(unsafe { oakaudio_manager_stop_output(m) }, OAKAUDIO_OK);
assert_eq!(unsafe { oakaudio_manager_clear_buffered_output(m) }, OAKAUDIO_OK);
}
+27 -1
View File
@@ -28,7 +28,8 @@ use std::ffi::{c_char, c_double, c_int, c_void};
use crate::bridge::audio as a;
use crate::error::Error;
use crate::handle::{
box_handle, free_box, guard, guard_i64, guard_void, unbox, CHandle, OakEngineAudioProcessor,
box_handle, free_box, guard, guard_i64, guard_int, guard_void, unbox, CHandle,
OakEngineAudioProcessor,
};
/// paNoDevice — no audio device selected.
@@ -150,6 +151,31 @@ pub extern "C" fn oakengine_audio_clear_buffered_output() -> c_int {
})
}
/// `oakengine_audio_output_levels` — per-channel linear peaks of the
/// buffered, not-yet-consumed output into `peaks` (up to `capacity`
/// entries). Returns the channel count (0 when nothing is buffered).
/// The UI's audio meter reads this; there is no C++ counterpart (the Qt
/// side metered inside the output callback, which is not bridged).
#[no_mangle]
pub unsafe extern "C" fn oakengine_audio_output_levels(peaks: *mut f32, capacity: c_int) -> c_int {
guard_int(|| {
if peaks.is_null() || capacity <= 0 {
return Err(Error::Invalid);
}
let m = manager();
if m.is_null() {
return Err(Error::Failed("no AudioManager instance".into()));
}
// The module returns the channel count (>= 0) or a negative
// OAKAUDIO_E_* code, which passes through untouched.
let n = a::oakaudio_manager_output_levels(m, peaks, capacity);
if n < 0 {
return Err(Error::Module(n));
}
Ok(n)
})
}
/// `oakengine_audio_push_to_output` — queue interleaved samples described
/// by the borrowed `OakAudioParams*` handle.
#[no_mangle]
+6
View File
@@ -216,6 +216,12 @@ pub fn oakaudio_manager_seconds(_self: CHandle, out: *mut c_double) -> c_int {
unsafe { oakaudio::ffi::manager::oakaudio_manager_seconds(_self, out) }
}
/// Direct call into the `oakaudio` crate (single-lib unification; the
/// `#[no_mangle]` export stays for the external C ABI).
pub fn oakaudio_manager_output_levels(_self: CHandle, peaks: *mut f32, capacity: c_int) -> c_int {
unsafe { oakaudio::ffi::manager::oakaudio_manager_output_levels(_self, peaks, capacity) }
}
/// Direct call into the `oakaudio` crate (single-lib unification; the
/// `#[no_mangle]` export stays for the external C ABI).
pub fn oakaudio_processor_init() -> CHandle {
+19
View File
@@ -692,6 +692,25 @@ pub unsafe extern "C" fn oakengine_project_footage_is_online(
})
}
/// `oakengine_project_footage_at` — the footage node at `index`, boxed
/// (freed with `oakengine_node_free`); NULL for an invalid index or
/// project. The node is borrowed from the project graph, so it stays
/// valid until the project is freed.
#[no_mangle]
pub unsafe extern "C" fn oakengine_project_footage_at(
self_: *const OakEngineProject,
index: c_int,
) -> *mut OakEngineNode {
guard_ptr(|| unsafe {
let h = unbox(self_)?;
let footage = project_node_at_of_type(h, TYPE_ID_FOOTAGE, index);
if footage.is_null() {
return Ok(std::ptr::null_mut());
}
Ok(box_handle::<OakEngineNode>(footage))
})
}
/// `oakengine_project_can_undo`.
#[no_mangle]
pub extern "C" fn oakengine_project_can_undo(self_: *const OakEngineProject) -> c_int {
+84 -21
View File
@@ -17,12 +17,14 @@
//! `engine/include/oakengine/{renderer,color,lut}.h` over the oakrender
//! module.
//!
//! - The **renderer** is a facade-owned box binding a sequence handle to
//! an output geometry; each render call submits an oakrender ticket
//! (`OakVideoTicketParams`), waits for it and returns the produced
//! frame (`OakCodecFrame` wrapped in `OakEngineFrame`). Audio rendering
//! submits the ticket but the crate's samples path is unimplemented, so
//! it fails with the reason in `last_error`.
//! - The **renderer** is a facade-owned box binding an output node
//! (usually a sequence, or any single node via
//! `oakengine_renderer_create_for_node`) to an output geometry; each
//! render call submits an oakrender ticket (`OakVideoTicketParams`),
//! waits for it and returns the produced frame (`OakCodecFrame` wrapped
//! in `OakEngineFrame`). Audio rendering submits the ticket but the
//! crate's samples path is unimplemented, so it fails with the reason
//! in `last_error`.
//! - The **frame accessors** read the wrapped `OakCodecFrame`
//! (`channel_count` has no crate accessor and reports 0).
//! - The **color processor** family maps onto
@@ -110,10 +112,11 @@ pub unsafe extern "C" fn oakengine_render_cache_set_multicam_node(
// Renderer
// ---------------------------------------------------------------------------
/// Facade-side renderer box: the bound sequence + output geometry.
/// Facade-side renderer box: the bound output node + output geometry.
struct RendererBox {
/// Unboxed sequence node handle (borrowed).
seq: CHandle,
/// Unboxed output node handle (borrowed): a sequence, or any node the
/// module can evaluate (footage, generator, ...).
output_node: CHandle,
/// Output width.
width: c_int,
/// Output height.
@@ -180,20 +183,20 @@ unsafe fn make_video_params(b: &RendererBox) -> Result<CHandle> {
}
}
/// `oakengine_renderer_create` — NULL for invalid arguments.
#[no_mangle]
pub unsafe extern "C" fn oakengine_renderer_create(
seq: *mut OakEngineSequence,
/// Build a renderer box for `output_node` (a sequence or any node the
/// module can evaluate) with the given geometry. Returns NULL for
/// non-positive geometry/rate or a pixel format outside the oakcore enum.
unsafe fn make_renderer_box(
output_node: CHandle,
width: c_int,
height: c_int,
pixel_format: c_int,
frame_rate_num: c_int,
frame_rate_den: c_int,
output_colorspace: *const c_char,
) -> *mut OakEngineRenderer {
guard_ptr(|| unsafe {
if seq.is_null() || width <= 0 || height <= 0 || frame_rate_num <= 0 || frame_rate_den <= 0
{
) -> Result<*mut OakEngineRenderer> {
unsafe {
if width <= 0 || height <= 0 || frame_rate_num <= 0 || frame_rate_den <= 0 {
return Ok(std::ptr::null_mut());
}
// Validate the pixel format against the oakcore enum. The
@@ -207,9 +210,8 @@ pub unsafe extern "C" fn oakengine_renderer_create(
return Ok(std::ptr::null_mut());
}
let _ = crate::handle::read_cstr(output_colorspace); // resolved by the module at render time
let seq_handle = unbox(seq)?;
let boxed = Box::new(RendererBox {
seq: seq_handle,
output_node,
width,
height,
pixel_format,
@@ -219,6 +221,67 @@ pub unsafe extern "C" fn oakengine_renderer_create(
last_error: String::new(),
});
Ok(Box::into_raw(boxed) as *mut OakEngineRenderer)
}
}
/// `oakengine_renderer_create` — NULL for invalid arguments.
#[no_mangle]
pub unsafe extern "C" fn oakengine_renderer_create(
seq: *mut OakEngineSequence,
width: c_int,
height: c_int,
pixel_format: c_int,
frame_rate_num: c_int,
frame_rate_den: c_int,
output_colorspace: *const c_char,
) -> *mut OakEngineRenderer {
guard_ptr(|| unsafe {
if seq.is_null() {
return Ok(std::ptr::null_mut());
}
let seq_handle = unbox(seq)?;
make_renderer_box(
seq_handle,
width,
height,
pixel_format,
frame_rate_num,
frame_rate_den,
output_colorspace,
)
})
}
/// `oakengine_renderer_create_for_node` — like `oakengine_renderer_create`,
/// but binds any node instead of a sequence: the surface for rendering a
/// single footage/generator node (the source monitor). The renderer is
/// freed with `oakengine_renderer_free` and renders with
/// `oakengine_renderer_render_frame`, exactly like the sequence renderer.
/// NULL for invalid arguments.
#[no_mangle]
pub unsafe extern "C" fn oakengine_renderer_create_for_node(
node: *mut OakEngineNode,
width: c_int,
height: c_int,
pixel_format: c_int,
frame_rate_num: c_int,
frame_rate_den: c_int,
output_colorspace: *const c_char,
) -> *mut OakEngineRenderer {
guard_ptr(|| unsafe {
if node.is_null() {
return Ok(std::ptr::null_mut());
}
let node_handle = unbox(node)?;
make_renderer_box(
node_handle,
width,
height,
pixel_format,
frame_rate_num,
frame_rate_den,
output_colorspace,
)
})
}
@@ -272,7 +335,7 @@ pub unsafe extern "C" fn oakengine_renderer_render_frame(
let b = renderer_mut(self_)?;
let video_params = make_video_params(b)?;
let params = OakVideoTicketParams {
output_node: b.seq,
output_node: b.output_node,
video_params,
audio_params: std::ptr::null(),
time_num: timestamp * i64::from(b.frame_rate_den),
@@ -325,7 +388,7 @@ pub unsafe extern "C" fn oakengine_renderer_render_audio(
let end_num = (start_timestamp + length_timestamp) * i64::from(b.frame_rate_den);
let den = i64::from(b.frame_rate_num);
let ticket = r::oakrender_ticket_render_audio(
b.seq,
b.output_node,
start_num,
den,
end_num,
+56
View File
@@ -54,6 +54,7 @@ use std::sync::Mutex;
use oakengine::audio::{
oakengine_audio_clear_buffered_output, oakengine_audio_create_instance,
oakengine_audio_output_levels,
oakengine_audio_destroy_instance, oakengine_audio_estimate_envelope_offset,
oakengine_audio_estimate_stretch_and_offset, oakengine_audio_get_input_device,
oakengine_audio_get_output_device, oakengine_audio_hard_reset, oakengine_audio_manager_handle,
@@ -1222,3 +1223,58 @@ fn processor_full_open_convert_cycle() {
unsafe { oakengine_audio_processor_free(p) };
});
}
// ---------------------------------------------------------------------------
// oakengine_audio_output_levels
// ---------------------------------------------------------------------------
/// The output-level meter export: validation, the no-output case, and a
/// real peak readback over pushed F32 stereo samples.
#[test]
fn audio_output_levels() {
with_manager(|| unsafe {
// Out-arg validation (facade E_INVALID; no manager needed).
let mut peaks = [0.0f32; 4];
assert_eq!(oakengine_audio_output_levels(std::ptr::null_mut(), 4), OAKENGINE_E_INVALID);
assert_eq!(oakengine_audio_output_levels(peaks.as_mut_ptr(), 0), OAKENGINE_E_INVALID);
assert_eq!(oakengine_audio_output_levels(peaks.as_mut_ptr(), -1), OAKENGINE_E_INVALID);
// Fresh-ish manager, nothing buffered: 0 channels.
assert_eq!(oakengine_audio_destroy_instance(), 0);
assert_eq!(oakengine_audio_create_instance(), 0);
assert_eq!(oakengine_audio_output_levels(peaks.as_mut_ptr(), 4), 0);
// Push 480 frames of packed F32 stereo: left ramps to 0.25, right
// ramps to ~1.0. The levels are the per-channel linear peaks.
assert_eq!(oakengine_audio_set_output_device(42), 0);
assert_eq!(oakengine_audio_clear_buffered_output(), 0);
let frames = 480usize;
let mut samples = Vec::with_capacity(frames * 2);
for i in 0..frames {
let t = i as f32 / frames as f32;
samples.push(0.25f32 * t);
samples.push(t);
}
let params = audio_params(48000, 0x3, 10); // 10 = packed F32
let rc = oakengine_audio_push_to_output(
params as *const c_void,
samples.as_ptr() as *const std::ffi::c_char,
(samples.len() * 4) as i64,
std::ptr::null_mut(),
0,
);
common::oakcore_audioparams_free(params);
assert_eq!(rc, 0);
let n = oakengine_audio_output_levels(peaks.as_mut_ptr(), 4);
assert_eq!(n, 2);
let last = (frames - 1) as f32 / frames as f32;
assert!((peaks[0] - 0.25 * last).abs() < 1e-6, "left peak: {}", peaks[0]);
assert!((peaks[1] - last).abs() < 1e-6, "right peak: {}", peaks[1]);
// Capacity smaller than the channel count truncates the write but
// still reports the real channel count.
let mut one = [0.0f32; 1];
assert_eq!(oakengine_audio_output_levels(one.as_mut_ptr(), 1), 2);
});
}
+154 -5
View File
@@ -45,8 +45,9 @@ use std::sync::{Mutex, MutexGuard};
use oakengine::handle::OakEngineAudioBuffer;
use oakengine::node::{
oakengine_node_factory_create_from_id, oakengine_node_free, oakengine_project_create,
oakengine_project_free, oakengine_project_new,
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,
@@ -73,9 +74,9 @@ use oakengine::render::{
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_free, oakengine_renderer_last_error,
oakengine_renderer_render_audio, oakengine_renderer_render_frame, oakengine_renderer_set_mode,
OakColorTransformPod,
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;
@@ -417,6 +418,154 @@ 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.
let media = temp_media_file();
let media_c = std::ffi::CString::new(media.to_string_lossy().into_owned()).unwrap();
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());
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)
// ---------------------------------------------------------------------------
+26
View File
@@ -320,6 +320,12 @@ unsafe extern "C" {
) -> c_int;
/// `oakengine_project_footage_count`.
pub fn oakengine_project_footage_count(self_: *const OakEngineProject) -> c_int;
/// `oakengine_project_footage_at` — boxed footage node at `index` (free
/// with `oakengine_node_free`); NULL for an invalid index.
pub fn oakengine_project_footage_at(
self_: *const OakEngineProject,
index: c_int,
) -> *mut OakEngineNode;
/// `oakengine_project_footage_filename` (buf/size).
pub fn oakengine_project_footage_filename(
self_: *const OakEngineProject,
@@ -616,6 +622,19 @@ unsafe extern "C" {
frame_rate_den: c_int,
output_colorspace: *const c_char,
) -> *mut OakEngineRenderer;
/// `oakengine_renderer_create_for_node` — like `oakengine_renderer_create`,
/// but binds any node instead of a sequence: the surface for rendering a
/// single footage node (the source monitor). Freed and rendered exactly
/// like the sequence renderer.
pub fn oakengine_renderer_create_for_node(
node: *mut OakEngineNode,
width: c_int,
height: c_int,
pixel_format: c_int,
frame_rate_num: c_int,
frame_rate_den: c_int,
output_colorspace: *const c_char,
) -> *mut OakEngineRenderer;
/// `oakengine_renderer_free` — consuming free (NULL no-op).
pub fn oakengine_renderer_free(self_: *mut OakEngineRenderer);
/// `oakengine_renderer_render_frame` — synchronous render of the frame at
@@ -644,6 +663,13 @@ unsafe extern "C" {
/// `oakengine_frame_free` — consuming free (NULL no-op).
pub fn oakengine_frame_free(self_: *mut OakEngineFrame);
// -- oakengine::audio --
/// `oakengine_audio_output_levels` — per-channel linear peaks of the
/// buffered output into `peaks` (up to `capacity` entries); returns
/// the channel count (0 = nothing buffered), negative on error.
pub fn oakengine_audio_output_levels(peaks: *mut f32, capacity: c_int) -> c_int;
// -- oakrender module C ABI (carried by the dylib) --
/// `oakrender_manager_init` — bring up the module's process-global
+181 -21
View File
@@ -41,13 +41,16 @@
//!
//! # What is still mock/stub
//!
//! * The source monitor's viewer frames are the shared synthetic SMPTE
//! pattern ([`frames`]): the facade renderer binds a *sequence* handle
//! only, so there is no surface to render a single footage node for the
//! material viewer. The program monitor renders real frames through the
//! facade CPU renderer ([`RealEngine::render_program_frame`]) at a proxy
//! resolution; the full-resolution async render worker (the facade's
//! worker module) is a separate process surface not bound yet.
//! * The source monitor renders the selected footage node's frame through
//! the facade CPU renderer
//! ([`RealEngine::render_source_frame`],
//! via the node-binding `oakengine_renderer_create_for_node`) at a proxy
//! resolution — the same pattern as the program monitor
//! ([`RealEngine::render_program_frame`]). Actual media *decode* is
//! still a module gap (the oakrender eval's footage hook is deferred),
//! so both viewers show the pipeline's generated frame, not the file's
//! pixels; the full-resolution async render worker (the facade's worker
//! module) is a separate process surface not bound yet.
//! * Effect stack — the selected clip's effect chain is bound: the stack
//! reads the chain through the facade (see
//! [`EffectStackDataSource`](EffectStackDataSource) for `RealEngine`)
@@ -653,10 +656,10 @@ pub struct RealEngine {
/// Cache of the CPU frames handed to the viewers, keyed by monitor.
/// Entries are the playhead frame that produced the image plus the scope
/// samples analyzed in the same pass, so a paused viewer never
/// regenerates its picture (or its scopes). The program monitor's entries
/// are real rendered frames (see [`RealEngine::render_program_frame`]);
/// the source monitor's are the synthetic pattern (the facade renderer
/// binds a sequence only — footage-node rendering is a documented gap).
/// regenerates its picture (or its scopes). Both monitors hold real
/// rendered frames (see [`RealEngine::render_program_frame`] /
/// [`RealEngine::render_source_frame`]); the synthetic pattern is only
/// the failure fallback.
cpu_frame_cache: Mutex<HashMap<Monitor, (i64, Arc<RenderImage>, ScopeData)>>,
/// The program monitor's cached renderer, created lazily from the
/// current sequence at a proxy resolution. The mutex both provides the
@@ -664,6 +667,12 @@ pub struct RealEngine {
/// the synchronous render calls. Reset to [`RendererSlot::Untried`]
/// (before the sequence is freed) in [`RealEngine::drop_project`].
renderer: Mutex<RendererSlot>,
/// The source monitor's cached renderer, created lazily from the
/// currently selected footage node at a proxy resolution (same slot
/// semantics as `renderer`). Reset to [`RendererSlot::Untried`] when
/// the selection changes or the project is dropped — the renderer binds
/// the footage node, so a new selection must bind the new node.
source_renderer: Mutex<RendererSlot>,
/// Whether the project has unsaved changes (mirrors the facade flag).
modified: bool,
}
@@ -692,6 +701,7 @@ impl RealEngine {
meter_phase: 0,
cpu_frame_cache: Mutex::new(HashMap::new()),
renderer: Mutex::new(RendererSlot::Untried),
source_renderer: Mutex::new(RendererSlot::Untried),
modified: false,
}
}
@@ -862,6 +872,140 @@ impl RealEngine {
Some(RendererHandle(renderer))
}
/// The material-bin footage index of the selected entry, if any. The bin
/// children are numbered `100 + index` (see [`RealEngine::rebuild_bin`]);
/// folder/project roots are not footage.
fn selected_footage_index(&self) -> Option<c_int> {
let id = self.selected_item?;
if id >= 100 {
Some((id - 100) as c_int)
} else {
None
}
}
/// The selected footage's facade node view (a boxed node handle the
/// caller frees with `oakengine_node_free`), or `None` when no footage
/// is selected or the index is out of range.
fn selected_footage_node(&self) -> Option<*mut OakEngineNode> {
let project = self.project_ptr()?;
let index = self.selected_footage_index()?;
let node = unsafe { oakengine_project_footage_at(project, index) };
if node.is_null() {
None
} else {
Some(node)
}
}
/// Creates the per-footage renderer at the proxy resolution (see
/// [`RealEngine::proxy_render_size`]). The renderer borrows the footage
/// node handle; the caller owns the slot it is stored in.
fn create_node_renderer(&self, node: *mut OakEngineNode) -> Option<RendererHandle> {
let (width, height) = self.proxy_render_size()?;
let rate = self.sequence_info.as_ref()?.format.rate;
let renderer = unsafe {
oakengine_renderer_create_for_node(
node,
width,
height,
PIXEL_FORMAT_F32,
rate.num as c_int,
rate.den as c_int,
std::ptr::null(),
)
};
if renderer.is_null() {
println!(
"[real engine] renderer_create_for_node failed; viewer keeps the synthetic frame"
);
return None;
}
Some(RendererHandle(renderer))
}
/// Renders one source-monitor frame through the facade CPU renderer:
/// creates the per-footage renderer lazily (cached in
/// `self.source_renderer`, bound to the currently selected footage
/// node), renders `frame`, analyzes the scope samples from the F32 RGBA
/// result, and downconverts to BGRA8. Returns `None` (the caller falls
/// back to the synthetic pattern) when no footage is selected, the
/// render manager is unavailable, or the render itself fails.
fn render_source_frame(&self, frame: Frame) -> Option<(RenderImage, ScopeData)> {
let node = self.selected_footage_node()?;
if !Self::ensure_render_manager() {
// SAFETY: `node` is a box from `selected_footage_node`.
unsafe { oakengine_node_free(node) };
return None;
}
let mut slot = self.source_renderer.lock().unwrap();
match &*slot {
RendererSlot::Unavailable => {
unsafe { oakengine_node_free(node) };
return None;
}
RendererSlot::Untried => {
let created = self.create_node_renderer(node);
*slot = match created {
Some(handle) => RendererSlot::Ready(handle),
None => RendererSlot::Unavailable,
};
}
RendererSlot::Ready(_) => {}
}
// SAFETY: `node` is a live box; freed on every path below.
unsafe { oakengine_node_free(node) };
let RendererSlot::Ready(handle) = &*slot else {
return None;
};
let renderer = handle.ptr();
let frame_ptr = unsafe { oakengine_renderer_render_frame(renderer, frame.0) };
if frame_ptr.is_null() {
let error = read_string(|buf, size| unsafe {
oakengine_renderer_last_error(renderer, buf, size)
});
println!("[real engine] source render_frame failed: {error}");
// Don't retry (and re-log) on every frame.
*slot = RendererSlot::Unavailable;
return None;
}
// Read the frame (F32 RGBA, rows padded to linesize), repack it
// tightly, then downconvert.
let (width, height, linesize, format) = unsafe {
(
oakengine_frame_width(frame_ptr),
oakengine_frame_height(frame_ptr),
oakengine_frame_linesize_bytes(frame_ptr),
oakengine_frame_format(frame_ptr),
)
};
let data = unsafe { oakengine_frame_data(frame_ptr) };
let mut image = None;
if width > 0 && height > 0 && format == PIXEL_FORMAT_F32 && !data.is_null() {
let row_bytes = (width * 4 * 4) as usize;
let linesize = (linesize as usize).max(row_bytes);
let mut samples = vec![0.0f32; (width * height * 4) as usize];
for y in 0..height as usize {
unsafe {
std::ptr::copy_nonoverlapping(
(data as *const u8).add(y * linesize),
samples.as_mut_ptr().add(y * row_bytes / 4) as *mut u8,
row_bytes,
);
}
}
let scope = analyze_f32_rgba(width as u32, height as u32, &samples);
image = Some((
f32_rgba_to_bgra_image(width as u32, height as u32, &samples),
scope,
));
}
unsafe {
oakengine_frame_free(frame_ptr);
}
image
}
/// Adopts a newly created/loaded facade project, freeing any previous
/// one, and rebuilds every snapshot. `blank` projects get a default
/// sequence; loaded ones use the first sequence.
@@ -907,6 +1051,7 @@ impl RealEngine {
/// borrowed from the project).
fn drop_project(&mut self) {
*self.renderer.lock().unwrap() = RendererSlot::Untried;
*self.source_renderer.lock().unwrap() = RendererSlot::Untried;
drop(self.sequence.take());
drop(self.project.take());
self.cpu_frame_cache.lock().unwrap().clear();
@@ -1443,9 +1588,17 @@ impl ProjectDataSource for RealEngine {
impl AudioMeterDataSource for RealEngine {
fn levels(&self) -> Vec<f32> {
// Real audio levels are not exposed by the facade; report a silent
// (but alive) meter.
vec![0.0, 0.0]
// Per-channel linear peaks of the engine's buffered audio output
// (facade `oakengine_audio_output_levels`, clamped to the meter's
// 0..1 range). Silent when nothing has been pushed to the output
// (no playback audio path yet) or on any facade error.
let mut peaks = [0.0f32; 8];
// SAFETY: `peaks` is a live 8-entry buffer; capacity matches.
let n = unsafe { oakengine_audio_output_levels(peaks.as_mut_ptr(), peaks.len() as c_int) };
if n <= 0 {
return vec![0.0, 0.0];
}
peaks[..n as usize].iter().map(|p| p.clamp(0.0, 1.0)).collect()
}
}
@@ -1480,15 +1633,14 @@ impl AppEngine for RealEngine {
return image.clone();
}
}
// The program monitor renders the current sequence through the
// facade CPU renderer (falling back to the synthetic pattern when
// rendering is unavailable). The source monitor keeps the synthetic
// pattern: the facade renderer binds a *sequence* handle only, so
// there is currently no surface to render a single footage node for
// the material viewer — that is a documented facade gap.
// Both monitors render through the facade CPU renderer (falling
// back to the synthetic pattern when rendering is unavailable): the
// program monitor renders the current sequence, the source monitor
// renders the currently selected footage node at the source clock's
// playhead frame.
let rendered = match monitor {
Monitor::Program => self.render_program_frame(frame),
Monitor::Source => None,
Monitor::Source => self.render_source_frame(frame),
};
let (image, scope) = match rendered {
Some((image, scope)) => (Arc::new(image), scope),
@@ -1562,7 +1714,15 @@ impl AppEngine for RealEngine {
}
fn select_item(&mut self, id: u64, cx: &mut Context<Self>) {
let changed = self.selected_item != Some(id);
self.selected_item = Some(id);
if changed {
// The source monitor renders the selected footage node: a new
// selection must rebind the renderer and drop the stale cached
// frame (the cache key only tracks the playhead frame).
*self.source_renderer.lock().unwrap() = RendererSlot::Untried;
self.cpu_frame_cache.lock().unwrap().remove(&Monitor::Source);
}
cx.notify();
}