Browse Source

Add experimental hardware acceleration support

Toby Chui 2 tuần trước cách đây
mục cha
commit
e421c44c31

+ 97 - 0
src/mod/media/transcoder/hwaccel.go

@@ -0,0 +1,97 @@
+package transcoder
+
+/*
+	hwaccel.go
+
+	Detects and caches a working Intel/AMD hardware H.264 encoder so
+	TranscodeAndStream can offload encoding from the CPU when the host
+	supports it, falling back to the existing libx264 software path
+	otherwise. Platform-specific profiles (which encoder, which ffmpeg
+	args) live in hwaccel_linux.go / hwaccel_windows.go / hwaccel_other.go.
+*/
+
+import (
+	"context"
+	"os/exec"
+	"sync"
+	"time"
+
+	"imuslab.com/arozos/mod/info/logger"
+)
+
+// hwEncoderProfile describes how to invoke a hardware-accelerated H.264
+// encoder as a drop-in replacement for the software libx264 path.
+type hwEncoderProfile struct {
+	Name        string                     // human-readable label for logging, e.g. "Intel/AMD VAAPI"
+	Codec       string                     // ffmpeg -vcodec value, e.g. "h264_vaapi"
+	PreInput    []string                   // extra global args inserted before -i (device init, etc.)
+	ScaleFilter func(height string) string // -vf value for the given target height ("" height = no scaling)
+	EncodeArgs  []string                   // encoder-specific args replacing "-preset superfast"
+}
+
+var (
+	hwProfileOnce sync.Once
+	hwProfile     *hwEncoderProfile // nil if no usable hardware encoder was found
+)
+
+// nv12ScaleFilter returns a -vf filter chain that performs the requested
+// resize (if any) and converts to NV12. This is what the hardware encoders
+// that accept plain system-memory frames (NVENC, QSV, AMF) expect as input -
+// unlike VAAPI, they upload to the GPU internally so no explicit hwupload
+// step is needed.
+func nv12ScaleFilter(height string) string {
+	if height == "" {
+		return "format=nv12"
+	}
+	return "scale=-1:" + height + ",format=nv12"
+}
+
+// getHWEncoderProfile probes the host once for a working Intel/AMD hardware
+// H.264 encoder and caches the result for the lifetime of the process.
+func getHWEncoderProfile() *hwEncoderProfile {
+	hwProfileOnce.Do(func() {
+		hwProfile = probeHWEncoders()
+		if hwProfile != nil {
+			logger.PrintAndLog("Transcoder", "Hardware transcoding enabled: "+hwProfile.Name, nil)
+		} else {
+			logger.PrintAndLog("Transcoder", "No usable hardware encoder found, using software (libx264) transcoding", nil)
+		}
+	})
+	return hwProfile
+}
+
+// probeHWEncoders tries each platform candidate in order and returns the
+// first one that can actually complete a throwaway encode, since an encoder
+// can be compiled into ffmpeg yet still fail when no compatible GPU/driver
+// is present on this particular machine.
+func probeHWEncoders() *hwEncoderProfile {
+	for _, profile := range candidateHWProfiles() {
+		if testHWEncoder(profile) {
+			return profile
+		}
+	}
+	return nil
+}
+
+// testHWEncoder runs a tiny, throwaway encode against the given profile to
+// confirm ffmpeg has a working hardware path on this host.
+func testHWEncoder(profile *hwEncoderProfile) bool {
+	args := append([]string{}, profile.PreInput...)
+	args = append(args,
+		"-hide_banner", "-loglevel", "error",
+		// 320x240: some encoders (observed with h264_nvenc) reject much
+		// smaller test frames as below their minimum encode dimensions.
+		"-f", "lavfi", "-i", "color=c=black:s=320x240:d=0.1",
+	)
+	if vf := profile.ScaleFilter(""); vf != "" {
+		args = append(args, "-vf", vf)
+	}
+	args = append(args, "-frames:v", "1", "-vcodec", profile.Codec)
+	args = append(args, profile.EncodeArgs...)
+	args = append(args, "-f", "null", "-")
+
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+	cmd := exec.CommandContext(ctx, "ffmpeg", args...)
+	return cmd.Run() == nil
+}

