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.
211 lines
6.8 KiB
Rust
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();
|
|
}
|