新增「转码」分区与手动合并逐任务配置,修掉 PC 端图表文字被拉扁

一、PC 端图表文字被拉伸(bug)
.spark 折线图把 viewBox 写死成 "0 0 300 48" 又带 preserveAspectRatio="none":
移动端元素宽≈300px 时缩放≈1 看不出问题,PC 上卡片宽约 1030px,横轴被放大 3.4 倍
而纵轴仍是 48px,图里的 <text> 被整体拉扁。现在按元素实际宽度算 viewBox 宽度
(取不到时回落 300),并监听 resize 重画;preserveAspectRatio 保留不影响 1:1 显示。

二、新增「转码」分区(transcode.go,第七个 tab)
原来只有「录播 + 弹幕 → 成片」一条路,已有成片想压小、换 HEVC、降分辨率只能手搓命令行。
现在从文件列表多选加入,每个文件一行独立配置:编码器、码率控制(跟随源片 / 目标码率 /
质量 CRF·ICQ / 源片百分比)、编码速度、输出帧率、分辨率(只列不高于源片的档)、
音频(拷贝 / 128k / 192k / 去掉),并实时预估产物大小与整批合计。
 - POST /api/transcode/probe | estimate | transcode,都在既有的口令鉴权之后
 - 目标码率模式按「码率 × 时长」出准数;质量模式用单调系数表插值并标注是粗估
 - 落盘沿用合并那套:先在源文件所在目录生成,完成后再 placeOutput 到输出目录,
   失败或中断删临时文件,不留半成品
 - 与合并共用同一张进度卡片和任务位(mergeSlot),串行排队,可暂停 / 停止

三、手动合并改成「一次排多个任务」
原来一次只能合一场。现在多选视频后按「文件名里的录制时间 + 主体名」自动归组
(相邻两段间隔 2 小时内算同一场),每组一个任务卡,各自配弹幕、偏移、输出名和
合成参数(码率 / 帧率 / 字号 / 编码器),可拆组、可移除;服务端串行跑,逐个回传结果。
请求体从 {videos,danmu,...} 扩成 {jobs:[{...}]},老的单任务写法继续可用。

四、审阅中一并修掉 / 加固的点
 - 容器总码率含音频,直接当视频码率用会错两处:预估体积时又加一遍音轨(算成双份),
   「跟随源片」时把音轨码率也编进视频里。现在分开记「视频流码率」与「容器总码率」,
   转码侧按「视频流码率 → 容器码率 − 音轨 → 体积 ÷ 时长 − 音轨」三级取值;
   合并的自动码率仍按老口径(容器含音频),单独用 streamOrContainerKbps 锁住
 - 源片本来没有音轨时,预估不再凭空加 128 kbps 音频
 - 目标码率加上界(200 Mbps),单位写错的数在开跑前就被挡住(合并的逐任务码率同理)
 - 探测结果加缓存(ffmpeg + 路径 + 体积 + 修改时间),体积估算跟着输入框重发时不再
   每次都起一个 ffmpeg;文件一变(录制中追加、换了文件)缓存自然失效
 - 只有音轨 / 只有封面图的文件、以及正在写入的文件,排队时就被挡住并说清原因
 - probeMedia / parseMediaInfo 合并了原先重复的两套解析(probeDuration 与
   probeVideoStream 各起一次 ffmpeg 解析同一段输出),burnDanmu 现在一次探测拿全
 - 前端:估算参数非法时把服务端给的原因显示在合计行(原来静默不动,像是没改成功);
   估算请求加序号,慢的旧回包不再覆盖新结果;转码下拉刷新时保留已勾选项;
   跑完的行标「完成 / 失败」并淡化;删掉没有对应元素、从未使用的 CSS 规则

验证
- gofmt / go vet 干净;go build(windows)与 GOOS=linux go build 均通过
- go test ./... 20.9s 全过;go test -race ./... 26.1s 全过(182 → 206 个用例,
  transcode_test.go 新增 20 个)
- 反向验证 C:\OtherFiles\acf-test\reverse_check.py 从 37 项加到 43 项,全过
- 51899 隔离实例 + 真 ffmpeg 冒烟 29 项全过:401 鉴权、probe 的 hasVideo/hasAudio、
  ts 容器(不带每流码率)探测到的视频码率 = 容器 429 − 音轨 127、
  HEVC 按 0.65 折算、参数非法 400 带原因、只有音轨的文件排队即被拒、
  输出目录不存在被拒、两任务队列真转成功并落到指定目录、缩放成片高度确实是 240、
  日志里带「第 n/N 个」和参数摘要
