Every video plays YouTube's way: hls.js reads the connection and picks the quality, or one is picked by hand, or Original plays the file as it is. The qualities (a ladder by size, H.264) are made on the fly in 3 s pieces from wherever the viewer seeks, one ffmpeg per play, paused when far enough ahead. 360 videos open in a look-around view (drag, pinch or scroll to zoom from 8 to 300 degrees, phone motion). Settings -> Transcode device picks the machine and device that makes them, for every machine's files: a machine transcoding another's file reads it through the hub by a token for that one file. Replaces the codec/size picker and the single-quality re-encoder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
138 lines
5.6 KiB
Go
138 lines
5.6 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 TestLadder(t *testing.T) {
|
||
vr := src("hevc", 7680, 3840, 50)
|
||
vr.Video.Spherical = true
|
||
r := Ladder(vr)
|
||
if len(r) != 4 || r[0].Name != "3840" || r[0].Height != 1920 || r[0].level != "5.2" || r[3].FPS != 25 || r[0].Label != "2160s" {
|
||
t.Fatalf("8K 360: %+v", r)
|
||
}
|
||
if r[0].codecs != "avc1.640034,mp4a.40.2" {
|
||
t.Fatalf("codecs %q", r[0].codecs)
|
||
}
|
||
r = Ladder(src("h264", 2292, 960, 60)) // a capture: its own size on top, then the steps under it
|
||
if len(r) != 3 || r[0].Label != "960p" || r[0].Width != 2292 || r[0].FPS != 60 || r[0].Bitrate != 5_000_000 || r[1].Label != "720p" || r[1].Width != 1720 || r[1].FPS != 30 {
|
||
t.Fatalf("2292×960: %+v", r)
|
||
}
|
||
r = Ladder(src("h264", 1920, 1080, 59.94))
|
||
if len(r) != 3 || r[0].Label != "1080p" || r[0].FPS != 59.94 || r[1].FPS != 29.97 || r[2].Width != 854 {
|
||
t.Fatalf("1080p: %+v", r)
|
||
}
|
||
if r = Ladder(src("h264", 1080, 1920, 30)); r[0].Width != 1080 || r[0].Height != 1920 { // standing up
|
||
t.Fatalf("portrait: %+v", r)
|
||
}
|
||
if r = Ladder(src("h264", 320, 240, 30)); len(r) != 1 || r[0].Width != 320 || r[0].Label != "240p" {
|
||
t.Fatalf("tiny: %+v", r)
|
||
}
|
||
silent := src("h264", 1280, 720, 30)
|
||
silent.Audio = nil
|
||
if r = Ladder(silent); strings.Contains(r[0].codecs, "mp4a") {
|
||
t.Fatalf("no sound, no AAC: %q", r[0].codecs)
|
||
}
|
||
if Ladder(Info{Duration: 10}) != nil {
|
||
t.Fatal("no picture, no ladder")
|
||
}
|
||
if fpsArg(29.97) != "30000/1001" || fpsArg(25) != "25" {
|
||
t.Fatal(fpsArg(29.97), fpsArg(25))
|
||
}
|
||
}
|
||
|
||
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 TestRecipes(t *testing.T) {
|
||
c := gridCaps()
|
||
in := src("hevc", 3840, 2160, 60)
|
||
r1080 := Ladder(in)[2]
|
||
// any device: the card first, decoding on it; then the iGPU the same way; then the CPU
|
||
got := strings.Join(engineNames(recipes(c, "", in, r1080)), "|")
|
||
if got != "Intel Arc A380|Intel Arc A380, decoded on the CPU|Intel UHD Graphics 750|Intel UHD Graphics 750, decoded on the CPU|CPU (libx264)" {
|
||
t.Fatalf("any: %s", got)
|
||
}
|
||
// the iGPU asked for: only it, then the CPU as the fallback
|
||
if got = strings.Join(engineNames(recipes(c, "/dev/dri/renderD128", in, r1080)), "|"); got != "Intel UHD Graphics 750|Intel UHD Graphics 750, decoded on the CPU|CPU (libx264)" {
|
||
t.Fatalf("iGPU: %s", got)
|
||
}
|
||
if got = strings.Join(engineNames(recipes(c, CPU, in, r1080)), "|"); got != "CPU (libx264)" {
|
||
t.Fatalf("cpu: %s", got)
|
||
}
|
||
// an AV1 source: neither card decodes it, so no same-card recipe
|
||
if got = strings.Join(engineNames(recipes(c, "/dev/dri/renderD129", src("av1", 1920, 1080, 30), r1080)), "|"); got != "Intel Arc A380, decoded on the CPU|CPU (libx264)" {
|
||
t.Fatalf("av1: %s", got)
|
||
}
|
||
// a machine with no GPU and no x264: nothing
|
||
bare := Caps{FFmpeg: "ffmpeg", FFprobe: "ffprobe", Encoders: []string{"aac"}}
|
||
if len(recipes(bare, "", in, r1080)) != 0 {
|
||
t.Fatal("no x264 means nothing")
|
||
}
|
||
// 30 fps from 60 for the lower rungs; HDR tone-mapped on both paths
|
||
r720 := Ladder(in)[3]
|
||
hdr := src("hevc", 3840, 2160, 60)
|
||
hdr.Video.ColorTransfer = "smpte2084"
|
||
rs := recipes(c, "", hdr, r720)
|
||
if !strings.HasPrefix(rs[0].VF, "fps=30,tonemap_vaapi") || !strings.Contains(rs[len(rs)-1].VF, "tonemap=hable") {
|
||
t.Fatalf("hdr: %q … %q", rs[0].VF, rs[len(rs)-1].VF)
|
||
}
|
||
if rs := recipes(c, "", in, r1080); strings.Contains(rs[0].VF, "fps=") {
|
||
t.Fatalf("same rate needs no fps filter: %q", rs[0].VF)
|
||
}
|
||
}
|
||
|
||
func TestDevices(t *testing.T) {
|
||
c := gridCaps()
|
||
c.Unusable = []GPU{{Device: "/dev/dri/renderD130", Name: "Something", Reason: "permission denied"}}
|
||
d := Devices(c)
|
||
if len(d) != 4 || !d[0].OK || !d[1].OK || d[2].OK || d[2].Reason == "" || d[3].ID != CPU || !d[3].OK {
|
||
t.Fatalf("%+v", d)
|
||
}
|
||
if d := Devices(Caps{Error: "ffmpeg is not installed"}); len(d) != 1 || d[0].OK {
|
||
t.Fatalf("no ffmpeg: %+v", d)
|
||
}
|
||
}
|
||
|
||
func TestPieceArgs(t *testing.T) {
|
||
in := src("h264", 1920, 1080, 60)
|
||
r := Ladder(in)[0]
|
||
rc := recipes(gridCaps(), "/dev/dri/renderD129", in, r)[0]
|
||
a := strings.Join(pieceArgs("/d", "/v.mkv", in, rc, r, 12), " ")
|
||
for _, want := range []string{"-hwaccel vaapi -hwaccel_device /dev/dri/renderD129", "-ss 36.000 -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 5000k -maxrate 7500k", "-level 4.2", "-c:a aac -b:a 160k -ac 2",
|
||
"-force_key_frames expr:gte(t,n_forced*3)", "-output_ts_offset 46.000", "-hls_time 2.50", "-start_number 12", "-hls_segment_filename /d/%d.ts"} {
|
||
if !strings.Contains(a, want) {
|
||
t.Errorf("missing %q in\n%s", want, a)
|
||
}
|
||
}
|
||
if strings.Contains(a, "-reconnect") {
|
||
t.Error("a path needs no reconnect")
|
||
}
|
||
if a := strings.Join(pieceArgs("/d", "http://10.0.0.234:8100/api/play-src/x", in, rc, r, 0), " "); !strings.Contains(a, "-reconnect 1") {
|
||
t.Error("a URL reconnects")
|
||
}
|
||
in.Audio = nil
|
||
if a := strings.Join(pieceArgs("/d", "/v", in, rc, r, 0), " "); !strings.Contains(a, "-an") {
|
||
t.Error("no sound means -an")
|
||
}
|
||
}
|