From 4f6c44a0d024ed00f611b01428c88b2db19761e9 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Wed, 26 Aug 2026 06:40:06 +0800 Subject: [PATCH] proxy: hardware-accelerated transcode with configurable, resource-aware concurrency Proxy generation no longer pegs the machine: - the transcode probes for a hardware H.264 encoder once per process and uses it when present (macOS h264_videotoolbox; Windows/Linux h264_nvenc -> h264_qsv -> h264_amf; libx264 remains the universal fallback and the untouched C++ parity path), with decode-side -hwaccel auto (HEVC 4:2:2 10-bit decodes in hardware on Apple Silicon / RTX 50+ / Intel GPUs and falls back to software cleanly); quality/preset map from the proxy CRF/preset per encoder - the concurrency limit is configurable (ProxyMaxConcurrent, default 1) in the Proxy Settings dialog; auto-generated jobs queue and the next starts when a slot frees. The invariant concurrency x per-job threads <= logical cores / 2 holds by construction (thread budget = half the cores / concurrency, clamped to [1,8], covered by a unit test), and on Unix ffmpeg runs nice -n 10 so the background task never starves the foreground - deleting a footage's proxy also removes it from the auto-generation queue --- assets/i18n/de.yaml | 1 + assets/i18n/en-US.yaml | 1 + assets/i18n/es.yaml | 1 + assets/i18n/fr.yaml | 1 + assets/i18n/ja.yaml | 1 + assets/i18n/pt.yaml | 1 + assets/i18n/ru.yaml | 1 + assets/i18n/zh-CN.yaml | 1 + crates/oak-app/src/dialogs.rs | 26 +++ crates/oak-app/src/oakui/real.rs | 36 +++- crates/oak-task/src/proxy.rs | 297 ++++++++++++++++++++++++++++++- 11 files changed, 362 insertions(+), 5 deletions(-) diff --git a/assets/i18n/de.yaml b/assets/i18n/de.yaml index 5ba00254b..d77a9a0f4 100644 --- a/assets/i18n/de.yaml +++ b/assets/i18n/de.yaml @@ -339,6 +339,7 @@ "proxydialog.height": "Proxy-Höhe" "proxydialog.crf": "Proxy-CRF" "proxydialog.preset": "Proxy-Preset" +"proxydialog.max_concurrent": "Max. parallele Aufträge" "proxydialog.include_audio": "Audio in Proxys einschließen" "proxydialog.ffmpeg": "ffmpeg-Programm" "proxydialog.generate": "Proxys generieren" diff --git a/assets/i18n/en-US.yaml b/assets/i18n/en-US.yaml index 4da35717b..547d8ab2b 100644 --- a/assets/i18n/en-US.yaml +++ b/assets/i18n/en-US.yaml @@ -339,6 +339,7 @@ "proxydialog.height": "Proxy Height" "proxydialog.crf": "Proxy CRF" "proxydialog.preset": "Proxy Preset" +"proxydialog.max_concurrent": "Max Concurrent Jobs" "proxydialog.include_audio": "Include audio in proxies" "proxydialog.ffmpeg": "ffmpeg Executable" "proxydialog.generate": "Generate Proxies" diff --git a/assets/i18n/es.yaml b/assets/i18n/es.yaml index 9e05614aa..399e1dbbc 100644 --- a/assets/i18n/es.yaml +++ b/assets/i18n/es.yaml @@ -339,6 +339,7 @@ "proxydialog.height": "Alto del proxy" "proxydialog.crf": "CRF del proxy" "proxydialog.preset": "Preajuste del proxy" +"proxydialog.max_concurrent": "Trabajos simultáneos máximos" "proxydialog.include_audio": "Incluir audio en los proxies" "proxydialog.ffmpeg": "Ejecutable de ffmpeg" "proxydialog.generate": "Generar proxies" diff --git a/assets/i18n/fr.yaml b/assets/i18n/fr.yaml index 12bd60253..8543b5ac4 100644 --- a/assets/i18n/fr.yaml +++ b/assets/i18n/fr.yaml @@ -339,6 +339,7 @@ "proxydialog.height": "Hauteur du proxy" "proxydialog.crf": "CRF du proxy" "proxydialog.preset": "Présélection du proxy" +"proxydialog.max_concurrent": "Tâches simultanées max." "proxydialog.include_audio": "Inclure l'audio dans les proxies" "proxydialog.ffmpeg": "Exécutable ffmpeg" "proxydialog.generate": "Générer les proxies" diff --git a/assets/i18n/ja.yaml b/assets/i18n/ja.yaml index d000b2214..a47c6241b 100644 --- a/assets/i18n/ja.yaml +++ b/assets/i18n/ja.yaml @@ -339,6 +339,7 @@ "proxydialog.height": "プロキシ高さ" "proxydialog.crf": "プロキシCRF" "proxydialog.preset": "プロキシプリセット" +"proxydialog.max_concurrent": "最大同時ジョブ数" "proxydialog.include_audio": "プロキシにオーディオを含める" "proxydialog.ffmpeg": "ffmpeg 実行ファイル" "proxydialog.generate": "プロキシを生成" diff --git a/assets/i18n/pt.yaml b/assets/i18n/pt.yaml index 2bad73469..ff7aedc82 100644 --- a/assets/i18n/pt.yaml +++ b/assets/i18n/pt.yaml @@ -339,6 +339,7 @@ "proxydialog.height": "Altura do proxy" "proxydialog.crf": "CRF do proxy" "proxydialog.preset": "Predefinição do proxy" +"proxydialog.max_concurrent": "Máx. de trabalhos simultâneos" "proxydialog.include_audio": "Incluir áudio nos proxies" "proxydialog.ffmpeg": "Executável do ffmpeg" "proxydialog.generate": "Gerar proxies" diff --git a/assets/i18n/ru.yaml b/assets/i18n/ru.yaml index 47756f4e9..aa9562564 100644 --- a/assets/i18n/ru.yaml +++ b/assets/i18n/ru.yaml @@ -339,6 +339,7 @@ "proxydialog.height": "Высота прокси" "proxydialog.crf": "CRF прокси" "proxydialog.preset": "Пресет прокси" +"proxydialog.max_concurrent": "Макс. параллельных задач" "proxydialog.include_audio": "Включать звук в прокси" "proxydialog.ffmpeg": "Исполняемый файл ffmpeg" "proxydialog.generate": "Создать прокси" diff --git a/assets/i18n/zh-CN.yaml b/assets/i18n/zh-CN.yaml index 084260a7e..e129b331e 100644 --- a/assets/i18n/zh-CN.yaml +++ b/assets/i18n/zh-CN.yaml @@ -339,6 +339,7 @@ "proxydialog.height": "代理高度" "proxydialog.crf": "代理 CRF" "proxydialog.preset": "代理预设" +"proxydialog.max_concurrent": "最大并发任务数" "proxydialog.include_audio": "代理包含音频" "proxydialog.ffmpeg": "ffmpeg 可执行文件" "proxydialog.generate": "生成代理" diff --git a/crates/oak-app/src/dialogs.rs b/crates/oak-app/src/dialogs.rs index a4f520064..904519d01 100644 --- a/crates/oak-app/src/dialogs.rs +++ b/crates/oak-app/src/dialogs.rs @@ -1068,6 +1068,7 @@ pub struct ProxyDialogContent { crf: Entity, preset: Entity, include_audio: Entity, + max_concurrent: Entity, ffmpeg_path: Entity, custom_params: Entity, /// Snapshot of the footage rows (refreshed after generate / delete). @@ -1162,6 +1163,21 @@ impl ProxyDialogContent { .with_label(i18n::tr("proxydialog.include_audio")) }); + let max_concurrent = cx.new(|cx| { + SpinBox::new( + 28, + SliderModel::new( + ValueKind::Integer, + 1.0, + 16.0, + 1.0, + config_get_int("ProxyMaxConcurrent", 1).clamp(1, 16) as f64, + ), + window, + cx, + ) + }); + let ffmpeg_path = cx.new(|cx| { let editor = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx)); PathField { @@ -1197,6 +1213,7 @@ impl ProxyDialogContent { crf, preset, include_audio, + max_concurrent, ffmpeg_path, custom_params, rows, @@ -1241,6 +1258,10 @@ impl ProxyDialogContent { config_set_int("ProxyCRF", i64::from(params.crf)); config_set_string("ProxyPreset", ¶ms.preset); config_set_bool("ProxyIncludeAudio", params.include_audio); + config_set_int( + "ProxyMaxConcurrent", + self.max_concurrent.read(cx).value().to_f64().clamp(1.0, 16.0) as i64, + ); config_set_string( CONFIG_KEY_FFMPEG_PATH, self.ffmpeg_path.read(cx).path(cx).trim(), @@ -1426,6 +1447,11 @@ impl Render for ProxyDialogContent { self.height.clone(), )), ) + .child(form_row( + &colors, + i18n::tr("proxydialog.max_concurrent").into(), + self.max_concurrent.clone(), + )) .child( div() .flex() diff --git a/crates/oak-app/src/oakui/real.rs b/crates/oak-app/src/oakui/real.rs index fc9362415..9fd72aba0 100644 --- a/crates/oak-app/src/oakui/real.rs +++ b/crates/oak-app/src/oakui/real.rs @@ -1038,6 +1038,9 @@ pub struct RealEngine { /// The sending half of `thumb_rx` (cloned into every job). thumb_tx: Mutex>, proxy_runs: Vec, + /// 待启动的代理生成队列(entry id):自动代理不并发轰炸系统—— + /// 同时只跑 [`PROXY_MAX_CONCURRENT`] 个转码,完成后从队列递补。 + proxy_queue: Vec, /// Last waveform-cache version seen by the tick (background waveform /// extractions bump it on insert → repaint the timeline). waveform_version_seen: u64, @@ -1182,6 +1185,7 @@ impl RealEngine { thumb_rx: Mutex::new(thumb_rx), thumb_tx: Mutex::new(thumb_tx), proxy_runs: Vec::new(), + proxy_queue: Vec::new(), waveform_version_seen: 0, multicam_frames: Arc::new(Mutex::new(MulticamFrameCache::default())), multicam_rx: Mutex::new(multicam_rx), @@ -2140,11 +2144,31 @@ impl RealEngine { // generates. if !finished.is_empty() { self.invalidate_preview_frames(cx); + // 有转码完成:从队列递补下一个(并发上限可调,见 + // proxy_max_concurrent)。 + self.pump_proxy_queue(cx); } else if changed { cx.notify(); } } + /// 自动代理转码的并发上限(Proxy Settings 对话框可调,默认 1; + /// 不变式"并发 × 每任务线程 ≤ 核数一半"的线程侧见 + /// [`oak_task::proxy::ProxyTask::transcode_thread_budget`])。 + fn proxy_max_concurrent() -> usize { + oak_task::proxy::ProxyTask::proxy_max_concurrent() as usize + } + + /// 按并发上限从队列递补启动代理生成(队列去重由入队侧保证)。 + fn pump_proxy_queue(&mut self, cx: &mut Context) { + while self.proxy_runs.len() < Self::proxy_max_concurrent() && !self.proxy_queue.is_empty() { + let id = self.proxy_queue.remove(0); + if let Err(err) = self.proxy_generate(id, cx) { + println!("[real engine] queued proxy generation skipped {id}: {err}"); + } + } + } + /// Auto-start proxy generation in the background for every footage /// that needs one (设计:全局 UseProxyMedia 开启时素材代理在后台 /// 自动生成,不要求手动点 Generate;触发点:导入素材、打开工程)。 @@ -2193,13 +2217,14 @@ impl RealEngine { .map(|node| node.identity()) .collect() }; + // 入队 + 按并发上限递补(不一次全部启动:每个转码本身就是 + // 多线程 ffmpeg,N 个并发会把 CPU 打满)。 for id in candidates { - // proxy_generate 自己处理路径构建/任务线程/状态标记;失败 - // 只记日志(自动路径不打扰用户)。 - if let Err(err) = self.proxy_generate(id, cx) { - println!("[real engine] auto proxy generation skipped {id}: {err}"); + if !self.proxy_queue.contains(&id) { + self.proxy_queue.push(id); } } + self.pump_proxy_queue(cx); } @@ -4754,6 +4779,9 @@ impl AppEngine for RealEngine { let Some(footage) = self.footage_of(id) else { return; }; + // 删除代理 = 明确不要了:同时移出自动生成队列(否则递补时 + // 会立刻重新生成一个)。 + self.proxy_queue.retain(|&q| q != id); let proxy_path = { let Some(project) = self.project.as_ref() else { return; diff --git a/crates/oak-task/src/proxy.rs b/crates/oak-task/src/proxy.rs index a9b62cc47..b9a50c385 100644 --- a/crates/oak-task/src/proxy.rs +++ b/crates/oak-task/src/proxy.rs @@ -72,6 +72,22 @@ pub struct ProxyTask { 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 @@ -171,6 +187,188 @@ impl ProxyTask { 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_common::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). @@ -240,16 +438,31 @@ impl TaskBehavior for ProxyTask { let working_filename = format!("{}.working.mp4", self.output_filename); let _ = std::fs::remove_file(&working_filename); - let args = Self::build_arguments( + // 硬件加速(能用硬编就一定用;探测不到回退 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) @@ -352,6 +565,88 @@ fn probe_source_duration_seconds(ffmpeg_path: &str, source_filename: &str) -> f6 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.