+ 38 - 0
src/mod/media/transcoder/hwaccel_linux.go

@@ -0,0 +1,38 @@
+//go:build linux
+// +build linux
+
+package transcoder
+
+/*
+	hwaccel_linux.go
+
+	NVIDIA GPUs are tried first via NVENC (its own proprietary encode API,
+	needs the vendor driver but not VAAPI). Intel and AMD GPUs both expose
+	hardware H.264 encoding through VAAPI via their respective iHD/Mesa
+	drivers, so a single fallback profile covers both vendors, using the
+	default DRM render node. If a given encoder is unsupported or its driver
+	isn't present, testHWEncoder (hwaccel.go) will fail that profile's probe
+	and the next candidate (or software encoding) is used instead.
+*/
+
+func candidateHWProfiles() []*hwEncoderProfile {
+	return []*hwEncoderProfile{
+		{
+			Name:        "NVIDIA NVENC",
+			Codec:       "h264_nvenc",
+			ScaleFilter: nv12ScaleFilter,
+			EncodeArgs:  []string{"-preset", "fast"},
+		},
+		{
+			Name:     "Intel/AMD VAAPI",
+			Codec:    "h264_vaapi",
+			PreInput: []string{"-vaapi_device", "/dev/dri/renderD128"},
+			ScaleFilter: func(height string) string {
+				if height == "" {
+					return "format=nv12,hwupload"
+				}
+				return "scale=-1:" + height + ",format=nv12,hwupload"
+			},
+		},
+	}
+}

+ 16 - 0
src/mod/media/transcoder/hwaccel_other.go

@@ -0,0 +1,16 @@
+//go:build !linux && !windows
+// +build !linux,!windows
+
+package transcoder
+
+/*
+	hwaccel_other.go
+
+	No Intel/AMD hardware encode path is implemented yet for platforms other
+	than Linux and Windows (e.g. macOS, BSD). These hosts always fall back to
+	software (libx264) transcoding.
+*/
+
+func candidateHWProfiles() []*hwEncoderProfile {
+	return nil
+}

+ 58 - 0
src/mod/media/transcoder/hwaccel_test.go

@@ -0,0 +1,58 @@
+package transcoder
+
+import "testing"
+
+// TestNV12ScaleFilter verifies the shared scale/format filter chain used by
+// the hardware encoders that accept plain system-memory frames.
+func TestNV12ScaleFilter(t *testing.T) {
+	cases := []struct {
+		name   string
+		height string
+		want   string
+	}{
+		{"no resize", "", "format=nv12"},
+		{"resize to 360", "360", "scale=-1:360,format=nv12"},
+		{"resize to 720", "720", "scale=-1:720,format=nv12"},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			got := nv12ScaleFilter(tc.height)
+			if got != tc.want {
+				t.Errorf("nv12ScaleFilter(%q) = %q, want %q", tc.height, got, tc.want)
+			}
+		})
+	}
+}
+
+// TestCandidateHWProfiles verifies that every profile the current platform
+// offers is well-formed: it has a name, a codec, and a scale filter that
+// does not panic and always returns a non-empty filter chain.
+func TestCandidateHWProfiles(t *testing.T) {
+	for _, profile := range candidateHWProfiles() {
+		if profile.Name == "" {
+			t.Errorf("profile has empty Name: %+v", profile)
+		}
+		if profile.Codec == "" {
+			t.Errorf("profile %q has empty Codec", profile.Name)
+		}
+		if profile.ScaleFilter == nil {
+			t.Fatalf("profile %q has nil ScaleFilter", profile.Name)
+		}
+		for _, height := range []string{"", "360", "720", "1080"} {
+			if vf := profile.ScaleFilter(height); vf == "" {
+				t.Errorf("profile %q ScaleFilter(%q) returned empty string", profile.Name, height)
+			}
+		}
+	}
+}
+
+// TestGetHWEncoderProfile_Cached verifies that repeated calls return the same
+// cached result (the underlying probe only runs once per process).
+func TestGetHWEncoderProfile_Cached(t *testing.T) {
+	first := getHWEncoderProfile()
+	second := getHWEncoderProfile()
+	if first != second {
+		t.Errorf("getHWEncoderProfile() returned different results across calls: %v vs %v", first, second)
+	}
+}

