Files
oak-editor/crates/oak-render/examples/bench_playback.rs
T
Mike-Solar 4a2614b3fc timeline: adjustment layers and first-class transitions
Adjustment layers (docs/zh/plans/adjustment-layers-and-transitions.md):
a new timeline block type whose effect chain grades the composite of
every video track below it, over its own range (spanning clips or a
slice of one). The graph path flushes the lower tracks at the block's
track boundary and sweeps the composite through the chain via a
transient texture-source node; the montage path mirrors it with
AdjustmentSpan tickets (wire-compatible), so worker previews and
exports agree. An empty-area context menu creates one; the block
trims/moves/deletes like a clip, with undo everywhere.

Transitions: seam blocks come alive - cross dissolve/fade/wipe/slide
evaluate both neighbors through the graph path with progress from the
transition's own range (never the whole clip). Ctrl+Shift+D or the clip
menu inserts a default transition; the gpui wedges render and drag to
resize offsets undoably, and TransitionRemoveCommand now restores
offsets and edges on undo. The transitionfx node form runs the same
shaders on an adjustment layer with progress_in auto-filled from the
layer's span (explicit value wins).

Also: every built-in effect name and parameter name is now
translatable (360 node.* keys per locale, zh-CN fully translated, two
coverage tests guard future gaps); the new nodes register in
nodes/mod.rs with the factory smoke table updated; textfootage and
adjustment-layer i18n keys included.
2026-09-10 22:03:15 +08:00

