新增「转码」分区与手动合并逐任务配置,修掉 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
+513
View File
@@ -0,0 +1,513 @@
package main
import (
"math"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// ==================== 参数校验 ====================
// 没填的项要补成默认值,不然拼出来的 ffmpeg 命令会带着一堆空参数
func TestTranscodeOptionsNormalize(t *testing.T) {
o := transcodeOptions{}
n := o.normalize()
if n.Mode != rateModeSource {
t.Errorf("模式空时应按「跟随源片」,实际 %q", n.Mode)
}
if n.Preset != defaultTranscodePreset {
t.Errorf("预设空时应取 %q,实际 %q", defaultTranscodePreset, n.Preset)
}
if n.Audio != audioCopy {
t.Errorf("音频空时应按直接拷贝,实际 %q", n.Audio)
}
}
// 各种填错的情况都要在开跑之前挡住,别等 ffmpeg 报一句看不懂的错
func TestTranscodeOptionsValidate(t *testing.T) {
ff := testFFmpeg(t)
bad := map[string]transcodeOptions{
"模式不认识": {Mode: "xyz"},
"选了码率却没填": {Mode: rateModeBitrate},
"质量值越界": {Mode: rateModeQuality, Quality: 99},
"比例越界": {Mode: rateModePercent, Percent: 1000},
"码率上界越界": {Mode: rateModeBitrate, Bitrate: maxBitrateKbps + 1},
"预设不认识": {Preset: "ultrafast"},
"帧率越界": {Fps: maxMergeFps + 1},
"缩放高度太小": {Scale: 10},
"音频处理不认识": {Audio: "mp3"},
"编码器本机没有": {Encoder: "av1_nvenc"},
}
for name, o := range bad {
if msg := o.validate(ff); msg == "" {
t.Errorf("%s:应当被挡住,实际通过了", name)
}
}
// 正常参数要能过
ok := transcodeOptions{Mode: rateModeBitrate, Bitrate: 2000, Preset: "fast", Fps: 30, Scale: 720, Audio: audioAAC128}
if msg := ok.validate(ff); msg != "" {
t.Errorf("正常参数不该被挡:%s", msg)
}
if msg := (transcodeOptions{Encoder: "auto"}).validate(ff); msg != "" {
t.Errorf("auto 表示自动挑,不该被挡:%s", msg)
}
}
// ==================== 体积估算 ====================
// 目标码率模式的体积是能算准的:码率 × 时长
func TestEstimateTranscodeBitrateMode(t *testing.T) {
p := transcodeProbe{Duration: 100, VideoKbps: 4000, AudioKbps: 128}
got := estimateTranscode(transcodeOptions{Mode: rateModeBitrate, Bitrate: 2000, Audio: audioAAC128}, p, "h264_qsv")
// 视频 2000 + 音频 128 = 2128 kbps × 100 秒 = 26.6 MB
want := int64((2000 + 128) * 1000 / 8 * 100)
if got.Bytes != want {
t.Errorf("预估体积应为 %d,实际 %d", want, got.Bytes)
}
if !got.Exact {
t.Error("目标码率模式算出来的是准数,不该标成粗估")
}
if got.VideoKbps != 2000 || got.AudioKbps != 128 {
t.Errorf("预估码率不对:视频 %d、音频 %d", got.VideoKbps, got.AudioKbps)
}
}
// 按比例模式:60% 就是源片的六成
func TestEstimateTranscodePercentMode(t *testing.T) {
p := transcodeProbe{Duration: 60, VideoKbps: 5000}
got := estimateTranscode(transcodeOptions{Mode: rateModePercent, Percent: 60, Audio: audioNone}, p, "libx264")
want := int64(3000 * 1000 / 8 * 60)
if got.Bytes != want {
t.Errorf("60%% 的源片码率应为 %d,实际 %d", want, got.Bytes)
}
if got.AudioKbps != 0 {
t.Errorf("去掉音轨后音频码率应为 0,实际 %d", got.AudioKbps)
}
}
// 质量模式只能粗估:数值越大体积越小,且要标成粗估
func TestEstimateTranscodeQualityMode(t *testing.T) {
p := transcodeProbe{Duration: 100, VideoKbps: 4000, AudioKbps: 128}
low := estimateTranscode(transcodeOptions{Mode: rateModeQuality, Quality: 18}, p, "libx264")
high := estimateTranscode(transcodeOptions{Mode: rateModeQuality, Quality: 28}, p, "libx264")
if low.Bytes <= high.Bytes {
t.Errorf("质量值 18(%d 字节)应比 28(%d 字节)体积大", low.Bytes, high.Bytes)
}
if low.Exact || high.Exact {
t.Error("质量模式只能粗估,不该标成准数")
}
if high.Note == "" {
t.Error("粗估要给出依据说明,界面上要能看出来这不是准数")
}
}
// 质量值的系数表要单调:数值越大系数越小
func TestQualityFactorMonotonic(t *testing.T) {
prev := math.Inf(1)
for q := 0; q <= 51; q++ {
f := qualityFactor(q)
if f > prev+1e-9 {
t.Fatalf("质量值 %d 的系数 %v 比上一个还大,压得越狠体积越大了", q, f)
}
if f <= 0 {
t.Fatalf("质量值 %d 的系数不该是 %v", q, f)
}
prev = f
}
// 表上的点要精确命中
if got := qualityFactor(18); math.Abs(got-1.0) > 1e-9 {
t.Errorf("CRF 18 是基准,系数应为 1,实际 %v", got)
}
}
// 缩到更低分辨率后,同样的码率要求下产物应更小
func TestEstimateTranscodeScalingShrinks(t *testing.T) {
p := transcodeProbe{Duration: 100, VideoKbps: 4000, Height: 1080}
full := estimateTranscode(transcodeOptions{Mode: rateModeSource}, p, "libx264")
half := estimateTranscode(transcodeOptions{Mode: rateModeSource, Scale: 540}, p, "libx264")
if half.Bytes >= full.Bytes {
t.Errorf("缩到 540p(%d)应比原样(%d)小", half.Bytes, full.Bytes)
}
// 按面积算:540/1080 = 0.5,码率约为四分之一
if half.VideoKbps > full.VideoKbps/3 {
t.Errorf("高度减半后码率应降到约四分之一,实际 %d → %d", full.VideoKbps, half.VideoKbps)
}
}
// 源片没有音轨时别凭空加一份音频码率(拷贝模式拷不出音轨来)
func TestEstimateTranscodeNoAudioTrack(t *testing.T) {
// HasAudio 没置位 = 探测不到音轨
p := transcodeProbe{Duration: 100, VideoKbps: 4000}
got := estimateTranscode(transcodeOptions{Mode: rateModeBitrate, Bitrate: 2000, Audio: audioCopy}, p, "libx264")
want := int64(2000 * 1000 / 8 * 100)
if got.Bytes != want {
t.Errorf("没有音轨时不该算音频:应 %d,实际 %d(音频 %d kbps)", want, got.Bytes, got.AudioKbps)
}
if got.AudioKbps != 0 {
t.Errorf("没有音轨时音频码率应为 0,实际 %d", got.AudioKbps)
}
// 显式要求重编码音频的话仍然按目标算(源片没有也只是多加一条空轨,不至于算错体积量级)
got = estimateTranscode(transcodeOptions{Mode: rateModeBitrate, Bitrate: 2000, Audio: audioAAC128}, p, "libx264")
if got.AudioKbps != 128 {
t.Errorf("显式重编码 128k 时应按 128 算,实际 %d", got.AudioKbps)
}
}
// 探测不到视频码率时按「体积 ÷ 时长」倒推,不能算成 0(那会显示成「—」)
func TestVideoKbpsFallsBackToFileSize(t *testing.T) {
// 100 秒、50 MB 的文件 → 约 4000 kbps,扣掉 128k 音轨
m := mediaInfo{duration: 100, hasAudio: true, audioKbps: 128}
got := videoKbpsOf(m, 50*1024*1024)
if got < 3000 || got > 4500 {
t.Errorf("倒推的源片码率应在 3000~4500 之间,实际 %d", got)
}
}
// 容器总码率含音频,不能直接当视频码率:算体积时会和音频重复计一遍,
// 「跟随源片」时也会把音轨的码率算进视频里
func TestVideoKbpsDropsAudioFromContainer(t *testing.T) {
m := mediaInfo{containerKbps: 3000, hasAudio: true, audioKbps: 192}
if got := videoKbpsOf(m, 0); got != 2808 {
t.Errorf("容器 3000 扣掉 192 音轨应为 2808,实际 %d", got)
}
// 视频流自己写了码率就以它为准,不再动
m.videoKbps = 2600
if got := videoKbpsOf(m, 0); got != 2600 {
t.Errorf("视频流自己写了码率就该用它,实际 %d", got)
}
// 没有音轨时不扣
m = mediaInfo{containerKbps: 3000}
if got := videoKbpsOf(m, 0); got != 3000 {
t.Errorf("没有音轨时不扣,实际 %d", got)
}
// 音轨码率探测不到时按经验值扣,别一点不扣
m = mediaInfo{containerKbps: 3000, hasAudio: true}
if got := videoKbpsOf(m, 0); got != 3000-defaultAudioKbps {
t.Errorf("音轨码率未知时应按 %d 扣,实际 %d", defaultAudioKbps, got)
}
}
// 合并那边的自动码率一直按「视频流码率,没有就用容器总码率(含音频)」算,
// 改口径会让所有自动合并的成片都变小一截,这里锁住
func TestStreamOrContainerKbpsKeepsOldBehavior(t *testing.T) {
if got := streamOrContainerKbps(mediaInfo{videoKbps: 2000, containerKbps: 2500}); got != 2000 {
t.Errorf("有视频流码率时应优先用它,实际 %d", got)
}
if got := streamOrContainerKbps(mediaInfo{containerKbps: 2500, hasAudio: true, audioKbps: 192}); got != 2500 {
t.Errorf("没有视频流码率时应退回容器总码率(含音频),实际 %d", got)
}
}
// 探测结果的解析:容器总码率和视频流码率要分开记,封面图不能算成画面
func TestParseMediaInfoSplitsBitrates(t *testing.T) {
out := "Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'x.mp4':\n" +
" Duration: 00:02:55.07, start: 0.000000, bitrate: 1767 kb/s\n" +
" Stream #0:0[0x1](und): Video: h264 (High) (avc1 / 0x31637661), yuv420p, 1920x1080 [SAR 1:1 DAR 16:9], 1234 kb/s, 30 fps, 30 tbr, 15360 tbn\n" +
" Stream #0:1[0x2](und): Audio: aac (LC) (mp4a / 0x6134706D), 48000 Hz, stereo, fltp, 192 kb/s\n"
m := parseMediaInfo(out)
if !m.hasVideo {
t.Error("有视频轨时 hasVideo 应为 true")
}
if m.videoKbps != 1234 || m.containerKbps != 1767 {
t.Errorf("视频流码率和容器总码率要分开记,实际 %d / %d", m.videoKbps, m.containerKbps)
}
if m.width != 1920 || m.height != 1080 || m.fps != 30 {
t.Errorf("画面参数没解析对:%dx%d %v fps", m.width, m.height, m.fps)
}
if !m.hasAudio || m.audioKbps != 192 {
t.Errorf("音轨没解析对:hasAudio=%v %d kbps", m.hasAudio, m.audioKbps)
}
if m.codec != "h264" {
t.Errorf("编码器名应为 h264,实际 %q", m.codec)
}
if d := m.duration; d < 175.0 || d > 175.2 {
t.Errorf("时长应为 175.07 秒,实际 %v", d)
}
// 只有封面图(attached pic)的文件不算有视频轨,否则转码时会当成画面
pic := " Duration: 00:00:10.00, start: 0.000000, bitrate: 128 kb/s\n" +
" Stream #0:0[0x1]: Video: mjpeg (attached pic), yuvj420p, 600x600, 90k tbr\n" +
" Stream #0:1: Audio: mp3, 44100 Hz, stereo, 128 kb/s\n"
pm := parseMediaInfo(pic)
if pm.hasVideo {
t.Error("封面图不该算视频轨")
}
if pm.width != 0 {
t.Errorf("封面图的分辨率不该当画面用,实际 %d", pm.width)
}
}
// 同一个文件反复探测要走缓存(体积估算跟着输入框重发,每次起一个 ffmpeg 会把 CPU 占住),
// 但文件变了必须重新探
func TestProbeMediaCacheInvalidatesOnChange(t *testing.T) {
ff := testFFmpeg(t)
setupMergeTest(t)
path := filepath.Join(recordSaveDir(), "缓存.mp4")
if out, err := exec.Command(ff, "-hide_banner", "-y", "-f", "lavfi", "-i",
"testsrc=size=160x120:rate=10:duration=1", "-c:v", "libx264", "-pix_fmt", "yuv420p", path).CombinedOutput(); err != nil {
t.Skipf("造不出测试视频,跳过:%v %s", err, out)
}
probeCache.Lock()
probeCache.m = nil
probeCache.Unlock()
first := probeMediaCached(ff, path)
if first.duration <= 0 {
t.Fatalf("探测不到时长:%+v", first)
}
// 第二次必须命中缓存:键的数量不该变
probeCache.Lock()
n1 := len(probeCache.m)
probeCache.Unlock()
if again := probeMediaCached(ff, path); again.duration != first.duration {
t.Errorf("命中缓存时结果应一致,实际 %v ≠ %v", again.duration, first.duration)
}
probeCache.Lock()
n2 := len(probeCache.m)
probeCache.Unlock()
if n1 != 1 || n2 != 1 {
t.Errorf("同一个文件第二次探测不该新增缓存项,实际 %d → %d", n1, n2)
}
// 换个更长的片子放同一个路径:体积和修改时间都变了,必须重新探
if out, err := exec.Command(ff, "-hide_banner", "-y", "-f", "lavfi", "-i",
"testsrc=size=160x120:rate=10:duration=3", "-c:v", "libx264", "-pix_fmt", "yuv420p", path).CombinedOutput(); err != nil {
t.Fatalf("重造测试视频失败:%v %s", err, out)
}
changed := probeMediaCached(ff, path)
if changed.duration <= first.duration+0.5 {
t.Errorf("文件换成 3 秒的了,时长该跟着变(%v → %v)", first.duration, changed.duration)
}
}
// ==================== ffmpeg 参数 ====================
// 指定的编码器要被用上;没指定时按本机可用的挑
func TestTranscodeArgsEncoder(t *testing.T) {
ff := testFFmpeg(t)
src := mediaInfo{duration: 10, width: 1920, height: 1080, fps: 30, videoKbps: 4000}
args, enc, _ := transcodeArgs(ff, "in.mp4", "out.mp4", transcodeOptions{Encoder: "libx264"}, src)
if enc != "libx264" {
t.Fatalf("指定 libx264 就该用它,实际 %s", enc)
}
if !containsSeq(args, "-c:v", "libx264") {
t.Errorf("命令里没有 -c:v libx264:%v", args)
}
// -c:v 必须在 -i 之后,否则 ffmpeg 会当成输入选项
iPos, cPos := indexOf(args, "in.mp4"), indexOf(args, "-c:v")
if iPos < 0 || cPos < 0 || cPos < iPos {
t.Errorf("-c:v 要放在 -i 之后,实际 %v", args)
}
_, auto, _ := transcodeArgs(ff, "in.mp4", "out.mp4", transcodeOptions{}, src)
if auto == "" {
t.Fatal("自动模式也该挑出一个编码器")
}
if !containsSeq(args, "-map", "0:v:0") {
t.Errorf("应显式只映射第一条视频流(跳过封面图),实际 %v", args)
}
}
// 质量模式下给质量参数、给目标码率模式下给码率参数,两者不能同时出现
func TestTranscodeArgsRateControl(t *testing.T) {
ff := testFFmpeg(t)
src := mediaInfo{duration: 10, width: 1920, height: 1080, fps: 30, videoKbps: 4000}
args, _, _ := transcodeArgs(ff, "in.mp4", "out.mp4",
transcodeOptions{Mode: rateModeBitrate, Bitrate: 1500}, src)
if !containsSeq(args, "-b:v", "1500k") {
t.Errorf("目标码率模式应给 -b:v 1500k,实际 %v", args)
}
if has(args, "-crf") || has(args, "-global_quality") {
t.Errorf("给了目标码率就不该再给质量参数:%v", args)
}
// 质量模式没填数值时用默认值,不能把 0 塞给 ffmpeg
args, _, _ = transcodeArgs(ff, "in.mp4", "out.mp4",
transcodeOptions{Mode: rateModeQuality, Encoder: "libx264"}, src)
if !containsSeq(args, "-crf", "23") {
t.Errorf("软编的质量模式应默认 CRF 23,实际 %v", args)
}
}
// 缩放和帧率:只缩不放,帧率变了才加 fps 滤镜
func TestTranscodeArgsFilters(t *testing.T) {
ff := testFFmpeg(t)
src := mediaInfo{duration: 10, width: 1920, height: 1080, fps: 30, videoKbps: 4000}
args, _, fps := transcodeArgs(ff, "in.mp4", "out.mp4",
transcodeOptions{Scale: 720, Fps: 60}, src)
vf := valueAfter(args, "-vf")
if !strings.Contains(vf, "scale=-2:720") {
t.Errorf("缩放应写进 -vf,实际 %q", vf)
}
if !strings.Contains(vf, "fps=60") {
t.Errorf("帧率变化应写进 -vf,实际 %q", vf)
}
if fps != 60 {
t.Errorf("返回的帧率应为 60,实际 %d", fps)
}
// 帧率跟源片一样时不加滤镜,白跑一遍滤镜只会更慢
_, _, fps = transcodeArgs(ff, "in.mp4", "out.mp4", transcodeOptions{Fps: 30}, src)
if fps != 0 {
t.Errorf("帧率没变时不该加 fps 滤镜,实际返回 %d", fps)
}
// 目标比源片高时也不放大
_, _, _ = transcodeArgs(ff, "in.mp4", "out.mp4", transcodeOptions{Scale: 2160}, src)
if vf := valueAfter(transcodeArgsArgs(ff, src), "-vf"); strings.Contains(vf, "scale") {
t.Errorf("不该把 1080p 放大到 2160p,实际 %q", vf)
}
}
// HEVC 要带 hvc1 标签,否则 Apple 系打不开
func TestTranscodeArgsHEVCTag(t *testing.T) {
ff := testFFmpeg(t)
src := mediaInfo{duration: 10, width: 1920, height: 1080, fps: 30, videoKbps: 4000}
args, _, _ := transcodeArgs(ff, "in.mp4", "out.mp4",
transcodeOptions{Encoder: "hevc_qsv", Mode: rateModeBitrate, Bitrate: 2000}, src)
if !containsSeq(args, "-tag:v", "hvc1") {
t.Errorf("HEVC 要写 hvc1,实际 %v", args)
}
args, _, _ = transcodeArgs(ff, "in.mp4", "out.mp4",
transcodeOptions{Encoder: "libx264", Mode: rateModeBitrate, Bitrate: 2000}, src)
if has(args, "-tag:v") {
t.Errorf("H.264 不该加 hvc1,实际 %v", args)
}
}
// 音频的三种处理要各自拼对
func TestTranscodeArgsAudio(t *testing.T) {
ff := testFFmpeg(t)
src := mediaInfo{duration: 10, width: 1280, height: 720, fps: 30, videoKbps: 2000, hasAudio: true, audioKbps: 128}
args, _, _ := transcodeArgs(ff, "in.mp4", "out.mp4", transcodeOptions{Audio: audioNone}, src)
if !has(args, "-an") {
t.Errorf("去掉音轨应给 -an,实际 %v", args)
}
args, _, _ = transcodeArgs(ff, "in.mp4", "out.mp4", transcodeOptions{Audio: audioAAC192}, src)
if !containsSeq(args, "-c:a", "aac") || !containsSeq(args, "-b:a", "192k") {
t.Errorf("AAC 192k 没拼对:%v", args)
}
args, _, _ = transcodeArgs(ff, "in.mp4", "out.mp4", transcodeOptions{Audio: audioCopy}, src)
if !containsSeq(args, "-c:a", "copy") {
t.Errorf("直接拷贝应给 -c:a copy,实际 %v", args)
}
}
// ==================== 端到端:真的转一个 ====================
// 拿 ffmpeg 造一段两秒的测试视频,再按各种参数真转一遍。
// 只验证参数和落盘,体积大小不断言(不同机器编码结果不一样)
func TestRunTranscodeProducesFile(t *testing.T) {
ff := testFFmpeg(t)
setupMergeTest(t)
src := filepath.Join(recordSaveDir(), "源片.mp4")
if out, err := exec.Command(ff, "-hide_banner", "-y", "-f", "lavfi", "-i",
"testsrc=size=640x360:rate=30:duration=2", "-c:v", "libx264", "-pix_fmt", "yuv420p", src).CombinedOutput(); err != nil {
t.Skipf("造不出测试视频,跳过:%v %s", err, out)
}
jobFinish("")
var got string
err := runMergeExclusive(func() error {
var e error
got, e = runTranscode(transcodeJobReq{
Dir: filepath.Base(recordSaveDir()), Name: filepath.Base(src),
Options: transcodeOptions{Mode: rateModeBitrate, Bitrate: 800, Scale: 360, Preset: "veryfast"},
}, mergeSaveDir(), 1, 1)
return e
})
jobFinish(jobErrText(err))
if err != nil {
t.Fatalf("转码失败:%v", err)
}
if got != "源片_转码.mp4" {
t.Errorf("返回的成片名不对:%q", got)
}
want := filepath.Join(mergeSaveDir(), "源片_转码.mp4")
if _, err := os.Stat(want); err != nil {
t.Fatalf("成片没落到 %s:%v", want, err)
}
// 转出来的文件不能是空壳
if n := fileSizeOrZero(want); n < 1024 {
t.Errorf("成片只有 %d 字节,不正常", n)
}
}
// 只有音轨的文件要当场挡住并说清原因,别等 ffmpeg 报一句「matches no streams」
func TestRunTranscodeRejectsAudioOnly(t *testing.T) {
ff := testFFmpeg(t)
setupMergeTest(t)
src := filepath.Join(recordSaveDir(), "只有声音.mp4")
if out, err := exec.Command(ff, "-hide_banner", "-y", "-f", "lavfi", "-i",
"sine=frequency=440:duration=1", "-c:a", "aac", "-vn", src).CombinedOutput(); err != nil {
t.Skipf("造不出只有音轨的文件,跳过:%v %s", err, out)
}
jobFinish("")
err := runMergeExclusive(func() error {
_, e := runTranscode(transcodeJobReq{
Dir: filepath.Base(recordSaveDir()), Name: filepath.Base(src),
}, mergeSaveDir(), 1, 1)
return e
})
jobFinish(jobErrText(err))
if err == nil {
t.Fatal("只有音轨的文件不该转得下去")
}
if !strings.Contains(err.Error(), "没有视频轨") {
t.Errorf("报错要说明是没有视频轨,实际:%v", err)
}
}
// ==================== 小工具 ====================
func has(args []string, v string) bool {
for _, a := range args {
if a == v {
return true
}
}
return false
}
func indexOf(args []string, v string) int {
for i, a := range args {
if a == v {
return i
}
}
return -1
}
// 参数里是否连续出现了这两个值
func containsSeq(args []string, a, b string) bool {
for i := 0; i+1 < len(args); i++ {
if args[i] == a && args[i+1] == b {
return true
}
}
return false
}
// 取 -vf 后面的值
func valueAfter(args []string, key string) string {
for i, a := range args {
if a == key && i+1 < len(args) {
return args[i+1]
}
}
return ""
}
// 单独构造一次参数给上面的用例用(避免重复拼 src)
func transcodeArgsArgs(ff string, src mediaInfo) []string {
args, _, _ := transcodeArgs(ff, "in.mp4", "out.mp4", transcodeOptions{Scale: 2160}, src)
return args
}