Files
oak-editor/crates/oak-task/src/proxy.rs
T
Mike-Solar c9ba8695fc windows: no console window for the release app and its children
- the oak-editor release build lacked the
  `windows_subsystem = "windows"` crate attribute, so launching the
  installed app opened a console window next to the editor (debug builds
  keep the console so `cargo run` shows logs);
- oak-worker, the OFX host and the ffmpeg/ffprobe helpers are
  console-subsystem binaries spawned by the GUI app, so each spawn now
  carries CREATE_NO_WINDOW through the shared
  oak_core::miscutils::hide_console_window helper (a no-op off Windows).
2026-09-24 19:39:16 +08:00

793 lines
25 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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/>.
//! `ProxyTask`, mirroring `src/task/src/proxy/proxy.h`.
//!
//! Transcodes a video stream to a proxy via an external `ffmpeg` process
//! (oakcodec kind `OAKCODEC_TASK_PROXY`). The two pure functions
//! [`ProxyTask::build_arguments`] and [`ProxyTask::parse_progress`] are
//! parity/golden candidates (see tests/parity_test.rs).
//!
//! CPP-PARITY: src/task/src/proxy/proxy.h
use std::io::{BufRead, BufReader};
use std::process::{Command, Stdio};
use oak_codec::proxymanager::ProxyManager;
use oak_codec::task::TaskRequest;
use crate::error::{Error, Result};
use crate::task::{Task, TaskBehavior};
/// The proxy parameters, mirroring `oakcodec_proxy_params` in
/// `include/codec/proxy.h` (kept as plain fields so no oakcodec handle is
/// needed to build arguments).
pub struct ProxyParams {
/// Target width (0 = unspecified/divider-based).
pub width: i32,
/// Target height (0 = unspecified/divider-based).
pub height: i32,
/// Resolution divider.
pub divider: i32,
/// Proxy format version.
pub version: i32,
/// CRF for the proxy encode.
pub crf: i32,
/// Whether audio is included in the proxy.
pub include_audio: bool,
/// Output container extension.
pub extension: String,
/// ffmpeg preset name.
pub preset: String,
}
/// A proxy transcode task: runs `ffmpeg` with arguments from
/// [`ProxyTask::build_arguments`], parsing progress lines via
/// [`ProxyTask::parse_progress`].
pub struct ProxyTask {
/// The shared task base.
pub base: Task,
/// Source media filename.
pub source_filename: String,
/// Target stream index.
pub stream_index: i32,
/// Proxy parameters.
pub params: ProxyParams,
/// Output proxy filename.
pub output_filename: String,
/// Total media duration in seconds (for progress scaling).
duration_seconds: f64,
}
/// 代理转码的编码器选择(按平台优先级探测到的第一个硬件编码器,
/// 否则软件 x264)。
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HwEncoder {
/// macOS VideoToolbox(`-c:v h264_videotoolbox`)。
VideoToolbox,
/// NVIDIA NVENC(Windows/Linux,`-c:v h264_nvenc`)。
Nvenc,
/// Intel QuickSync(`-c:v h264_qsv`)。
Qsv,
/// AMD AMF(`-c:v h264_amf`)。
Amf,
/// 软件 libx264(C++ 路径)。
Software,
}
impl ProxyTask {
/// Build a proxy task from an oakcodec request and proxy params,
/// mirroring the C++ constructor (divider-based requests take the source
/// fraction).
pub fn new(request: &TaskRequest, params: ProxyParams) -> ProxyTask {
let mut params = params;
if request.proxy_width > 0 && request.proxy_height > 0 {
params.width = request.proxy_width;
params.height = request.proxy_height;
params.divider = 1;
}
let title = format!(
"Generating Proxy {}:{}",
request.input_filename, request.stream_index
);
ProxyTask {
base: Task::new(&title, None),
source_filename: request.input_filename.to_string(),
stream_index: request.stream_index,
params,
output_filename: request.output_filename.to_string(),
duration_seconds: 0.0,
}
}
/// Build the `ffmpeg` command-line argument vector for the proxy
/// transcode. Mirrors `build_arguments` in proxy.cpp; the exact ordering
/// and flag spelling are parity-locked (golden test).
///
/// CPP-PARITY: src/task/src/proxy/proxy.cpp (build_arguments)
pub fn build_arguments(
source_filename: &str,
stream_index: i32,
params: &ProxyParams,
output_filename: &str,
) -> Vec<String> {
let scale_filter = if params.divider > 1 {
format!(
"scale=w=trunc(iw/{}/2)*2:h=trunc(ih/{}/2)*2",
params.divider, params.divider
)
} else {
format!(
"scale=w={}:h={}:force_original_aspect_ratio=decrease",
params.width, params.height
)
};
let container_format = if params.extension.is_empty() {
"mp4".to_string()
} else {
params.extension.clone()
};
let mut args: Vec<String> = vec![
"-y".to_string(),
"-nostats".to_string(),
"-progress".to_string(),
"pipe:1".to_string(),
"-i".to_string(),
source_filename.to_string(),
"-map".to_string(),
format!("0:{stream_index}"),
];
if params.include_audio {
args.extend([
"-map".to_string(),
"0:a?".to_string(),
"-c:a".to_string(),
"aac".to_string(),
"-b:a".to_string(),
"128k".to_string(),
]);
} else {
args.push("-an".to_string());
}
args.extend([
"-vf".to_string(),
scale_filter,
"-c:v".to_string(),
"libx264".to_string(),
"-preset".to_string(),
params.preset.clone(),
"-crf".to_string(),
params.crf.to_string(),
"-pix_fmt".to_string(),
"yuv420p".to_string(),
"-movflags".to_string(),
"+faststart".to_string(),
"-f".to_string(),
container_format,
output_filename.to_string(),
]);
args
}
// ---- 硬件加速与资源控制 --------------------------------------------------
//
// build_arguments 是 C++ 纯软件路径(parity 锁定,不动);实际转码走
// [`ProxyTask::build_transcode_arguments`]:能用硬件编码器就一定用
// (macOS VideoToolbox;Windows/Linux 的 NVENC/QSV/AMF;探测不到回退
// libx264),解码侧 `-hwaccel auto`(HEVC 4:2:2 10-bit 在
// Apple Silicon、RTX 50 系+、Intel 显卡上都能硬解;硬解失败 ffmpeg
// 自动回退软解)。
/// 探测本机可用的硬件 H.264 编码器(每进程每 ffmpeg 路径缓存一次:
/// `ffmpeg -hide_banner -encoders` 的输出里按平台优先级找)。
pub fn probe_hw_encoder(ffmpeg_path: &str) -> HwEncoder {
static CACHE: std::sync::OnceLock<
std::sync::Mutex<std::collections::HashMap<String, HwEncoder>>,
> = std::sync::OnceLock::new();
let cache = CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
let mut cache = cache.lock().unwrap_or_else(|e| e.into_inner());
if let Some(enc) = cache.get(ffmpeg_path) {
return *enc;
}
let enc = Self::probe_hw_encoder_uncached(ffmpeg_path);
cache.insert(ffmpeg_path.to_string(), enc);
enc
}
fn probe_hw_encoder_uncached(ffmpeg_path: &str) -> HwEncoder {
let mut command = Command::new(ffmpeg_path);
command.args(["-hide_banner", "-encoders"]);
oak_core::miscutils::hide_console_window(&mut command);
let out = command.output();
let Ok(out) = out else {
return HwEncoder::Software;
};
let text = String::from_utf8_lossy(&out.stdout);
let has = |name: &str| text.contains(name);
// 平台优先级:macOS 只走 VideoToolbox;Windows NVENC → QSV →
// AMF;Linux NVENC → QSV → AMF(VAAPI 需要 hwupload 帧上传链,
// 暂不支持,回退软件)。
let preference: &[(&str, HwEncoder)] = if cfg!(target_os = "macos") {
&[("h264_videotoolbox", HwEncoder::VideoToolbox)]
} else {
&[
("h264_nvenc", HwEncoder::Nvenc),
("h264_qsv", HwEncoder::Qsv),
("h264_amf", HwEncoder::Amf),
]
};
for (name, enc) in preference {
// `-encoders` lists every compiled-in encoder — a full build
// advertises h264_nvenc/qsv/amf even on machines without the
// matching hardware — so a one-frame null encode decides
// whether the candidate can actually initialize. Failures fall
// through to the next candidate and finally to software (the
// old list-only check made proxy generation fail outright on
// GPU-less Windows machines with such a build).
if has(name) && Self::encoder_usable(ffmpeg_path, name) {
return *enc;
}
}
HwEncoder::Software
}
/// Whether `encoder` can initialize and encode one tiny frame; see the
/// caller for why the `-encoders` list is not enough.
fn encoder_usable(ffmpeg_path: &str, encoder: &str) -> bool {
let mut command = Command::new(ffmpeg_path);
command.args([
"-hide_banner",
"-loglevel",
"error",
"-f",
"lavfi",
"-i",
"color=size=64x64:rate=1:duration=1",
"-frames:v",
"1",
"-c:v",
encoder,
"-f",
"null",
"-",
]);
oak_core::miscutils::hide_console_window(&mut command);
let out = command.output();
matches!(out, Ok(out) if out.status.success())
}
/// libx264 预设名 → NVENC p1..p7(NVENC 预设是编号)。
fn nvenc_preset(preset: &str) -> &'static str {
match preset {
"ultrafast" => "p1",
"superfast" => "p2",
"veryfast" => "p3",
"faster" => "p4",
"fast" => "p5",
"medium" => "p6",
_ => "p7",
}
}
/// libx264 预设名 → AMF quality 档。
fn amf_quality(preset: &str) -> &'static str {
match preset {
"ultrafast" | "superfast" | "veryfast" | "faster" | "fast" => "speed",
"medium" => "balanced",
_ => "quality",
}
}
/// The video-codec arguments for `enc` (quality mapped from the
/// proxy CRF: NVENC `-cq`、QSV `-global_quality`、AMF `-qp_i/-qp_p`
/// 与 CRF 同刻度直接用;VideoToolbox 用 qscale 语义 `-q:v`)。
fn hw_video_args(enc: HwEncoder, params: &ProxyParams) -> Vec<String> {
match enc {
HwEncoder::VideoToolbox => vec![
"-c:v".to_string(),
"h264_videotoolbox".to_string(),
"-q:v".to_string(),
params.crf.to_string(),
],
HwEncoder::Nvenc => vec![
"-c:v".to_string(),
"h264_nvenc".to_string(),
"-preset".to_string(),
Self::nvenc_preset(&params.preset).to_string(),
"-cq".to_string(),
params.crf.to_string(),
],
HwEncoder::Qsv => vec![
"-c:v".to_string(),
"h264_qsv".to_string(),
"-global_quality".to_string(),
params.crf.to_string(),
],
HwEncoder::Amf => vec![
"-c:v".to_string(),
"h264_amf".to_string(),
"-quality".to_string(),
Self::amf_quality(&params.preset).to_string(),
"-rc".to_string(),
"cqp".to_string(),
"-qp_i".to_string(),
params.crf.to_string(),
"-qp_p".to_string(),
params.crf.to_string(),
],
HwEncoder::Software => vec![
"-c:v".to_string(),
"libx264".to_string(),
"-preset".to_string(),
params.preset.clone(),
"-crf".to_string(),
params.crf.to_string(),
],
}
}
/// 实际转码用的参数:build_arguments 的软件路径 + 硬件加速与线程
/// 控制(解码 `-hwaccel auto`;编码按探测结果;`-threads` 限制
/// 解码/编码线程——后台任务不把机器打满)。
pub fn build_transcode_arguments(
source_filename: &str,
stream_index: i32,
params: &ProxyParams,
output_filename: &str,
enc: HwEncoder,
threads: i32,
) -> Vec<String> {
// 软件路径直接复用 parity 参数,仅在最前面加解码 hwaccel 与
// 线程数(不改变 C++ 的参数顺序语义之外的任何东西)。
let base = Self::build_arguments(source_filename, stream_index, params, output_filename);
let mut args: Vec<String> = vec!["-y".to_string(), "-nostats".to_string()];
if threads > 0 {
args.extend(["-threads".to_string(), threads.to_string()]);
}
// Hardware decode only rides along with a hardware encoder: a
// d3d11va/vaapi-decoded surface cannot feed libx264 directly (it
// would need an explicit hwdownload/format filter chain), so the
// software path stays fully software — which is also what makes it
// portable across machines without decode support.
if enc != HwEncoder::Software {
args.extend(["-hwaccel".to_string(), "auto".to_string()]);
}
// base 以 -y -nostats 开头,跳过这两个,余下的按序接上;编码
// 器参数(-c:v 起)在 hw 时整段替换。
let mut rest = base[2..].to_vec();
if enc != HwEncoder::Software {
// 找到 "-c:v libx264" 段(-c:v 起到 -pix_fmt 前),换成 hw 参数。
if let Some(cv) = rest.iter().position(|a| a == "-c:v") {
let end = rest[cv..]
.iter()
.position(|a| a == "-pix_fmt")
.map(|p| cv + p)
.unwrap_or(rest.len());
rest.splice(cv..end, Self::hw_video_args(enc, params));
}
}
args.append(&mut rest);
args
}
/// 代理转码并发上限的配置键(Proxy Settings 对话框可调;
/// 默认 1——后台任务不给系统造成压力)。
pub const CONFIG_KEY_PROXY_MAX_CONCURRENT: &str = "ProxyMaxConcurrent";
/// 配置的代理转码并发上限([1, 16])。
pub fn proxy_max_concurrent() -> i32 {
oak_core::configstore::ConfigStore::instance()
.get_int(None, Self::CONFIG_KEY_PROXY_MAX_CONCURRENT, 1)
.clamp(1, 16)
}
/// 后台转码的线程预算(不变式:并发数 × 每任务线程数 ≤
/// 逻辑处理器数的一半——机器永远留一半以上给前台)。`concurrent`
/// 是当前的并发上限配置。
pub fn transcode_thread_budget(concurrent: i32) -> i32 {
let cores = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(2) as i32;
let half = (cores / 2).max(1);
(half / concurrent.max(1)).clamp(1, 8)
}
/// Parse a single `ffmpeg -progress` output line into a progress value in
/// 0.0..=1.0 given the total duration. Mirrors `parse_progress` in
/// proxy.cpp (golden test).
///
/// CPP-PARITY: src/task/src/proxy/proxy.cpp (parse_progress)
pub fn parse_progress(line: &str, duration_seconds: f64) -> Option<f64> {
if duration_seconds <= 0.0 {
return None;
}
let out_time_us: i64 = if let Some(rest) = line.strip_prefix("out_time_us=") {
rest.trim().parse().unwrap_or(-1)
} else if let Some(rest) = line.strip_prefix("out_time_ms=") {
// Despite the name, ffmpeg reports this value in microseconds.
rest.trim().parse().unwrap_or(-1)
} else {
-1
};
if out_time_us < 0 {
return None;
}
let progress = out_time_us as f64 / 1_000_000.0 / duration_seconds;
Some(if progress < 0.0 {
0.0
} else if progress > 1.0 {
1.0
} else {
progress
})
}
}
impl TaskBehavior for ProxyTask {
/// Spawn `ffmpeg` with the built arguments, feed `-progress` lines to
/// [`ProxyTask::parse_progress`] and emit them as task progress.
fn run(&mut self, task: &mut Task) -> Result<()> {
// Direct call into oakcodec's proxy manager (single-lib
// unification: the old two-stage C ABI getter is gone; an empty
// string means "not found"). The `FFmpegPath` config takes
// precedence when set (C++ `OAK_CONFIG("FFmpegPath")`).
let configured = oak_core::configstore::ConfigStore::instance()
.get(None, "FFmpegPath")
.unwrap_or_default();
let ffmpeg_path = ProxyManager::find_ffmpeg(&configured);
if ffmpeg_path.is_empty() {
task.set_error(
"Failed to generate proxy: ffmpeg executable was not found. Set the ffmpeg path in Preferences > Disk > Proxy Settings.",
);
return Err(Error::Failed("ffmpeg executable was not found".to_string()));
}
// Create the output directory if needed.
if let Some(parent) = std::path::Path::new(&self.output_filename).parent() {
if !parent.as_os_str().is_empty()
&& !parent.exists()
&& std::fs::create_dir_all(parent).is_err()
{
task.set_error("Failed to create proxy output directory");
return Err(Error::Failed(
"Failed to create proxy output directory".to_string(),
));
}
}
let _ = std::fs::remove_file(&self.output_filename);
let working_filename = format!("{}.working.mp4", self.output_filename);
let _ = std::fs::remove_file(&working_filename);
// 硬件加速(能用硬编就一定用;探测不到回退 libx264)+ 线程
// 预算(后台任务不把机器打满)。
let encoder = Self::probe_hw_encoder(&ffmpeg_path);
let threads = Self::transcode_thread_budget(Self::proxy_max_concurrent());
let args = Self::build_transcode_arguments(
&self.source_filename,
self.stream_index,
&self.params,
&working_filename,
encoder,
threads,
);
// Probe the source duration for progress scaling (0 when unknown).
self.duration_seconds = probe_source_duration_seconds(&ffmpeg_path, &self.source_filename);
// Unix 上降优先级跑(nice 10:代理是后台任务,不能和前台
// 抢系统);Windows 没有 nice,线程预算已限制占用。
#[cfg(unix)]
let mut command = {
let mut c = Command::new("nice");
c.arg("-n").arg("10").arg(&ffmpeg_path);
c
};
#[cfg(not(unix))]
let mut command = Command::new(&ffmpeg_path);
command
.args(&args)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
// A GUI Oak spawning a console child would flash a console window.
oak_core::miscutils::hide_console_window(&mut command);
let mut child = match command.spawn() {
Ok(child) => child,
Err(_) => {
task.set_error("Failed to start ffmpeg for proxy generation");
return Err(Error::Failed("Failed to start ffmpeg".to_string()));
}
};
// Drain stderr in a thread so a chatty ffmpeg cannot block us; keep
// the tail so the failure message says *why* the transcode failed
// (ffmpeg explains it there, and users see it in the task error).
let stderr = child.stderr.take();
let stderr_tail = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
if let Some(stderr) = stderr {
let tail = stderr_tail.clone();
std::thread::spawn(move || {
for line in BufReader::new(stderr).lines().map_while(|line| line.ok()) {
let mut tail = tail.lock().unwrap_or_else(|e| e.into_inner());
if tail.len() == 8 {
tail.remove(0);
}
tail.push(line);
}
});
}
let stdout = child.stdout.take();
let mut last_progress = 0.0_f64;
if let Some(stdout) = stdout {
let reader = BufReader::new(stdout);
for line in reader.lines() {
match line {
Ok(line) => {
if let Some(progress) = Self::parse_progress(&line, self.duration_seconds) {
if progress >= 0.0 && progress - last_progress > 0.001 {
last_progress = progress;
task.emit_progress(progress);
}
}
}
Err(_) => break,
}
if task.is_cancelled() {
let _ = child.kill();
let _ = child.wait();
let _ = std::fs::remove_file(&working_filename);
task.set_error("Proxy generation was cancelled");
return Err(Error::Cancelled);
}
}
}
let exit_status = child.wait();
if !matches!(exit_status, Ok(status) if status.success()) {
let _ = std::fs::remove_file(&working_filename);
let detail = stderr_tail
.lock()
.map(|tail| tail.join("; "))
.unwrap_or_default();
let message = if detail.is_empty() {
"ffmpeg failed to generate proxy".to_string()
} else {
format!("ffmpeg failed to generate proxy: {detail}")
};
eprintln!("{message}");
task.set_error(&message);
return Err(Error::Failed(message));
}
if !std::path::Path::new(&working_filename).exists() {
task.set_error("ffmpeg finished but proxy file was not created");
return Err(Error::Failed(
"ffmpeg finished but proxy file was not created".to_string(),
));
}
if std::fs::rename(&working_filename, &self.output_filename).is_err() {
task.set_error("Failed to move proxy into place");
return Err(Error::Failed("Failed to move proxy into place".to_string()));
}
task.emit_progress(1.0);
Ok(())
}
}
/// Probe the source duration via `ffprobe` next to ffmpeg; 0.0 when
/// unavailable (in which case no intermediate progress is reported).
fn probe_source_duration_seconds(ffmpeg_path: &str, source_filename: &str) -> f64 {
let ffprobe = std::path::Path::new(ffmpeg_path).with_file_name("ffprobe");
if !ffprobe.exists() {
return 0.0;
}
let mut command = Command::new(&ffprobe);
command.args([
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
source_filename,
]);
oak_core::miscutils::hide_console_window(&mut command);
let output = command.output();
match output {
Ok(out) if out.status.success() => {
let text = String::from_utf8_lossy(&out.stdout);
text.trim().parse().unwrap_or(0.0)
}
_ => 0.0,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn params() -> ProxyParams {
ProxyParams {
width: 1280,
height: 720,
divider: 1,
version: 1,
crf: 23,
include_audio: true,
extension: "mp4".to_string(),
preset: "veryfast".to_string(),
}
}
/// 软件路径 = parity 参数 + 线程前缀(全软件流水线:`-hwaccel auto`
/// 会让硬解出的 surface 喂不进 libx264);编码器参数原样
/// (libx264 + preset + crf)。
#[test]
fn transcode_arguments_software_keeps_parity_body() {
let args = ProxyTask::build_transcode_arguments(
"/src.mov",
0,
&params(),
"/dst.mp4",
HwEncoder::Software,
4,
);
assert!(
!args.iter().any(|a| a == "-hwaccel"),
"the software path must not request hardware decoding: {args:?}"
);
assert!(args.windows(2).any(|w| w == ["-threads", "4"]));
assert!(args.windows(2).any(|w| w == ["-c:v", "libx264"]));
assert!(args.windows(2).any(|w| w == ["-crf", "23"]));
// -threads 在 -i 之前(解码侧选项)。
let i_pos = args.iter().position(|a| a == "-i").unwrap();
let threads_pos = args.iter().position(|a| a == "-threads").unwrap();
assert!(threads_pos < i_pos);
}
/// 硬件编码器整段替换 -c:v 段(不再出现 libx264/crf),各自的
/// 质量/预设映射正确。
#[test]
fn transcode_arguments_hw_swaps_encoder() {
let vt = ProxyTask::build_transcode_arguments(
"/src.mov",
0,
&params(),
"/dst.mp4",
HwEncoder::VideoToolbox,
4,
);
assert!(vt.windows(2).any(|w| w == ["-c:v", "h264_videotoolbox"]));
assert!(vt.windows(2).any(|w| w == ["-q:v", "23"]));
assert!(!vt.iter().any(|a| a == "libx264" || a == "-crf"));
let nv = ProxyTask::build_transcode_arguments(
"/src.mov",
0,
&params(),
"/dst.mp4",
HwEncoder::Nvenc,
4,
);
assert!(nv.windows(2).any(|w| w == ["-c:v", "h264_nvenc"]));
assert!(nv.windows(2).any(|w| w == ["-preset", "p3"]));
assert!(nv.windows(2).any(|w| w == ["-cq", "23"]));
// 硬编路径保留解码侧 hwaccel。
assert!(nv.windows(2).any(|w| w == ["-hwaccel", "auto"]));
let qsv = ProxyTask::build_transcode_arguments(
"/src.mov",
0,
&params(),
"/dst.mp4",
HwEncoder::Qsv,
4,
);
assert!(qsv.windows(2).any(|w| w == ["-c:v", "h264_qsv"]));
assert!(qsv.windows(2).any(|w| w == ["-global_quality", "23"]));
let amf = ProxyTask::build_transcode_arguments(
"/src.mov",
0,
&params(),
"/dst.mp4",
HwEncoder::Amf,
4,
);
assert!(amf.windows(2).any(|w| w == ["-c:v", "h264_amf"]));
assert!(amf.windows(2).any(|w| w == ["-quality", "speed"]));
// -pix_fmt/-movflags 等后续段在替换后仍然保留。
assert!(vt.windows(2).any(|w| w == ["-pix_fmt", "yuv420p"]));
}
/// 线程预算不变式:并发 × 线程 ≤ 逻辑处理器数一半;区间 [1, 8]。
#[test]
fn transcode_thread_budget_invariant() {
let cores = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(2) as i32;
let half = (cores / 2).max(1);
for concurrent in [1, 2, 4, 8, 16] {
let b = ProxyTask::transcode_thread_budget(concurrent);
assert!((1..=8).contains(&b));
assert!(
b * concurrent <= half.max(1) || b == 1,
"concurrency {concurrent} x threads {b} exceeds half of {cores} cores"
);
}
// 并发为 1 时独占一半核。
assert_eq!(ProxyTask::transcode_thread_budget(1), half.clamp(1, 8));
}
/// End-to-end: transcode a real 4K H.265 4:2:2 10-bit file (the
/// class of media the 4K playback lag was reported on) to a 720p
/// proxy. Skipped when the fixture or ffmpeg is missing.
#[test]
fn proxy_task_transcodes_hevc_422_10bit() {
let src = "/tmp/oakperf/hevc422-4k.mp4";
if !std::path::Path::new(src).exists() {
eprintln!("fixture missing; skipping");
return;
}
let ffmpeg = ProxyManager::find_ffmpeg("");
if ffmpeg.is_empty() {
eprintln!("ffmpeg not found; skipping");
return;
}
let out = "/tmp/oakperf/proxy-720p.mp4";
let _ = std::fs::remove_file(out);
let request = oak_codec::task::TaskRequest {
kind: oak_codec::task::TaskKind::Proxy,
input_filename: src,
output_filename: out,
stream_index: 0,
sample_rate: 0,
channel_layout: 0,
sample_format: 0,
proxy_width: 1280,
proxy_height: 720,
};
let params = ProxyParams {
width: 1280,
height: 720,
divider: 1,
version: 1,
crf: 23,
include_audio: true,
extension: "mp4".to_string(),
preset: "veryfast".to_string(),
};
let mut proxy_task = ProxyTask::new(&request, params);
let mut task = Task::new("probe", None);
proxy_task
.run(&mut task)
.expect("the proxy transcode succeeds on 4K HEVC 4:2:2 10-bit");
let meta = std::fs::metadata(out).expect("the proxy file exists");
assert!(meta.len() > 0, "the proxy file is non-empty");
}
}