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
|
|
@ -26,16 +26,26 @@ type PrewarmOptions struct {
|
|||
CacheSubtitles bool // library.cache_subtitles
|
||||
CacheThumbnails bool // library.cache_thumbnails
|
||||
Workers int // concurrent ffmpeg jobs (each is heavy); default 2
|
||||
|
||||
// Trickplay (library.trickplay): generate ONE montage sprite per file sampled
|
||||
// every TrickplayIntervalSec at TrickplayWidth. Replaces live scrubber
|
||||
// extraction during playback (no contention with the active stream).
|
||||
Trickplay bool
|
||||
TrickplayIntervalSec float64
|
||||
TrickplayWidth int
|
||||
}
|
||||
|
||||
// prewarmJob is one extraction unit: all text subtitles of a file in one ffmpeg
|
||||
// pass (thumb=false) or a single thumbnail frame (thumb=true).
|
||||
// pass (subtitle job), a single thumbnail frame (thumb=true), or the trickplay
|
||||
// montage sprite for a file (trick=true).
|
||||
type prewarmJob struct {
|
||||
path string
|
||||
thumb bool
|
||||
subIdx []int // subtitle stream indices to extract in ONE pass (subtitle job)
|
||||
posSec float64 // frame position in seconds (thumbnail job)
|
||||
width int // frame width (thumbnail job)
|
||||
path string
|
||||
thumb bool
|
||||
trick bool // trickplay sprite job
|
||||
subIdx []int // subtitle stream indices to extract in ONE pass (subtitle job)
|
||||
posSec float64 // frame position in seconds (thumbnail job)
|
||||
width int // frame/tile width (thumbnail + trickplay jobs)
|
||||
duration float64 // runtime seconds (trickplay job)
|
||||
}
|
||||
|
||||
// PrewarmSidecars extracts text subtitles (→ WebVTT) and the panel's sample
|
||||
|
|
@ -48,7 +58,7 @@ type prewarmJob struct {
|
|||
// the item moves on, and ctx cancellation (Ctrl-C / daemon shutdown) stops
|
||||
// cleanly. Safe to call after every scan — only missing/stale caches do work.
|
||||
func PrewarmSidecars(ctx context.Context, cache *LibraryCache, opts PrewarmOptions) {
|
||||
if cache == nil || opts.FFmpegPath == "" || (!opts.CacheSubtitles && !opts.CacheThumbnails) {
|
||||
if cache == nil || opts.FFmpegPath == "" || (!opts.CacheSubtitles && !opts.CacheThumbnails && !opts.Trickplay) {
|
||||
return
|
||||
}
|
||||
workers := opts.Workers
|
||||
|
|
@ -59,7 +69,7 @@ func PrewarmSidecars(ctx context.Context, cache *LibraryCache, opts PrewarmOptio
|
|||
jobs := make(chan prewarmJob)
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
subCached, thumbCached, failed := 0, 0, 0
|
||||
subCached, thumbCached, trickCached, failed := 0, 0, 0, 0
|
||||
var sampleErr string // first extraction error, surfaced in the summary so a
|
||||
// systemic ffmpeg failure (vs one corrupt file) is diagnosable from "N failed".
|
||||
|
||||
|
|
@ -101,6 +111,28 @@ func PrewarmSidecars(ctx context.Context, cache *LibraryCache, opts PrewarmOptio
|
|||
continue
|
||||
}
|
||||
|
||||
if j.trick {
|
||||
if _, ok := mediainfo.ReadCachedTrickplay(j.path, j.width); ok {
|
||||
continue
|
||||
}
|
||||
// Full-decode pass (samples 1 frame per interval over the whole
|
||||
// file) — generous deadline like subtitles; idempotent + cached.
|
||||
jctx, cancel := context.WithTimeout(ctx, 45*time.Minute)
|
||||
_, err := mediainfo.GenerateTrickplay(jctx, opts.FFmpegPath, j.path, opts.TrickplayIntervalSec, j.width, j.duration)
|
||||
cancel()
|
||||
mu.Lock()
|
||||
if err != nil {
|
||||
failed++
|
||||
if sampleErr == "" {
|
||||
sampleErr = err.Error()
|
||||
}
|
||||
} else {
|
||||
trickCached++
|
||||
}
|
||||
mu.Unlock()
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract only the indices not already fresh, and do them in ONE
|
||||
// ffmpeg pass — a multi-GB remux is demuxed once for all its text
|
||||
// tracks instead of once per track.
|
||||
|
|
@ -177,15 +209,32 @@ func PrewarmSidecars(ctx context.Context, cache *LibraryCache, opts PrewarmOptio
|
|||
}
|
||||
}
|
||||
}
|
||||
if opts.Trickplay && opts.TrickplayIntervalSec > 0 {
|
||||
dur := 0.0
|
||||
if item.MediaInfo.Video != nil {
|
||||
dur = item.MediaInfo.Video.Duration
|
||||
}
|
||||
if dur > 0 {
|
||||
w := opts.TrickplayWidth
|
||||
if w <= 0 {
|
||||
w = 240
|
||||
}
|
||||
select {
|
||||
case jobs <- prewarmJob{path: item.FilePath, trick: true, width: w, duration: dur}:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
if subCached > 0 || thumbCached > 0 || failed > 0 {
|
||||
if subCached > 0 || thumbCached > 0 || trickCached > 0 || failed > 0 {
|
||||
if failed > 0 && sampleErr != "" {
|
||||
log.Printf("[prewarm] %d subtitles, %d thumbnails cached, %d failed (e.g. %s)", subCached, thumbCached, failed, sampleErr)
|
||||
log.Printf("[prewarm] %d subtitles, %d thumbnails, %d trickplay cached, %d failed (e.g. %s)", subCached, thumbCached, trickCached, failed, sampleErr)
|
||||
} else {
|
||||
log.Printf("[prewarm] %d subtitles, %d thumbnails cached, %d failed", subCached, thumbCached, failed)
|
||||
log.Printf("[prewarm] %d subtitles, %d thumbnails, %d trickplay cached, %d failed", subCached, thumbCached, trickCached, failed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue