为什么
转码页的多选框只列 /api/files 的内容,也就是录播目录和成片目录两处;后端的
resolveTranscodeInput 走 resolveFile,也只认 record/merge 两个目录名。结果是
录播原片被合并且清掉之后(或者录播目录本来就空),转码页里只剩合并结果可挑,
磁盘上别处的视频一个都进不来。
怎么改
- transcodeJobReq 加 Path 字段,与 dir/name 二选一;新增 resolveTranscodeJob()
把「填了路径就按路径找(认绝对路径和从资源管理器复制来的带引号路径,走
resolveMergeInput)、否则回落 dir+name」收在一处,两条路最后统一过
mergeVideoExts 白名单 —— 路径这条能指向磁盘上任何文件,不挡的话手滑把 .jsonl
丢进来要等 ffmpeg 跑起来才报错。三个调用点和 runTranscode 全部改走它。
- transcodeProbe 回显绝对路径,页面拿它当任务身份:同名文件在不同目录里不会再
被认成一个,重复加入也因此挡得住。
- 转码卡片改成两栏:左边保留原来的文件列表多选,右边加「一行一个路径」的输入框
和「浏览…」按钮(复用已有的目录选择器,可多选、可跨文件夹跨盘攒,选完只追加不
覆盖)。openPick 多了个可选的第 5 参数用来指定起始目录,浏览转码文件时从成片
目录开始(录播目录可能是空的)。
- 报错前缀改用 transcodeJobReq.label():按路径进来时 Name 是空的,直接拼 j.Name
会变成「:参数不合法」。
验证
gofmt -l 干净;go vet 干净;Windows 与 GOOS=linux 编译均通过。
go test ./... 全绿;go test -race -count=1 ./... 27.2s 全绿;顶层用例 209 个
(含子测试 226),本次新增 3 个:-run 单独跑过 TestResolveTranscodeJobByPath /
TestResolveTranscodeJobRejectsBadPath / TestTranscodeProbeHandlerAcceptsPath。
反向验证 44/44:把 resolveTranscodeJob 的路径分支退回 resolveFile 后,上面两个
用例确实变红。
转码接口冒烟 40/40(新增 11 项按路径的:探测回显路径、估算、不存在 400、非视频
后缀 400、指向文件夹 400、真排队转出成片)。
页面 jsdom 冒烟 49/49 且无 JS 报错(新增 12 项:路径框存在、空框不给加入、手填能
进列表并带上绝对路径、探测请求按 path 发、重复加入不重复、浏览弹窗打开/追加不
覆盖/关闭/清空)。
606 lines
22 KiB
Go
606 lines
22 KiB
Go
package main
|
||
|
||
import (
|
||
"encoding/json"
|
||
"math"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"strconv"
|
||
"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
|
||
}
|
||
|
||
// ==================== 源文件定位 ====================
|
||
|
||
// 填了路径就按路径找,录播/成片文件夹之外的文件也要能转;没填才回落到 dir + name
|
||
func TestResolveTranscodeJobByPath(t *testing.T) {
|
||
setupMergeTest(t)
|
||
outside := t.TempDir()
|
||
video := filepath.Join(outside, "别处的录播.mp4")
|
||
writeTestFile(t, video, "x")
|
||
|
||
// 绝对路径要认,从资源管理器复制来的带引号路径也要认
|
||
for _, in := range []string{video, `"` + video + `"`} {
|
||
got, err := resolveTranscodeJob(transcodeJobReq{Path: in})
|
||
if err != nil {
|
||
t.Fatalf("%q 应当能解析:%v", in, err)
|
||
}
|
||
if normalizePath(got) != normalizePath(video) {
|
||
t.Errorf("%q 解析成了 %q,想要 %q", in, got, video)
|
||
}
|
||
}
|
||
|
||
// 不填路径时还是按录播目录 + 文件名找,老写法不能坏
|
||
rec := filepath.Join(recordSaveDir(), "录播里的.mp4")
|
||
writeTestFile(t, rec, "x")
|
||
got, err := resolveTranscodeJob(transcodeJobReq{Dir: "record", Name: "录播里的.mp4"})
|
||
if err != nil {
|
||
t.Fatalf("dir + name 应当能解析:%v", err)
|
||
}
|
||
if normalizePath(got) != normalizePath(rec) {
|
||
t.Errorf("dir + name 解析成了 %q,想要 %q", got, rec)
|
||
}
|
||
}
|
||
|
||
// 路径这条能指向磁盘上任何文件,格式白名单必须照样过一遍
|
||
func TestResolveTranscodeJobRejectsBadPath(t *testing.T) {
|
||
setupMergeTest(t)
|
||
outside := t.TempDir()
|
||
danmu := filepath.Join(outside, "某场.jsonl")
|
||
writeTestFile(t, danmu, "{}")
|
||
sub := filepath.Join(outside, "子文件夹")
|
||
if err := os.MkdirAll(sub, 0755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
bad := map[string]transcodeJobReq{
|
||
"不是视频后缀": {Path: danmu},
|
||
"指向文件夹": {Path: sub},
|
||
"路径不存在": {Path: filepath.Join(outside, "没有这个文件.mp4")},
|
||
"路径和名字都没填": {},
|
||
}
|
||
for name, j := range bad {
|
||
if _, err := resolveTranscodeJob(j); err == nil {
|
||
t.Errorf("%s:应当被挡住,实际通过了", name)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 探测接口要认 {path} —— 这是「转码任意文件」的实际入口,只测 resolve 不足以证明接线对了。
|
||
// 这里故意用一个不是视频的文件:探测拿不到流信息,但接口仍要 200 并把路径原样回显
|
||
func TestTranscodeProbeHandlerAcceptsPath(t *testing.T) {
|
||
setupMergeTest(t)
|
||
outside := t.TempDir()
|
||
video := filepath.Join(outside, "别处.mp4")
|
||
writeTestFile(t, video, "not a real video")
|
||
|
||
body := `{"files":[{"path":` + strconv.Quote(video) + `}]}`
|
||
req := httptest.NewRequest("POST", "/api/transcode/probe", strings.NewReader(body))
|
||
rec := httptest.NewRecorder()
|
||
transcodeProbeHandler(rec, req)
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("按路径探测应当 200,实际 %d:%s", rec.Code, rec.Body.String())
|
||
}
|
||
var resp struct {
|
||
Files []struct {
|
||
Name string `json:"name"`
|
||
Path string `json:"path"`
|
||
} `json:"files"`
|
||
}
|
||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(resp.Files) != 1 || resp.Files[0].Name != "别处.mp4" {
|
||
t.Fatalf("探测结果不对:%+v", resp.Files)
|
||
}
|
||
if normalizePath(resp.Files[0].Path) != normalizePath(video) {
|
||
t.Errorf("回显的 path 是 %q,想要 %q", resp.Files[0].Path, video)
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|