feat(trickplay): scan-time montage sprite for the web scrubber
Pre-generate ONE trickplay sprite (montage JPEG of frames sampled every library.trickplay.interval, default 10s) + a JSON manifest per file during the scan/auto-scan prewarm, cached in .unarr next to the media. The web scrubber shows tiles from it instead of extracting frames live — removing the ffmpeg contention with the active stream that broke seekbar previews (the original 'no thumbnail' report was the auto-scan prewarm decoding the same file the HLS transcode was reading, not a seek-index fault). - config: [library.trickplay] enabled/interval/width (default on, 10s, 240px), editable + a toggle; IntervalSeconds() with a 10s fallback. - mediainfo: GenerateTrickplay (one ffmpeg fps=1/interval,scale,tile pass; idle I/O priority; ceil() frame count so no black trailing tile; a 16.7M-px cap coarsens the interval for long media so a single sprite stays decodable on iOS/Safari) + sprite/manifest sidecar cache helpers. - engine: /trickplay endpoint (manifest JSON, ?kind=sprite JPEG); the agent owns the tile width so the web requests by path only; thumb:<sha256> token reused. - prewarm: a trickplay job per item, gated; scan.go + daemon.go wire the config. Tests: parseDims; synthetic 3x2 / exact-multiple / 1x1; real-file e2e smoke (S02E08 → 143 tiles, 662KB sprite). Non-breaking: the existing 5-frame panel prewarm + on-demand /thumbnail stay until the web migrates to the sprite.
This commit is contained in:
parent
7877e1de42
commit
8e37293b7d
7 changed files with 553 additions and 21 deletions
107
internal/library/mediainfo/trickplay_test.go
Normal file
107
internal/library/mediainfo/trickplay_test.go
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
package mediainfo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseDims(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
w, h int
|
||||
ok bool
|
||||
}{
|
||||
{"Stream #0:0: Video: mjpeg, yuvj420p(pc), 720x270 [SAR 1:1 DAR 8:3]", 720, 270, true},
|
||||
{" Stream #0:0: Video: h264 (High), yuv420p, 3840x2160, 23.98 fps", 3840, 2160, true},
|
||||
{"Stream #0:1: Audio: aac, 48000 Hz, stereo", 0, 0, false}, // no Video:
|
||||
{"", 0, 0, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
w, h, err := parseDims(c.in)
|
||||
if c.ok {
|
||||
if err != nil || w != c.w || h != c.h {
|
||||
t.Errorf("parseDims(%q) = %d,%d,%v; want %d,%d,nil", c.in, w, h, err, c.w, c.h)
|
||||
}
|
||||
} else if err == nil {
|
||||
t.Errorf("parseDims(%q) expected error, got %dx%d", c.in, w, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// makeClip writes a synthetic 16:9 test clip of the given duration (seconds).
|
||||
func makeClip(t *testing.T, ff, path string, durSec int) {
|
||||
t.Helper()
|
||||
mk := exec.Command(ff, "-nostdin", "-loglevel", "error", "-y",
|
||||
"-f", "lavfi", "-i", fmt.Sprintf("testsrc=duration=%d:size=640x360:rate=10", durSec),
|
||||
"-pix_fmt", "yuv420p", path)
|
||||
if out, err := mk.CombinedOutput(); err != nil {
|
||||
t.Fatalf("make test clip: %v: %s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateTrickplay builds synthetic clips and asserts the sprite grid +
|
||||
// manifest. ffmpeg-gated (skips without it, like the encode benchmark).
|
||||
func TestGenerateTrickplay(t *testing.T) {
|
||||
ff, err := exec.LookPath("ffmpeg")
|
||||
if err != nil {
|
||||
t.Skip("ffmpeg not on PATH")
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
durSec int
|
||||
wantCount int
|
||||
wantCols, wantRows int
|
||||
}{
|
||||
// fps=1/10 emits a frame at 0,10,20,… while t<dur → ceil(dur/10) frames.
|
||||
{"non_multiple_55s", 55, 6, 3, 2}, // ceil(55/10)=6
|
||||
{"exact_multiple_60s", 60, 6, 3, 2}, // ceil(60/10)=6 (NOT 7 — the off-by-one)
|
||||
{"short_clip_5s", 5, 1, 1, 1}, // 1x1 grid
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
clip := filepath.Join(dir, "clip.mp4")
|
||||
makeClip(t, ff, clip, c.durSec)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
m, err := GenerateTrickplay(ctx, ff, clip, 10, 240, float64(c.durSec))
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateTrickplay: %v", err)
|
||||
}
|
||||
if m.Count != c.wantCount || m.Cols != c.wantCols || m.Rows != c.wantRows {
|
||||
t.Errorf("grid: count=%d cols=%d rows=%d; want %d/%d/%d",
|
||||
m.Count, m.Cols, m.Rows, c.wantCount, c.wantCols, c.wantRows)
|
||||
}
|
||||
if m.TileWidth != 240 {
|
||||
t.Errorf("tileWidth=%d; want 240", m.TileWidth)
|
||||
}
|
||||
if m.TileHeight < 130 || m.TileHeight > 140 {
|
||||
t.Errorf("tileHeight=%d; want ~135 (16:9)", m.TileHeight)
|
||||
}
|
||||
if m.IntervalSec != 10 {
|
||||
t.Errorf("intervalSec=%v; want 10 (no cap at this size)", m.IntervalSec)
|
||||
}
|
||||
if fi, err := os.Stat(TrickplaySpritePath(clip, 240)); err != nil || fi.Size() == 0 {
|
||||
t.Errorf("sprite not written: %v", err)
|
||||
}
|
||||
m2, ok := ReadCachedTrickplay(clip, 240)
|
||||
if !ok || m2.Count != m.Count || m2.TileHeight != m.TileHeight || m2.Cols != m.Cols {
|
||||
t.Errorf("ReadCachedTrickplay mismatch: ok=%v got=%+v want=%+v", ok, m2, m)
|
||||
}
|
||||
// Stale media (newer mtime) must invalidate the cache.
|
||||
future := time.Now().Add(2 * time.Hour)
|
||||
if err := os.Chtimes(clip, future, future); err == nil {
|
||||
if _, ok := ReadCachedTrickplay(clip, 240); ok {
|
||||
t.Error("ReadCachedTrickplay returned stale sprite after media mtime bumped")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue