internal/thumbs is internal/media, with Inspect: what a file holds, from ffprobe

The package was named for its first job; it now also decides what a file is for the
Files tab (image, video, audio) and reads its streams, which the viewer will need. Inspect
is the richer probe — codec, profile, pixel format, size, frame rate, HDR transfer, the
audio beside it, container and duration — run as root through sudo when asked.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-10-11 16:54:15 -07:00
co-authored by Claude Fable 5.1
parent beec9269fe
commit af189b1fa1
8 changed files with 174 additions and 32 deletions
+218
View File
@@ -0,0 +1,218 @@
package media
import (
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"errors"
"os"
"strconv"
"sync"
"time"
bolt "go.etcd.io/bbolt"
)
// Cache keeps finished previews in one database file under Ham Lab's own data directory —
// thumbs.db, a bbolt key/value store: pure Go, so the binary stays static, and one file
// per machine rather than a tree of hash-named JPEGs. An entry is one file's preview keyed
// by its path, size and mtime, so a file that changes gets a new preview and an unchanged
// one is never decoded twice. A file ffmpeg could not decode is kept the same way, with
// ffmpeg's reason and no picture, so a folder of odd files does not re-run ffmpeg on every
// visit. Nothing expires: a preview is a few kilobytes, and only files somebody actually
// scrolled past are ever made. The file is opened the first time something is stored, so a
// machine nobody browses with previews on never has one.
type Cache struct {
Path string
mu sync.Mutex
db *bolt.DB
err error
}
// Entry is one cached preview, or the remembered reason there is none.
type Entry struct {
Path string `json:"path"`
Engine string `json:"engine,omitempty"`
Codec string `json:"codec,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
Made time.Time `json:"made"`
Failure string `json:"failure,omitempty"` // set instead of JPEG when ffmpeg refused the file
JPEG []byte `json:"-"`
}
var (
bucketThumbs = []byte("thumbs")
bucketMeta = []byte("meta")
keyCount = []byte("count")
)
// Key identifies one file's preview. "v1" is the preview format: bump it to retire every
// cached preview at once.
func Key(path string, size, mtime int64) string {
h := sha256.Sum256([]byte("v1\x00" + path + "\x00" + strconv.FormatInt(size, 10) + "\x00" + strconv.FormatInt(mtime, 10)))
return hex.EncodeToString(h[:])
}
func NewCache(path string) *Cache { return &Cache{Path: path} }
// open the database once; an open that failed is not retried, and previews still get made
// and served — they are simply not kept, and the log says why.
func (c *Cache) open() (*bolt.DB, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.db != nil || c.err != nil {
return c.db, c.err
}
db, err := bolt.Open(c.Path, 0o600, &bolt.Options{Timeout: 2 * time.Second})
if err != nil {
c.err = err
return nil, err
}
if err := db.Update(func(tx *bolt.Tx) error {
if _, err := tx.CreateBucketIfNotExists(bucketThumbs); err != nil {
return err
}
_, err := tx.CreateBucketIfNotExists(bucketMeta)
return err
}); err != nil {
db.Close()
c.err = err
return nil, err
}
c.db = db
return db, nil
}
// Close releases the file. Previews after it are made but not kept.
func (c *Cache) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.db == nil {
return nil
}
err := c.db.Close()
c.db, c.err = nil, errors.New("cache closed")
return err
}
// Get returns the entry for a key. A database that does not exist yet is a miss, not a reason
// to make one.
func (c *Cache) Get(key string) (Entry, bool, error) {
c.mu.Lock()
db := c.db
c.mu.Unlock()
if db == nil {
if _, err := os.Stat(c.Path); err != nil {
return Entry{}, false, nil
}
var err error
if db, err = c.open(); err != nil {
return Entry{}, false, err
}
}
var e Entry
found := false
err := db.View(func(tx *bolt.Tx) error {
v := tx.Bucket(bucketThumbs).Get([]byte(key))
if v == nil {
return nil
}
var err error
e, err = decode(v) // copies: a value is only valid inside the transaction
found = err == nil
return err
})
return e, found, err
}
// Put stores an entry, replacing any under the same key.
func (c *Cache) Put(key string, e Entry) error {
if len(e.JPEG) == 0 && e.Failure == "" {
return errors.New("empty preview")
}
if e.Made.IsZero() {
e.Made = time.Now()
}
db, err := c.open()
if err != nil {
return err
}
v, err := encode(e)
if err != nil {
return err
}
return db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketThumbs)
k := []byte(key)
fresh := b.Get(k) == nil
if err := b.Put(k, v); err != nil {
return err
}
if fresh {
m := tx.Bucket(bucketMeta)
return m.Put(keyCount, be64(count(m)+1))
}
return nil
})
}
// Stats is how many previews are kept and how big the file is.
func (c *Cache) Stats() (entries int64, bytes int64) {
st, err := os.Stat(c.Path)
if err != nil {
return 0, 0
}
bytes = st.Size()
db, err := c.open()
if err != nil {
return 0, bytes
}
_ = db.View(func(tx *bolt.Tx) error { entries = count(tx.Bucket(bucketMeta)); return nil })
return entries, bytes
}
func count(m *bolt.Bucket) int64 {
if v := m.Get(keyCount); len(v) == 8 {
return int64(binary.BigEndian.Uint64(v))
}
return 0
}
func be64(n int64) []byte {
b := make([]byte, 8)
binary.BigEndian.PutUint64(b, uint64(n))
return b
}
// A value is a 4-byte header length, the header as JSON, then the JPEG bytes.
func encode(e Entry) ([]byte, error) {
hdr, err := json.Marshal(e)
if err != nil {
return nil, err
}
v := make([]byte, 4+len(hdr)+len(e.JPEG))
binary.BigEndian.PutUint32(v, uint32(len(hdr)))
copy(v[4:], hdr)
copy(v[4+len(hdr):], e.JPEG)
return v, nil
}
func decode(v []byte) (Entry, error) {
if len(v) < 4 {
return Entry{}, errors.New("short value")
}
n := int(binary.BigEndian.Uint32(v))
if 4+n > len(v) {
return Entry{}, errors.New("bad header length")
}
var e Entry
if err := json.Unmarshal(v[4:4+n], &e); err != nil {
return Entry{}, err
}
if rest := v[4+n:]; len(rest) > 0 {
e.JPEG = append([]byte(nil), rest...)
}
return e, nil
}
+142
View File
@@ -0,0 +1,142 @@
package media
import (
"bytes"
"context"
"encoding/json"
"errors"
"os/exec"
"strconv"
"strings"
"hamlab/internal/priv"
)
// Stream is one stream of a media file, as ffprobe reports it.
type Stream struct {
Index int `json:"index"`
Type string `json:"type"` // video | audio
Codec string `json:"codec"`
Profile string `json:"profile,omitempty"`
PixFmt string `json:"pix_fmt,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
FPS float64 `json:"fps,omitempty"`
ColorTransfer string `json:"color_transfer,omitempty"` // smpte2084 / arib-std-b67 mean HDR
Channels int `json:"channels,omitempty"`
AttachedPic bool `json:"attached_pic,omitempty"`
}
// Info is what a file holds: the picture to take, the sound to go with it, and the whole.
type Info struct {
Container string `json:"container"` // "matroska,webm", "mov,mp4,m4a,3gp,3g2,mj2"
Duration float64 `json:"duration"`
BitRate int64 `json:"bit_rate,omitempty"` // bits per second, whole file
Size int64 `json:"size,omitempty"`
Video *Stream `json:"video,omitempty"` // the first real picture stream; cover art is not it
Audio *Stream `json:"audio,omitempty"` // the first sound stream
}
// HDR reports a picture that needs tone mapping to look right on an SDR screen.
func (i Info) HDR() bool {
return i.Video != nil && (i.Video.ColorTransfer == "smpte2084" || i.Video.ColorTransfer == "arib-std-b67")
}
type probeJSON struct {
Streams []struct {
Index int `json:"index"`
CodecType string `json:"codec_type"`
CodecName string `json:"codec_name"`
Profile string `json:"profile"`
PixFmt string `json:"pix_fmt"`
Width int `json:"width"`
Height int `json:"height"`
RFrameRate string `json:"r_frame_rate"`
AvgFrameRate string `json:"avg_frame_rate"`
ColorTransfer string `json:"color_transfer"`
Channels int `json:"channels"`
Disposition struct {
AttachedPic int `json:"attached_pic"`
} `json:"disposition"`
} `json:"streams"`
Format struct {
FormatName string `json:"format_name"`
Duration string `json:"duration"`
BitRate string `json:"bit_rate"`
Size string `json:"size"`
} `json:"format"`
}
// Inspect asks ffprobe what a file is. As root through sudo when asked, for a file only
// root may read. An error about the file itself is a DecodeError.
func Inspect(ctx context.Context, c Caps, path string, root bool) (Info, error) {
if c.FFprobe == "" {
return Info{}, errors.New("ffprobe is not installed")
}
argv := []string{c.FFprobe, "-v", "error", "-show_entries",
"stream=index,codec_type,codec_name,profile,pix_fmt,width,height,r_frame_rate,avg_frame_rate,color_transfer,channels,disposition:format=format_name,duration,bit_rate,size",
"-of", "json", path}
if root {
argv = priv.Wrap(argv...)
}
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
var errb bytes.Buffer
cmd.Stderr = &errb
out, err := cmd.Output()
if err != nil {
if ctx.Err() != nil {
return Info{}, ctx.Err()
}
msg := lastLine(errb.String())
if msg == "" {
msg = "ffprobe: " + err.Error()
}
return Info{}, &DecodeError{Msg: msg}
}
var pj probeJSON
if err := json.Unmarshal(out, &pj); err != nil {
return Info{}, &DecodeError{Msg: "ffprobe: unreadable answer"}
}
in := Info{Container: pj.Format.FormatName}
in.Duration, _ = strconv.ParseFloat(pj.Format.Duration, 64)
in.BitRate, _ = strconv.ParseInt(pj.Format.BitRate, 10, 64)
in.Size, _ = strconv.ParseInt(pj.Format.Size, 10, 64)
var art *Stream
for _, s := range pj.Streams {
st := &Stream{Index: s.Index, Type: s.CodecType, Codec: s.CodecName, Profile: s.Profile, PixFmt: s.PixFmt, Width: s.Width, Height: s.Height,
ColorTransfer: s.ColorTransfer, Channels: s.Channels, AttachedPic: s.Disposition.AttachedPic != 0}
st.FPS = fps(s.AvgFrameRate)
if st.FPS == 0 {
st.FPS = fps(s.RFrameRate)
}
switch {
case s.CodecType == "video" && st.AttachedPic:
if art == nil {
art = st
}
case s.CodecType == "video" && in.Video == nil:
in.Video = st
case s.CodecType == "audio" && in.Audio == nil:
in.Audio = st
}
}
if in.Video == nil {
in.Video = art // cover art is the only picture there is
}
return in, nil
}
// fps parses ffprobe's "60000/1001".
func fps(r string) float64 {
n, d, ok := strings.Cut(r, "/")
if !ok {
v, _ := strconv.ParseFloat(r, 64)
return v
}
nf, _ := strconv.ParseFloat(n, 64)
df, _ := strconv.ParseFloat(d, 64)
if df == 0 {
return 0
}
return nf / df
}
+26
View File
@@ -0,0 +1,26 @@
package media
import (
"fmt"
"os"
"os/user"
"strconv"
"syscall"
)
// ownerOf is "root:render 0660" for a device node, the thing to say when it cannot be opened.
func ownerOf(st os.FileInfo) string {
sys, ok := st.Sys().(*syscall.Stat_t)
if !ok {
return ""
}
u := strconv.Itoa(int(sys.Uid))
if x, err := user.LookupId(u); err == nil {
u = x.Username
}
g := strconv.Itoa(int(sys.Gid))
if x, err := user.LookupGroupId(g); err == nil {
g = x.Name
}
return fmt.Sprintf("%s:%s %04o", u, g, st.Mode().Perm())
}
+619
View File
@@ -0,0 +1,619 @@
// Package thumbs makes the Files tab's previews: one small JPEG for an image or a video,
// by ffmpeg, decoded on a GPU where the machine has one it can use and on the CPU
// otherwise.
//
// Nothing about the machine is assumed. A probe looks ffmpeg and ffprobe up, opens every
// /dev/dri/renderD* and exercises it (a VAAPI upload and download of a synthetic frame),
// so a node the daemon's user cannot open, or a driver that does not speak VAAPI, is
// reported once rather than tried per file. vainfo, where installed, says which codecs
// each node decodes, and a video goes to the first node that lists its codec; without
// vainfo the first working node is tried and ffmpeg falls back to software on its own when
// the stream turns out unsupported. A hardware run that fails outright is retried on the
// CPU before the file is called undecodable. Images are always decoded on the CPU: one
// frame of a JPEG at a reduced DCT size costs less than the trip through a GPU.
package media
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
"hamlab/internal/metrics"
"hamlab/internal/priv"
)
// GPU is one hardware decoder as the probe found it.
type GPU struct {
API string `json:"api"` // vaapi | cuda
Device string `json:"device"` // /dev/dri/renderD128; "0" for cuda (its device index)
Name string `json:"name,omitempty"` // "Intel Arc A380"
Codecs []string `json:"codecs,omitempty"` // ffmpeg codec names it decodes; nil when nothing could say
Reason string `json:"reason,omitempty"` // Unusable only: why
Permission bool `json:"permission,omitempty"` // Unusable only: the node exists but this user may not open it
}
// Caps is what the machine can make previews with, as probed.
type Caps struct {
FFmpeg string `json:"ffmpeg,omitempty"`
FFprobe string `json:"ffprobe,omitempty"`
Nice string `json:"nice,omitempty"`
Version string `json:"version,omitempty"`
GPUs []GPU `json:"gpus"` // usable, in preference order
Unusable []GPU `json:"unusable,omitempty"` // render nodes that exist but cannot be used, with the reason
Error string `json:"error,omitempty"` // why nothing can be made at all
ProbedAt time.Time `json:"probed_at"`
}
// Opts shapes one preview.
type Opts struct {
Size int // the long edge in pixels; 256 when zero
Timeout time.Duration // for the whole job, ffprobe and every ffmpeg attempt; 30 s when zero
}
func (o Opts) defaults() Opts {
if o.Size <= 0 {
o.Size = 256
}
if o.Timeout <= 0 {
o.Timeout = 30 * time.Second
}
return o
}
// Result is a finished preview.
type Result struct {
JPEG []byte `json:"-"`
Engine string `json:"engine"` // "cpu", or "vaapi:/dev/dri/renderD129"
Codec string `json:"codec"`
Width int `json:"width"`
Height int `json:"height"`
}
// ErrUnsupported: not an image or a video by its name.
var ErrUnsupported = errors.New("not an image or video")
// A DecodeError is ffmpeg's verdict on the file itself. It will not change until the
// file does, so the caller may remember it.
type DecodeError struct{ Msg string }
func (e *DecodeError) Error() string { return e.Msg }
// ---- what gets a preview ----
var imageExt = set("png jpg jpeg jpe jfif gif webp bmp tif tiff avif heic heif hif jxl svg ico psd tga thm")
var videoExt = set("mp4 m4v mkv webm mov avi wmv flv mpg mpeg mpe m2v ts m2ts mts 3gp 3g2 ogv ogm mxf vob asf rm rmvb divx f4v mjpeg mjpg 360 osv insv lrf")
var audioExt = set("mp3 flac m4a m4b aac ogg oga opus wav wma aiff aif alac ape wv mka")
func set(s string) map[string]bool {
m := map[string]bool{}
for _, w := range strings.Fields(s) {
m[w] = true
}
return m
}
// Kind is "image", "video", "audio" or "" for a file name. An audio file's preview is its
// cover art, when it carries any.
func Kind(name string) string {
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(name), "."))
switch {
case imageExt[ext]:
return "image"
case videoExt[ext]:
return "video"
case audioExt[ext]:
return "audio"
}
return ""
}
// ---- the probe ----
// Probe finds out what this process can make previews with. It runs ffmpeg against every
// render node, so it takes a few hundred milliseconds; callers keep the answer.
func Probe(ctx context.Context) Caps {
c := Caps{ProbedAt: time.Now(), GPUs: []GPU{}}
c.FFmpeg, _ = exec.LookPath("ffmpeg")
c.FFprobe, _ = exec.LookPath("ffprobe")
c.Nice, _ = exec.LookPath("nice")
switch {
case c.FFmpeg == "" && c.FFprobe == "":
c.Error = "ffmpeg is not installed"
case c.FFmpeg == "":
c.Error = "ffmpeg is not installed (ffprobe is)"
case c.FFprobe == "":
c.Error = "ffprobe is not installed (ffmpeg is)"
}
if c.Error != "" {
return c
}
c.Version = ffmpegVersion(ctx, c.FFmpeg)
accels := hwaccels(ctx, c.FFmpeg)
if accels["vaapi"] {
nodes, _ := filepath.Glob("/dev/dri/renderD*")
sort.Strings(nodes)
vainfo, _ := exec.LookPath("vainfo")
for _, dev := range nodes {
g := GPU{API: "vaapi", Device: dev, Name: metrics.RenderNodeName(dev)}
if err := openable(dev); err != nil {
g.Reason, g.Permission = err.Error(), errors.Is(err, os.ErrPermission)
c.Unusable = append(c.Unusable, g)
continue
}
if msg := hwWorks(ctx, c.FFmpeg, "vaapi=va:"+dev); msg != "" {
g.Reason = msg
c.Unusable = append(c.Unusable, g)
continue
}
if vainfo != "" {
g.Codecs = vainfoCodecs(ctx, vainfo, dev)
}
c.GPUs = append(c.GPUs, g)
}
}
if accels["cuda"] && exists("/dev/nvidiactl") {
g := GPU{API: "cuda", Device: "0", Name: "NVIDIA GPU"}
if msg := hwWorks(ctx, c.FFmpeg, "cuda=cu:0"); msg != "" {
g.Reason = msg
c.Unusable = append(c.Unusable, g)
} else {
c.GPUs = append(c.GPUs, g)
}
}
return c
}
// openable: the node can be opened read-write, which is what a VAAPI driver needs. The
// usual reason it can't be is the owning group: a render node is root:render 0660 on
// most distributions, and a daemon running as an ordinary user is not in render.
func openable(dev string) error {
f, err := os.OpenFile(dev, os.O_RDWR, 0)
if err != nil {
if errors.Is(err, os.ErrPermission) {
owner := ""
if st, serr := os.Stat(dev); serr == nil {
owner = ownerOf(st)
}
if owner != "" {
return fmt.Errorf("%w: %s is %s", os.ErrPermission, dev, owner)
}
return fmt.Errorf("%w: cannot open %s", os.ErrPermission, dev)
}
return err
}
f.Close()
return nil
}
// hwWorks initialises a hardware device in ffmpeg and moves one synthetic frame through
// it. "" when it works, else the last thing ffmpeg said.
func hwWorks(ctx context.Context, ffmpeg, device string) string {
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
name, rest, _ := strings.Cut(device, "=") // "vaapi", "va:/dev/…"
label, _, _ := strings.Cut(rest, ":") // "va"
cmd := exec.CommandContext(ctx, ffmpeg, "-nostdin", "-hide_banner", "-v", "error", "-init_hw_device", device, "-filter_hw_device", label,
"-f", "lavfi", "-i", "color=c=black:s=64x64:r=1:d=1", "-vf", "format=nv12,hwupload,hwdownload,format=nv12", "-frames:v", "1", "-f", "null", "-")
var errb bytes.Buffer
cmd.Stderr = &errb
if err := cmd.Run(); err != nil {
msg := lastLine(errb.String())
if msg == "" {
msg = err.Error()
}
return name + ": " + msg
}
return ""
}
func ffmpegVersion(ctx context.Context, ffmpeg string) string {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
out, _ := exec.CommandContext(ctx, ffmpeg, "-version").Output()
f := strings.Fields(string(out)) // "ffmpeg version 7.1.5-0+deb13u1 Copyright …"
if len(f) >= 3 && f[1] == "version" {
v := f[2]
if i := strings.IndexAny(v, "-+~"); i > 0 {
v = v[:i]
}
return v
}
return ""
}
func hwaccels(ctx context.Context, ffmpeg string) map[string]bool {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
out, _ := exec.CommandContext(ctx, ffmpeg, "-hide_banner", "-hwaccels").Output()
m := map[string]bool{}
for _, l := range strings.Split(string(out), "\n") {
l = strings.TrimSpace(l)
if l != "" && !strings.HasPrefix(l, "Hardware acceleration methods") {
m[l] = true
}
}
return m
}
var vaProfileRe = regexp.MustCompile(`VAProfile([A-Za-z0-9]+?)(?:Simple|Main|High|Profile|Baseline|Constrained|Scc|Advanced|Version|Stereo|Multiview)\w*\s*:\s*VAEntrypointVLD`)
// vaCodec maps a VAProfile family to the ffmpeg codec it decodes.
var vaCodec = map[string]string{"H264": "h264", "HEVC": "hevc", "VP9": "vp9", "AV1": "av1", "MPEG2": "mpeg2video", "VC1": "vc1", "VP8": "vp8", "JPEG": "mjpeg", "MPEG4": "mpeg4", "H263": "h263"}
func vainfoCodecs(ctx context.Context, vainfo, dev string) []string {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
out, _ := exec.CommandContext(ctx, vainfo, "--display", "drm", "--device", dev).Output()
return parseVainfo(string(out))
}
// parseVainfo keeps the decode (VLD) profiles and names their codecs the way ffmpeg does.
func parseVainfo(text string) []string {
seen := map[string]bool{}
for _, m := range vaProfileRe.FindAllStringSubmatch(text, -1) {
if c, ok := vaCodec[m[1]]; ok {
seen[c] = true
}
}
out := make([]string, 0, len(seen))
for c := range seen {
out = append(out, c)
}
sort.Strings(out)
return out
}
// pick is the GPU for a codec: the first that lists it, else the first that could not
// say (ffmpeg will fall back to software by itself if that guess is wrong), else none.
func (c Caps) pick(codec string) *GPU {
var unknown *GPU
for i := range c.GPUs {
g := &c.GPUs[i]
if g.Codecs == nil {
if unknown == nil {
unknown = g
}
continue
}
for _, x := range g.Codecs {
if x == codec {
return g
}
}
}
return unknown
}
// ---- making one ----
type fileInfo struct {
index string // the stream to take
codec string
w, h int
duration float64
}
type attempt struct {
gpu *GPU
lowres int
seek float64
}
func (a attempt) engine() string {
if a.gpu == nil {
return "cpu"
}
return a.gpu.API + ":" + a.gpu.Device
}
// Make produces the preview of one file.
func Make(ctx context.Context, c Caps, path string, o Opts) (Result, error) {
kind := Kind(path)
if kind == "" {
return Result{}, ErrUnsupported
}
if c.Error != "" {
return Result{}, errors.New(c.Error)
}
o = o.defaults()
ctx, cancel := context.WithTimeout(ctx, o.Timeout)
defer cancel()
fi, err := probeFile(ctx, c, path)
if err != nil {
return Result{}, err
}
var last string
for _, eng := range engines(c, kind, fi) {
for _, a := range attempts(eng, kind, fi, o.Size) {
data, msg, err := run(ctx, c, a, path, fi, o.Size)
if err == nil && len(data) > 0 {
return Result{JPEG: data, Engine: a.engine(), Codec: fi.codec, Width: fi.w, Height: fi.h}, nil
}
if ctx.Err() != nil {
return Result{}, ctx.Err() // the clock ran out: not the file's fault
}
if err != nil { // the decoder refused: try the next engine, not another seek
last = msg
break
}
last = "no frame at " + strconv.FormatFloat(a.seek, 'f', 1, 64) + " s"
}
}
if last == "" {
last = "could not decode"
}
return Result{}, &DecodeError{Msg: last}
}
// engines is the order to try: the fitting GPU first for a video, then the CPU.
func engines(c Caps, kind string, fi fileInfo) []*GPU {
if kind == "video" {
if g := c.pick(fi.codec); g != nil {
return []*GPU{g, nil}
}
}
return []*GPU{nil}
}
// attempts for one engine: a seek into a video, then from its start if that found no
// frame (a wrong duration in the header); a reduced-size decode of a JPEG, then full size.
func attempts(gpu *GPU, kind string, fi fileInfo, size int) []attempt {
if kind == "video" {
s := seekFor(fi.duration)
if s > 0 {
return []attempt{{gpu: gpu, seek: s}, {gpu: gpu}}
}
return []attempt{{gpu: gpu}}
}
if fi.codec == "mjpeg" && gpu == nil {
if l := lowresFor(fi.w, fi.h, size); l > 0 {
return []attempt{{lowres: l}, {}}
}
}
return []attempt{{gpu: gpu}}
}
// seekFor: a tenth of the way in, never more than 30 s, so a title card or a black lead-in
// is skipped and a long file still seeks to a keyframe near its start.
func seekFor(duration float64) float64 {
if duration <= 0 {
return 0
}
s := duration / 10
if s > 30 {
s = 30
}
if s < 0.5 {
return 0
}
return s
}
// lowresFor: how many halvings a JPEG decoder may take while the picture stays at least
// twice the preview size. libjpeg does this in the DCT, so a 24 MP photo decodes at 1.5 MP.
func lowresFor(w, h, size int) int {
m := w
if h < m {
m = h
}
l := 0
for l < 3 && m>>(l+1) >= size*2 {
l++
}
return l
}
type probeOut struct {
Streams []struct {
Index int `json:"index"`
Codec string `json:"codec_name"`
Width int `json:"width"`
Height int `json:"height"`
Disposition struct {
AttachedPic int `json:"attached_pic"`
} `json:"disposition"`
} `json:"streams"`
Format struct {
Duration string `json:"duration"`
} `json:"format"`
}
// probeFile asks ffprobe which stream to take and what is in it.
func probeFile(ctx context.Context, c Caps, path string) (fileInfo, error) {
cmd := exec.CommandContext(ctx, c.FFprobe, "-v", "error", "-select_streams", "v", "-show_entries", "stream=index,codec_name,width,height,disposition:format=duration", "-of", "json", path)
var errb bytes.Buffer
cmd.Stderr = &errb
out, err := cmd.Output()
if err != nil {
if ctx.Err() != nil {
return fileInfo{}, ctx.Err()
}
msg := lastLine(errb.String())
if msg == "" {
msg = "ffprobe: " + err.Error()
}
return fileInfo{}, &DecodeError{Msg: msg}
}
var po probeOut
if err := json.Unmarshal(out, &po); err != nil {
return fileInfo{}, &DecodeError{Msg: "ffprobe: unreadable answer"}
}
if len(po.Streams) == 0 {
return fileInfo{}, &DecodeError{Msg: "no video stream"}
}
s := po.Streams[0]
for _, x := range po.Streams { // cover art rides along as a video stream; the picture is not the film
if x.Disposition.AttachedPic == 0 {
s = x
break
}
}
fi := fileInfo{index: strconv.Itoa(s.Index), codec: s.Codec, w: s.Width, h: s.Height}
fi.duration, _ = strconv.ParseFloat(po.Format.Duration, 64)
return fi, nil
}
// run is one ffmpeg: the JPEG on success; on failure what ffmpeg said and the error.
// Empty output with a clean exit means the seek found nothing.
func run(ctx context.Context, c Caps, a attempt, path string, fi fileInfo, size int) ([]byte, string, error) {
args := []string{"-nostdin", "-hide_banner", "-loglevel", "error", "-y"}
if a.gpu != nil {
args = append(args, "-hwaccel", a.gpu.API, "-hwaccel_device", a.gpu.Device)
}
if a.lowres > 0 {
args = append(args, "-lowres", strconv.Itoa(a.lowres))
}
if a.seek > 0 {
args = append(args, "-noaccurate_seek", "-ss", strconv.FormatFloat(a.seek, 'f', 3, 64))
}
args = append(args, "-i", path, "-map", "0:"+fi.index, "-frames:v", "1",
"-vf", fmt.Sprintf("scale=w=%d:h=%d:force_original_aspect_ratio=decrease:flags=bicubic", size, size),
"-f", "image2pipe", "-c:v", "mjpeg", "-q:v", "4", "pipe:1")
name, argv := c.FFmpeg, args
if c.Nice != "" { // a burst of previews must not crowd out what the machine is for
name, argv = c.Nice, append([]string{"-n", "10", c.FFmpeg}, args...)
}
cmd := exec.CommandContext(ctx, name, argv...)
var out, errb bytes.Buffer
cmd.Stdout, cmd.Stderr = &out, &errb
if err := cmd.Run(); err != nil {
msg := lastLine(errb.String())
if msg == "" {
msg = err.Error()
}
return nil, msg, err
}
return out.Bytes(), "", nil
}
// ---- the same, as root ----
// ProbeRoot is Probe run as root through this binary, for the Files tab's root mode: root
// opens every render node, whatever group the daemon's user is in.
func ProbeRoot(ctx context.Context, self string) (Caps, error) {
full := priv.Wrap(self, "thumb", "--probe")
cmd := exec.CommandContext(ctx, full[0], full[1:]...)
var errb bytes.Buffer
cmd.Stderr = &errb
out, err := cmd.Output()
if err != nil {
if msg := lastLine(errb.String()); msg != "" {
return Caps{}, errors.New(msg)
}
return Caps{}, err
}
var c Caps
if err := json.Unmarshal(out, &c); err != nil {
return Caps{}, err
}
return c, nil
}
type helperOut struct {
Engine string `json:"engine,omitempty"`
Codec string `json:"codec,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
JPEG []byte `json:"jpeg,omitempty"`
Error string `json:"error,omitempty"`
Permanent bool `json:"permanent,omitempty"` // a DecodeError: the file, not the moment
}
// MakeRoot is Make run as root through this binary, with the caps ProbeRoot found.
func MakeRoot(ctx context.Context, self string, c Caps, path string, o Opts) (Result, error) {
o = o.defaults()
caps, _ := json.Marshal(c)
full := priv.Wrap(self, "thumb", "--size", strconv.Itoa(o.Size), "--timeout", o.Timeout.String(), path)
cmd := exec.CommandContext(ctx, full[0], full[1:]...)
cmd.Stdin = bytes.NewReader(caps)
var errb bytes.Buffer
cmd.Stderr = &errb
out, err := cmd.Output()
if err != nil {
if ctx.Err() != nil {
return Result{}, ctx.Err()
}
if msg := lastLine(errb.String()); msg != "" {
return Result{}, errors.New(msg)
}
return Result{}, err
}
var h helperOut
if err := json.Unmarshal(out, &h); err != nil {
return Result{}, err
}
if h.Error != "" {
if h.Permanent {
return Result{}, &DecodeError{Msg: h.Error}
}
return Result{}, errors.New(h.Error)
}
return Result{JPEG: h.JPEG, Engine: h.Engine, Codec: h.Codec, Width: h.Width, Height: h.Height}, nil
}
// Helper implements `hamlab thumb …`, the root side of the above. `--probe` prints Caps;
// `[--size N] [--timeout D] <path>` reads Caps from stdin and prints the preview as JSON.
// Returns the exit code.
func Helper(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
fail := func(err error) int { fmt.Fprintln(stderr, err.Error()); return 1 }
ctx := context.Background()
o := Opts{}
path := ""
for i := 0; i < len(args); i++ {
switch a := args[i]; {
case a == "--probe":
return emit(stdout, Probe(ctx), stderr)
case a == "--size" && i+1 < len(args):
o.Size, _ = strconv.Atoi(args[i+1])
i++
case a == "--timeout" && i+1 < len(args):
o.Timeout, _ = time.ParseDuration(args[i+1])
i++
case strings.HasPrefix(a, "-"):
return fail(errors.New("usage: hamlab thumb --probe | [--size N] [--timeout D] <path>"))
default:
path = a
}
}
if path == "" || !filepath.IsAbs(path) {
return fail(errors.New("usage: hamlab thumb --probe | [--size N] [--timeout D] <absolute path>"))
}
var c Caps
if err := json.NewDecoder(io.LimitReader(stdin, 1<<20)).Decode(&c); err != nil {
return fail(fmt.Errorf("caps on stdin: %w", err))
}
res, err := Make(ctx, c, path, o)
if err != nil {
var de *DecodeError
return emit(stdout, helperOut{Error: err.Error(), Permanent: errors.As(err, &de)}, stderr)
}
return emit(stdout, helperOut{Engine: res.Engine, Codec: res.Codec, Width: res.Width, Height: res.Height, JPEG: res.JPEG}, stderr)
}
func emit(w io.Writer, v any, stderr io.Writer) int {
if err := json.NewEncoder(w).Encode(v); err != nil {
fmt.Fprintln(stderr, err.Error())
return 1
}
return 0
}
// ---- small helpers ----
func lastLine(s string) string {
lines := strings.Split(strings.TrimSpace(s), "\n")
return strings.TrimSpace(lines[len(lines)-1])
}
func exists(p string) bool { _, err := os.Stat(p); return err == nil }
+241
View File
@@ -0,0 +1,241 @@
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
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
}