apps\hub\src\main\java\cn\nfarms\hub\HubViewModel.kt¶
源码路径:
apps\hub\src\main\java\cn\nfarms\hub\HubViewModel.kt· 223 行
package cn.nfarms.hub
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import cn.nfarms.core.secure.Crypto
import cn.nfarms.hub.engine.DownloadJob
import cn.nfarms.hub.engine.Ffmpeg
import cn.nfarms.hub.engine.FfmpegJob
import cn.nfarms.hub.engine.HarnessStartJob
import cn.nfarms.hub.engine.ModelCatalog
import cn.nfarms.hub.engine.ModelFile
import cn.nfarms.hub.task.TaskRecord
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class HubUi(
val tasks: List<TaskRecord> = emptyList(),
val models: List<ModelRow> = emptyList(),
val ffmpeg: FfmpegStatus = FfmpegStatus(),
val harness: HarnessUi = HarnessUi(),
val message: String? = null,
)
data class ModelRow(
val file: ModelFile,
val present: Boolean,
/** 已下载但校验和不符 —— 这个状态必须显眼,不能当成「有」 */
val corrupt: Boolean,
val bytes: Long,
)
data class FfmpegStatus(
val path: String? = null,
/** ffmpeg 是不是 GPL 构建(决定能不能用 libx264)—— 本轮固定 false,见 Ffmpeg 类注释 */
val gpl: Boolean = false,
)
data class HarnessUi(
val running: Boolean = false,
val real: Boolean = false,
val url: String? = null,
val port: Int = 7909,
val log: List<String> = emptyList(),
)
class HubViewModel(private val c: Container) : ViewModel() {
private val modelsDir = c.modelsDir
private val outDir = c.outDir
private val _ui = MutableStateFlow(HubUi())
val ui: StateFlow<HubUi> = _ui.asStateFlow()
init {
viewModelScope.launch {
c.scheduler.tasks.collect { list -> _ui.value = _ui.value.copy(tasks = list) }
}
refreshModels()
refreshFfmpeg()
viewModelScope.launch {
while (true) {
refreshHarness()
delay(1_000)
}
}
}
// ---------------------------------------------------------------- 模型
/**
* 界面上显示的下载目录。
*
* 从真实路径取,而不是写死字符串 —— 之前写的是
* `App files/nfarms/media/models`,而 `AppFiles.media("models")` 实际给出
* `files/nfarms/models`(media() 的语义是"在 data 目录下建一个子目录",
* 不是"media 子目录")。用户照着界面去找文件会找不到。
*/
fun modelsDirLabel(): String = "App files/" + modelsDir.path.removePrefix(c.filesDirPath() + "/")
fun refreshModels() {
viewModelScope.launch(Dispatchers.IO) {
val rows = ModelCatalog.sets.flatMap { set ->
set.files.map { f ->
val target = File(modelsDir, f.fileName)
val present = target.isFile
val ok = present && (f.sha256.isBlank() ||
Crypto.sha256File(target).equals(f.sha256, ignoreCase = true))
ModelRow(f, present && ok, present && !ok, if (present) target.length() else 0L)
}
}
withContext(Dispatchers.Main) { _ui.value = _ui.value.copy(models = rows) }
}
}
/**
* 下载一个权重。
*
* 注意那个 [holder]:`scheduler.submit` **在作业真正开始时才**把 id 交回来,
* 而下载完成的回调要用 id 去回填产物。用一个单元素数组占位,
* 提交后立刻填上 —— 打开回调时它一定已经有值了(作业跑完至少几百毫秒)。
* 这比「先随机生成 id 再传给调度器」简单,而且不会把 id 的生成规则漏到外面。
*/
fun download(f: ModelFile) {
val holder = arrayOfNulls<String>(1)
val id = c.scheduler.submit(
DownloadJob(f, modelsDir) { path, sha ->
holder[0]?.let { c.scheduler.attachArtifact(it, path, sha) }
refreshModels()
},
)
holder[0] = id
}
fun verifyAll() {
viewModelScope.launch(Dispatchers.IO) {
var bad = 0
for (set in ModelCatalog.sets) for (f in set.files) {
val target = File(modelsDir, f.fileName)
if (!target.isFile) continue
val actual = Crypto.sha256File(target)
if (f.sha256.isNotBlank() && !actual.equals(f.sha256, ignoreCase = true)) bad++
}
refreshModels()
withContext(Dispatchers.Main) {
_ui.value = _ui.value.copy(
message = if (bad == 0) "全部权重校验通过" else "$bad 个权重校验失败(已标红)",
)
}
}
}
fun deleteModel(f: ModelFile) {
viewModelScope.launch(Dispatchers.IO) {
File(modelsDir, f.fileName).delete()
refreshModels()
}
}
// ---------------------------------------------------------------- ffmpeg
fun refreshFfmpeg() {
val exe = Ffmpeg.locate(c.workDir)
_ui.value = _ui.value.copy(ffmpeg = _ui.value.ffmpeg.copy(path = exe?.absolutePath))
}
fun runPreset(kind: String, src: String) {
val exe = Ffmpeg.locate(c.workDir) ?: run {
_ui.value = _ui.value.copy(
message = "没找到 ffmpeg —— 放到 ${File(c.workDir, "bin/ffmpeg").absolutePath} 再刷新",
)
return
}
val ext = if (kind == "thumb") "jpg" else "mp4"
val out = File(outDir, "$kind-${System.currentTimeMillis()}.$ext")
val args = when (kind) {
"remux" -> Ffmpeg.presetRemux(src, out.absolutePath)
"audio" -> Ffmpeg.presetExtractAudio(src, out.absolutePath)
"thumb" -> Ffmpeg.presetThumbnail(src, out.absolutePath)
else -> Ffmpeg.presetTranscode(src, out.absolutePath)
}
c.scheduler.submit(
FfmpegJob(
exe = exe,
args = args,
outputPath = out.absolutePath,
title = "$kind · ${File(src).name}",
detail = "输出 ${out.name}",
totalUs = Ffmpeg.probeDurationUs(exe, src),
) { path -> _ui.value = _ui.value.copy(message = "完成:$path") },
)
}
// ---------------------------------------------------------------- harness
fun startHarness(port: Int) {
c.scheduler.submit(
HarnessStartJob(c.harness, port) {
refreshHarness()
},
)
}
fun stopHarness() {
c.harness.stop()
_ui.value = _ui.value.copy(harness = _ui.value.harness.copy(running = false, url = null))
}
private fun refreshHarness() {
val s = c.harness.current
val alive = s != null && s.process.isAlive
_ui.value = _ui.value.copy(
harness = _ui.value.harness.copy(
running = alive,
real = c.harness.isRealHarness(),
url = if (alive) (s?.url ?: _ui.value.harness.url) else null,
log = s?.snapshot()?.takeLast(60) ?: _ui.value.harness.log,
),
)
}
// ---------------------------------------------------------------- 杂项
fun cancel(id: String) = c.scheduler.cancel(id)
fun clearFinished() = c.scheduler.clearFinished()
fun dismissMessage() { _ui.value = _ui.value.copy(message = null) }
/** 造一个非媒体文件,用来验证「ffmpeg 失败时是否如实报错」。 */
fun makeSampleSource(): String {
val f = File(c.workDir, "sample.txt")
f.writeText("这不是媒体文件 —— 用它点转码会如实报 ffmpeg 的错,那也是一种验收。")
_ui.value = _ui.value.copy(message = "已放置测试文件:${f.absolutePath}")
return f.absolutePath
}
fun outDir(): File = outDir
}