Преглед на файлове

Add storyboard scrub-bar previews to Movie player

Generates a cached sprite sheet (one keyframe every N seconds) per video via a new /media/storyboard/ endpoint. The sheet is stored beside the video under .metadata/.storyboard/ so the ffmpeg pass runs only once. Both the full-page player (index.html) and the embedded player (embedded.html) fetch the layout and image on load, then slice the sheet with CSS background-position on timeline hover, showing a thumbnail and timestamp. Includes unit tests for the planning logic and cache-path helpers.
Toby Chui преди 2 седмици
родител
ревизия
bb182e476a

+ 1 - 0
src/mediaServer.go

@@ -48,6 +48,7 @@ func mediaServer_init() {
 		http.HandleFunc("/media/transcode/", mediaServer.ServeVideoWithTranscode)
 		http.HandleFunc("/media/transcode/audio/", mediaServer.ServeAudioWithTranscode)
 		http.HandleFunc("/media/duration/", mediaServer.GetAudioDuration)
+		http.HandleFunc("/media/storyboard/", mediaServer.ServeStoryboard)
 	} else {
 		//ffmpeg not installed. Redirect transcode endpoint back to /media/
 		http.HandleFunc("/media/transcode/", func(w http.ResponseWriter, r *http.Request) {

+ 151 - 0
src/mod/media/mediaserver/mediaserver.go

@@ -12,12 +12,14 @@ import (
 	"path/filepath"
 	"strconv"
 	"strings"
+	"sync"
 	"time"
 
 	"imuslab.com/arozos/mod/auth"
 	"imuslab.com/arozos/mod/compatibility"
 	"imuslab.com/arozos/mod/filesystem"
 	fs "imuslab.com/arozos/mod/filesystem"
+	hidden "imuslab.com/arozos/mod/filesystem/hidden"
 	"imuslab.com/arozos/mod/filesystem/metadata"
 	"imuslab.com/arozos/mod/info/logger"
 	"imuslab.com/arozos/mod/media/transcoder"
@@ -586,3 +588,152 @@ func (s *Instance) GetAudioDuration(w http.ResponseWriter, r *http.Request) {
 	w.Header().Set("Content-Type", "application/json")
 	w.Write(js)
 }
+
+// storyboardLocks serialises generation per source file so that several viewers
+// opening the same video cannot each start their own ffmpeg pass.
+var storyboardLocks sync.Map
+
+// ServeStoryboard serves the scrub-preview storyboard for a video.
+//
+//	?file=<vpath>            -> JSON layout describing the sheet
+//	?file=<vpath>&image=1    -> the tiled JPEG itself
+//
+// The sheet is cached beside the video inside the same file system abstraction,
+// under <video folder>/.metadata/.storyboard/, so it travels with the media and
+// is discarded along with the folder it belongs to. The ffmpeg cost is therefore
+// paid once per video, not once per session.
+func (s *Instance) ServeStoryboard(w http.ResponseWriter, r *http.Request) {
+	targetFsh, _, realFilepath, err := s.ValidateSourceFile(w, r)
+	if err != nil {
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+
+	// Deliberately a native existence check, not an abstraction one: this asks
+	// whether ffmpeg can *read* the source directly. A remote-backed video would
+	// have to be buffered to local disk in full first, which is far too
+	// expensive to do just for hover previews. The generated sheet is a separate
+	// matter and is always stored back through the abstraction below.
+	if targetFsh.RequireBuffer || !filesystem.FileExists(realFilepath) {
+		utils.SendErrorResponse(w, "storyboard not supported for this file system")
+		return
+	}
+	if targetFsh.ReadOnly {
+		utils.SendErrorResponse(w, "storyboard cache not writable on a read only file system")
+		return
+	}
+
+	fshAbs := targetFsh.FileSystemAbstraction
+	cacheFolder := transcoder.StoryboardCacheFolder(realFilepath)
+	basename := filepath.Base(realFilepath)
+	imagePath := cacheFolder + basename + ".jpg"
+	metaPath := cacheFolder + basename + ".json"
+
+	layout, err := s.ensureStoryboard(targetFsh, realFilepath, cacheFolder, imagePath, metaPath)
+	if err != nil {
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+
+	if r.FormValue("image") != "" {
+		stream, err := fshAbs.ReadStream(imagePath)
+		if err != nil {
+			utils.SendErrorResponse(w, "storyboard image unavailable")
+			return
+		}
+		defer stream.Close()
+		w.Header().Set("Content-Type", "image/jpeg")
+		w.Header().Set("Cache-Control", "private, max-age=86400")
+		io.Copy(w, stream)
+		return
+	}
+
+	js, _ := json.Marshal(layout)
+	w.Header().Set("Content-Type", "application/json")
+	w.Write(js)
+}
+
+// ensureStoryboard returns the cached layout for a video, rendering the sheet
+// first if it is missing or out of date.
+func (s *Instance) ensureStoryboard(fsh *filesystem.FileSystemHandler, realFilepath string, cacheFolder string, imagePath string, metaPath string) (*transcoder.StoryboardLayout, error) {
+	fshAbs := fsh.FileSystemAbstraction
+
+	if layout := readStoryboardMeta(fshAbs, realFilepath, imagePath, metaPath); layout != nil {
+		return layout, nil
+	}
+
+	lockAny, _ := storyboardLocks.LoadOrStore(imagePath, &sync.Mutex{})
+	lock := lockAny.(*sync.Mutex)
+	lock.Lock()
+	defer lock.Unlock()
+	defer storyboardLocks.Delete(imagePath)
+
+	// Another request may have finished generating while we waited for the lock.
+	if layout := readStoryboardMeta(fshAbs, realFilepath, imagePath, metaPath); layout != nil {
+		return layout, nil
+	}
+
+	if err := fshAbs.MkdirAll(cacheFolder, 0755); err != nil {
+		return nil, errors.New("could not create storyboard cache folder")
+	}
+	// Keep the metadata folders out of the way in file listings, as the
+	// thumbnail cache does.
+	hidden.HideFile(filepath.Dir(filepath.Clean(cacheFolder)))
+	hidden.HideFile(cacheFolder)
+
+	duration, err := transcoder.GetAudioDuration(realFilepath)
+	if err != nil || duration <= 0 {
+		return nil, errors.New("could not determine media duration")
+	}
+
+	// ffmpeg renders into local scratch space and hands the bytes back, so the
+	// sheet can be stored through the abstraction that owns the video rather
+	// than written to whatever native path happens to match. Without this a
+	// remote-backed video would leave its cache stranded in the local tmp dir.
+	sheet, layout, err := transcoder.GenerateStoryboard(realFilepath, s.options.TmpDirectory, duration)
+	if err != nil {
+		s.options.Logger.PrintAndLog("Storyboard", "generation failed for "+filepath.Base(realFilepath), err)
+		return nil, errors.New("storyboard generation failed")
+	}
+
+	if err := fshAbs.WriteFile(imagePath, sheet, 0644); err != nil {
+		s.options.Logger.PrintAndLog("Storyboard", "could not store sheet for "+filepath.Base(realFilepath), err)
+		return nil, errors.New("could not store storyboard")
+	}
+
+	// Written after the sheet so a reader never finds metadata without an image.
+	if js, err := json.Marshal(layout); err == nil {
+		if err := fshAbs.WriteFile(metaPath, js, 0644); err != nil {
+			s.options.Logger.PrintAndLog("Storyboard", "could not store layout for "+filepath.Base(realFilepath), err)
+		}
+	}
+	return &layout, nil
+}
+
+// readStoryboardMeta loads a cached layout, treating a missing, unreadable or
+// stale cache as a miss. A sheet is stale once the video has been modified more
+// recently than the sheet itself, so a re-encode re-renders the previews.
+func readStoryboardMeta(fshAbs filesystem.FileSystemAbstraction, realFilepath string, imagePath string, metaPath string) *transcoder.StoryboardLayout {
+	if !fshAbs.FileExists(imagePath) || !fshAbs.FileExists(metaPath) {
+		return nil
+	}
+
+	videoModTime, videoErr := fshAbs.GetModTime(realFilepath)
+	sheetModTime, sheetErr := fshAbs.GetModTime(imagePath)
+	if videoErr == nil && sheetErr == nil && videoModTime > sheetModTime {
+		return nil
+	}
+
+	raw, err := fshAbs.ReadFile(metaPath)
+	if err != nil {
+		return nil
+	}
+	var layout transcoder.StoryboardLayout
+	if err := json.Unmarshal(raw, &layout); err != nil {
+		return nil
+	}
+	if layout.Interval <= 0 || layout.Count <= 0 || layout.TileWidth <= 0 || layout.TileHeight <= 0 {
+		return nil
+	}
+	return &layout
+}

+ 233 - 0
src/mod/media/transcoder/storyboard.go

@@ -0,0 +1,233 @@
+package transcoder
+
+/*
+	Storyboard.go
+
+	Generates "storyboard" sprite sheets for video scrub-bar previews: a single
+	tiled JPEG holding one downscaled frame every N seconds. The player loads one
+	image into memory and slices it with CSS, so hovering the timeline never has
+	to hit ffmpeg — which matters because seeking a software-decoded H.265 source
+	per hover would be far too slow to feel interactive.
+*/
+
+import (
+	"bytes"
+	"context"
+	"errors"
+	"fmt"
+	"image"
+	_ "image/jpeg" // registers the JPEG decoder used by image.DecodeConfig
+	"math"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"time"
+)
+
+// StoryboardCacheFolder returns the folder holding the storyboard sheet for a
+// given video file.
+//
+// Sheets live beside the media they describe, following the same ".metadata"
+// convention the thumbnail cache uses, so a storyboard travels with its video
+// across the file system abstraction and disappears with the folder it belongs
+// to. The trailing separator matches how the metadata package builds its own
+// cache paths, so callers can concatenate a filename directly.
+func StoryboardCacheFolder(videoRealPath string) string {
+	return filepath.ToSlash(
+		filepath.Join(filepath.Clean(filepath.Dir(videoRealPath)), "/.metadata/.storyboard/")) + "/"
+}
+
+const (
+	// Tile width in pixels; height follows the source aspect ratio.
+	storyboardTileWidth = 160
+	// Tiles per row in the generated sheet.
+	storyboardCols = 10
+	// Frame budget: how many tiles we aim for regardless of clip length.
+	storyboardTargetTiles = 120
+	// Hard ceiling so a very long film cannot produce an enormous sheet.
+	storyboardMaxTiles = 240
+	// Sampling interval bounds, in seconds.
+	storyboardMinInterval = 2.0
+	storyboardMaxInterval = 60.0
+	// Generation is capped so a pathological input cannot pin a core forever.
+	storyboardTimeout = 5 * time.Minute
+)
+
+// StoryboardLayout describes the geometry of a generated sheet. The player needs
+// every field to map a hovered timestamp onto the right tile.
+type StoryboardLayout struct {
+	Interval   float64 `json:"interval"`   // seconds represented by each tile
+	Count      int     `json:"count"`      // tiles that carry a real frame
+	Cols       int     `json:"cols"`       // tiles per row
+	Rows       int     `json:"rows"`       // rows in the sheet
+	TileWidth  int     `json:"tileWidth"`  // pixel width of one tile
+	TileHeight int     `json:"tileHeight"` // pixel height of one tile
+	Duration   float64 `json:"duration"`   // source duration in seconds
+}
+
+// PlanStoryboard picks the sampling interval and grid for a clip of the given
+// duration.
+//
+// The interval scales with length so short clips get fine-grained previews and
+// long ones stay within a sane sheet size: roughly storyboardTargetTiles frames,
+// clamped to [storyboardMinInterval, storyboardMaxInterval], then widened again
+// if the tile count would exceed storyboardMaxTiles.
+//
+// Kept free of ffmpeg so the sizing rules can be unit-tested directly.
+func PlanStoryboard(duration float64) (StoryboardLayout, error) {
+	if duration <= 0 || math.IsNaN(duration) || math.IsInf(duration, 0) {
+		return StoryboardLayout{}, errors.New("invalid duration")
+	}
+
+	interval := duration / storyboardTargetTiles
+	if interval < storyboardMinInterval {
+		interval = storyboardMinInterval
+	}
+	if interval > storyboardMaxInterval {
+		interval = storyboardMaxInterval
+	}
+
+	count := int(math.Ceil(duration / interval))
+	if count > storyboardMaxTiles {
+		count = storyboardMaxTiles
+		interval = duration / float64(count)
+	}
+	if count < 1 {
+		count = 1
+	}
+
+	cols := storyboardCols
+	if count < cols {
+		cols = count
+	}
+	rows := int(math.Ceil(float64(count) / float64(cols)))
+
+	return StoryboardLayout{
+		Interval:  interval,
+		Count:     count,
+		Cols:      cols,
+		Rows:      rows,
+		TileWidth: storyboardTileWidth,
+		Duration:  duration,
+	}, nil
+}
+
+// GenerateStoryboard renders the sprite sheet for inputFile and returns the
+// encoded JPEG together with the layout describing it.
+//
+// The sheet is deliberately returned as bytes rather than written to a path:
+// ffmpeg can only write to a native filesystem path, but the caller may be
+// serving a video that lives behind an arozfs abstraction (S3, remote mounts,
+// …). Rendering into scratch space under workDir and handing the bytes back
+// lets the caller store the result through whichever file system actually owns
+// the video, instead of stranding it on local disk.
+//
+// Decoding is restricted to keyframes (-skip_frame nokey), which is what makes
+// this affordable: a full decode of a two-hour software-decoded H.265 file would
+// take many minutes, while a keyframe-only pass runs an order of magnitude
+// faster and is more than accurate enough for thumbnails.
+func GenerateStoryboard(inputFile string, workDir string, duration float64) ([]byte, StoryboardLayout, error) {
+	layout, err := PlanStoryboard(duration)
+	if err != nil {
+		return nil, StoryboardLayout{}, err
+	}
+
+	if strings.TrimSpace(workDir) == "" {
+		workDir = os.TempDir()
+	}
+	if err := os.MkdirAll(workDir, 0775); err != nil {
+		return nil, StoryboardLayout{}, fmt.Errorf("scratch directory unavailable: %w", err)
+	}
+
+	// The .jpg suffix matters: ffmpeg picks its muxer from the output extension.
+	scratch, err := os.CreateTemp(workDir, "storyboard-*.jpg")
+	if err != nil {
+		return nil, StoryboardLayout{}, fmt.Errorf("could not create scratch file: %w", err)
+	}
+	scratchPath := scratch.Name()
+	scratch.Close()
+	defer os.Remove(scratchPath)
+
+	vf := fmt.Sprintf("fps=1/%.6f,scale=%d:-2,tile=%dx%d",
+		layout.Interval, storyboardTileWidth, layout.Cols, layout.Rows)
+
+	ctx, cancel := context.WithTimeout(context.Background(), storyboardTimeout)
+	defer cancel()
+
+	cmd := exec.CommandContext(ctx, "ffmpeg",
+		"-y",
+		"-skip_frame", "nokey", // decode keyframes only
+		"-i", inputFile,
+		"-an", "-sn", // no audio or subtitle streams
+		"-vf", vf,
+		"-frames:v", "1", // a single tiled sheet
+		"-q:v", "5",
+		scratchPath,
+	)
+
+	if out, err := cmd.CombinedOutput(); err != nil {
+		if ctx.Err() == context.DeadlineExceeded {
+			return nil, StoryboardLayout{}, errors.New("storyboard generation timed out")
+		}
+		return nil, StoryboardLayout{}, fmt.Errorf("ffmpeg failed: %w (%s)", err, lastLines(string(out), 2))
+	}
+
+	sheet, err := os.ReadFile(scratchPath)
+	if err != nil {
+		return nil, StoryboardLayout{}, fmt.Errorf("storyboard not written: %w", err)
+	}
+	if len(sheet) == 0 {
+		return nil, StoryboardLayout{}, errors.New("storyboard came out empty")
+	}
+
+	// Read the real tile height back from the sheet: it depends on the source
+	// aspect ratio, which we do not know until ffmpeg has scaled a frame.
+	cfg, _, err := image.DecodeConfig(bytes.NewReader(sheet))
+	if err != nil {
+		return nil, StoryboardLayout{}, fmt.Errorf("unreadable storyboard: %w", err)
+	}
+	if cfg.Width <= 0 || cfg.Height <= 0 || layout.Cols <= 0 || layout.Rows <= 0 {
+		return nil, StoryboardLayout{}, errors.New("unexpected storyboard dimensions")
+	}
+
+	layout.TileWidth = cfg.Width / layout.Cols
+	layout.TileHeight = cfg.Height / layout.Rows
+	if layout.TileWidth <= 0 || layout.TileHeight <= 0 {
+		return nil, StoryboardLayout{}, errors.New("unexpected storyboard tile size")
+	}
+
+	return sheet, layout, nil
+}
+
+// lastLines trims ffmpeg's very verbose output down to the tail, which is where
+// the actual failure reason lives.
+func lastLines(s string, n int) string {
+	if s == "" {
+		return ""
+	}
+	lines := []string{}
+	start := 0
+	for i := 0; i < len(s); i++ {
+		if s[i] == '\n' {
+			if i > start {
+				lines = append(lines, s[start:i])
+			}
+			start = i + 1
+		}
+	}
+	if start < len(s) {
+		lines = append(lines, s[start:])
+	}
+	if len(lines) > n {
+		lines = lines[len(lines)-n:]
+	}
+	out := ""
+	for i, l := range lines {
+		if i > 0 {
+			out += " | "
+		}
+		out += l
+	}
+	return out
+}

+ 304 - 0
src/mod/media/transcoder/storyboard_test.go

@@ -0,0 +1,304 @@
+package transcoder
+
+import (
+	"math"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+// TestStoryboardCacheFolder verifies sheets are cached beside the video, under
+// the same ".metadata" convention the thumbnail cache uses.
+func TestStoryboardCacheFolder(t *testing.T) {
+	cases := []struct {
+		name  string
+		video string
+		want  string
+	}{
+		{
+			name:  "nested path",
+			video: filepath.Join("files", "users", "bob", "Video", "show.mkv"),
+			want:  "files/users/bob/Video/.metadata/.storyboard/",
+		},
+		{
+			name:  "folder with dots in the name",
+			video: filepath.Join("media", "Show.S01.1080p", "ep1.mkv"),
+			want:  "media/Show.S01.1080p/.metadata/.storyboard/",
+		},
+		{
+			name:  "file at the root of a relative path",
+			video: "clip.mp4",
+			want:  ".metadata/.storyboard/",
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			if got := StoryboardCacheFolder(tc.video); got != tc.want {
+				t.Errorf("expected %q, got %q", tc.want, got)
+			}
+		})
+	}
+}
+
+// TestStoryboardCacheFolder_AlwaysSlashTerminated verifies callers can append a
+// filename directly, and that the result never contains backslashes so the same
+// path works across the file system abstractions.
+func TestStoryboardCacheFolder_AlwaysSlashTerminated(t *testing.T) {
+	videos := []string{
+		filepath.Join("a", "b", "c.mkv"),
+		filepath.Join("a", "video.mp4"),
+		"solo.webm",
+	}
+
+	for _, v := range videos {
+		got := StoryboardCacheFolder(v)
+		if !strings.HasSuffix(got, "/") {
+			t.Errorf("%q: expected a trailing slash, got %q", v, got)
+		}
+		if strings.Contains(got, "\\") {
+			t.Errorf("%q: expected forward slashes only, got %q", v, got)
+		}
+		// A bare filename has no parent, so the prefix is legitimately absent —
+		// assert on the suffix, which every case must share.
+		if !strings.HasSuffix(got, ".metadata/.storyboard/") {
+			t.Errorf("%q: expected the metadata cache convention, got %q", v, got)
+		}
+	}
+}
+
+// TestPlanStoryboard_InvalidDuration verifies that non-positive or non-finite
+// durations are rejected rather than producing a nonsensical grid.
+func TestPlanStoryboard_InvalidDuration(t *testing.T) {
+	cases := []struct {
+		name     string
+		duration float64
+	}{
+		{"zero", 0},
+		{"negative", -12.5},
+		{"NaN", math.NaN()},
+		{"positive infinity", math.Inf(1)},
+		{"negative infinity", math.Inf(-1)},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			if _, err := PlanStoryboard(tc.duration); err == nil {
+				t.Errorf("expected an error for duration %v, got nil", tc.duration)
+			}
+		})
+	}
+}
+
+// TestPlanStoryboard_IntervalBounds checks the sampling interval across
+// realistic clip lengths.
+//
+// The two ceilings can conflict: past roughly four hours, honouring
+// storyboardMaxInterval would need more than storyboardMaxTiles tiles. The tile
+// ceiling is a hard resource bound on sheet size, so it wins and the interval is
+// allowed to widen — but only in exactly that case.
+func TestPlanStoryboard_IntervalBounds(t *testing.T) {
+	cases := []struct {
+		name     string
+		duration float64
+	}{
+		{"ten second clip", 10},
+		{"four minute music video", 243},
+		{"twenty two minute episode", 1320},
+		{"forty five minute episode", 2700},
+		{"two hour film", 7200},
+		{"six hour recording", 21600},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			layout, err := PlanStoryboard(tc.duration)
+			if err != nil {
+				t.Fatalf("unexpected error: %v", err)
+			}
+			if layout.Interval < storyboardMinInterval && tc.duration > storyboardMinInterval {
+				t.Errorf("interval %.3f below minimum %.3f", layout.Interval, storyboardMinInterval)
+			}
+			if layout.Interval > storyboardMaxInterval && layout.Count != storyboardMaxTiles {
+				t.Errorf("interval %.3f exceeds maximum %.3f without hitting the %d tile ceiling (count %d)",
+					layout.Interval, storyboardMaxInterval, storyboardMaxTiles, layout.Count)
+			}
+		})
+	}
+}
+
+// TestPlanStoryboard_ShortInputsRespectIntervalCeiling verifies that anything
+// short enough to fit inside the tile budget does honour the interval ceiling.
+func TestPlanStoryboard_ShortInputsRespectIntervalCeiling(t *testing.T) {
+	// storyboardMaxInterval * storyboardMaxTiles is the longest clip that can be
+	// covered without widening the interval past its ceiling.
+	longestFullyCovered := storyboardMaxInterval * float64(storyboardMaxTiles)
+
+	for _, d := range []float64{60, 600, 3600, 7200, longestFullyCovered} {
+		layout, err := PlanStoryboard(d)
+		if err != nil {
+			t.Fatalf("duration %.0f: unexpected error: %v", d, err)
+		}
+		if layout.Interval > storyboardMaxInterval+0.001 {
+			t.Errorf("duration %.0f: interval %.3f exceeds maximum %.3f",
+				d, layout.Interval, storyboardMaxInterval)
+		}
+	}
+}
+
+// TestPlanStoryboard_GridCoversWholeClip is the important invariant: the grid
+// must hold at least one tile for every sampled instant, otherwise ffmpeg's tile
+// filter would emit a second sheet and the tail of the video would be missing.
+func TestPlanStoryboard_GridCoversWholeClip(t *testing.T) {
+	durations := []float64{1, 5, 30, 243, 1320, 2700, 7200, 21600, 86400}
+
+	for _, d := range durations {
+		layout, err := PlanStoryboard(d)
+		if err != nil {
+			t.Fatalf("duration %.0f: unexpected error: %v", d, err)
+		}
+		if layout.Cols*layout.Rows < layout.Count {
+			t.Errorf("duration %.0f: grid %dx%d cannot hold %d tiles",
+				d, layout.Cols, layout.Rows, layout.Count)
+		}
+		if covered := float64(layout.Count) * layout.Interval; covered < d-0.001 {
+			t.Errorf("duration %.0f: tiles cover only %.2fs", d, covered)
+		}
+	}
+}
+
+// TestPlanStoryboard_TileCountCeiling verifies long inputs do not blow past the
+// sheet-size ceiling, and that the interval widens instead.
+func TestPlanStoryboard_TileCountCeiling(t *testing.T) {
+	durations := []float64{7200, 21600, 43200, 86400}
+
+	for _, d := range durations {
+		layout, err := PlanStoryboard(d)
+		if err != nil {
+			t.Fatalf("duration %.0f: unexpected error: %v", d, err)
+		}
+		if layout.Count > storyboardMaxTiles {
+			t.Errorf("duration %.0f: produced %d tiles, ceiling is %d",
+				d, layout.Count, storyboardMaxTiles)
+		}
+		if layout.Count < 1 {
+			t.Errorf("duration %.0f: produced %d tiles", d, layout.Count)
+		}
+	}
+}
+
+// TestPlanStoryboard_ShortClipGridShrinks verifies a clip shorter than one full
+// row does not claim a 10-wide grid it cannot fill.
+func TestPlanStoryboard_ShortClipGridShrinks(t *testing.T) {
+	layout, err := PlanStoryboard(6) // 6s at the 2s floor -> 3 tiles
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if layout.Cols > layout.Count {
+		t.Errorf("expected at most %d columns, got %d", layout.Count, layout.Cols)
+	}
+	if layout.Rows != 1 {
+		t.Errorf("expected a single row, got %d", layout.Rows)
+	}
+}
+
+// TestPlanStoryboard_DurationEchoed verifies the source duration is carried
+// through to the layout, since the player maps hover position against it.
+func TestPlanStoryboard_DurationEchoed(t *testing.T) {
+	layout, err := PlanStoryboard(1234.5)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if layout.Duration != 1234.5 {
+		t.Errorf("expected duration 1234.5, got %v", layout.Duration)
+	}
+	if layout.TileWidth != storyboardTileWidth {
+		t.Errorf("expected tile width %d, got %d", storyboardTileWidth, layout.TileWidth)
+	}
+}
+
+// TestGenerateStoryboard_InvalidDuration verifies the duration guard runs before
+// any attempt to invoke ffmpeg, so the call is safe on hosts without it.
+func TestGenerateStoryboard_InvalidDuration(t *testing.T) {
+	sheet, _, err := GenerateStoryboard("nonexistent.mkv", t.TempDir(), 0)
+	if err == nil {
+		t.Error("expected an error for zero duration, got nil")
+	}
+	if sheet != nil {
+		t.Errorf("expected no sheet bytes on failure, got %d", len(sheet))
+	}
+}
+
+// TestGenerateStoryboard_LeavesNoScratchFiles verifies the scratch file used to
+// bridge ffmpeg's native-only output is always cleaned up, including on the
+// failure path where ffmpeg cannot read the input.
+func TestGenerateStoryboard_LeavesNoScratchFiles(t *testing.T) {
+	workDir := t.TempDir()
+
+	// A missing input makes ffmpeg fail (or the binary may be absent entirely);
+	// either way the scratch file must not be left behind.
+	_, _, err := GenerateStoryboard(filepath.Join(workDir, "missing.mkv"), workDir, 120)
+	if err == nil {
+		t.Skip("ffmpeg unexpectedly succeeded on a missing input")
+	}
+
+	entries, readErr := os.ReadDir(workDir)
+	if readErr != nil {
+		t.Fatalf("could not inspect scratch dir: %v", readErr)
+	}
+	for _, e := range entries {
+		if strings.HasPrefix(e.Name(), "storyboard-") {
+			t.Errorf("scratch file %q was left behind", e.Name())
+		}
+	}
+}
+
+// TestGenerateStoryboard_CreatesMissingWorkDir verifies a not-yet-created
+// scratch directory is created rather than failing the whole render.
+func TestGenerateStoryboard_CreatesMissingWorkDir(t *testing.T) {
+	workDir := filepath.Join(t.TempDir(), "nested", "scratch")
+
+	// Duration is valid, so this proceeds past the guard and into scratch setup.
+	// It then fails at ffmpeg, which is fine — we only care that the directory
+	// was created rather than the call erroring out early.
+	GenerateStoryboard(filepath.Join(workDir, "missing.mkv"), workDir, 120)
+
+	if info, err := os.Stat(workDir); err != nil || !info.IsDir() {
+		t.Errorf("expected the scratch directory to be created, got err=%v", err)
+	}
+}
+
+// TestLastLines checks the ffmpeg stderr trimming helper.
+func TestLastLines(t *testing.T) {
+	cases := []struct {
+		name  string
+		input string
+		n     int
+		want  string
+	}{
+		{"empty", "", 2, ""},
+		{"single line", "only", 2, "only"},
+		{"fewer than n", "a\nb", 5, "a | b"},
+		{"trims to last n", "a\nb\nc\nd", 2, "c | d"},
+		{"ignores blank lines", "a\n\nb\n", 2, "a | b"},
+		{"no trailing newline", "a\nb\nc", 1, "c"},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			if got := lastLines(tc.input, tc.n); got != tc.want {
+				t.Errorf("expected %q, got %q", tc.want, got)
+			}
+		})
+	}
+}
+
+// TestLastLines_NoNewlinesInOutput verifies the helper always collapses to a
+// single line, so it cannot break a one-line log entry.
+func TestLastLines_NoNewlinesInOutput(t *testing.T) {
+	got := lastLines("first\nsecond\nthird\nfourth", 3)
+	if strings.Contains(got, "\n") {
+		t.Errorf("expected a single-line result, got %q", got)
+	}
+}

