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)
}
}