crates\nfarms-core\src\ffmpeg.rs¶
源码路径:
crates\nfarms-core\src\ffmpeg.rs· 849 行
//! ffmpeg / ffprobe 集成 —— C# `Ffmpeg.cs` 的等价移植。
//!
//! 这是**三页共用的地基**:Tools › ffmpeg(转码/截图/媒体信息)、
//! Player › 视频播放器(抽帧)、Player › 拉流与解析(把 m3u8/rtmp/http 落到本地)。
//! 所以它先进 `nfarms-core`,界面上那三页都只调用它。
//!
//! 与原版的两处**有意**差异:
//!
//! 1. **参数用数组传,不用拼字符串**。原版自己写了个 `Q()` 去加引号转义,
//! 路径里带引号就废了;Rust 的 `Command::args` 天然不需要转义。
//! 2. **没有全局缓存**。原版用静态字段缓存探测结果(并在后台扫 PATH,
//! 因为环境变量里可能有断开的网络盘)。这里做成 [`Ffmpeg`] 值,
//! 由调用方决定放哪儿 —— 界面侧本来就把它跑在后台任务里。
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use crate::{Log, Paths};
/// 官方下载页(原版从 Browser.cs 里取,这里直接写出来)。
pub const HINT_URL: &str = "https://www.gyan.dev/ffmpeg/builds/";
// ---------------------------------------------------------------- 超时/收尾上限
//
// 这几个值以前是散在 6 个调用点里的裸字面量(`30_000` / `900_000` / …):
// 想知道"拉流到底给了多久"得翻到那一行,想统一调还得先 grep 出全部 6 处
// (而且很容易漏掉 `media_info` 里成对出现的那两个)。
// 收成具名常量后每个操作的预算一眼可见,调用点也只剩名字。
//
// 之所以不做成设置项:`Settings` 与设置页都在别的模块里,而目前没有任何用户
// 反馈要求这几个值可调;真要可调时把常量换成 `Settings` 字段即可,**调用点
// 一行都不用改** —— 这正是先收成常量的收益。
//
// 取值一律宁可给宽:拉流/转码被判超时会 kill 掉进程,前面白跑的进度全丢。
/// 媒体信息:`ffprobe` / `ffmpeg -i` 只读文件头,30 秒是给网络盘留的余量。
const TIMEOUT_MEDIA_INFO_MS: u64 = 30_000;
/// 抽帧截图:要从头解到目标位置,2 分钟;慢盘上的超大文件例外。
const TIMEOUT_SNAPSHOT_MS: u64 = 120_000;
/// 拉流第一遍 `-c copy`:只重封装、不重编码,15 分钟。
const TIMEOUT_PULL_COPY_MS: u64 = 900_000;
/// 拉流兜底转码(真要重编码 x264):比 copy 慢一个量级,给 30 分钟。
const TIMEOUT_PULL_TRANSCODE_MS: u64 = 1_800_000;
/// 本地转码为 mp4:一小时以上的素材很常见,给满 1 小时。
const TIMEOUT_TRANSCODE_MS: u64 = 3_600_000;
/// 读输出线程的收尾上限(毫秒)。
///
/// 子进程一退出管道写端就关了,`read_to_end` 立刻返回,正常情况几毫秒就够;
/// 这个上限只用来兜住"子进程又派生了进程、写端被孙子进程继承"的情形 ——
/// 那时管道不会关,**无上限的 `join()` 会让整个调用永久卡住**(而它跑在后台
/// 任务里,界面上只表现为一直转圈)。等不到就只拿已经读到的部分,不拖住调用方。
const OUTPUT_DRAIN_TIMEOUT_MS: u64 = 2_000;
/// 子进程执行结果(对应 C# `Ffmpeg.RunResult`)。
#[derive(Debug, Clone, Default)]
pub struct RunResult {
/// 0 = 成功;-1 = 超时被 kill;-2 = 起不来;-3 = 进程结束了但系统没给出退出码
pub code: i32,
pub out: String,
pub err: String,
}
impl RunResult {
pub fn ok(&self) -> bool {
self.code == 0
}
}
/// ffmpeg / ffprobe 的定位结果。
#[derive(Debug, Clone, Default)]
pub struct Ffmpeg {
pub dir: PathBuf,
pub exe: Option<PathBuf>,
pub probe: Option<PathBuf>,
}
impl Ffmpeg {
/// 找 ffmpeg。搜索顺序与原版一致:
/// `libs\ffmpeg\` → `libs\` → exe 所在目录 → `PATH`。
pub fn detect(paths: &Paths) -> Self {
let dir = paths.lib("ffmpeg");
let dirs = [dir.clone(), paths.libs(), paths.exe_dir().to_path_buf()];
let mut exe = None;
let mut probe = None;
for d in &dirs {
if exe.is_none() {
let f = d.join("ffmpeg.exe");
if f.is_file() {
exe = Some(f);
}
}
if probe.is_none() {
let f = d.join("ffprobe.exe");
if f.is_file() {
probe = Some(f);
}
}
}
// PATH 兜底。原版把这一步放后台线程(网络盘上的 File::exists 一次要几秒);
// 这里不缓存也不异步 —— 调用方(界面)本来就在后台任务里跑它,
// 而且只在本地目录都没找到时才会走到这里。
if exe.is_none() {
exe = find_on_path("ffmpeg.exe");
}
if probe.is_none() {
probe = find_on_path("ffprobe.exe");
}
Self { dir, exe, probe }
}
pub fn available(&self) -> bool {
self.exe.is_some()
}
pub fn probe_available(&self) -> bool {
self.probe.is_some()
}
/// 常见的误操作:把 FFmpeg 的**源码包**解到 `libs\ffmpeg`
/// (有 configure/Makefile 但没有 exe)。这种目录对程序完全没用,还占几百 MB。
///
/// **边界(以前没写)**:只检查 `libs\ffmpeg` 这一个目录,而且只要
/// `detect()` 的兜底命中过 —— `libs\`、程序目录或 `PATH` 上任意一处有
/// `ffmpeg.exe`(`available()` 为真)—— 就直接返回 `false`。也就是说源码包
/// 放进 `libs\` 或和 exe 混在一起时**认不出来**,这是已知的能力边界,
/// 界面文案(`status_text`)也只承诺 `libs\ffmpeg` 这一处。
/// 不顺手把候选目录都查一遍的原因:`Ffmpeg` 只保存首选目录 `self.dir`,
/// 其余候选是 `detect()` 里的局部变量,要覆盖就得改结构体与 `detect` 的
/// 语义(以及三页调用方的判断),为一个"用户按提示放文件就不会发生"的场景
/// 不值得;边界写清楚,真碰上了再加。
pub fn looks_like_source_package(&self) -> bool {
if self.available() || !self.dir.is_dir() {
return false;
}
self.dir.join("configure").exists()
|| self.dir.join("Makefile").exists()
|| self.dir.join("ffbuild").join("common.mak").exists()
}
pub fn status_text(&self) -> String {
if self.available() {
// **不用 unwrap**。原来这里写 `self.exe.as_ref().unwrap()`,
// 安全性的依据是「`available()` 等价于 `exe.is_some()`」这个
// **隐含约定** —— 代码里没有任何东西保证它。哪天 `available()`
// 换成别的判据(比如"目录里有文件就算"),这里立刻变成运行期 panic,
// 而 panic 点在 `status_text()` 这种"只是显示一行字"的函数里,
// 代价是整页崩掉。写成 `if let` 之后,与 `available()` 怎么实现无关。
let Some(exe) = &self.exe else {
return "ffmpeg 状态不一致:available() 为真但 exe 为空".to_string();
};
return format!(
"ffmpeg 可用: {}{}",
exe.display(),
if self.probe_available() {
"(含 ffprobe)"
} else {
"(无 ffprobe,媒体信息用 ffmpeg -i)"
}
);
}
if self.looks_like_source_package() {
return "libs\\ffmpeg 里放的是 FFmpeg 源码包(有 configure/Makefile,但没有 ffmpeg.exe):请换成 Windows 二进制压缩包,或点「清理」(会移到 bak\\)".to_string();
}
format!(
"ffmpeg 未安装:把 ffmpeg.exe(建议连 ffprobe.exe)放到 {} 即可启用拉流/解码/转码",
self.dir.display()
)
}
/// 把误放的源码/杂物移到 `bak\removed-ffmpeg-<时间戳>\` —— **不直接删用户文件**。
///
/// 返回移动了几项;**只要有一项没搬动就返回 `Err`**,消息里带上失败的文件名。
/// 以前是 `Ok(moved)`:一项都没搬走时界面显示"已移走 0 项",用户分不清
/// 这是"本来就没东西可搬"还是"全被占用、一项都没成功"。
pub fn clean_non_binary(&self, paths: &Paths, log: Option<&Log>) -> Result<usize, String> {
if !self.looks_like_source_package() {
return Err("libs\\ffmpeg 里没有发现源码包".to_string());
}
let stamp = crate::time::local_now()
.chars()
.filter(|c| c.is_ascii_digit())
.take(14)
.collect::<String>();
let dest = paths.bak().join(format!("removed-ffmpeg-{stamp}"));
std::fs::create_dir_all(&dest).map_err(|e| format!("建目录失败: {e}"))?;
let mut moved = 0usize;
// 搬不动的项以前是直接忽略(`if … .is_ok()`):用户看到"已移走 5 项",
// 不知道还有几项留在原地,更不知道要先去关掉占着它们的程序。
let mut failed: Vec<String> = Vec::new();
let entries = std::fs::read_dir(&self.dir).map_err(|e| format!("读目录失败: {e}"))?;
for e in entries.flatten() {
let name = e.file_name();
let lower = name.to_string_lossy().to_ascii_lowercase();
if lower == "ffmpeg.exe" || lower == "ffprobe.exe" {
continue;
}
let target = dest.join(&name);
match std::fs::rename(e.path(), &target) {
Ok(()) => moved += 1,
// 同卷 rename 的失败原因现实里只剩两种:文件被别的程序占着
// (资源管理器预览、正在跑的构建),或权限不足。
Err(_) => failed.push(name.to_string_lossy().into_owned()),
}
}
if let Some(l) = log {
let mut line = format!(
"已把 libs\\ffmpeg 里的非二进制内容移到 {}(共 {moved} 项)",
dest.display()
);
if !failed.is_empty() {
line.push_str(&format!(
";{} 项没搬动:{}",
failed.len(),
failed.join("、")
));
}
l.write("ffmpeg", &line);
}
move_report(moved, &failed)
}
/// 媒体信息。有 ffprobe 用它,没有就用 `ffmpeg -i`(它会往 stderr 打信息)。
pub fn media_info(&self, path: &Path) -> String {
let Some(exe) = &self.exe else {
return self.status_text();
};
// 网络地址没有 is_file 可言:同一张卡片上的说明写着"源文件可以是本地
// 文件,也可以是 http/m3u8/rtmp 地址",以前这里对任何输入都先要求
// `is_file()`,用户照着文案粘一个 m3u8 进来只会得到"文件不存在"。
// 本地路径仍然保留这个检查 —— 拼错路径时"文件不存在"比 ffmpeg 的
// 一长串报错有用得多。
if !input_is_remote(path) && !path.is_file() {
return format!("文件不存在: {}", path.display());
}
let r = if let Some(probe) = &self.probe {
run(
probe,
&[
"-v".into(),
"error".into(),
"-show_entries".into(),
"format=duration,size,bit_rate,format_name".into(),
"-show_entries".into(),
"stream=codec_type,codec_name,width,height,r_frame_rate".into(),
"-of".into(),
"default=noprint_wrappers=1".into(),
path.display().to_string(),
],
TIMEOUT_MEDIA_INFO_MS,
)
} else {
run(
exe,
&["-hide_banner".into(), "-i".into(), path.display().to_string()],
TIMEOUT_MEDIA_INFO_MS,
)
};
let text = format!("{}\n{}", r.out, r.err).trim().to_string();
if text.is_empty() {
return format!("没读到媒体信息(code={})", r.code);
}
clip_chars(&text, 1200)
}
/// 时长(毫秒),拿不到返回 0。
pub fn duration_ms(&self, path: &Path) -> i64 {
let info = self.media_info(path);
parse_duration_ms(&info).unwrap_or(0)
}
/// 拉流:把 http/m3u8/rtmp 流落到本地文件。优先 `-c copy`,失败再转码。
///
/// `progress` 用来把「正在做什么」报给界面 —— 拉流可能跑几分钟,
/// 界面上必须看得见阶段,而不是一个转圈。
pub fn pull_to_file(
&self,
url: &str,
out_file: &Path,
mut progress: Option<&mut dyn FnMut(&str)>,
) -> Result<(), String> {
let Some(exe) = &self.exe else {
return Err(self.status_text());
};
let _ = std::fs::remove_file(out_file);
if let Some(p) = progress.as_deref_mut() {
p("拉流(直接封装)…");
}
let mut r = run(
exe,
&[
"-y".into(),
"-hide_banner".into(),
"-loglevel".into(),
"warning".into(),
"-i".into(),
url.to_string(),
"-c".into(),
"copy".into(),
// MP4 容器里的 AAC 必须存成 ASC(带 AudioSpecificConfig),而 HLS
// 的音轨是 ADTS 帧;不加这个 bitstream filter,`-c copy` 要么直接报
// "Malformed AAC bitstream",要么产出一个播放器认不出音轨的文件。
// (C# 原版同样有这一行,只是没写理由。)
"-bsf:a".into(),
"aac_adtstoasc".into(),
out_file.display().to_string(),
],
TIMEOUT_PULL_COPY_MS,
);
let mut ok = r.ok() && file_big_enough(out_file, 1024);
if !ok {
if let Some(p) = progress {
p("直接封装不行,改为转码(耗时较长)…");
}
r = run(
exe,
&[
"-y".into(),
"-hide_banner".into(),
"-loglevel".into(),
"warning".into(),
"-i".into(),
url.to_string(),
"-c:v".into(),
"libx264".into(),
"-preset".into(),
"veryfast".into(),
"-crf".into(),
"23".into(),
"-c:a".into(),
"aac".into(),
"-b:a".into(),
"128k".into(),
out_file.display().to_string(),
],
TIMEOUT_PULL_TRANSCODE_MS,
);
ok = r.ok() && file_big_enough(out_file, 1024);
}
if !ok {
// 不带"拉流失败: "前缀:唯一调用方(界面 · 视频拉流与解析)自己会加,
// 两边都加的结果是用户看到"拉流失败: 拉流失败: …"。
return Err(long_op_failure_reason(&r));
}
Ok(())
}
/// 转码为播放器能播的 mp4(H.264 / AAC + faststart)。
pub fn transcode_to_mp4(
&self,
src: &Path,
out_file: &Path,
progress: Option<&mut dyn FnMut(&str)>,
) -> Result<(), String> {
let Some(exe) = &self.exe else {
return Err(self.status_text());
};
// 与 `media_info` 同理:文案允许 http/m3u8/rtmp,这里就不能只认本地文件。
if !input_is_remote(src) && !src.is_file() {
return Err(format!("文件不存在: {}", src.display()));
}
if let Some(p) = progress {
p("转码为 mp4(H.264/AAC)…");
}
let r = run(
exe,
&[
"-y".into(),
"-hide_banner".into(),
"-loglevel".into(),
"warning".into(),
"-i".into(),
src.display().to_string(),
"-c:v".into(),
"libx264".into(),
"-preset".into(),
"veryfast".into(),
"-crf".into(),
"23".into(),
"-c:a".into(),
"aac".into(),
"-b:a".into(),
"128k".into(),
"-movflags".into(),
"+faststart".into(),
out_file.display().to_string(),
],
TIMEOUT_TRANSCODE_MS,
);
if !r.ok() || !file_big_enough(out_file, 1024) {
// 同上:前缀("转码失败: ")由界面加,这里只说原因,避免出现两层前缀。
return Err(long_op_failure_reason(&r));
}
Ok(())
}
/// 抽帧截图(视频播放器做「跳转到某一秒」时就靠它)。
pub fn snapshot(&self, src: &Path, out_png: &Path, at_seconds: f64) -> Result<(), String> {
let Some(exe) = &self.exe else {
return Err(self.status_text());
};
let r = run(
exe,
&[
"-y".into(),
"-hide_banner".into(),
"-loglevel".into(),
"warning".into(),
"-ss".into(),
fmt_seconds(at_seconds),
"-i".into(),
src.display().to_string(),
"-frames:v".into(),
"1".into(),
out_png.display().to_string(),
],
TIMEOUT_SNAPSHOT_MS,
);
if !r.ok() || !out_png.is_file() {
// 两个调用方各加各的前缀(媒体工具页"截图失败: "、播放器"抽帧失败: "),
// 这里只说原因。
return Err(failure_reason(&r));
}
Ok(())
}
/// 一键安装指引(ffmpeg 没打包进软件:体积大且有许可考量)。
pub fn install_hint(&self) -> String {
format!(
"ffmpeg 没打包进软件(体积大且有许可考量)。请到 {HINT_URL}\r\n\r\n下载后把 ffmpeg.exe(建议连 ffprobe.exe)放进:\r\n{}\r\n\r\n放好后无需重启,点「检测内核」即可。",
self.dir.display()
)
}
}
/// 用系统资源管理器打开 ffmpeg 目录(不存在就建)。
pub fn open_dir(dir: &Path) {
let _ = std::fs::create_dir_all(dir);
#[cfg(windows)]
{
let _ = Command::new("explorer.exe").arg(dir).spawn();
}
// 非 Windows 原来整段是空操作:目录建了、界面毫无反应,用户只会以为
// "打开目录"这个按钮坏了。xdg-open(Linux)/open(macOS)有一个能用就行;
// 两个都没有(例如 Android)时仍是静默失败,但那种环境下本来也没有
// 可打开的桌面文件管理器 —— 至少 Linux/macOS 上这条路径现在通了。
#[cfg(not(windows))]
{
for opener in ["xdg-open", "open"] {
if Command::new(opener)
.arg(dir)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.is_ok()
{
break;
}
}
}
}
// ---------------------------------------------------------------- 内部
/// 汇总「清理源码包」的结果:全部搬走才算成功。
///
/// 以前无论搬走几项都返回 `Ok(moved)`,界面就显示"已移走 {n} 项到 bak\" ——
/// 一项都没搬走时显示的是"已移走 0 项",用户判断不出这是"本来就没东西可搬"
/// 还是"全被占用、一项都没成功"。现在把失败项原样报出来并给出下一步。
fn move_report(moved: usize, failed: &[String]) -> Result<usize, String> {
if failed.is_empty() {
return Ok(moved);
}
Err(format!(
"已移走 {moved} 项,但有 {} 项没能移走:{}(多半是被别的程序占用或权限不足:先关掉占着这些文件的程序,再点一次「清理源码包」)",
failed.len(),
failed.join("、")
))
}
/// 输入是不是"网络地址"而不是本地文件路径。
///
/// 只认 `协议://` 这一种形状:没有它的一律按本地路径处理,这样**拼错的本地
/// 路径仍然能得到"文件不存在"的提示**,而不是被当成 URL 直接塞给 ffmpeg
/// (那样用户只会看到一屏 ffmpeg 的报错)。
fn looks_like_url(s: &str) -> bool {
let s = s.trim();
let Some((scheme, rest)) = s.split_once("://") else {
return false;
};
!scheme.is_empty()
&& scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.')
&& !rest.is_empty()
}
/// `Path` 版的 [`looks_like_url`] —— 本模块的调用点手里都是 `PathBuf`
/// (界面把输入框的字符串直接 `PathBuf::from` 了),所以在这里统一转一次。
fn input_is_remote(p: &Path) -> bool {
looks_like_url(&p.to_string_lossy())
}
/// 把 `run()` 的结果变成一句给用户看的"为什么失败"。
///
/// **不带"××失败: "前缀**:三处调用方(媒体工具页、播放器、拉流页)各自都会加,
/// 两边都加的结果是用户看到"转码失败: 转码失败: …"。
/// ffmpeg 偶尔会失败但 stderr 是空的(例如进程被外部结束),那种情况以前会得到
/// 一句空错误,这里兜底成退出码 —— 至少让用户知道发生了什么。
fn failure_reason(r: &RunResult) -> String {
let detail = clip_chars(r.err.trim(), 300);
if detail.is_empty() {
return format!("ffmpeg 以退出码 {} 结束,且没有输出错误信息", r.code);
}
detail
}
/// 长耗时操作(拉流/转码)的失败原因:超时的时候补一句可能的原因。
///
/// 这两个操作动辄跑几分钟,用户看到"超时(3600000 ms)"时最需要知道的是
/// "是不是我这文件太大",而不是再去猜。截图/媒体信息不用这个函数 ——
/// 它们的超时跟文件大小关系不大。
fn long_op_failure_reason(r: &RunResult) -> String {
let reason = failure_reason(r);
if r.code == -1 {
return format!("{reason}\n常见原因:文件过大、网络太慢或源站没有响应。");
}
reason
}
/// 起一个线程把管道读干净,读完把字节经 channel 送回主线程。
///
/// 用 channel 而不是"线程 + `join()`":`join()` 没有超时,写端被孙子进程
/// 继承时会把调用方永久卡住(见 [`OUTPUT_DRAIN_TIMEOUT_MS`])。
fn collect_pipe<R>(mut p: R) -> std::sync::mpsc::Receiver<Vec<u8>>
where
R: std::io::Read + Send + 'static,
{
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut buf = Vec::new();
let _ = p.read_to_end(&mut buf);
// 主线程已经放弃等待时 Receiver 已被 drop,这里 send 必然失败:
// 忽略即可,线程本来就要结束了,没必要为"没人收"再报一次错。
let _ = tx.send(buf);
});
rx
}
/// 等到 `deadline` 为止收线程的结果;超时就返回空,把线程丢在那儿自己结束。
fn drain_bounded(rx: &std::sync::mpsc::Receiver<Vec<u8>>, deadline: Instant) -> Vec<u8> {
let left = deadline.saturating_duration_since(Instant::now());
rx.recv_timeout(left).unwrap_or_default()
}
/// 超时后结束子进程。Windows 上用 `taskkill /T /F` 连带**整棵进程树**。
///
/// `Child::kill()` 只结束直接子进程:`ffmpeg` 前面常常还套着一层 `cmd /c`
/// (或者播放器拉流时的封装脚本),只杀父进程会把真正在解码的那个留在后台
/// 继续吃 CPU、继续占着输出文件。taskkill 起不来或失败时退回 `Child::kill()`,
/// 保证兜底行为与改之前一致(至少能停住直接子进程)。
#[cfg(windows)]
fn kill_process_tree(child: &mut std::process::Child) {
use std::os::windows::process::CommandExt;
// 不给 taskkill 开控制台窗口:这是后台任务,弹黑框会吓人一跳。
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
let pid = child.id().to_string();
let ok = Command::new("taskkill")
.args(["/T", "/F", "/PID", &pid])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.creation_flags(CREATE_NO_WINDOW)
.status()
.map(|s| s.success())
.unwrap_or(false);
if !ok {
let _ = child.kill();
}
}
/// 非 Windows 没有 taskkill,也不值得为它引入 setsid/进程组:直接杀子进程。
#[cfg(not(windows))]
fn kill_process_tree(child: &mut std::process::Child) {
let _ = child.kill();
}
/// 超时时的错误文本。
///
/// **必须带上已经收到的 stderr**:原来这里写死 `format!("超时({timeout_ms} ms)")`,
/// 而上一行刚收到的 stderr 被直接丢掉 —— ffmpeg 的原因与进度都打在 stderr 上,
/// 丢掉之后用户只看到一句"超时",分不清是网络断了、文件坏了还是参数写错。
fn timeout_error(timeout_ms: u64, err_bytes: &[u8]) -> String {
let tail = clip_chars(&String::from_utf8_lossy(err_bytes), 300);
if tail.is_empty() {
format!("超时({timeout_ms} ms,进程已被结束)")
} else {
format!("超时({timeout_ms} ms,进程已被结束);最后输出:\n{tail}")
}
}
fn file_big_enough(p: &Path, min: u64) -> bool {
std::fs::metadata(p).map(|m| m.len() > min).unwrap_or(false)
}
fn clip_chars(s: &str, max: usize) -> String {
let s = s.trim();
if s.chars().count() <= max {
s.to_string()
} else {
s.chars().take(max).collect::<String>() + "…"
}
}
/// C# `atSeconds.ToString("0.###")` 的等价写法:最多三位小数,不补零。
fn fmt_seconds(v: f64) -> String {
let s = format!("{v:.3}");
let s = s.trim_end_matches('0').trim_end_matches('.');
if s.is_empty() { "0".to_string() } else { s.to_string() }
}
fn find_on_path(name: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path) {
if dir.as_os_str().is_empty() {
continue;
}
let cand = dir.join(name);
if cand.is_file() {
return Some(cand);
}
}
None
}
/// 跑一个子进程,带超时,分别收 stdout / stderr。
pub fn run(exe: &Path, args: &[String], timeout_ms: u64) -> RunResult {
let mut child = match Command::new(exe)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) => {
return RunResult {
code: -2,
out: String::new(),
// 带上 exe 路径:这个函数被三页 + wireshark 共用,只报 OS 错误时
// 看不出是哪个程序没起来(ffmpeg?ffprobe?dumpcap?)。
err: format!("无法启动 {}: {e}", exe.display()),
};
}
};
let h_out = child.stdout.take().map(collect_pipe);
let h_err = child.stderr.take().map(collect_pipe);
let deadline = Instant::now() + Duration::from_millis(timeout_ms);
let mut timed_out = false;
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => {
if Instant::now() >= deadline {
kill_process_tree(&mut child);
let _ = child.wait();
timed_out = true;
break;
}
std::thread::sleep(Duration::from_millis(20));
}
Err(_) => break,
}
}
// 有界收尾(原因见 `OUTPUT_DRAIN_TIMEOUT_MS`)。两个管道共用同一个截止时间,
// 所以最坏也只多等一个上限,而不是每个管道各等一次。
let drain_deadline = Instant::now() + Duration::from_millis(OUTPUT_DRAIN_TIMEOUT_MS);
let out_bytes = h_out
.map(|rx| drain_bounded(&rx, drain_deadline))
.unwrap_or_default();
let err_bytes = h_err
.map(|rx| drain_bounded(&rx, drain_deadline))
.unwrap_or_default();
if timed_out {
return RunResult {
code: -1,
out: String::from_utf8_lossy(&out_bytes).into_owned(),
err: timeout_error(timeout_ms, &err_bytes),
};
}
RunResult {
code: child.wait().map(|s| s.code().unwrap_or(-3)).unwrap_or(-3),
out: String::from_utf8_lossy(&out_bytes).into_owned(),
err: String::from_utf8_lossy(&err_bytes).into_owned(),
}
}
/// 从 `duration=12.345`(ffprobe)或 `Duration: 00:01:02.34`(ffmpeg -i)里取出毫秒数。
///
/// 手写解析而不是引 regex:只有两种固定形状,不值得多一个依赖。
pub fn parse_duration_ms(info: &str) -> Option<i64> {
for line in info.lines() {
let l = line.trim();
// ffprobe:duration=12.345
if let Some(rest) = l.strip_prefix("duration=")
&& let Ok(v) = rest.trim().parse::<f64>()
{
return Some((v * 1000.0) as i64);
}
// ffmpeg: Duration: 00:01:02.34, start: ...
if let Some(idx) = l.find("Duration:") {
let rest = l[idx + "Duration:".len()..].trim();
let token = rest.split(',').next().unwrap_or("").trim();
if let Some(ms) = parse_hhmmss(token) {
return Some(ms);
}
}
}
None
}
fn parse_hhmmss(s: &str) -> Option<i64> {
let parts: Vec<&str> = s.split(':').collect();
if parts.len() != 3 {
return None;
}
let h: i64 = parts[0].trim().parse().ok()?;
let m: i64 = parts[1].trim().parse().ok()?;
let sec: f64 = parts[2].trim().parse().ok()?;
Some((h * 3600 + m * 60) * 1000 + (sec * 1000.0) as i64)
}
#[cfg(test)]
mod tests {
use super::*;
fn paths_in(dir: &Path) -> Paths {
Paths::from_exe_dir(dir)
}
#[test]
fn missing_ffmpeg_says_how_to_install() {
let dir = std::env::temp_dir().join("nfarms-ffmpeg-test-none");
let _ = std::fs::create_dir_all(&dir);
let ff = Ffmpeg::detect(&paths_in(&dir));
// 这台机器的 PATH 上可能真有 ffmpeg,所以只断言两件事之一
if !ff.available() {
assert!(ff.status_text().contains("未安装"));
assert!(ff.install_hint().contains("ffmpeg.exe"));
}
}
#[test]
fn source_package_is_recognised_and_cleaned_without_deleting_the_exe() {
let dir = std::env::temp_dir().join("nfarms-ffmpeg-test-src");
let _ = std::fs::remove_dir_all(&dir);
let libs = dir.join("libs").join("ffmpeg");
std::fs::create_dir_all(libs.join("ffbuild")).unwrap();
std::fs::write(libs.join("configure"), b"# configure").unwrap();
std::fs::write(libs.join("ffbuild").join("common.mak"), b"mak").unwrap();
std::fs::write(libs.join("ffmpeg.exe"), b"fake exe").unwrap();
let paths = paths_in(&dir);
let ff = Ffmpeg::detect(&paths);
// 有 ffmpeg.exe,所以「可用」,不该被判成源码包
assert!(ff.available(), "本地目录里有 exe 就该认出来");
assert!(!ff.looks_like_source_package());
// 把 exe 删掉,这时才该被判成源码包
std::fs::remove_file(libs.join("ffmpeg.exe")).unwrap();
let ff2 = Ffmpeg::detect(&paths);
if !ff2.available() {
assert!(ff2.looks_like_source_package(), "有 configure 就是源码包");
let moved = ff2.clean_non_binary(&paths, None).unwrap();
assert!(moved >= 1, "至少该把 configure 挪走");
assert!(!libs.join("configure").exists(), "configure 应该已被移走");
// 挪到 bak\ 下,而不是删掉
let bak = paths.bak();
let found = std::fs::read_dir(&bak)
.map(|it| {
it.flatten().any(|e| {
e.file_name()
.to_string_lossy()
.starts_with("removed-ffmpeg-")
})
})
.unwrap_or(false);
assert!(found, "应该挪到 bak\\removed-ffmpeg-* 下");
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn duration_parsing_handles_both_shapes() {
assert_eq!(parse_duration_ms("duration=12.345\n"), Some(12345));
assert_eq!(
parse_duration_ms(" Duration: 00:01:02.34, start: 0.000000, bitrate: 100 kb/s"),
Some(62340)
);
assert_eq!(parse_duration_ms("no duration here"), None);
}
#[test]
fn seconds_formatting_matches_csharp_0_dot_hash() {
assert_eq!(fmt_seconds(0.0), "0");
assert_eq!(fmt_seconds(1.5), "1.5");
assert_eq!(fmt_seconds(12.3456), "12.346");
assert_eq!(fmt_seconds(3.0), "3");
}
#[test]
fn run_captures_output_and_reports_exit_code() {
// 用 cmd 当被测进程:它一定存在,输出也确定
let comspec = std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string());
let r = run(
Path::new(&comspec),
&["/c".into(), "echo nfarms-ffmpeg-probe".into()],
10_000,
);
assert!(r.ok(), "cmd /c echo 应该成功:{:?}", r);
assert!(r.out.contains("nfarms-ffmpeg-probe"));
}
#[test]
fn run_reports_timeout_instead_of_hanging() {
let comspec = std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string());
// ping 一个不可达地址并等很久;200ms 超时应该把它 kill 掉
let r = run(
Path::new(&comspec),
&["/c".into(), "ping -n 30 127.0.0.1 > nul".into()],
400,
);
assert_eq!(r.code, -1, "超时要报 -1,而不是一直等");
assert!(r.err.contains("超时"));
}
}