211 lines
6.8 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/>.
//! Real-footage playback benchmark: renders `N` sequential frames of a
//! real media file through the real oak-worker pool at the app's preview
//! proxy size, mimicking the playback pre-render window (Playback
//! priority, interleaved claiming, immediate slot release). Reports
//! throughput and completion latency so worker-side decode/render
//! hotspots can be measured end to end.
//!
//! Run from the repo root:
//!
//! ```sh
//! cargo run --release -p oakrender --example bench_playback -- <media> [frames] [workers] [long_edge]
//! ```
//!
//! `frames` defaults to 240, `workers` to the adaptive policy, and
//! `long_edge` to 480 (the app's preview proxy size). To profile a
//! worker while this runs: `pgrep oak-worker | head -1 | xargs sample 10`.
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use oak_core::Rational;
use oak_render::ipc::SLOT_FORMAT_BGRA8;
use oak_render::procpool::{DispatcherConfig, ProcessDispatcher};
use oak_render::ticket::{TicketPayload, TicketResult, VideoTicketParams};
use oak_render::worker::{Job, JobDispatch, JobSchedule};
/// Locate the oak-worker binary (see bench_process).
fn worker_bin() -> PathBuf {
if let Ok(p) = std::env::var("OAK_WORKER_BIN") {
return PathBuf::from(p);
}
if let Ok(exe) = std::env::current_exe() {
if let Some(examples) = exe.parent() {
if let Some(profile) = examples.parent() {
let candidate = profile.join(format!("oak-worker{}", std::env::consts::EXE_SUFFIX));
if candidate.exists() {
return candidate;
}
}
}
}
PathBuf::from("oak-worker")
}
fn main() {
let media = std::env::args()
.nth(1)
.unwrap_or_else(|| "tests/demo.mp4".to_string());
let frames: usize = std::env::args()
.nth(2)
.and_then(|s| s.parse().ok())
.unwrap_or(240);
let workers: Option<usize> = std::env::args().nth(3).and_then(|s| s.parse().ok());
let long_edge: i32 = std::env::args()
.nth(4)
.and_then(|s| s.parse().ok())
.unwrap_or(480);
// The app's preview proxy size: the sequence's aspect scaled to the
// long edge (demo.mp4 is 16:9 1080p).
let (width, height) = ((long_edge as f64 * 16.0 / 9.0).round() as i32, long_edge);
let config = DispatcherConfig {
worker_bin: Some(worker_bin()),
workers: workers.unwrap_or(0),
slots_per_worker: 8,
width,
height,
slot_format: SLOT_FORMAT_BGRA8,
batch_size: 0,
graph_snapshot: None,
handshake_timeout_ms: 30_000,
};
let dispatcher = ProcessDispatcher::new(config).expect("dispatcher config");
dispatcher.start().expect("workers start + handshake");
let worker_count = dispatcher.worker_count();
println!("oak-worker pool: {worker_count} worker(s), {frames} x {width}x{height} BGRA8 frames of {media}");
// One completion record per frame: (ticket/frame, submit, completion).
let results = Arc::new(Mutex::new(Vec::<(i64, Instant, Instant)>::new()));
let start = Instant::now();
for i in 0..frames {
let results = results.clone();
let dc = dispatcher.clone();
let frame = i as i64;
let media_clone = media.clone();
let job = Job {
node_identity: 1,
time: Rational::new(frame, 25),
params: Arc::new(VideoTicketParams {
viewer: 1,
project: String::new(),
time: Rational::new(frame, 25),
force_size: Some((width, height)),
force_format: None,
cache: None,
cache_dir: None,
cache_id: None,
cache_timebase: None,
// A single footage clip covers the whole timeline.
footage: Some((media_clone, 0)),
montage: Vec::new(),
adjustments: Vec::new(),
}),
audio: None,
produce: Arc::new(|_, _| {
Err(oak_render::error::Error::Failed(
"process backend does not use the in-process producer".into(),
))
}),
done: Box::new(move |result: TicketResult| match result {
Ok(TicketPayload::ShmFrame(f)) => {
results
.lock()
.unwrap_or_else(|e| e.into_inner())
.push((frame, start, Instant::now()));
dc.release_frame(&f);
}
Ok(TicketPayload::ShmAudio(a)) => {
dc.release_audio_frame(&a);
}
Ok(other) => {
eprintln!("unexpected payload: {other:?}");
}
Err(e) => {
eprintln!("frame {frame} failed: {e}");
}
}),
// Playback priority, the pre-render window's schedule.
schedule: JobSchedule::playback(frame, frame, 0),
};
if !dispatcher.post(job) {
eprintln!("post refused at frame {frame}");
break;
}
}
// Pump until every completion has landed.
let deadline = Instant::now() + Duration::from_secs(300);
loop {
dispatcher.poll();
let done = results.lock().unwrap_or_else(|e| e.into_inner()).len();
if done >= frames {
break;
}
if Instant::now() > deadline {
eprintln!("timeout: {done}/{frames} completions");
break;
}
std::thread::sleep(Duration::from_millis(2));
}
let elapsed = start.elapsed();
let entries: Vec<(i64, Instant, Instant)> =
results.lock().unwrap_or_else(|e| e.into_inner()).drain(..).collect();
let completed = entries.len();
let throughput = completed as f64 / elapsed.as_secs_f64();
// Per-frame completion latency (submit -> done), an end-to-end proxy
// for the worker's per-frame render cost under load.
let mut latencies: Vec<f64> = entries
.iter()
.map(|(_, submit, done)| (*done - *submit).as_secs_f64() * 1000.0)
.collect();
latencies.sort_by(|a, b| a.partial_cmp(b).unwrap());
let report = |name: &str, value: String| println!("{name:<38} {value}");
report("frames completed", completed.to_string());
report("total wall time", format!("{:.2} s", elapsed.as_secs_f64()));
report(
"throughput",
format!("{throughput:.1} fps ({:.1} ms/frame)", 1000.0 / throughput.max(f64::EPSILON)),
);
if !latencies.is_empty() {
let mean = latencies.iter().sum::<f64>() / latencies.len() as f64;
report("completion latency mean", format!("{mean:.1} ms"));
report(
"completion latency p50/p95/max",
format!(
"{:.1} / {:.1} / {:.1} ms",
latencies[latencies.len() / 2],
latencies[((latencies.len() as f64 * 0.95) as usize).min(latencies.len() - 1)],
latencies.last().unwrap()
),
);
}
report(
"main-heap frame copies",
oak_render::procpool::main_heap_frame_copies().to_string(),
);
dispatcher.shutdown();
}