+ 38 - 0
src/mod/media/transcoder/hwaccel_windows.go

@@ -0,0 +1,38 @@
+//go:build windows
+// +build windows
+
+package transcoder
+
+/*
+	hwaccel_windows.go
+
+	On Windows, ffmpeg's h264_nvenc (NVIDIA), h264_qsv (Intel Quick Sync) and
+	h264_amf (AMD AMF) encoders all accept plain system-memory frames
+	directly, so no explicit hardware device/frames-context setup is needed
+	(unlike VAAPI on Linux). NVIDIA is tried first as the most broadly capable
+	path, then Intel Quick Sync, then AMD as the fallback for machines with
+	neither.
+*/
+
+func candidateHWProfiles() []*hwEncoderProfile {
+	return []*hwEncoderProfile{
+		{
+			Name:        "NVIDIA NVENC",
+			Codec:       "h264_nvenc",
+			ScaleFilter: nv12ScaleFilter,
+			EncodeArgs:  []string{"-preset", "fast"},
+		},
+		{
+			Name:        "Intel Quick Sync (QSV)",
+			Codec:       "h264_qsv",
+			ScaleFilter: nv12ScaleFilter,
+			EncodeArgs:  []string{"-preset", "fast"},
+		},
+		{
+			Name:        "AMD AMF",
+			Codec:       "h264_amf",
+			ScaleFilter: nv12ScaleFilter,
+			EncodeArgs:  []string{"-quality", "speed", "-usage", "transcoding"},
+		},
+	}
+}

+ 36 - 10
src/mod/media/transcoder/transcoder.go

