Files
ttstd-infinite-canvas/backend/internal/app/video_transcode_test.go

179 lines
5.4 KiB
Go
Raw Permalink 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 app
import (
"encoding/binary"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
"errors"
"yingce/backend/internal/model"
)
// stsdBox 构造仅含 first-sample-entry fourcc 的最小 stsd box(置于 moov 切片内)。
func stsdBoxWithFourcc(fourcc string) []byte {
b := make([]byte, 24)
binary.BigEndian.PutUint32(b[0:4], 24)
copy(b[4:8], "stsd")
// body: version/flags(4) + entry_count(4)=1 + entry_size(4) + fourcc(4)
binary.BigEndian.PutUint32(b[12:16], 1)
binary.BigEndian.PutUint32(b[16:20], 8)
copy(b[20:24], fourcc)
return b
}
func TestCodecFromMoov(t *testing.T) {
cases := []struct {
fourcc string
want string
}{
{"hvc1", videoCodecH265},
{"hev1", videoCodecH265},
{"avc1", videoCodecH264},
{"av01", videoCodecAV1},
{"vp09", videoCodecVP9},
}
for _, c := range cases {
if got := codecFromMoov(stsdBoxWithFourcc(c.fourcc)); got != c.want {
t.Errorf("codecFromMoov(%s) = %q, want %q", c.fourcc, got, c.want)
}
}
if got := codecFromMoov([]byte{0, 1, 2, 3}); got != "" {
t.Errorf("garbage moov codec = %q, want empty", got)
}
}
func TestProbeVideoCodecReadsRealFile(t *testing.T) {
// 构造 ftyp + moov(含 hvc1 stsd) 的最小 mp4 文件,验证 probeVideoCodec 走文件读取路径。
stsd := stsdBoxWithFourcc("hvc1")
moov := make([]byte, 8+len(stsd))
binary.BigEndian.PutUint32(moov[0:4], uint32(len(moov)))
copy(moov[4:8], "moov")
copy(moov[8:], stsd)
full := make([]byte, 0, 16+len(moov))
ftyp := make([]byte, 16)
binary.BigEndian.PutUint32(ftyp[0:4], 16)
copy(ftyp[4:8], "ftyp")
copy(ftyp[8:12], "isom")
full = append(full, ftyp...)
full = append(full, moov...)
path := filepath.Join(t.TempDir(), "clip.mp4")
if err := os.WriteFile(path, full, 0o644); err != nil {
t.Fatal(err)
}
if got := probeVideoCodec(path); got != videoCodecH265 {
t.Errorf("probeVideoCodec = %q, want h265", got)
}
}
// TestBackfillRejudgesLegacyNoneVideos 覆盖修复:判定规则变更前(H.265/MPEG-4 Part 2
// 曾被误判可播)落 none 的存量本地视频,启动回填必须重新按 codec 判定——
// H.264 保持 none(幂等),MPEG-4 触发转码并最终落到 failed/ready 终态。
func TestBackfillRejudgesLegacyNoneVideos(t *testing.T) {
service, db := newProjectAssetLinkTestService(t)
dataDir := t.TempDir()
service.dataDir = dataDir
seedLegacy := func(id, fourcc string) {
t.Helper()
stsd := stsdBoxWithFourcc(fourcc)
moov := make([]byte, 8+len(stsd))
binary.BigEndian.PutUint32(moov[0:4], uint32(len(moov)))
copy(moov[4:8], "moov")
copy(moov[8:], stsd)
ftyp := make([]byte, 16)
binary.BigEndian.PutUint32(ftyp[0:4], 16)
copy(ftyp[4:8], "ftyp")
copy(ftyp[8:12], "isom")
full := append(append([]byte{}, ftyp...), moov...)
rel := filepath.Join("clips", id+".mp4")
p := filepath.Join(dataDir, "resources", filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, full, 0o644); err != nil {
t.Fatal(err)
}
res := model.Resource{
ID: id, UserID: "user-1", Kind: "video", Status: model.ResourceStatusReady,
Provider: "local", ObjectKey: rel, PlaybackStatus: model.PlaybackStatusNone,
}
if err := db.Create(&res).Error; err != nil {
t.Fatal(err)
}
}
seedLegacy("legacy-h264", "avc1")
seedLegacy("legacy-mpeg4", "mp4v")
service.BackfillPlaybackTranscodes()
var h264 model.Resource
if err := db.First(&h264, "id = ?", "legacy-h264").Error; err != nil {
t.Fatal(err)
}
if h264.PlaybackStatus != model.PlaybackStatusNone {
t.Fatalf("H.264 存量 none 行被错误改判为 %q", h264.PlaybackStatus)
}
// MPEG-4 行应被抢占转码。fake mp4 不含真实视频流,ffmpeg 解码必败 → failed;
// 若某环境恰有同名 ready 副本则也接受。轮询直到终态,避免 goroutine 竞态。
if _, err := exec.LookPath("ffmpeg"); err != nil {
t.Log("无 ffmpeg,跳过 MPEG-4 终态断言")
return
}
var mp4v model.Resource
deadline := time.Now().Add(10 * time.Second)
for {
if err := db.First(&mp4v, "id = ?", "legacy-mpeg4").Error; err != nil {
t.Fatal(err)
}
if mp4v.PlaybackStatus == model.PlaybackStatusFailed || mp4v.PlaybackStatus == model.PlaybackStatusReady {
break
}
if time.Now().After(deadline) {
t.Fatalf("MPEG-4 存量行未达终态,停在 %q(error=%q)", mp4v.PlaybackStatus, mp4v.PlaybackError)
}
time.Sleep(100 * time.Millisecond)
}
if mp4v.PlaybackStatus != model.PlaybackStatusFailed {
t.Fatalf("fake mp4v 应转码失败落 failed,实际 %q", mp4v.PlaybackStatus)
}
}
type failingResourceSaver struct {
failTimes int
calls int
}
func (s *failingResourceSaver) SaveResource(*model.Resource) error {
s.calls++
if s.calls <= s.failTimes {
return errors.New("db busy")
}
return nil
}
func TestPersistPlaybackResourceRetriesThenSucceeds(t *testing.T) {
saver := &failingResourceSaver{failTimes: 2}
if err := persistPlaybackResource(saver, &model.Resource{ID: "r1"}, "test"); err != nil {
t.Fatal(err)
}
if saver.calls != playbackPersistAttempts {
t.Fatalf("calls = %d, want %d", saver.calls, playbackPersistAttempts)
}
}
func TestPersistPlaybackResourceReturnsAfterExhaustedRetries(t *testing.T) {
saver := &failingResourceSaver{failTimes: 10}
if err := persistPlaybackResource(saver, &model.Resource{ID: "r1"}, "test"); err == nil {
t.Fatal("expected persist error")
}
if saver.calls != playbackPersistAttempts {
t.Fatalf("calls = %d, want %d", saver.calls, playbackPersistAttempts)
}
}