Found by walking every handler and the UI with odd inputs, then a battery of requests against the live hub. Fixed: - A save over the 8 MiB cap was cut short in place: the file was truncated, then the first 8 MiB written, and the caller told 204. Now the body is read whole first and a body over the cap is a 413 with the file untouched. - A link to a file showed the link's own time; now the file's, so the row reads right and the preview key follows the file. - Reading a link to nothing answered 200 with an empty body (the open failed after the headers in root mode); now a 404 that names the target. - A play request with an unknown codec or size was answered by inspecting the file first, so an audio-only file said "no video stream" instead of what was wrong; the choice is checked before any work, and is a 400. - A stream that ended in failure was handed back to the next identical request for ten minutes; a failed one is now dropped and a fresh one made. - A player that left while its stream was still finding a first segment made the loop try the next recipe; it stops. A stream closed mid-start is not started again. - Ending a root-mode ffmpeg could wait forever: the daemon cannot signal root's processes, so the wait is bounded and the helper's own grace does the rest. - A page going away (a phone closing the tab) releases its stream by a beacon, rather than ten minutes later by the sweeper. - The path box resolves like a shell — relative to the folder shown, . and .. applied, trailing slashes dropped — in the tab and in the Move picker, with a test. A typed path that is a file opens it; a remembered folder that is gone falls back to / once. - A dropped folder arrived as a nameless empty file; it is refused with a word. The upload toast counts what was actually uploaded. - The viewer names a device, pipe or socket, and a dangling link, instead of calling them binary files. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
129 lines
5.5 KiB
Go
129 lines
5.5 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)
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|