diff --git a/crates/oakaudio/src/ffi.rs b/crates/oakaudio/src/ffi.rs index 7ee9ed45e..e4d745419 100644 --- a/crates/oakaudio/src/ffi.rs +++ b/crates/oakaudio/src/ffi.rs @@ -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 { diff --git a/crates/oakaudio/src/manager.rs b/crates/oakaudio/src/manager.rs index 7fec54ae6..04100079a 100644 --- a/crates/oakaudio/src/manager.rs +++ b/crates/oakaudio/src/manager.rs @@ -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 { + 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![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() diff --git a/crates/oakaudio/src/previewdevice.rs b/crates/oakaudio/src/previewdevice.rs index fa6de1ef5..e4b3b7c32 100644 --- a/crates/oakaudio/src/previewdevice.rs +++ b/crates/oakaudio/src/previewdevice.rs @@ -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 + } +} diff --git a/crates/oakaudio/tests/manager_test.rs b/crates/oakaudio/tests/manager_test.rs index def9a1b53..bb8b48da4 100644 --- a/crates/oakaudio/tests/manager_test.rs +++ b/crates/oakaudio/tests/manager_test.rs @@ -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); +} diff --git a/crates/oakengine/src/audio.rs b/crates/oakengine/src/audio.rs index 635ab9d2a..366fdfc0f 100644 --- a/crates/oakengine/src/audio.rs +++ b/crates/oakengine/src/audio.rs @@ -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] diff --git a/crates/oakengine/src/bridge/audio.rs b/crates/oakengine/src/bridge/audio.rs index 8410fa673..84c874f09 100644 --- a/crates/oakengine/src/bridge/audio.rs +++ b/crates/oakengine/src/bridge/audio.rs @@ -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 { diff --git a/crates/oakengine/src/node.rs b/crates/oakengine/src/node.rs index 585743a6f..4a9f565ee 100644 --- a/crates/oakengine/src/node.rs +++ b/crates/oakengine/src/node.rs @@ -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::(footage)) + }) +} + /// `oakengine_project_can_undo`. #[no_mangle] pub extern "C" fn oakengine_project_can_undo(self_: *const OakEngineProject) -> c_int { diff --git a/crates/oakengine/src/render.rs b/crates/oakengine/src/render.rs index f4a07094c..865c63cb0 100644 --- a/crates/oakengine/src/render.rs +++ b/crates/oakengine/src/render.rs @@ -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 { } } -/// `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, diff --git a/crates/oakengine/tests/it_audio.rs b/crates/oakengine/tests/it_audio.rs index 4ab4919e8..45521c5f8 100644 --- a/crates/oakengine/tests/it_audio.rs +++ b/crates/oakengine/tests/it_audio.rs @@ -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); + }); +} diff --git a/crates/oakengine/tests/it_render.rs b/crates/oakengine/tests/it_render.rs index a13c0c48f..0870f16ef 100644 --- a/crates/oakengine/tests/it_render.rs +++ b/crates/oakengine/tests/it_render.rs @@ -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) // --------------------------------------------------------------------------- diff --git a/src/oakui/ffi.rs b/src/oakui/ffi.rs index cd9dc74a0..02c31572f 100644 --- a/src/oakui/ffi.rs +++ b/src/oakui/ffi.rs @@ -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 diff --git a/src/oakui/real.rs b/src/oakui/real.rs index 619d63aac..19ff6d736 100644 --- a/src/oakui/real.rs +++ b/src/oakui/real.rs @@ -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, 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, + /// 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, /// 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 { + 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 { + 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 { - // 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) { + 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(); }