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:
co-authored by
Claude Fable 5.1
parent
beec9269fe
commit
af189b1fa1
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user