package thumbs 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 VAProfileHEVCMain444_12 : VAEntrypointVLD VAProfileVP9Profile2 : VAEntrypointVLD VAProfileAV1Profile0 : VAEntrypointEncSliceLP ` got := parseVainfo(text) want := []string{"h264", "hevc", "mjpeg", "mpeg2video", "vp9"} if len(got) != len(want) { t.Fatalf("got %v, want %v", got, want) } for i := range want { if got[i] != want[i] { t.Fatalf("got %v, want %v", got, want) } } } 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 := Cache{Dir: t.TempDir()} k := Key("/a", 1, 2) if _, _, found := c.Get(k); found { t.Fatal("empty cache found something") } if err := c.Put(k, []byte("jpeg")); err != nil { t.Fatal(err) } if b, _, found := c.Get(k); !found || string(b) != "jpeg" { t.Fatal("put then get") } k2 := Key("/b", 1, 2) if err := c.PutFailure(k2, "no video stream"); err != nil { t.Fatal(err) } if b, why, found := c.Get(k2); !found || b != nil || why != "no video stream" { t.Fatal("a failure is remembered with its reason") } if err := c.Put(k, nil); err == nil { t.Fatal("an empty preview must not be kept") } if left, _ := filepath.Glob(filepath.Join(c.Dir, "*", ".tmp-*")); len(left) != 0 { t.Fatal("temp files left behind") } } // 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 }