A re-encode's playlist is now the whole film on a fixed 4 s grid. A segment that isn't on disk and isn't coming soon moves the encode there; runs stop short of segments already made, and gaps are filled after. Every run is cut the same way (-ss, -output_ts_offset start+10, frag_discont), so segments from different runs join. Repackaging keeps its growing playlist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
158 lines
6.9 KiB
Go
158 lines
6.9 KiB
Go
package media
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func src(codec string, w, h int, fps float64) Info {
|
|
return Info{Duration: 100, Video: &Track{Codec: codec, Width: w, Height: h, FPS: fps}, Audio: &Track{Codec: "opus", Channels: 2}}
|
|
}
|
|
|
|
func TestFit(t *testing.T) {
|
|
q := func(id string) Quality { x, _ := qualityByID(id); return x }
|
|
for _, c := range []struct {
|
|
q string
|
|
in Info
|
|
codec string
|
|
w, h, kbs int
|
|
}{
|
|
{"1080p", src("hevc", 3840, 2160, 60), "h264", 1920, 1080, 10000},
|
|
{"original", src("hevc", 3840, 2160, 30), "h264", 3840, 2160, 20000},
|
|
{"720p", src("h264", 2292, 960, 60), "h264", 1718, 720, 5000}, // odd width made even
|
|
{"1080p", src("h264", 1280, 720, 30), "h264", 1280, 720, 4000}, // never upscaled
|
|
{"480p", src("h264", 1920, 1080, 30), "hevc", 852, 480, 1200}, // 853.3 rounds down, then even
|
|
{"original", src("av1", 640, 360, 24), "av1", 640, 360, 1200},
|
|
} {
|
|
w, h, k := fit(q(c.q), c.in, c.codec)
|
|
if w != c.w || h != c.h || k != c.kbs {
|
|
t.Errorf("%s %s %dx%d@%.0f → %dx%d %dk, want %dx%d %dk", c.q, c.codec, c.in.Video.Width, c.in.Video.Height, c.in.Video.FPS, w, h, k, c.w, c.h, c.kbs)
|
|
}
|
|
}
|
|
}
|
|
|
|
func gridCaps() Caps {
|
|
return Caps{FFmpeg: "ffmpeg", FFprobe: "ffprobe", Encoders: []string{"aac", "libsvtav1", "libx264", "libx265"},
|
|
GPUs: []GPU{
|
|
{API: "vaapi", Device: "/dev/dri/renderD128", Name: "Intel UHD Graphics 750", Integrated: true, Codecs: []string{"h264", "hevc", "vp9"}, Encodes: []string{"h264", "hevc"}},
|
|
{API: "vaapi", Device: "/dev/dri/renderD129", Name: "Intel Arc A380", Codecs: []string{"h264", "hevc", "vp9"}, Encodes: []string{"av1", "h264", "hevc"}},
|
|
}}
|
|
}
|
|
|
|
func engineNames(r []Recipe) []string {
|
|
out := make([]string, len(r))
|
|
for i := range r {
|
|
out[i] = r[i].Engine
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestRecipesOrder(t *testing.T) {
|
|
c := gridCaps()
|
|
// h264 from hevc: the card first, decoding on it; then the iGPU the same way; then the CPU
|
|
got := engineNames(recipes(c, "h264", src("hevc", 3840, 2160, 60), 1920, 1080, 10000))
|
|
want := []string{"Intel Arc A380 (vaapi)", "Intel Arc A380 (vaapi, decoded on the CPU)", "Intel UHD Graphics 750 (vaapi)", "Intel UHD Graphics 750 (vaapi, decoded on the CPU)", "CPU (libx264)"}
|
|
if strings.Join(got, "|") != strings.Join(want, "|") {
|
|
t.Fatalf("got %v", got)
|
|
}
|
|
// av1 source: neither card decodes it, so no same-card recipe, and only the Arc encodes av1
|
|
got = engineNames(recipes(c, "av1", src("av1", 1920, 1080, 30), 1920, 1080, 4800))
|
|
want = []string{"Intel Arc A380 (vaapi, decoded on the CPU)", "CPU (SVT-AV1)"}
|
|
if strings.Join(got, "|") != strings.Join(want, "|") {
|
|
t.Fatalf("av1: got %v", got)
|
|
}
|
|
// copy is one recipe, no filter, hvc1 for hevc
|
|
r := recipes(c, "copy", src("hevc", 1920, 1080, 30), 1920, 1080, 0)
|
|
if len(r) != 1 || r[0].VF != "" || !has(r[0].Video, "hvc1") || !has(r[0].Video, "copy") {
|
|
t.Fatalf("copy: %+v", r)
|
|
}
|
|
// a machine with no GPU and no x264: nothing for h264, something for av1
|
|
bare := Caps{FFmpeg: "ffmpeg", FFprobe: "ffprobe", Encoders: []string{"aac", "libsvtav1"}}
|
|
if len(recipes(bare, "h264", src("h264", 1280, 720, 30), 1280, 720, 4000)) != 0 {
|
|
t.Fatal("no x264 means no h264")
|
|
}
|
|
if EngineFor(bare, "av1") != "CPU (SVT-AV1)" || EngineFor(bare, "h264") != "" || EngineFor(bare, "copy") != "Original codec" {
|
|
t.Fatal("engineFor")
|
|
}
|
|
if EngineFor(c, "hevc") != "Intel Arc A380 (vaapi)" {
|
|
t.Fatalf("engineFor on grid: %q", EngineFor(c, "hevc"))
|
|
}
|
|
// HDR gets tone-mapped on both paths
|
|
hdr := src("hevc", 3840, 2160, 60)
|
|
hdr.Video.ColorTransfer = "smpte2084"
|
|
r = recipes(c, "h264", hdr, 1920, 1080, 10000)
|
|
if !strings.HasPrefix(r[0].VF, "tonemap_vaapi") || !strings.Contains(r[len(r)-1].VF, "tonemap=hable") {
|
|
t.Fatalf("hdr: %q … %q", r[0].VF, r[len(r)-1].VF)
|
|
}
|
|
}
|
|
|
|
func TestHLSArgs(t *testing.T) {
|
|
in := src("h264", 1920, 1080, 60)
|
|
r := recipes(gridCaps(), "h264", in, 1920, 1080, 10000)[0]
|
|
a := strings.Join(hlsArgs("/d", "/v.mkv", in, r, false), " ")
|
|
for _, want := range []string{"-hwaccel vaapi -hwaccel_device /dev/dri/renderD129", "-i /v.mkv", "-map 0:v:0 -map 0:a:0?", "-vf scale_vaapi=w=1920:h=1080:format=nv12", "-c:v h264_vaapi -b:v 10000k -maxrate 12500k", "-c:a aac -b:a 160k -ac 2", "-g 240 -force_key_frames expr:gte(t,n_forced*4)", "-hls_segment_type fmp4", "-hls_segment_filename /d/seg%05d.m4s", "/d/index.m3u8"} {
|
|
if !strings.Contains(a, want) {
|
|
t.Errorf("missing %q in\n%s", want, a)
|
|
}
|
|
}
|
|
// aac stays as it is; no audio means none; copy sets no keyframes
|
|
in.Audio.Codec = "aac"
|
|
if a := strings.Join(hlsArgs("/d", "/v", in, r, false), " "); !strings.Contains(a, "-c:a copy") {
|
|
t.Error("aac should be copied")
|
|
}
|
|
in.Audio = nil
|
|
if a := strings.Join(hlsArgs("/d", "/v", in, r, false), " "); !strings.Contains(a, " -an ") {
|
|
t.Error("no audio stream → -an")
|
|
}
|
|
cp := recipes(gridCaps(), "copy", in, 1920, 1080, 0)[0]
|
|
if a := strings.Join(hlsArgs("/d", "/v", in, cp, true), " "); strings.Contains(a, "force_key_frames") || strings.Contains(a, "-vf") {
|
|
t.Error("copy must not ask for keyframes or filter")
|
|
}
|
|
}
|
|
|
|
func TestSegArgs(t *testing.T) {
|
|
in := src("h264", 1920, 1080, 25)
|
|
r := recipes(gridCaps(), "h264", in, 1920, 1080, 10000)[0]
|
|
first := strings.Join(segArgs("/d", "/v.mkv", in, r, 0, 900, 900, "init.mp4"), " ")
|
|
for _, want := range []string{"-ss 0 -i /v.mkv", "-output_ts_offset 10", "-start_number 0", "movflags=+frag_discont", "-hls_fmp4_init_filename init.mp4", "-hls_segment_filename /d/seg%05d.m4s", "/d/run.m3u8", "-force_key_frames expr:gte(t,n_forced*4)"} {
|
|
if !strings.Contains(first, want) {
|
|
t.Errorf("first run: missing %q in\n%s", want, first)
|
|
}
|
|
}
|
|
if strings.Contains(first, " -t ") {
|
|
t.Error("a run to the end has no -t")
|
|
}
|
|
// a seek: segments 30..44, stopping half a frame short of 45, which is on disk
|
|
seek := strings.Join(segArgs("/d", "/v.mkv", in, r, 30, 45, 900, "init-run.mp4"), " ")
|
|
for _, want := range []string{"-ss 120 -i /v.mkv", "-t 59.980", "-output_ts_offset 130", "-start_number 30", "-hls_fmp4_init_filename init-run.mp4"} {
|
|
if !strings.Contains(seek, want) {
|
|
t.Errorf("seek run: missing %q in\n%s", want, seek)
|
|
}
|
|
}
|
|
if strings.Index(seek, "-ss 120") > strings.Index(seek, "-i /v.mkv") || strings.Index(seek, "-hwaccel") > strings.Index(seek, "-i /v.mkv") {
|
|
t.Error("-ss and the decoder's options go before -i")
|
|
}
|
|
for d, n := range map[float64]int{9: 3, 8.2: 2, 8.31: 3, 64: 16, 0.1: 1, 3600: 900} {
|
|
if got := segCount(d); got != n {
|
|
t.Errorf("segCount(%v) = %d, want %d", d, got, n)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseProgress(t *testing.T) {
|
|
b := []byte("frame=10\nout_time_us=1000000\nspeed=2.5x\nprogress=continue\nframe=20\nout_time_us=2500000\nspeed=3.1x\nprogress=continue\n")
|
|
s, sp := parseProgress(b)
|
|
if s != 2.5 || sp != 3.1 {
|
|
t.Fatalf("got %v %v", s, sp)
|
|
}
|
|
}
|
|
|
|
func TestValidChoice(t *testing.T) {
|
|
if err := ValidChoice("h264", "720p"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if ValidChoice("vp8", "720p") == nil || ValidChoice("h264", "9p") == nil || ValidChoice("", "") == nil {
|
|
t.Fatal("bad choices must be refused")
|
|
}
|
|
}
|