// 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 . //! `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 { 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 = 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::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 out = Command::new(ffmpeg_path) .args(["-hide_banner", "-encoders"]) .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 { if has(name) { return *enc; } } HwEncoder::Software } /// 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 { 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(¶ms.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(¶ms.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 { // 软件路径直接复用 parity 参数,仅在最前面加解码 hwaccel 与 // 线程数(不改变 C++ 的参数顺序语义之外的任何东西)。 let base = Self::build_arguments(source_filename, stream_index, params, output_filename); let mut args: Vec = vec!["-y".to_string(), "-nostats".to_string()]; if threads > 0 { args.extend(["-threads".to_string(), threads.to_string()]); } 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 { 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() { if 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()); 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. let stderr = child.stderr.take(); if let Some(stderr) = stderr { std::thread::spawn(move || { let _ = BufReader::new(stderr).lines().count(); }); } 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); task.set_error("ffmpeg failed to generate proxy"); return Err(Error::Failed("ffmpeg failed to generate proxy".to_string())); } 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 output = Command::new(&ffprobe) .args([ "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", source_filename, ]) .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/线程前缀;编码器参数 /// 原样(libx264 + preset + crf)。 #[test] fn transcode_arguments_software_keeps_parity_body() { let args = ProxyTask::build_transcode_arguments( "/src.mov", 0, ¶ms(), "/dst.mp4", HwEncoder::Software, 4, ); assert!(args.windows(2).any(|w| w == ["-hwaccel", "auto"])); 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/-hwaccel 在 -i 之前(解码侧选项)。 let i_pos = args.iter().position(|a| a == "-i").unwrap(); let hw_pos = args.iter().position(|a| a == "-hwaccel").unwrap(); assert!(hw_pos < i_pos); } /// 硬件编码器整段替换 -c:v 段(不再出现 libx264/crf),各自的 /// 质量/预设映射正确。 #[test] fn transcode_arguments_hw_swaps_encoder() { let vt = ProxyTask::build_transcode_arguments( "/src.mov", 0, ¶ms(), "/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, ¶ms(), "/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"])); let qsv = ProxyTask::build_transcode_arguments( "/src.mov", 0, ¶ms(), "/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, ¶ms(), "/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"); } }