新增「转码」分区与手动合并逐任务配置,修掉 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:
+168
-35
@@ -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) + `}`))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user