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 }