bbolt instead of a tree of hash-named JPEGs: pure Go, so the binary stays static; one file per machine; an entry carries where the preview came from (engine, codec, size, when) beside the picture, and a count rides along so the tab can say what it holds without walking anything. A miss never creates the file, so a machine nobody browses with previews on never has one; a cache that cannot be opened is logged and previews are still made and served, just not kept. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
242 lines
8.0 KiB
Go
242 lines
8.0 KiB
Go
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 := 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
|
|
}
|