Files
acfunlive-remake/transcode_test.go
T
tamakyi 3c17503a21 转码的源文件不再限死在录播/成片目录,可以填任意路径
为什么
  转码页的多选框只列 /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 发、重复加入不重复、浏览弹窗打开/追加不
  覆盖/关闭/清空)。
2026-09-27 11:10:00 +08:00

606 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}