- 页面 jsdom 冒烟 37 项全过、无 JS 报错(分组 / 拆组 / 逐任务参数 / 转码行渲染 /
  跳过无视频轨文件 / 估算失败提示 / 结果徽标)
This commit is contained in:
tamakyi
2026-09-27 10:36:29 +08:00
parent 3e16e78003
commit a409c5b4dd
11 changed files with 2724 additions and 267 deletions
+168 -35
View File
@@ -27,13 +27,39 @@ var mergeVideoExts = map[string]struct{}{
var mergeDanmuExts = map[string]struct{}{".jsonl": {}, ".ass": {}}
// 手动合并的请求体
//
// 两种写法:只填 videos/danmu 是单个任务(老样子);填 jobs 是一次排多个任务,
// 每个任务自带视频、弹幕和合成参数,服务端按顺序串行合并
type mergeRequest struct {
Videos []string `json:"videos"` // 视频文件,可多个(断流分段):文件名或绝对/相对路径
Danmu string `json:"danmu"` // 弹幕文件:.jsonl 直接用,.ass / .nick.ass 先还原成 jsonl
Offset *float64 `json:"offset"` // 手动指定的偏移(秒),不填则按文件名里的录制时间推算
OutName string `json:"outName"` // 输出文件名,留空用默认名且自动避开重名
Options mergeOptions `json:"options"` // 这一次合成的参数,不填的项用全局设置
Jobs []mergeJobReq `json:"jobs"` // 批量:每个任务一套自己的文件和参数
}
// 批量里的一个合并任务
type mergeJobReq struct {
Videos []string `json:"videos"` // 这一组的视频,多个就是同一场的断流分段,会先拼起来
Danmu string `json:"danmu"` // 这一组的弹幕文件
Offset *float64 `json:"offset"` // 这一组的偏移
OutName string `json:"outName"` // 这一组的输出文件名
Options mergeOptions `json:"options"` // 这一组的合成参数
}
// 一次最多排多少个合并任务。再多就得等太久,队列状态也会一直占着
const maxMergeJobs = 50
// 批量任务里每个任务的结果,页面按它显示「第几个、成功还是失败」
type mergeJobResult struct {
OutName string `json:"outName"`
Err string `json:"err"` // 空表示成功
}
func (r mergeJobResult) ok() bool { return r.Err == "" }
// 检查结果里每个视频的信息
type mergeVideoInfo struct {
Name string `json:"name"`
@@ -65,6 +91,7 @@ type mergePlan struct {
out string // 成片的绝对路径
meta danmuMeta
offset *float64
opts mergeOptions // 这一组自己的合成参数
info mergeInfo
}
@@ -75,6 +102,10 @@ var mergeRun struct {
outName string
lastErr string
doneAt int64
// 批量合并的进度:一共几个、已经做完几个、每个的结果
total int
done int
results []mergeJobResult
}
// 手动合并是否正在进行
@@ -84,6 +115,15 @@ func mergeRunSnapshot() (busy bool, out string, lastErr string, doneAt int64) {
return mergeRun.busy, mergeRun.outName, mergeRun.lastErr, mergeRun.doneAt
}
// 批量合并的队列进度,页面用它显示「第 2/3 个」和各任务的结果
func mergeQueueSnapshot() (busy bool, done, total int, results []mergeJobResult) {
mergeRun.Lock()
defer mergeRun.Unlock()
out := make([]mergeJobResult, len(mergeRun.results))
copy(out, mergeRun.results)
return mergeRun.busy, mergeRun.done, mergeRun.total, out
}
// 解析用户填的路径:可以先当文件名在录播/成片文件夹里找,也可以填绝对或相对路径
func resolveMergeInput(value string) (string, error) {
value = strings.TrimSpace(value)
@@ -359,9 +399,44 @@ func freeOutPath(dir, base string) string {
return cand
}
// 把一次请求拆成若干个合并任务并逐个检查。全部检查通过才返回:
// 排到第 3 个才发现文件不对、前两个已经合完了,这种情况很难跟用户解释
func buildMergePlans(req mergeRequest) ([]mergePlan, error) {
if len(req.Jobs) == 0 {
plan, err := buildMergePlan(req)
if err != nil {
return nil, err
}
return []mergePlan{plan}, nil
}
if len(req.Jobs) > maxMergeJobs {
return nil, fmt.Errorf("一次最多排 %d 个合并任务,这次有 %d 个", maxMergeJobs, len(req.Jobs))
}
plans := make([]mergePlan, 0, len(req.Jobs))
for i, job := range req.Jobs {
plan, err := buildMergePlan(mergeRequest{
Videos: job.Videos,
Danmu: job.Danmu,
Offset: job.Offset,
OutName: job.OutName,
Options: job.Options,
})
if err != nil {
return nil, fmt.Errorf("第 %d 个任务:%v", i+1, err)
}
plans = append(plans, plan)
}
return plans, nil
}
// 解析请求、检查文件并算出合并参数
func buildMergePlan(req mergeRequest) (mergePlan, error) {
var plan mergePlan
ffmpegFile := getFFmpeg()
plan.opts = req.Options
if msg := req.Options.validate(ffmpegFile); msg != "" {
return plan, errors.New(msg)
}
// 视频:一行一个,允许夹带空行
var values []string
@@ -431,7 +506,6 @@ func buildMergePlan(req mergeRequest) (mergePlan, error) {
DanmuHasAvatar: src.hasAvatar,
Source: src.source,
}
ffmpegFile := getFFmpeg()
for _, f := range plan.videos {
fi, err := os.Stat(f)
if err != nil {
@@ -489,8 +563,9 @@ func buildMergePlan(req mergeRequest) (mergePlan, error) {
return plan, nil
}
// 真正执行合并,进度既打到运行日志里,也同步给 web UI 的任务卡片
func runManualMerge(plan mergePlan) error {
// 真正执行合并,进度既打到运行日志里,也同步给 web UI 的任务卡片。
// idx / total 是批量队列里的位置(单任务传 1, 1),只用来在日志和界面上标「第几个」
func runManualMerge(plan mergePlan, idx, total int) error {
ffmpegFile := getFFmpeg()
if ffmpegFile == "" {
return errors.New("找不到 ffmpeg,请先在设置里填写 ffmpeg 路径")
@@ -501,8 +576,11 @@ func runManualMerge(plan mergePlan) error {
for _, v := range plan.info.Videos {
inputBytes += v.Size
}
if !jobStart("手动合并", plan.info.OutName, inputBytes, plan.info.Duration) {
return errors.New("已经有别的合并任务在跑,请先等它结束")
if !jobStart(jobKindManualMerge, plan.info.OutName, inputBytes, plan.info.Duration) {
return errJobNotStarted
}
if idx > 0 && total > 0 {
jobQueue(idx, total)
}
src, err := prepareDanmuSource(plan.danmu, true)
@@ -561,13 +639,18 @@ func runManualMerge(plan mergePlan) error {
// 成片目录可能是网络盘或外接盘,直接往里写、写到一半失败会在那里留个半成品
tmpOut := tempMergeOutput(plan.danmu, filepath.Base(plan.out))
lPrintf("手动合并开始:视频 %s,弹幕 %s,偏移 %.3f 秒(%s)",
filepath.Base(video), filepath.Base(jsonl), offset, note)
// 批量合并时在日志里标出这是第几个,翻日志的时候对得上号
tag := ""
if total > 1 {
tag = fmt.Sprintf("(第 %d/%d 个)", idx, total)
}
lPrintf("手动合并开始%s:视频 %s,弹幕 %s,偏移 %.3f 秒(%s)",
tag, filepath.Base(video), filepath.Base(jsonl), offset, note)
lPrintf("先在本地合成 %s,完成后落到 %s", filepath.Base(tmpOut), plan.out)
// 标记成正在写入,避免在文件管理里把还没写完的成片删掉
markFileActive(tmpOut, true)
burnErr := burnDanmu(jsonl, video, tmpOut, offset)
burnErr := burnDanmu(jsonl, video, tmpOut, offset, plan.opts)
markFileActive(tmpOut, false)
if burnErr != nil {
_ = os.Remove(tmpOut)
@@ -579,7 +662,7 @@ func runManualMerge(plan mergePlan) error {
return err
}
lPrintln("手动合并完成:" + plan.out)
lPrintln("手动合并完成" + tag + ":" + plan.out)
// 通知:手动合并没有 uid,能从文件名对上哪个主播就带上名字,对不上就用通用文案
uid := guessUIDByFileName(plan.out)
if uid == 0 {
@@ -589,24 +672,38 @@ func runManualMerge(plan mergePlan) error {
if uid > 0 {
text = longID(uid) + " 的录播和弹幕(手动合并)完成\n" + plan.out
}
if total > 1 {
text += fmt.Sprintf("\n(第 %d/%d 个)", idx, total)
}
sendNotify(notifyEventMergeDone, uid, nameForLog(uid), "", "", plan.out, text)
return nil
}
// 检查合并参数,回显时长、弹幕条数和推测的偏移,不做实际合成
// 检查合并参数,回显时长、弹幕条数和推测的偏移,不做实际合成。
// 请求里带 jobs 时一次检查多个任务,返回 {jobs:[...]};单个任务仍直接返回 info
func mergeInfoHandler(w http.ResponseWriter, r *http.Request) {
var req mergeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest)
return
}
plan, err := buildMergePlan(req)
plans, err := buildMergePlans(req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(plan.info); err != nil {
if len(req.Jobs) == 0 {
if err := json.NewEncoder(w).Encode(plans[0].info); err != nil {
lPrintErrf("输出合并检查结果失败:%v", err)
}
return
}
infos := make([]mergeInfo, 0, len(plans))
for _, p := range plans {
infos = append(infos, p.info)
}
if err := json.NewEncoder(w).Encode(map[string]any{"jobs": infos}); err != nil {
lPrintErrf("输出合并检查结果失败:%v", err)
}
}
@@ -618,7 +715,7 @@ func mergeHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest)
return
}
plan, err := buildMergePlan(req)
plans, err := buildMergePlans(req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
@@ -642,10 +739,14 @@ func mergeHandler(w http.ResponseWriter, r *http.Request) {
return
}
mergeRun.busy = true
mergeRun.outName = plan.info.OutName
mergeRun.outName = plans[0].info.OutName
mergeRun.lastErr = ""
mergeRun.total = len(plans)
mergeRun.done = 0
mergeRun.results = nil
mergeRun.Unlock()
total := len(plans)
safeGo("手动合并", func() {
defer releaseMergeSlot()
defer func() {
@@ -654,33 +755,65 @@ func mergeHandler(w http.ResponseWriter, r *http.Request) {
mergeRun.doneAt = time.Now().Unix()
mergeRun.Unlock()
}()
err := runManualMerge(plan)
// 任务卡片挂在这条链路上,无论成败都要收尾,否则进度会一直留在界面上
jobFinish(jobErrText(err))
if err == nil {
return
}
msg := err.Error()
switch err {
case errNoDanmu:
msg = "弹幕文件里没有弹幕"
lPrintWarn("弹幕文件里没有弹幕,取消手动合并")
case errJobStopped:
// 手动停止不是失败,别让界面弹「合并失败」
lPrintWarn("手动合并已被手动停止")
stopped := false
for i, plan := range plans {
mergeRun.Lock()
mergeRun.lastErr = ""
mergeRun.outName = plan.info.OutName
mergeRun.done = i
mergeRun.Unlock()
return
default:
lPrintErrf("手动合并失败:%v", err)
notifyMergeFailure(guessUIDByFileName(plan.out), plan.out, err)
err := runManualMerge(plan, i+1, total)
// 任务卡片挂在这条链路上,无论成败都要收尾,否则进度会一直留在界面上。
// 只有真的起了任务才收尾:没起成说明占着卡片的是别的任务,别把人家的进度清掉
if err != errJobNotStarted {
jobFinish(jobErrText(err))
}
if err == nil {
mergeRun.Lock()
mergeRun.results = append(mergeRun.results, mergeJobResult{OutName: plan.info.OutName})
mergeRun.Unlock()
continue
}
msg := err.Error()
switch err {
case errNoDanmu:
msg = "弹幕文件里没有弹幕"
lPrintWarn("弹幕文件里没有弹幕,跳过这个任务")
case errJobStopped:
// 手动停止不是失败,别让界面弹「合并失败」;排在后面的任务也不再接着做
lPrintWarn("手动合并已被手动停止,剩下的任务不再继续")
stopped = true
default:
lPrintErrf("手动合并失败:%v", err)
notifyMergeFailure(guessUIDByFileName(plan.out), plan.out, err)
}
mergeRun.Lock()
mergeRun.results = append(mergeRun.results, mergeJobResult{OutName: plan.info.OutName, Err: msg})
mergeRun.lastErr = msg
mergeRun.Unlock()
if stopped {
break
}
}
mergeRun.Lock()
mergeRun.lastErr = msg
mergeRun.done = len(mergeRun.results)
mergeRun.Unlock()
if total > 1 {
ok, failed := 0, 0
mergeRun.Lock()
for _, r := range mergeRun.results {
if r.ok() {
ok++
} else {
failed++
}
}
mergeRun.Unlock()
lPrintf("手动合并队列结束:共 %d 个,成功 %d 个,失败 %d 个", total, ok, failed)
}
})
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"outName":` + strconv.Quote(plan.info.OutName) + `}`))
_, _ = w.Write([]byte(`{"count":` + strconv.Itoa(total) +
`,"outName":` + strconv.Quote(plans[0].info.OutName) + `}`))
}