Single-lib cleanup: the per-crate src/bridge/ and src/ffi.rs layers are gone (oakundo/oakcommon/oaknode/oaktimeline/oakcodec/oakaudio/ oakrender/oaktask/oakplugin/oakstorage); cross-crate calls are plain Rust, CHandle marshalling shrinks to the oakengine boundary, and tests call the Rust APIs directly (pure C-ABI wrapper tests removed where the domain layer already covers the behavior). exporter.h family implemented: oakengine_export_render (CLI contract), oakengine_export_render_with_params (was a stub), last_error and progress callback; synchronous path reuses task_create_export + start_sync. Fixes on the way: oaktask video ticket self-deadlock, audio params dropped on the export path, codec encoder AAC slicing and H.264 time base. Real-mp4 tests cover both entry points, progress and the illegal-argument matrix. Also: oakstorage session maps null project handles to None (version- info path), configstore test double literal 3.14 -> 3.15 (clippy PI lint), oakaudio output callback scratch buffer + env-aware P1 test, cli media round-trip test uses a generated 16-frame clip (no more minute-long debug runs).
212 lines
6.6 KiB
Rust
212 lines
6.6 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/>.
|
|
|
|
//! M12 P4: timeline audio waveforms.
|
|
//!
|
|
//! The [`WaveformCache`] holds per-clip min/max peak data extracted
|
|
//! through `oakengine_waveform_extract` (the real FFmpeg-backed
|
|
//! extraction); the engine refreshes it when the timeline rebuilds. The
|
|
//! [`OakClipDecorator`] draws the peaks into the timeline's clip body.
|
|
//!
|
|
//! The extraction is keyed by `(clip id, filename)` and cached for the
|
|
//! engine lifetime; the plan's disk cache is a later refinement (the
|
|
//! extractor itself is the real implementation).
|
|
|
|
use std::collections::HashMap;
|
|
use std::ffi::{c_char, c_int};
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use gpui::timeline::clip::ClipDecorator;
|
|
use gpui::timeline::ClipId;
|
|
use gpui::timeline::FrameRange;
|
|
use gpui::{Bounds, Hsla, Pixels, Window};
|
|
|
|
/// One min/max amplitude pair (mirror of the oakaudio waveform C ABI
|
|
/// `MinMax`: two f32s).
|
|
#[repr(C)]
|
|
#[derive(Debug, Clone, Copy, Default)]
|
|
pub struct MinMax {
|
|
/// Minimum amplitude in the window.
|
|
pub min: f32,
|
|
/// Maximum amplitude in the window.
|
|
pub max: f32,
|
|
}
|
|
|
|
/// The cached waveform of one clip.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ClipWaveform {
|
|
/// Per-point min/max (first channel).
|
|
pub peaks: Vec<MinMax>,
|
|
/// Channel count of the media.
|
|
pub channel_count: i32,
|
|
/// Media sample rate.
|
|
pub sample_rate: i32,
|
|
/// Samples per point at extraction time.
|
|
pub samples_per_point: i32,
|
|
/// The clip's length in timeline frames.
|
|
pub duration_frames: i64,
|
|
}
|
|
|
|
/// Per-clip waveform cache (thread-safe; filled by the engine).
|
|
pub struct WaveformCache {
|
|
/// Peaks by clip id.
|
|
map: Mutex<HashMap<u64, Arc<ClipWaveform>>>,
|
|
/// Timeline frame rate (frames per second) for frame→time mapping.
|
|
fps: f32,
|
|
}
|
|
|
|
impl WaveformCache {
|
|
/// Create an empty cache at the given frame rate.
|
|
pub fn new(fps: f32) -> Arc<WaveformCache> {
|
|
Arc::new(WaveformCache {
|
|
map: Mutex::new(HashMap::new()),
|
|
fps: if fps > 0.0 { fps } else { 25.0 },
|
|
})
|
|
}
|
|
|
|
/// The cached waveform of `clip`, if extracted.
|
|
pub fn get(&self, clip: u64) -> Option<Arc<ClipWaveform>> {
|
|
self.map.lock().unwrap_or_else(|e| e.into_inner()).get(&clip).cloned()
|
|
}
|
|
|
|
/// Ensure `clip` (with media `filename`, `duration_frames` long) has a
|
|
/// waveform, extracting it through the real codec-backed extractor
|
|
/// when missing. Failures (missing media, no audio stream) leave the
|
|
/// clip silent (no waveform drawn).
|
|
pub fn refresh(&self, clip: u64, filename: &str, duration_frames: i64) {
|
|
if self.get(clip).is_some() {
|
|
return;
|
|
}
|
|
let Some(waveform) = extract(filename, duration_frames, self.fps) else {
|
|
return;
|
|
};
|
|
let mut map = self.map.lock().unwrap_or_else(|e| e.into_inner());
|
|
if !map.contains_key(&clip) {
|
|
map.insert(clip, Arc::new(waveform));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Extract a clip's waveform (first channel) via the facade's waveform
|
|
/// C ABI.
|
|
fn extract(filename: &str, duration_frames: i64, _fps: f32) -> Option<ClipWaveform> {
|
|
let cname = std::ffi::CString::new(filename).ok()?;
|
|
const SAMPLES_PER_POINT: c_int = 256;
|
|
unsafe {
|
|
let mut channels: c_int = 0;
|
|
let needed = crate::oakui::ffi::oakengine_waveform_extract(
|
|
cname.as_ptr(),
|
|
0, // audio-stream index (probe numbering)
|
|
SAMPLES_PER_POINT,
|
|
std::ptr::null_mut(),
|
|
0,
|
|
&mut channels,
|
|
);
|
|
if needed <= 0 || channels <= 0 {
|
|
return None;
|
|
}
|
|
// The extractor's `out_pairs` receives `point_count *
|
|
// channel_count` channel-interleaved pairs (capacity is in points),
|
|
// so the buffer is sized for the media's channel count.
|
|
let channel_count = channels.max(1) as usize;
|
|
let mut raw = vec![MinMax::default(); needed as usize * channel_count];
|
|
let rc = crate::oakui::ffi::oakengine_waveform_extract(
|
|
cname.as_ptr(),
|
|
0,
|
|
SAMPLES_PER_POINT,
|
|
raw.as_mut_ptr(),
|
|
needed,
|
|
&mut channels,
|
|
);
|
|
if rc < 0 {
|
|
return None;
|
|
}
|
|
// Keep only the first channel: the pairs are interleaved per point
|
|
// (point i channel c at index i * channels + c).
|
|
let count = rc.min(needed) as usize;
|
|
let mut peaks = Vec::with_capacity(count);
|
|
peaks.extend((0..count).map(|i| raw[i * channel_count]));
|
|
Some(ClipWaveform {
|
|
peaks,
|
|
channel_count: channels.max(1),
|
|
sample_rate: 48000,
|
|
samples_per_point: SAMPLES_PER_POINT,
|
|
duration_frames,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// The timeline's clip decorator: draws extracted waveforms into audio
|
|
/// clips (M12 P4). Reads the engine-populated cache.
|
|
pub struct OakClipDecorator {
|
|
/// The waveform cache.
|
|
pub cache: Arc<WaveformCache>,
|
|
}
|
|
|
|
impl ClipDecorator for OakClipDecorator {
|
|
fn paint_waveform(
|
|
&mut self,
|
|
window: &mut Window,
|
|
clip: ClipId,
|
|
_visible_range: FrameRange,
|
|
bounds: Bounds<Pixels>,
|
|
) {
|
|
let Some(waveform) = self.cache.get(clip.0) else {
|
|
return;
|
|
};
|
|
if waveform.peaks.is_empty() || bounds.size.width.as_f32() <= 0.0 {
|
|
return;
|
|
}
|
|
let width = bounds.size.width.as_f32() as usize;
|
|
if width == 0 {
|
|
return;
|
|
}
|
|
let height = bounds.size.height.as_f32();
|
|
let mid = bounds.origin.y.as_f32() + height / 2.0;
|
|
let half = (height / 2.0).max(1.0) * 0.9;
|
|
|
|
// Media seconds covered by one waveform point.
|
|
let secs_per_point =
|
|
waveform.samples_per_point.max(1) as f32 / waveform.sample_rate.max(1) as f32;
|
|
let frames_per_point = secs_per_point * self.cache.fps;
|
|
|
|
// Sample one peak per horizontal pixel column.
|
|
let color = Hsla {
|
|
h: 0.55,
|
|
s: 0.5,
|
|
l: 0.55,
|
|
a: 0.85,
|
|
};
|
|
for x in 0..width {
|
|
let t = x as f32 / width as f32;
|
|
let frame = (t * waveform.duration_frames as f32) as usize;
|
|
let point_index = (frame as f32 / frames_per_point.max(0.0001)) as usize;
|
|
let point_index = point_index.min(waveform.peaks.len() - 1);
|
|
let p = waveform.peaks[point_index];
|
|
let y_top = mid - p.max.max(0.0) * half;
|
|
let y_bot = mid - p.min.min(0.0) * half;
|
|
let x_px = bounds.origin.x.as_f32() + x as f32;
|
|
window.paint_quad(gpui::fill(
|
|
gpui::Bounds::from_corners(
|
|
gpui::point(Pixels::from(x_px), Pixels::from(y_top)),
|
|
gpui::point(Pixels::from(x_px + 1.0), Pixels::from(y_bot.max(y_top))),
|
|
),
|
|
color,
|
|
));
|
|
}
|
|
}
|
|
}
|