+ 1 - 0
src/web/Movie/backend/common.js

@@ -11,6 +11,7 @@ var BACKEND_PATH  = APP_NAME + "/backend/";
 // ── Server API endpoints (relative from any page in this app) ────────────────
 var MEDIA_API     = "../media";               // ?file=<vpath>  streams a file
 var TRANSCODE_API  = "../media/transcode";            // ?file
+var STORYBOARD_API = "../media/storyboard/";          // ?file[&image=1]  scrub previews
 var AGI_INTERFACE = "../system/ajgi/interface?script=";
 
 // ── Script paths (used when calling ao_module_agirun from the frontend) ──────

+ 132 - 0
src/web/Movie/embedded.html

@@ -102,6 +102,38 @@
         }
         #spacer { flex: 1; }
 
+        /* ── Scrub-bar hover preview ───────────────────────────────────────── */
+        #scrub-preview {
+            display: none;
+            position: absolute;
+            bottom: 100%;
+            margin-bottom: 12px;
+            transform: translateX(-50%);
+            pointer-events: none;
+            z-index: 8;
+            flex-direction: column;
+            align-items: center;
+            gap: 6px;
+        }
+        #scrub-preview.active { display: flex; }
+        #scrub-preview-thumb {
+            display: none;
+            border-radius: 8px;
+            background-color: #000;
+            background-repeat: no-repeat;
+            box-shadow: 0 4px 18px rgba(0,0,0,0.8), 0 0 0 2px rgba(255,255,255,0.14);
+        }
+        #scrub-preview-thumb.ready { display: block; }
+        #scrub-preview-time {
+            background: rgba(0,0,0,0.82);
+            border-radius: 6px;
+            padding: 3px 9px;
+            font-size: 12px; font-weight: 600;
+            color: #fff; white-space: nowrap;
+            font-family: -apple-system, BlinkMacSystemFont, sans-serif;
+            font-variant-numeric: tabular-nums;
+        }
+
         /* ── Transcode-seek freeze frame ───────────────────────────────────── */
         /* Deliberately no z-index: both sit immediately after <video> in the DOM,
            so they paint above the (unpositioned) video but below every later
@@ -571,6 +603,10 @@
         <div id="progress-wrap">
             <div id="progress-bar" style="width:0%"></div>
             <div id="progress-thumb" style="left:0%"></div>
+            <div id="scrub-preview">
+                <div id="scrub-preview-thumb"></div>
+                <div id="scrub-preview-time">0:00</div>
+            </div>
         </div>
         <div id="controls-row">
             <button class="ctrl-btn" id="ctrl-play" title="Play / Pause (Space)">
@@ -711,6 +747,100 @@ if (files && files.length > 0) {
     }
 }
 
+// ── Scrub-bar hover preview (storyboard) ──────────────────────────────────────
+// The server renders one sprite sheet per file holding a downscaled frame every
+// few seconds; we fetch it once and keep it in memory, so hovering the timeline
+// costs nothing. Asking ffmpeg for a frame per hover would be hopeless on a
+// software-decoded H.265 source, which is exactly where previews matter most.
+var storyboard      = null;   // layout from the server + the loaded sheet URL
+var storyboardToken = 0;      // invalidates in-flight loads when the file changes
+var storyboardTimer = null;
+
+function resetStoryboard() {
+    storyboardToken++;
+    storyboard = null;
+    clearTimeout(storyboardTimer);
+    storyboardTimer = null;
+    $('#scrub-preview-thumb').removeClass('ready');
+    hideScrubPreview();
+}
+
+// Building the sheet runs ffmpeg, so hold off briefly and let playback (and any
+// transcode) get established before adding more load.
+function scheduleStoryboardLoad(filepath) {
+    resetStoryboard();
+    if (!filepath) { return; }
+    var token = storyboardToken;
+    storyboardTimer = setTimeout(function () { loadStoryboard(filepath, token); }, 3000);
+}
+
+function loadStoryboard(filepath, token) {
+    var base = STORYBOARD_API + '?file=' + encodeURIComponent(filepath);
+    fetch(base)
+        .then(function (r) { return r.json(); })
+        .then(function (meta) {
+            if (token !== storyboardToken) { return; }   // switched file while loading
+            if (!meta || meta.error || !meta.interval || !meta.tileWidth) { return; }
+            var img = new Image();
+            img.onload = function () {
+                if (token !== storyboardToken) { return; }
+                meta.url   = img.src;
+                storyboard = meta;
+            };
+            img.src = base + '&image=1';
+        })
+        .catch(function () {});   // previews are optional — stay silent on failure
+}
+
+// Whole-file duration, whichever playback mode is active
+function scrubTotalDuration() {
+    if (isTranscodedVideo) { return transcodeDuration; }
+    return vid.duration || 0;
+}
+
+function initScrubPreview() {
+    var $wrap = $('#progress-wrap');
+    $wrap.on('mousemove', function (e) {
+        var total = scrubTotalDuration();
+        var rect  = this.getBoundingClientRect();
+        if (!total || !isFinite(total) || rect.width <= 0) { hideScrubPreview(); return; }
+        var ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
+        showScrubPreview(ratio * total, ratio, rect.width);
+    });
+    $wrap.on('mouseleave', hideScrubPreview);
+}
+
+function showScrubPreview(time, ratio, barWidth) {
+    $('#scrub-preview-time').text(formatTime(time));
+
+    var $thumb = $('#scrub-preview-thumb');
+    var halfWidth;
+    if (storyboard && storyboard.url) {
+        var idx = Math.max(0, Math.min(storyboard.count - 1,
+                                       Math.floor(time / storyboard.interval)));
+        var col = idx % storyboard.cols;
+        var row = Math.floor(idx / storyboard.cols);
+        $thumb.addClass('ready').css({
+            'width':               storyboard.tileWidth + 'px',
+            'height':              storyboard.tileHeight + 'px',
+            'background-image':    'url("' + storyboard.url + '")',
+            'background-position': (-col * storyboard.tileWidth) + 'px '
+                                 + (-row * storyboard.tileHeight) + 'px'
+        });
+        halfWidth = storyboard.tileWidth / 2;
+    } else {
+        // Sheet not ready (or unavailable) — the timestamp alone is still useful
+        $thumb.removeClass('ready');
+        halfWidth = 28;
+    }
+
+    // Keep the popup within the bar rather than letting it hang off an edge
+    var x = Math.max(halfWidth, Math.min(barWidth - halfWidth, ratio * barWidth));
+    $('#scrub-preview').css('left', x + 'px').addClass('active');
+}
+
+function hideScrubPreview() { $('#scrub-preview').removeClass('active'); }
+
 // ── Transcode seek (seek-by-reload) ───────────────────────────────────────────
 // Formats the browser can't decode are streamed through ffmpeg, so "seeking"
 // means restarting the transcode at a new offset. Replacing .src blanks the
@@ -1469,6 +1599,8 @@ function initMain(){
     initVideoControls();
     initContextMenu();
     initSettingsPopup();
+    initScrubPreview();
+    if (currentFile) { scheduleStoryboardLoad(currentFile.filepath); }
     initSubtitleMenu();
     initSubtitleSettings();
     initKeyboard();

+ 136 - 0
src/web/Movie/index.html

@@ -522,6 +522,39 @@ body.always-show-volume #volume-slider { display: block; }
     100% { opacity: 0;    transform: scale(1.32); }
 }
 
+/* ─── Scrub-bar hover preview ────────────────────────────────────────────────── */
+#scrub-preview {
+    display: none;
+    position: absolute;
+    bottom: 100%;
+    margin-bottom: 12px;
+    transform: translateX(-50%);
+    pointer-events: none;
+    z-index: 8;
+    flex-direction: column;
+    align-items: center;
+    gap: 6px;
+}
+#scrub-preview.active { display: flex; }
+#scrub-preview-thumb {
+    display: none;
+    border-radius: 8px;
+    background-color: #000;
+    background-repeat: no-repeat;
+    box-shadow: 0 4px 18px rgba(0,0,0,0.8), 0 0 0 2px rgba(255,255,255,0.14);
+}
+#scrub-preview-thumb.ready { display: block; }
+#scrub-preview-time {
+    background: rgba(0,0,0,0.82);
+    border-radius: 6px;
+    padding: 3px 9px;
+    font-size: 12px;
+    font-weight: 600;
+    color: #fff;
+    white-space: nowrap;
+    font-variant-numeric: tabular-nums;
+}
+
 /* ─── Transcode-seek freeze frame ────────────────────────────────────────────── */
 /* Deliberately no z-index: both sit immediately after <video> in the DOM, so they
    paint above the (unpositioned) video but below every later positioned sibling
@@ -1735,6 +1768,10 @@ img.ctx-icon { height: 16px; opacity: 0.85; display: block; }
                 <div id="progress-wrap">
                     <div id="progress-bar" style="width:0%"></div>
                     <div id="progress-thumb" style="left:0%"></div>
+                    <div id="scrub-preview">
+                        <div id="scrub-preview-thumb"></div>
+                        <div id="scrub-preview-time">0:00</div>
+                    </div>
                 </div>
                 <div id="controls-row">
                     <button class="ctrl-btn" id="ctrl-prev"    title="Previous (←)"><img src="img/icons/skip_previous_white.svg" alt=""></button>
@@ -2325,6 +2362,101 @@ function closeMovieInfo() {
     showView('library');
 }
 
+// ─── Scrub-bar hover preview (storyboard) ─────────────────────────────────────
+// The server renders one sprite sheet per file holding a downscaled frame every
+// few seconds; we fetch it once and keep it in memory, so hovering the timeline
+// costs nothing. Asking ffmpeg for a frame per hover would be hopeless on a
+// software-decoded H.265 source, which is exactly where previews matter most.
+var storyboard      = null;   // layout from the server + the loaded sheet URL
+var storyboardToken = 0;      // invalidates in-flight loads when the file changes
+var storyboardTimer = null;
+
+function resetStoryboard() {
+    storyboardToken++;
+    storyboard = null;
+    clearTimeout(storyboardTimer);
+    storyboardTimer = null;
+    $('#scrub-preview-thumb').removeClass('ready');
+    hideScrubPreview();
+}
+
+// Building the sheet runs ffmpeg, so hold off briefly and let playback (and any
+// transcode) get established before adding more load.
+function scheduleStoryboardLoad(filepath) {
+    resetStoryboard();
+    if (!filepath) { return; }
+    var token = storyboardToken;
+    storyboardTimer = setTimeout(function () { loadStoryboard(filepath, token); }, 3000);
+}
+
+function loadStoryboard(filepath, token) {
+    var base = STORYBOARD_API + '?file=' + encodeURIComponent(filepath);
+    fetch(base)
+        .then(function (r) { return r.json(); })
+        .then(function (meta) {
+            if (token !== storyboardToken) { return; }   // switched file while loading
+            if (!meta || meta.error || !meta.interval || !meta.tileWidth) { return; }
+            var img = new Image();
+            img.onload = function () {
+                if (token !== storyboardToken) { return; }
+                meta.url   = img.src;
+                storyboard = meta;
+            };
+            img.src = base + '&image=1';
+        })
+        .catch(function () {});   // previews are optional — stay silent on failure
+}
+
+// Whole-file duration, whichever playback mode is active
+function scrubTotalDuration() {
+    if (castMode && castDuration > 0) { return castDuration; }
+    if (isTranscodedVideo) { return transcodeDuration; }
+    return document.getElementById('main-video').duration || 0;
+}
+
+function initScrubPreview() {
+    var $wrap = $('#progress-wrap');
+    $wrap.on('mousemove', function (e) {
+        var total = scrubTotalDuration();
+        var rect  = this.getBoundingClientRect();
+        if (!total || !isFinite(total) || rect.width <= 0) { hideScrubPreview(); return; }
+        var ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
+        showScrubPreview(ratio * total, ratio, rect.width);
+    });
+    $wrap.on('mouseleave', hideScrubPreview);
+}
+
+function showScrubPreview(time, ratio, barWidth) {
+    $('#scrub-preview-time').text(formatTime(time));
+
+    var $thumb = $('#scrub-preview-thumb');
+    var halfWidth;
+    if (storyboard && storyboard.url) {
+        var idx = Math.max(0, Math.min(storyboard.count - 1,
+                                       Math.floor(time / storyboard.interval)));
+        var col = idx % storyboard.cols;
+        var row = Math.floor(idx / storyboard.cols);
+        $thumb.addClass('ready').css({
+            'width':               storyboard.tileWidth + 'px',
+            'height':              storyboard.tileHeight + 'px',
+            'background-image':    'url("' + storyboard.url + '")',
+            'background-position': (-col * storyboard.tileWidth) + 'px '
+                                 + (-row * storyboard.tileHeight) + 'px'
+        });
+        halfWidth = storyboard.tileWidth / 2;
+    } else {
+        // Sheet not ready (or unavailable) — the timestamp alone is still useful
+        $thumb.removeClass('ready');
+        halfWidth = 28;
+    }
+
+    // Keep the popup within the bar rather than letting it hang off an edge
+    var x = Math.max(halfWidth, Math.min(barWidth - halfWidth, ratio * barWidth));
+    $('#scrub-preview').css('left', x + 'px').addClass('active');
+}
+
+function hideScrubPreview() { $('#scrub-preview').removeClass('active'); }
+
 // ─── Transcode seek (seek-by-reload) ──────────────────────────────────────────
 // Formats the browser can't decode are streamed through ffmpeg, so "seeking"
 // means restarting the transcode at a new offset. Replacing .src blanks the
@@ -2603,6 +2735,7 @@ $(document).ready(function () {
     initSearch();
     initContextMenu();
     initSettingsPopup();
+    initScrubPreview();
     initSubtitleMenu();
     initSubtitleSettings();
 
@@ -3230,6 +3363,8 @@ function startPlayback(index) {
     $('#now-playing-title, #topbar-title').text(ep.name);
     ao_module_setWindowTitle('Movie – ' + ep.name);
 
+    scheduleStoryboardLoad(ep.filepath);
+
     renderSidebar(currentEpisodes, index);
 
     // New playback — drop any manual show/hide the user applied to the last one
@@ -3326,6 +3461,7 @@ function closePlayer() {
     saveWatchPosition();
     cancelCountdown();
     hideSeekFreeze();
+    resetStoryboard();
     if (watchSaveInterval) { clearInterval(watchSaveInterval); watchSaveInterval = null; }
     var vid = document.getElementById('main-video');
     vid.pause();