@@ -30,29 +30,55 @@ const (
 
 // Transcode and stream the given file. Make sure ffmpeg is installed before calling to transcoder.
 // startTime is a seek offset in seconds; pass 0 to start from the beginning.
+// When the host has a usable Intel or AMD hardware encoder (probed once and
+// cached by getHWEncoderProfile), it is used in place of libx264 to keep CPU
+// load down; otherwise this falls back to the original software path.
 func TranscodeAndStream(w http.ResponseWriter, r *http.Request, inputFile string, resolution TranscodeOutputResolution, startTime float64) {
 	// Build the FFmpeg command based on the resolution parameter
 	var cmd *exec.Cmd
 
-	transcodeFormatArgs := []string{"-f", "mp4", "-vcodec", "libx264", "-preset", "superfast", "-g", "60", "-movflags", "frag_keyframe+empty_moov+faststart", "pipe:1"}
-	var preInputArgs []string
-	if startTime > 0.001 {
-		preInputArgs = []string{"-ss", fmt.Sprintf("%.3f", startTime)}
-	}
-	var middleArgs []string
+	var height string
 	switch resolution {
 	case "360p":
-		middleArgs = []string{"-i", inputFile, "-vf", "scale=-1:360"}
+		height = "360"
 	case "720p":
-		middleArgs = []string{"-i", inputFile, "-vf", "scale=-1:720"}
+		height = "720"
 	case "1080p":
-		middleArgs = []string{"-i", inputFile, "-vf", "scale=-1:1080"}
+		height = "1080"
 	case "":
-		middleArgs = []string{"-i", inputFile}
+		height = ""
 	default:
 		http.Error(w, "Invalid resolution parameter", http.StatusBadRequest)
 		return
 	}
+
+	var preInputArgs []string
+	if startTime > 0.001 {
+		preInputArgs = append(preInputArgs, "-ss", fmt.Sprintf("%.3f", startTime))
+	}
+
+	var videoCodecArgs []string
+	var vf string
+	if hw := getHWEncoderProfile(); hw != nil {
+		preInputArgs = append(append([]string{}, hw.PreInput...), preInputArgs...)
+		vf = hw.ScaleFilter(height)
+		videoCodecArgs = append([]string{"-vcodec", hw.Codec}, hw.EncodeArgs...)
+	} else {
+		if height != "" {
+			vf = "scale=-1:" + height
+		}
+		videoCodecArgs = []string{"-vcodec", "libx264", "-preset", "superfast"}
+	}
+
+	middleArgs := []string{"-i", inputFile}
+	if vf != "" {
+		middleArgs = append(middleArgs, "-vf", vf)
+	}
+
+	transcodeFormatArgs := []string{"-f", "mp4"}
+	transcodeFormatArgs = append(transcodeFormatArgs, videoCodecArgs...)
+	transcodeFormatArgs = append(transcodeFormatArgs, "-g", "60", "-movflags", "frag_keyframe+empty_moov+faststart", "pipe:1")
+
 	var args []string
 	args = append(args, preInputArgs...)
 	args = append(args, middleArgs...)

+ 116 - 25
src/web/Movie/embedded.html

@@ -102,6 +102,45 @@
         }
         #spacer { flex: 1; }
 
+        /* ── 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 — controls and menus stay on top and clickable. */
+        #seek-freeze {
+            display: none;
+            position: absolute; inset: 0;
+            width: 100%; height: 100%;
+            object-fit: contain;
+            background: #000;
+            pointer-events: none;
+        }
+        #seek-spinner {
+            display: none;
+            position: absolute; inset: 0;
+            align-items: center; justify-content: center;
+            pointer-events: none;
+        }
+        #seek-spinner.active { display: flex; }
+        .seek-spinner-box {
+            display: flex; flex-direction: column;
+            align-items: center; gap: 10px;
+            background: rgba(0,0,0,0.55);
+            backdrop-filter: blur(4px);
+            -webkit-backdrop-filter: blur(4px);
+            border-radius: 14px;
+            padding: 18px 24px;
+            font-size: 12px; color: var(--text);
+            font-family: -apple-system, BlinkMacSystemFont, sans-serif;
+        }
+        .seek-spinner-box .spinner {
+            width: 38px; height: 38px;
+            border: 3px solid rgba(255,255,255,0.18);
+            border-top-color: var(--accent);
+            border-radius: 50%;
+            animation: seekSpin 0.8s linear infinite;
+        }
+        @keyframes seekSpin { to { transform: rotate(360deg); } }
+
         /* ── Subtitle display ──────────────────────────────────────────────── */
         #subtitle-display {
             display: none;
@@ -385,6 +424,16 @@
 
     <video id="main-video" preload="metadata"></video>
 
+    <!-- Frozen last frame + spinner, shown while a transcode seek re-buffers.
+         Must stay directly after <video> — stacking relies on DOM order. -->
+    <canvas id="seek-freeze"></canvas>
+    <div id="seek-spinner">
+        <div class="seek-spinner-box">
+            <div class="spinner"></div>
+            <span>Buffering…</span>
+        </div>
+    </div>
+
     <!-- Subtitle overlay -->
     <div id="subtitle-display"></div>
 
@@ -662,6 +711,60 @@ if (files && files.length > 0) {
     }
 }
 
