一、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 报错(分组 / 拆组 / 逐任务参数 / 转码行渲染 /
跳过无视频轨文件 / 估算失败提示 / 结果徽标)
514 lines
19 KiB
Go
514 lines
19 KiB
Go
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
|
||
}
|