跳转至

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("超时"));
    }
}