+// ── 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
+// <video> to black until the first frame of the new segment arrives, which can
+// take seconds. Freeze the last painted frame under a spinner instead, and only
+// reveal the live element once it is genuinely playing again.
+var seekFreezeTimer = null;
+
+function showSeekFreeze() {
+    var canvas = document.getElementById('seek-freeze');
+    if (canvas && vid.videoWidth > 0 && vid.videoHeight > 0) {
+        canvas.width  = vid.videoWidth;
+        canvas.height = vid.videoHeight;
+        try {
+            canvas.getContext('2d').drawImage(vid, 0, 0, canvas.width, canvas.height);
+            $(canvas).show();
+        } catch (e) {
+            // No decodable frame right now (e.g. seeking again mid-buffer) —
+            // leave whatever is already frozen on screen rather than flashing black.
+        }
+    }
+    $('#seek-spinner').addClass('active');
+    clearTimeout(seekFreezeTimer);
+    // Safety net: a stalled transcode must never leave the overlay stuck on
+    seekFreezeTimer = setTimeout(hideSeekFreeze, 30000);
+}
+
+function hideSeekFreeze() {
+    clearTimeout(seekFreezeTimer);
+    seekFreezeTimer = null;
+    $('#seek-freeze').hide();
+    $('#seek-spinner').removeClass('active');
+}
+
+// Restart the transcode stream at `seconds`, holding the last frame until the
+// new segment plays. Clamped to the known duration.
+function transcodeSeekTo(seconds) {
+    if (!currentFile) { return; }
+    var pos = Math.max(0, seconds);
+    if (transcodeDuration > 0) { pos = Math.min(transcodeDuration, pos); }
+
+    showSeekFreeze();
+    transcodeSeekOffset = pos;
+    vid.src = TRANSCODE_API + '?file=' + encodeURIComponent(currentFile.filepath)
+            + '&start=' + pos.toFixed(3);
+    vid.load();
+    vid.play();
+}
+
+// Current playback position in whole-file terms (transcode streams restart at 0)
+function effectivePlaybackTime() {
+    return isTranscodedVideo ? (vid.currentTime + transcodeSeekOffset) : vid.currentTime;
+}
+
 // ── Video controls ────────────────────────────────────────────────────────────
 function initVideoControls() {
     var $prog  = $('#progress-bar');
@@ -691,12 +794,7 @@ function initVideoControls() {
     // Progress bar — seek-by-reload for transcoded streams
     $('#progress-wrap').on('click', function (e) {
         if (isTranscodedVideo && transcodeDuration > 0 && currentFile) {
-            var seekTo = (e.offsetX / $(this).width()) * transcodeDuration;
-            transcodeSeekOffset = seekTo;
-            vid.src = TRANSCODE_API + '?file=' + encodeURIComponent(currentFile.filepath)
-                    + '&start=' + seekTo.toFixed(3);
-            vid.load();
-            vid.play();
+            transcodeSeekTo((e.offsetX / $(this).width()) * transcodeDuration);
             return;
         }
         if (vid.duration) {
@@ -750,13 +848,11 @@ function initVideoControls() {
         if (!repeatSingle) { return; }
         if (isTranscodedVideo && transcodeSeekOffset > 0 && currentFile) {
             // The stream started part-way in — restart it from the beginning
-            transcodeSeekOffset = 0;
-            vid.src = TRANSCODE_API + '?file=' + encodeURIComponent(currentFile.filepath);
-            vid.load();
+            transcodeSeekTo(0);
         } else {
             vid.currentTime = 0;
+            vid.play();
         }
-        vid.play();
     });
 
     $(vid).on('volumechange', function () {
@@ -765,6 +861,13 @@ function initVideoControls() {
         localStorage.setItem('movie_muted', vid.muted ? '1' : '0');
     });
 
+    // Drop the transcode-seek freeze frame the moment the new segment is live.
+    // 'playing' is the accurate signal; 'loadeddata' covers a seek made while
+    // paused (where 'playing' never fires), and 'error' avoids a stuck overlay.
+    $(vid).on('playing', hideSeekFreeze);
+    $(vid).on('loadeddata', function () { if (vid.paused) { hideSeekFreeze(); } });
+    $(vid).on('error', hideSeekFreeze);
+
     // Auto-hide controls on mouse movement
     $('#player-wrap').on('mousemove touchstart', showControls);
 
@@ -837,11 +940,7 @@ function showResumePopup(savedPos, duration) {
 
     $('#resume-btn-continue').off('click').on('click', function () {
         if (isTranscodedVideo && currentFile) {
-            transcodeSeekOffset = pendingResumePos;
-            vid.src = TRANSCODE_API + '?file=' + encodeURIComponent(currentFile.filepath)
-                    + '&start=' + pendingResumePos.toFixed(3);
-            vid.load();
-            vid.play();
+            transcodeSeekTo(pendingResumePos);
         } else {
             vid.currentTime = pendingResumePos;
             vid.play();
@@ -1309,11 +1408,7 @@ function initKeyboard() {
             case 'ArrowRight':
                 e.preventDefault();
                 if (isTranscodedVideo && transcodeDuration > 0 && currentFile) {
-                    var newPos = Math.min(transcodeDuration, vid.currentTime + transcodeSeekOffset + 10);
-                    transcodeSeekOffset = newPos;
-                    vid.src = TRANSCODE_API + '?file=' + encodeURIComponent(currentFile.filepath)
-                            + '&start=' + newPos.toFixed(3);
-                    vid.load(); vid.play();
+                    transcodeSeekTo(effectivePlaybackTime() + 10);
                 } else {
                     vid.currentTime = Math.min(vid.duration || 0, vid.currentTime + 10);
                 }
@@ -1322,11 +1417,7 @@ function initKeyboard() {
             case 'ArrowLeft':
                 e.preventDefault();
                 if (isTranscodedVideo && transcodeDuration > 0 && currentFile) {
-                    var newPos = Math.max(0, vid.currentTime + transcodeSeekOffset - 10);
-                    transcodeSeekOffset = newPos;
-                    vid.src = TRANSCODE_API + '?file=' + encodeURIComponent(currentFile.filepath)
-                            + '&start=' + newPos.toFixed(3);
-                    vid.load(); vid.play();
+                    transcodeSeekTo(effectivePlaybackTime() - 10);
                 } else {
                     vid.currentTime = Math.max(0, vid.currentTime - 10);
                 }

+ 112 - 23
src/web/Movie/index.html

@@ -522,6 +522,36 @@ body.always-show-volume #volume-slider { display: block; }
     100% { opacity: 0;    transform: scale(1.32); }
 }
 
+/* ─── 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
+   — controls, top bar and menus all stay on top and clickable. */
+#seek-freeze {
+    display: none;
+    position: absolute; inset: 0;
+    width: 100%; height: 100%;
+    object-fit: contain;
+    background: #000;
+    pointer-events: none;
+}
+#seek-spinner {
+    display: none;
+    position: absolute; inset: 0;
+    align-items: center; justify-content: center;
+    pointer-events: none;
+}
+#seek-spinner.active { display: flex; }
+.seek-spinner-box {
+    display: flex; flex-direction: column;
+    align-items: center; gap: 10px;
+    background: rgba(0,0,0,0.55);
+    backdrop-filter: blur(4px);
+    -webkit-backdrop-filter: blur(4px);
+    border-radius: 14px;
+    padding: 18px 24px;
+    font-size: 12px; color: var(--text);
+}
+
 #time-display { font-size: 13px; color: rgba(255,255,255,0.8); margin-left: 6px; }
 #now-playing-title {
     font-size: 15px; font-weight: 600;
@@ -1547,6 +1577,17 @@ img.ctx-icon { height: 16px; opacity: 0.85; display: block; }
                 <button class="ctrl-btn" id="ctrl-cast-top" title="Cast to Arozcast" onclick="openCastDialog()"><img src="img/icons/cast_white.svg" alt="" width="18" height="18"></button>
             </div>
             <video id="main-video" preload="metadata"></video>
+
+            <!-- Frozen last frame + spinner, shown while a transcode seek re-buffers.
+                 Must stay directly after <video> — stacking relies on DOM order. -->
+            <canvas id="seek-freeze"></canvas>
+            <div id="seek-spinner">
+                <div class="seek-spinner-box">
+                    <div class="spinner"></div>
+                    <span>Buffering…</span>
+                </div>
+            </div>
+
             <div id="subtitle-display"></div>
 
             <!-- Centre play/pause flash indicator -->
@@ -2284,6 +2325,64 @@ function closeMovieInfo() {
     showView('library');
 }
 
+// ─── 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
+// <video> to black until the first frame of the new segment arrives, which can
+// take seconds. Freeze the last painted frame under a spinner instead, and only
+// reveal the live element once it is genuinely playing again.
+var seekFreezeTimer = null;
+
+function showSeekFreeze() {
+    var vid    = document.getElementById('main-video');
+    var canvas = document.getElementById('seek-freeze');
+    if (canvas && vid.videoWidth > 0 && vid.videoHeight > 0) {
+        canvas.width  = vid.videoWidth;
+        canvas.height = vid.videoHeight;
+        try {
+            canvas.getContext('2d').drawImage(vid, 0, 0, canvas.width, canvas.height);
+            $(canvas).show();
+        } catch (e) {
+            // No decodable frame right now (e.g. seeking again mid-buffer) —
+            // leave whatever is already frozen on screen rather than flashing black.
+        }
+    }
+    $('#seek-spinner').addClass('active');
+    clearTimeout(seekFreezeTimer);
+    // Safety net: a stalled transcode must never leave the overlay stuck on
+    seekFreezeTimer = setTimeout(hideSeekFreeze, 30000);
+}
+
+function hideSeekFreeze() {
+    clearTimeout(seekFreezeTimer);
+    seekFreezeTimer = null;
+    $('#seek-freeze').hide();
+    $('#seek-spinner').removeClass('active');
+}
+
+// Restart the transcode stream at `seconds`, holding the last frame until the
+// new segment plays. Clamped to the known duration.
+function transcodeSeekTo(seconds) {
+    if (playingIndex < 0 || !currentEpisodes[playingIndex]) { return; }
+    var vid = document.getElementById('main-video');
+    var pos = Math.max(0, seconds);
+    if (transcodeDuration > 0) { pos = Math.min(transcodeDuration, pos); }
+
+    showSeekFreeze();
+    transcodeSeekOffset = pos;
+    vid.src = TRANSCODE_API + '?file='
+            + encodeURIComponent(currentEpisodes[playingIndex].filepath)
+            + '&start=' + pos.toFixed(3);
+    vid.load();
+    vid.play();
+}
+
+// Current playback position in whole-file terms (transcode streams restart at 0)
+function effectivePlaybackTime() {
+    var vid = document.getElementById('main-video');
+    return isTranscodedVideo ? (vid.currentTime + transcodeSeekOffset) : vid.currentTime;
+}
+
 // ─── Watch position (resume) ──────────────────────────────────────────────────
 function saveWatchPosition() {
     var vid = document.getElementById('main-video');
@@ -2319,11 +2418,7 @@ function showResumePopup(savedPos, duration) {
 
     $('#resume-btn-continue').off('click').on('click', function () {
         if (isTranscodedVideo && playingIndex >= 0 && currentEpisodes[playingIndex]) {
-            var ep = currentEpisodes[playingIndex];
-            transcodeSeekOffset = pendingResumePos;
-            vid.src = TRANSCODE_API + '?file=' + encodeURIComponent(ep.filepath) + '&start=' + pendingResumePos.toFixed(3);
-            vid.load();
-            vid.play();
+            transcodeSeekTo(pendingResumePos);
         } else {
             vid.currentTime = pendingResumePos;
             vid.play();
@@ -3098,6 +3193,7 @@ function isWebPlayable(ext) {
 }
 function startPlayback(index) {
     cancelCountdown();
+    hideSeekFreeze();   // never carry a frozen frame across to a different episode
     $('#resume-popup').removeClass('active');
     if (!currentEpisodes || currentEpisodes.length === 0) { return; }
     playingIndex = index;
@@ -3229,6 +3325,7 @@ function closePlayer() {
     }
     saveWatchPosition();
     cancelCountdown();
+    hideSeekFreeze();
     if (watchSaveInterval) { clearInterval(watchSaveInterval); watchSaveInterval = null; }
     var vid = document.getElementById('main-video');
     vid.pause();
@@ -3324,13 +3421,7 @@ function initVideoControls() {
             return;
         }
         if (isTranscodedVideo && transcodeDuration > 0 && playingIndex >= 0 && currentEpisodes[playingIndex]) {
-            var pct = e.offsetX / $(this).width();
-            var seekTo = pct * transcodeDuration;
-            transcodeSeekOffset = seekTo;
-            var ep = currentEpisodes[playingIndex];
-            vid.src = TRANSCODE_API + '?file=' + encodeURIComponent(ep.filepath) + '&start=' + seekTo.toFixed(3);
-            vid.load();
-            vid.play();
+            transcodeSeekTo((e.offsetX / $(this).width()) * transcodeDuration);
             return;
         }
         if (vid.duration) {
@@ -3393,6 +3484,13 @@ function initVideoControls() {
         localStorage.setItem('movie_muted', vid.muted ? '1' : '0');
     });
 
+    // Drop the transcode-seek freeze frame the moment the new segment is live.
+    // 'playing' is the accurate signal; 'loadeddata' covers a seek made while
+    // paused (where 'playing' never fires), and 'error' avoids a stuck overlay.
+    $(vid).on('playing', hideSeekFreeze);
+    $(vid).on('loadeddata', function () { if (vid.paused) { hideSeekFreeze(); } });
+    $(vid).on('error', hideSeekFreeze);
+
     // Auto-hide controls
     $('#video-container').on('mousemove touchstart', function () { showControls(); });
 
@@ -3783,12 +3881,7 @@ function initKeyboard() {
                         castCurrentTime = Math.min(castDuration, castCurrentTime + 10);
                         _castUpdateProgressUI();
                     } else if (isTranscodedVideo && playingIndex >= 0 && currentEpisodes[playingIndex]) {
-                        var newPos = vid.currentTime + transcodeSeekOffset + 10;
-                        if (transcodeDuration > 0) { newPos = Math.min(transcodeDuration, newPos); }
-                        transcodeSeekOffset = newPos;
-                        var seekEp = currentEpisodes[playingIndex];
-                        vid.src = TRANSCODE_API + '?file=' + encodeURIComponent(seekEp.filepath) + '&start=' + newPos.toFixed(3);
-                        vid.load(); vid.play();
+                        transcodeSeekTo(effectivePlaybackTime() + 10);
                     } else { vid.currentTime = Math.min(vid.duration || 0, vid.currentTime + 10); }
                     showControls(); break;
                 case 'ArrowLeft':
@@ -3798,11 +3891,7 @@ function initKeyboard() {
                         castCurrentTime = Math.max(0, castCurrentTime - 10);
                         _castUpdateProgressUI();
                     } else if (isTranscodedVideo && playingIndex >= 0 && currentEpisodes[playingIndex]) {
-                        var newPos = Math.max(0, vid.currentTime + transcodeSeekOffset - 10);
-                        transcodeSeekOffset = newPos;
-                        var seekEp = currentEpisodes[playingIndex];
-                        vid.src = TRANSCODE_API + '?file=' + encodeURIComponent(seekEp.filepath) + '&start=' + newPos.toFixed(3);
-                        vid.load(); vid.play();
+                        transcodeSeekTo(effectivePlaybackTime() - 10);
                     } else { vid.currentTime = Math.max(0, vid.currentTime - 10); }
                     showControls(); break;
                 case 'ArrowUp':