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 }