新增「转码」分区与手动合并逐任务配置,修掉 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:
+125
-1
@@ -68,7 +68,7 @@ Dialogue: 0,0:00:03.50,0:00:13.50,Danmu,丙(303),20,20,2,,{\move(400,24,-40,24)}
|
||||
// (正是它的职责),测试就会莫名其妙失败
|
||||
func execManualMerge(plan mergePlan) error {
|
||||
jobFinish("")
|
||||
err := runMergeExclusive(func() error { return runManualMerge(plan) })
|
||||
err := runMergeExclusive(func() error { return runManualMerge(plan, 1, 1) })
|
||||
jobFinish(jobErrText(err))
|
||||
return err
|
||||
}
|
||||
@@ -844,3 +844,127 @@ func TestFindDanmuForVideo(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 一次排多个合并任务 ====================
|
||||
|
||||
// 造一组能过检查的「视频 + 弹幕」:视频随便写点字节,弹幕用真能解析的 jsonl
|
||||
// 文件要落在录播目录里,因为请求里只带文件名,resolveMergeInput 按录播目录找
|
||||
func writeMergePair(t *testing.T, name string) (string, string) {
|
||||
t.Helper()
|
||||
video := filepath.Join(recordSaveDir(), name+".mp4")
|
||||
writeTestFile(t, video, "video")
|
||||
jsonl := filepath.Join(recordSaveDir(), name+".jsonl")
|
||||
writeTestFile(t, jsonl,
|
||||
"{\"playResX\":1920,\"playResY\":1080,\"fontSize\":48,\"startTime\":0}\n"+
|
||||
"{\"uid\":101,\"content\":\"第一条\",\"time\":1,\"lane\":0}\n")
|
||||
return filepath.Base(video), filepath.Base(jsonl)
|
||||
}
|
||||
|
||||
// 批量请求要能拆成多个方案,且每个方案各自带上自己的参数
|
||||
func TestBuildMergePlansBatch(t *testing.T) {
|
||||
setupMergeTest(t)
|
||||
v1, d1 := writeMergePair(t, "2026-09-25 01-00-00 甲 第一场")
|
||||
v2, d2 := writeMergePair(t, "2026-09-25 02-00-00 乙 第二场")
|
||||
|
||||
br1, fps2 := 1500, 60
|
||||
plans, err := buildMergePlans(mergeRequest{Jobs: []mergeJobReq{
|
||||
{Videos: []string{v1}, Danmu: d1, Options: mergeOptions{Bitrate: &br1}},
|
||||
{Videos: []string{v2}, Danmu: d2, Options: mergeOptions{Fps: &fps2}},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("批量检查失败:%v", err)
|
||||
}
|
||||
if len(plans) != 2 {
|
||||
t.Fatalf("应拆成 2 个方案,实际 %d", len(plans))
|
||||
}
|
||||
if plans[0].opts.bitrate() != 1500 {
|
||||
t.Errorf("第一个任务的码率没带上:%d", plans[0].opts.bitrate())
|
||||
}
|
||||
if plans[1].opts.bitrate() != config.MergeBitrate {
|
||||
t.Errorf("第二个任务没填码率就该用全局设置,实际 %d", plans[1].opts.bitrate())
|
||||
}
|
||||
if plans[1].opts.fps() != 60 {
|
||||
t.Errorf("第二个任务的帧率没带上:%d", plans[1].opts.fps())
|
||||
}
|
||||
// 输出名各自独立
|
||||
if plans[0].info.OutName == plans[1].info.OutName {
|
||||
t.Errorf("两个任务的输出名撞了:%s", plans[0].info.OutName)
|
||||
}
|
||||
|
||||
// 单个任务(老写法)仍然只出一个方案
|
||||
one, err := buildMergePlans(mergeRequest{Videos: []string{v1}, Danmu: d1})
|
||||
if err != nil || len(one) != 1 {
|
||||
t.Fatalf("单个任务应只出 1 个方案,实际 %d(err=%v)", len(one), err)
|
||||
}
|
||||
}
|
||||
|
||||
// 排在后面的任务有问题时要连序号一起报出来,并且一个都不该开始
|
||||
func TestBuildMergePlansReportsBadIndex(t *testing.T) {
|
||||
setupMergeTest(t)
|
||||
v1, d1 := writeMergePair(t, "2026-09-25 01-00-00 甲 第一场")
|
||||
|
||||
_, err := buildMergePlans(mergeRequest{Jobs: []mergeJobReq{
|
||||
{Videos: []string{v1}, Danmu: d1},
|
||||
{Videos: []string{v1}, Danmu: "不存在的弹幕.jsonl"},
|
||||
}})
|
||||
if err == nil {
|
||||
t.Fatal("第二个任务的文件不存在,应当报错")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "第 2 个任务") {
|
||||
t.Errorf("报错里该带上序号,实际:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 一次排太多要挡住,别让人不小心把几十场全排上
|
||||
func TestBuildMergePlansCapsCount(t *testing.T) {
|
||||
setupMergeTest(t)
|
||||
v1, d1 := writeMergePair(t, "2026-09-25 01-00-00 甲 第一场")
|
||||
jobs := make([]mergeJobReq, 0, maxMergeJobs+1)
|
||||
for i := 0; i < maxMergeJobs+1; i++ {
|
||||
jobs = append(jobs, mergeJobReq{Videos: []string{v1}, Danmu: d1})
|
||||
}
|
||||
if _, err := buildMergePlans(mergeRequest{Jobs: jobs}); err == nil {
|
||||
t.Errorf("超过 %d 个任务应当被挡住", maxMergeJobs)
|
||||
}
|
||||
}
|
||||
|
||||
// 逐任务参数的兜底和校验
|
||||
func TestMergeOptionsFallbackAndValidate(t *testing.T) {
|
||||
ff := testFFmpeg(t)
|
||||
config.MergeBitrate, config.MergeFps, config.DanmuFontSize = 2000, 30, 42
|
||||
defer func() { config.MergeBitrate, config.MergeFps, config.DanmuFontSize = 0, 0, 0 }()
|
||||
|
||||
var empty mergeOptions
|
||||
if empty.bitrate() != 2000 || empty.fps() != 30 || empty.fontSize() != 42 {
|
||||
t.Errorf("没填的项应回落到全局设置,实际 %d / %d / %d",
|
||||
empty.bitrate(), empty.fps(), empty.fontSize())
|
||||
}
|
||||
// 填 0 是有明确含义的,不能被当成「没填」
|
||||
zero := 0
|
||||
if (mergeOptions{Bitrate: &zero}).bitrate() != 0 {
|
||||
t.Error("填了 0 就应当是 0(自动码率),不是全局设置的值")
|
||||
}
|
||||
if msg := (mergeOptions{Fps: &zero}).validate(ff); msg != "" {
|
||||
t.Errorf("帧率 0(跟随源片)不该被挡:%s", msg)
|
||||
}
|
||||
if msg := (mergeOptions{Encoder: "auto"}).validate(ff); msg != "" {
|
||||
t.Errorf("auto 表示自动挑,不该被挡:%s", msg)
|
||||
}
|
||||
bad := -1
|
||||
if msg := (mergeOptions{Bitrate: &bad}).validate(ff); msg == "" {
|
||||
t.Error("负的码率应当被挡住")
|
||||
}
|
||||
over := maxMergeFps + 1
|
||||
if msg := (mergeOptions{Fps: &over}).validate(ff); msg == "" {
|
||||
t.Error("超出上限的帧率应当被挡住")
|
||||
}
|
||||
if msg := (mergeOptions{Encoder: "av1_nvenc"}).validate(ff); msg == "" {
|
||||
t.Error("本机没有的编码器应当被挡住")
|
||||
}
|
||||
// 本机可用的编码器要放行
|
||||
for _, e := range availableEncoders(ff) {
|
||||
if msg := (mergeOptions{Encoder: e}).validate(ff); msg != "" {
|
||||
t.Errorf("本机可用的 %s 不该被挡:%s", e, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user