跳转至

apps\hub\src\main\java\cn\nfarms\hub\engine\Ffmpeg.kt

源码路径:apps\hub\src\main\java\cn\nfarms\hub\engine\Ffmpeg.kt · 156 行

package cn.nfarms.hub.engine

import cn.nfarms.hub.task.HubJob
import cn.nfarms.hub.task.JobCtl
import java.io.File
import java.util.concurrent.TimeUnit

/**
 * FFmpeg 封装。
 *
 * # 三条边界(都是这一版刻意划的)
 *
 * 1. **只补 Media3 缺的格式**。播放走 Media3/ExoPlayer;HUB 里的 ffmpeg 只做
 *    「转码、抽帧、合流、抽音轨」这些播放器不做的事。两套解码并存不是浪费,
 *    是因为 ExoPlayer 不转码。
 * 2. **合规**:ffmpeg 的默认构建是 **LGPL**(不启用 `--enable-gpl`)。要用 libx264
 *    就得 GPL —— 那会把整个 APK 拖进 GPL。所以这里**默认只用 LGPL 可用的
 *    编码器**(mpeg4 / aac),要 x264 得换构建并接受 GPL,这个选择**写进界面**。
 * 3. **不内置二进制**。APK 里塞一份 60 MB 的 ffmpeg 只为了让「演示」能跑,
 *    不是这个 App 该做的事;这里按「有没有可执行文件」如实报状态,
 *    没有就说「未安装 + 怎么装」。
 *
 * # 为什么用 `-progress` 而不是解析 stderr
 *
 * stderr 里的 `frame= ... time=00:00:03.20` 是给人看的,格式随版本变;
 * `-progress pipe:1` 是**稳定契约**(`out_time_us=...`、`progress=end`),
 * 解析它比正则匹 stderr 可靠得多。
 */
object Ffmpeg {

    /** 可执行文件候选位置:先看 App 私有目录(用户自己推上去的),再看系统 PATH。 */
    fun locate(workDir: File): File? {
        val local = File(workDir, "bin/ffmpeg")
        if (local.canExecute()) return local
        return sequenceOf("ffmpeg", "/system/bin/ffmpeg", "/data/local/tmp/ffmpeg")
            .map { File(it) }
            .firstOrNull { it.canExecute() }
    }

    /**
     * 跑一次 ffmpeg,把进度折算成 0..1。
     *
     * @param totalUs 预期总时长(微秒)。给 `null` 时进度不可计量,只报 indeterminate。
     */
    fun run(
        exe: File,
        args: List<String>,
        totalUs: Long?,
        ctl: JobCtl,
    ): Int {
        val cmd = mutableListOf(exe.absolutePath, "-hide_banner", "-y", "-progress", "pipe:1")
        cmd += args
        val pb = ProcessBuilder(cmd)
            .redirectErrorStream(false)
            .start()

        // stderr 单独收:出错时那就是唯一线索,不能被丢掉
        val errTail = ArrayDeque<String>()
        val errThread = Thread {
            pb.errorStream.bufferedReader().forEachLine { line ->
                synchronized(errTail) {
                    errTail.addLast(line)
                    if (errTail.size > 40) errTail.removeFirst()
                }
            }
        }.apply { isDaemon = true; start() }

        // stdout 是 `-progress` 的键值流
        pb.inputStream.bufferedReader().forEachLine { line ->
            if (ctl.isCancelled) {
                pb.destroy()
            }
            when {
                line.startsWith("out_time_us=") && totalUs != null && totalUs > 0 -> {
                    val us = line.substringAfter('=').toLongOrNull() ?: return@forEachLine
                    ctl.progress((us.toFloat() / totalUs).coerceIn(0f, 1f), "转码中")
                }
                line == "progress=end" -> ctl.progress(1f, "收尾中")
            }
        }

        val ok = pb.waitFor(60, TimeUnit.MINUTES)
        if (!ok) {
            pb.destroyForcibly()
            throw IllegalStateException("ffmpeg 超时(60 分钟)")
        }
        errThread.join(2_000)

        if (ctl.isCancelled) {
            throw cn.nfarms.hub.task.CancelledException()
        }
        val code = pb.exitValue()
        if (code != 0) {
            val tail = synchronized(errTail) { errTail.joinToString("\n") }
            throw IllegalStateException("ffmpeg 退出码 $code\n$tail")
        }
        return code
    }

    /** 常见操作的参数模板 —— 界面上的预设按钮直接取这里,免得 UI 里拼字符串。 */
    fun presetRemux(src: String, dst: String) = listOf("-i", src, "-c", "copy", dst)

    fun presetExtractAudio(src: String, dst: String) =
        listOf("-i", src, "-vn", "-c:a", "aac", "-b:a", "128k", dst)

    /**
     * 抽帧。**默认用 mpeg4** 而不是 libx264:见类注释第 2 条(LGPL/GPL)。
     */
    fun presetThumbnail(src: String, dst: String, atSeconds: Int = 1) = listOf(
        "-ss", atSeconds.toString(), "-i", src, "-frames:v", "1",
        "-c:v", "mpeg4", "-q:v", "4", dst,
    )

    fun presetTranscode(src: String, dst: String) = listOf(
        "-i", src, "-c:v", "mpeg4", "-q:v", "5", "-c:a", "aac", "-b:a", "128k", dst,
    )

    /** 从媒体文件读时长(微秒):ffprobe 有就用,没有就返回 null。 */
    fun probeDurationUs(exe: File, src: String): Long? {
        val probe = File(exe.parentFile, "ffprobe")
        val bin = if (probe.canExecute()) probe else {
            locate(exe.parentFile ?: File("."))?.let { File(it.parentFile, "ffprobe") }
        } ?: return null
        if (!bin.canExecute()) return null
        return runCatching {
            val p = ProcessBuilder(
                bin.absolutePath, "-v", "error", "-show_entries", "format=duration",
                "-of", "default=nw=1:nk=1", src,
            ).redirectErrorStream(true).start()
            val text = p.inputStream.bufferedReader().readText().trim()
            p.waitFor(20, TimeUnit.SECONDS)
            (text.toDoubleOrNull()?.times(1_000_000))?.toLong()
        }.getOrNull()
    }
}

/** 一次 ffmpeg 调用 = 一个 HUB 任务。 */
class FfmpegJob(
    private val exe: File,
    private val args: List<String>,
    private val outputPath: String,
    override val title: String,
    override val detail: String,
    private val totalUs: Long?,
    private val onArtifact: (path: String) -> Unit,
) : HubJob {
    override val kind: String = "ffmpeg"

    override fun run(ctl: JobCtl) {
        Ffmpeg.run(exe, args, totalUs, ctl)
        val out = File(outputPath)
        if (!out.isFile) throw IllegalStateException("ffmpeg 报成功,但产物不存在:${out.absolutePath}")
        onArtifact(out.absolutePath)
    }
}