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>
254 lines
8.3 KiB
Go
254 lines
8.3 KiB
Go
package media
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"image"
|
|
"image/color"
|
|
"image/jpeg"
|
|
"image/png"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestKind(t *testing.T) {
|
|
for name, want := range map[string]string{"a.JPG": "image", "b.heic": "image", "c.mkv": "video", "GH010098.MP4": "video", "x.OSV": "video", "d.txt": "", "e": "", ".jpg": "image"} {
|
|
if got := Kind(name); got != want {
|
|
t.Errorf("Kind(%q) = %q, want %q", name, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestKeyFollowsTheFile(t *testing.T) {
|
|
a := Key("/x/a.jpg", 10, 20)
|
|
if a != Key("/x/a.jpg", 10, 20) || len(a) != 64 {
|
|
t.Fatal("key is not stable")
|
|
}
|
|
for _, b := range []string{Key("/x/b.jpg", 10, 20), Key("/x/a.jpg", 11, 20), Key("/x/a.jpg", 10, 21)} {
|
|
if b == a {
|
|
t.Fatal("key ignores part of the file's identity")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseVainfo(t *testing.T) {
|
|
text := `vainfo: Driver version: Intel iHD driver for Intel(R) Gen Graphics - 25.2.3 ()
|
|
VAProfileMPEG2Simple : VAEntrypointVLD
|
|
VAProfileH264Main : VAEntrypointVLD
|
|
VAProfileH264High : VAEntrypointEncSliceLP
|
|
VAProfileJPEGBaseline : VAEntrypointVLD
|
|
VAProfileHEVCMain10 : VAEntrypointVLD
|
|
VAProfileHEVCMain10 : VAEntrypointEncSlice
|
|
VAProfileHEVCMain444_12 : VAEntrypointVLD
|
|
VAProfileVP9Profile2 : VAEntrypointVLD
|
|
VAProfileAV1Profile0 : VAEntrypointEncSliceLP
|
|
`
|
|
dec, enc := parseVainfo(text)
|
|
same := func(got, want []string) bool {
|
|
if len(got) != len(want) {
|
|
return false
|
|
}
|
|
for i := range want {
|
|
if got[i] != want[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
if !same(dec, []string{"h264", "hevc", "mjpeg", "mpeg2video", "vp9"}) {
|
|
t.Fatalf("decodes = %v", dec)
|
|
}
|
|
if !same(enc, []string{"av1", "h264", "hevc"}) {
|
|
t.Fatalf("encodes = %v", enc)
|
|
}
|
|
if d, e := parseVainfo(""); d != nil || e != nil {
|
|
t.Fatal("nothing from nothing")
|
|
}
|
|
}
|
|
|
|
func TestPickAndEngines(t *testing.T) {
|
|
igpu := GPU{API: "vaapi", Device: "/dev/dri/renderD128", Codecs: []string{"h264", "hevc"}}
|
|
arc := GPU{API: "vaapi", Device: "/dev/dri/renderD129", Codecs: []string{"av1", "h264", "hevc"}}
|
|
c := Caps{FFmpeg: "ffmpeg", FFprobe: "ffprobe", GPUs: []GPU{igpu, arc}}
|
|
if g := c.pick("av1"); g == nil || g.Device != arc.Device {
|
|
t.Fatal("av1 should go to the card that lists it")
|
|
}
|
|
if g := c.pick("h264"); g == nil || g.Device != igpu.Device {
|
|
t.Fatal("h264 should go to the first card that lists it")
|
|
}
|
|
if g := c.pick("vc1"); g != nil {
|
|
t.Fatal("a codec nobody lists is software")
|
|
}
|
|
// a card nothing could describe is tried, after the ones that answered
|
|
c.GPUs = append(c.GPUs, GPU{API: "cuda", Device: "0"})
|
|
if g := c.pick("vc1"); g == nil || g.API != "cuda" {
|
|
t.Fatal("an undescribed card is the guess of last resort")
|
|
}
|
|
// videos: GPU then CPU; images: CPU only
|
|
if e := engines(c, "video", fileInfo{codec: "hevc"}); len(e) != 2 || e[0] == nil || e[1] != nil {
|
|
t.Fatal("a video tries the GPU, then the CPU")
|
|
}
|
|
if e := engines(c, "image", fileInfo{codec: "mjpeg"}); len(e) != 1 || e[0] != nil {
|
|
t.Fatal("an image is CPU only")
|
|
}
|
|
}
|
|
|
|
func TestAttempts(t *testing.T) {
|
|
a := attempts(nil, "video", fileInfo{duration: 120}, 256)
|
|
if len(a) != 2 || a[0].seek != 12 || a[1].seek != 0 {
|
|
t.Fatalf("video attempts = %+v", a)
|
|
}
|
|
if a := attempts(nil, "video", fileInfo{duration: 7200}, 256); a[0].seek != 30 {
|
|
t.Fatalf("seek capped at 30 s, got %v", a[0].seek)
|
|
}
|
|
if a := attempts(nil, "video", fileInfo{duration: 2}, 256); len(a) != 1 || a[0].seek != 0 {
|
|
t.Fatalf("a short clip is taken from its start, got %+v", a)
|
|
}
|
|
if a := attempts(nil, "image", fileInfo{codec: "mjpeg", w: 6000, h: 4000}, 256); len(a) != 2 || a[0].lowres != 2 || a[1].lowres != 0 {
|
|
t.Fatalf("jpeg attempts = %+v", a)
|
|
}
|
|
if a := attempts(nil, "image", fileInfo{codec: "mjpeg", w: 800, h: 600}, 256); len(a) != 1 || a[0].lowres != 0 {
|
|
t.Fatalf("a small jpeg decodes whole, got %+v", a)
|
|
}
|
|
if a := attempts(nil, "image", fileInfo{codec: "png", w: 6000, h: 4000}, 256); len(a) != 1 || a[0].lowres != 0 {
|
|
t.Fatalf("only jpeg has lowres, got %+v", a)
|
|
}
|
|
}
|
|
|
|
func TestCache(t *testing.T) {
|
|
c := NewCache(filepath.Join(t.TempDir(), "thumbs.db"))
|
|
k := Key("/a", 1, 2)
|
|
if _, found, err := c.Get(k); found || err != nil {
|
|
t.Fatal("an empty cache found something, or complained")
|
|
}
|
|
if _, err := os.Stat(c.Path); err == nil {
|
|
t.Fatal("a miss must not create the database")
|
|
}
|
|
if n, b := c.Stats(); n != 0 || b != 0 {
|
|
t.Fatal("stats of nothing")
|
|
}
|
|
if err := c.Put(k, Entry{Path: "/a", Engine: "cpu", Codec: "png", Width: 640, Height: 360, JPEG: []byte("jpeg")}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
e, found, err := c.Get(k)
|
|
if err != nil || !found || string(e.JPEG) != "jpeg" || e.Engine != "cpu" || e.Width != 640 || e.Path != "/a" || e.Made.IsZero() {
|
|
t.Fatalf("put then get: %+v %v %v", e, found, err)
|
|
}
|
|
k2 := Key("/b", 1, 2)
|
|
if err := c.Put(k2, Entry{Path: "/b", Failure: "no video stream"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if e, found, _ := c.Get(k2); !found || e.JPEG != nil || e.Failure != "no video stream" {
|
|
t.Fatal("a failure is remembered with its reason")
|
|
}
|
|
if err := c.Put(k, Entry{}); err == nil {
|
|
t.Fatal("an empty preview must not be kept")
|
|
}
|
|
// replacing an entry does not count it twice
|
|
if err := c.Put(k, Entry{Path: "/a", JPEG: []byte("jpeg2")}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
n, b := c.Stats()
|
|
if n != 2 || b == 0 {
|
|
t.Fatalf("stats = %d entries, %d bytes", n, b)
|
|
}
|
|
// survives a reopen
|
|
if err := c.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c2 := NewCache(c.Path)
|
|
if e, found, _ := c2.Get(k); !found || string(e.JPEG) != "jpeg2" {
|
|
t.Fatal("not there after reopening")
|
|
}
|
|
if n, _ := c2.Stats(); n != 2 {
|
|
t.Fatal("count lost on reopen")
|
|
}
|
|
c2.Close()
|
|
}
|
|
|
|
// The real thing, when ffmpeg is here: a PNG and a generated video each come back as a
|
|
// JPEG no larger than the asked size, with the aspect kept.
|
|
func TestMakeWithFFmpeg(t *testing.T) {
|
|
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
|
t.Skip("no ffmpeg")
|
|
}
|
|
c := Probe(context.Background())
|
|
if c.Error != "" {
|
|
t.Skip(c.Error)
|
|
}
|
|
dir := t.TempDir()
|
|
img := image.NewRGBA(image.Rect(0, 0, 640, 360))
|
|
for y := 0; y < 360; y++ {
|
|
for x := 0; x < 640; x++ {
|
|
img.Set(x, y, color.RGBA{uint8(x / 3), uint8(y), 90, 255})
|
|
}
|
|
}
|
|
pngPath := filepath.Join(dir, "a.png")
|
|
f, _ := os.Create(pngPath)
|
|
_ = png.Encode(f, img)
|
|
f.Close()
|
|
res, err := Make(context.Background(), c, pngPath, Opts{Size: 200})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
check(t, res, 200, 112, "cpu")
|
|
|
|
// a two-second test pattern, with the always-built-in mpeg4 encoder
|
|
vid := filepath.Join(dir, "v.mp4")
|
|
out, err := exec.Command("ffmpeg", "-nostdin", "-v", "error", "-f", "lavfi", "-i", "testsrc=size=320x180:rate=10:duration=2", "-c:v", "mpeg4", "-q:v", "5", vid).CombinedOutput()
|
|
if err != nil {
|
|
t.Skipf("could not make a test video: %s", out)
|
|
}
|
|
res, err = Make(context.Background(), c, vid, Opts{Size: 100})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
check(t, res, 100, 56, "")
|
|
if res.Codec != "mpeg4" || res.Width != 320 {
|
|
t.Fatalf("probe said %s %dx%d", res.Codec, res.Width, res.Height)
|
|
}
|
|
|
|
// not media, and media that isn't
|
|
if _, err := Make(context.Background(), c, filepath.Join(dir, "x.txt"), Opts{}); err != ErrUnsupported {
|
|
t.Fatalf("a .txt is unsupported, got %v", err)
|
|
}
|
|
bad := filepath.Join(dir, "bad.mp4")
|
|
_ = os.WriteFile(bad, []byte("not a video at all"), 0o644)
|
|
_, err = Make(context.Background(), c, bad, Opts{})
|
|
var de *DecodeError
|
|
if err == nil || !isDecodeErr(err, &de) {
|
|
t.Fatalf("garbage should be a DecodeError, got %v", err)
|
|
}
|
|
// the clock: a cancelled context is not the file's fault
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Nanosecond)
|
|
defer cancel()
|
|
if _, err := Make(ctx, c, pngPath, Opts{}); err == nil || isDecodeErr(err, &de) {
|
|
t.Fatalf("a timeout must not read as a decode failure, got %v", err)
|
|
}
|
|
}
|
|
|
|
func check(t *testing.T, res Result, w, h int, engine string) {
|
|
t.Helper()
|
|
im, err := jpeg.Decode(bytes.NewReader(res.JPEG))
|
|
if err != nil {
|
|
t.Fatalf("not a jpeg: %v", err)
|
|
}
|
|
if b := im.Bounds(); b.Dx() != w || b.Dy() < h-1 || b.Dy() > h+1 { // the short side rounds either way
|
|
t.Fatalf("got %dx%d, want %dx%d", b.Dx(), b.Dy(), w, h)
|
|
}
|
|
if engine != "" && res.Engine != engine {
|
|
t.Fatalf("engine %q, want %q", res.Engine, engine)
|
|
}
|
|
}
|
|
|
|
func isDecodeErr(err error, de **DecodeError) bool {
|
|
x, ok := err.(*DecodeError)
|
|
if ok {
|
|
*de = x
|
|
}
|
|
return ok
|
|
}
|