Files: previews — a toggle that shows a thumbnail per image and video, made by ffmpeg on the machine's GPU where it has one, cached under the data dir

A previews button on the Files tab turns the icon column into a 256 px thumbnail for
every image and video row, loaded as rows scroll near. internal/thumbs probes what the
machine can do rather than assuming it: ffmpeg and ffprobe, every /dev/dri/renderD*
opened and exercised, vainfo's decode profiles per node where it exists. A video goes to
the first GPU that lists its codec, with ffmpeg's own software fallback behind it and a
CPU retry behind that; images decode on the CPU, JPEGs at a reduced DCT size. Previews
are kept under <data dir>/thumbs/ keyed by path, size and mtime, with undecodable files
remembered too. Root mode makes them through the same root helper the file reads use.
Where ffmpeg is missing the tab offers to install it; where a render node exists but is
closed to the daemon's user, it offers a drop-in adding the group and a restart.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-10-11 15:20:27 -07:00
co-authored by Claude Fable 5.1
parent e7034cd478
commit f8c9479e93
13 changed files with 1483 additions and 16 deletions
+217
View File
@@ -0,0 +1,217 @@
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
}