The viewer tries the file as it is first. When the browser gives up — Safari and an MKV, HEVC without a hardware decoder — or on a tap, a picker: codec (H.264, HEVC, AV1, or the original codec repackaged) and size (original, 1080p, 720p, 480p), each codec labelled with the engine this machine would use. POST /api/files/play runs ffmpeg into an HLS event playlist of fragmented-MP4 segments under <data dir>/transcode/<id>/; Safari plays HLS itself and everything else goes through hls.js, loaded only then. The recipes are tried best first: a card on the bus before the integrated GPU, decoding on the same card before decoding on the CPU and uploading, the software encoders last; a recipe that produces no first segment is dropped for the next. The seekable range grows as ffmpeg gets ahead; status says how far. Streams are reused for the same request, closed when the player goes, swept after ten idle minutes, and the folder is emptied at start. Root mode runs ffmpeg as root through a helper that ends when the daemon lets go of it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
120 lines
5.2 KiB
Go
120 lines
5.2 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 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)
|
|
}
|
|
}
|