diff --git a/internal/api/handlers/playback.go b/internal/api/handlers/playback.go index 4f0ea516..cecd5b92 100644 --- a/internal/api/handlers/playback.go +++ b/internal/api/handlers/playback.go @@ -1444,10 +1444,14 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req // subtitleURLExt returns the URL file extension for a subtitle codec. // ASS/SSA tracks get ".ass" so the frontend can request raw ASS data for -// client-side rendering (JASSUB); all other text formats get ".vtt". +// client-side rendering (JASSUB); PGS tracks get ".sup" for client-side +// bitmap rendering (libpgs); all other text formats get ".vtt". func subtitleURLExt(codec string) string { - if playback.IsASS(codec) { + switch { + case playback.IsASS(codec): return ".ass" + case playback.IsPGS(codec): + return ".sup" } return ".vtt" } @@ -1474,7 +1478,10 @@ func buildSubtitleURLs(sessionID string, file *models.MediaFile, downloaded []su embeddedOffset := len(file.ExternalSubtitles) for i, track := range file.SubtitleTracks { - if playback.NeedsBurnIn(track.Codec) { + // PGS bitmap tracks are deliverable as .sup streams for + // client-side rendering; DVD/DVB bitmap tracks still have no + // non-burn-in delivery path, so they stay hidden. + if playback.NeedsBurnIn(track.Codec) && !playback.IsPGS(track.Codec) { continue } diff --git a/internal/api/handlers/playback_subtitle_urls_test.go b/internal/api/handlers/playback_subtitle_urls_test.go new file mode 100644 index 00000000..b28c21a4 --- /dev/null +++ b/internal/api/handlers/playback_subtitle_urls_test.go @@ -0,0 +1,81 @@ +package handlers + +import ( + "testing" + + "github.com/Silo-Server/silo-server/internal/models" +) + +func TestSubtitleURLExt(t *testing.T) { + cases := []struct { + codec string + want string + }{ + {"ass", ".ass"}, + {"ssa", ".ass"}, + {"pgs", ".sup"}, + {"hdmv_pgs_subtitle", ".sup"}, + {"subrip", ".vtt"}, + {"srt", ".vtt"}, + {"", ".vtt"}, + } + for _, tc := range cases { + if got := subtitleURLExt(tc.codec); got != tc.want { + t.Errorf("subtitleURLExt(%q) = %q, want %q", tc.codec, got, tc.want) + } + } +} + +func TestBuildSubtitleURLs_IncludesPGSButNotOtherBitmaps(t *testing.T) { + file := &models.MediaFile{ + SubtitleTracks: []models.SubtitleTrack{ + {Index: 0, Language: "en", Codec: "subrip"}, + {Index: 1, Language: "en", Codec: "hdmv_pgs_subtitle"}, + {Index: 2, Language: "fr", Codec: "dvd_subtitle"}, + {Index: 3, Language: "de", Codec: "dvb_subtitle"}, + }, + } + + urls := buildSubtitleURLs("sess-1", file, nil) + + if len(urls) != 2 { + t.Fatalf("expected 2 subtitle URLs (text + PGS), got %d: %+v", len(urls), urls) + } + + srt := urls[0] + if srt.Codec != "subrip" || srt.URL != "/stream/sess-1/subtitles/0.vtt" { + t.Errorf("unexpected text track entry: %+v", srt) + } + + pgs := urls[1] + if pgs.Codec != "hdmv_pgs_subtitle" { + t.Errorf("expected PGS track to be included, got %+v", pgs) + } + if pgs.URL != "/stream/sess-1/subtitles/1.sup" { + t.Errorf("PGS track should get a .sup URL, got %q", pgs.URL) + } + if pgs.FontBundleURL != "" { + t.Errorf("PGS track must not advertise a font bundle, got %q", pgs.FontBundleURL) + } +} + +func TestBuildSubtitleURLs_PGSIndexAccountsForExternalOffset(t *testing.T) { + file := &models.MediaFile{ + ExternalSubtitles: []models.ExternalSubtitle{ + {Path: "/media/movie.en.srt", Language: "en", Format: "srt"}, + }, + SubtitleTracks: []models.SubtitleTrack{ + {Index: 0, Language: "en", Codec: "pgs"}, + }, + } + + urls := buildSubtitleURLs("sess-2", file, nil) + + if len(urls) != 2 { + t.Fatalf("expected 2 subtitle URLs, got %d: %+v", len(urls), urls) + } + pgs := urls[1] + if pgs.Index != 1 || pgs.URL != "/stream/sess-2/subtitles/1.sup" { + t.Errorf("PGS track index should include the external offset, got %+v", pgs) + } +} diff --git a/internal/api/handlers/stream.go b/internal/api/handlers/stream.go index 8851ff8c..83225a27 100644 --- a/internal/api/handlers/stream.go +++ b/internal/api/handlers/stream.go @@ -219,7 +219,10 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) { // Check embedded tracks. if embeddedIndex < len(file.SubtitleTracks) { track := file.SubtitleTracks[embeddedIndex] - if playback.NeedsBurnIn(track.Codec) { + // PGS is the one bitmap codec we can deliver without burn-in: the + // track is copied losslessly into a .sup stream and rendered + // client-side. DVD/DVB bitmap subs still require burn-in. + if playback.NeedsBurnIn(track.Codec) && !playback.IsPGS(track.Codec) { writeError(w, http.StatusBadRequest, "bad_request", "Bitmap subtitle tracks cannot be extracted as text") return @@ -417,12 +420,24 @@ func (h *StreamHandler) handleTransportStartFailure(ctx context.Context, session func (h *StreamHandler) streamEmbeddedSubtitle(w http.ResponseWriter, r *http.Request, file *models.MediaFile, embeddedIndex int, session *playback.Session) { track := file.SubtitleTracks[embeddedIndex] outFormat := "vtt" - if playback.IsASS(track.Codec) { + switch { + case playback.IsASS(track.Codec): outFormat = "ass" + case playback.IsPGS(track.Codec): + outFormat = "sup" } - seek := subtitleSeekPosition(r, session) - duration := subtitleWindowDuration(r) + // ASS and PGS are fetched exactly once and consumed whole by their + // client-side renderers (JASSUB / libpgs), so they must never be + // windowed. Note subtitleSeekPosition falls back to the session's + // last reported position even without a ?position= query — relying + // on StreamExtractSubtitle's codec guard alone would still log a + // misleading nonzero seek here. + var seek, duration float64 + if outFormat == "vtt" { + seek = subtitleSeekPosition(r, session) + duration = subtitleWindowDuration(r) + } slog.InfoContext(r.Context(), "subtitle stream requested", "file_id", file.ID, "embedded_index", embeddedIndex, @@ -433,9 +448,12 @@ func (h *StreamHandler) streamEmbeddedSubtitle(w http.ResponseWriter, r *http.Re "duration_seconds", duration, ) - if outFormat == "ass" { + switch outFormat { + case "ass": w.Header().Set("Content-Type", "text/x-ssa; charset=utf-8") - } else { + case "sup": + w.Header().Set("Content-Type", "application/octet-stream") + default: w.Header().Set("Content-Type", "text/vtt; charset=utf-8") } w.Header().Set("Access-Control-Allow-Origin", "*") diff --git a/internal/playback/subtitle_stream.go b/internal/playback/subtitle_stream.go index 3d13a9b9..39a147e3 100644 --- a/internal/playback/subtitle_stream.go +++ b/internal/playback/subtitle_stream.go @@ -62,55 +62,12 @@ func StreamExtractSubtitle(ctx context.Context, opts StreamExtractOpts) error { return errors.New("StreamExtractSubtitle: InputPath is required") } - outCodec, outFormat := streamExtractOutput(opts.SourceCodec) - bin := opts.FFmpegPath if bin == "" { bin = "ffmpeg" } - args := []string{ - "-hide_banner", "-nostats", "-loglevel", "error", - } - - // Input seek (before -i) is the fast variant: ffmpeg jumps near the - // requested position before demuxing. ASS can't use it because the - // output needs the [Script Info] header which only sits at offset 0. - seekApplied := opts.SeekSeconds > 0 && !IsASS(opts.SourceCodec) - if seekApplied { - args = append(args, "-ss", strconv.FormatFloat(opts.SeekSeconds, 'f', 3, 64)) - } - - // Duration limit must be an *input* option (placed before -i) so it - // caps how much of the file we read. Placed as an output option, -t - // combined with -copyts stops output when PTS reaches the given - // value — which with a non-zero seek is already in the past, so - // ffmpeg would emit only the WEBVTT header and zero cues. - if opts.DurationSeconds > 0 { - args = append(args, "-t", strconv.FormatFloat(opts.DurationSeconds, 'f', 3, 64)) - } - - args = append(args, - "-i", opts.InputPath, - "-map", fmt.Sprintf("0:s:%d", opts.TrackIndex), - "-c:s", outCodec, - ) - - // When we seek the input, preserve the absolute source timestamps - // in the output. Without this ffmpeg rebases cues to start at 0, - // which makes every cue play `opts.SeekSeconds` earlier than it - // should — the symptom is subtitles that look "out of sync" with - // the video the player is showing at the same media time. - if seekApplied { - args = append(args, "-copyts", "-avoid_negative_ts", "disabled") - } - - args = append(args, - "-f", outFormat, - "pipe:1", - ) - - cmd := exec.CommandContext(ctx, bin, args...) + cmd := exec.CommandContext(ctx, bin, streamExtractArgs(opts)...) stdout, err := cmd.StdoutPipe() if err != nil { return fmt.Errorf("stdout pipe: %w", err) @@ -152,6 +109,59 @@ func StreamExtractSubtitle(ctx context.Context, opts StreamExtractOpts) error { return nil } +// streamExtractArgs builds the ffmpeg argument list for a streaming +// subtitle extract. +func streamExtractArgs(opts StreamExtractOpts) []string { + outCodec, outFormat := streamExtractOutput(opts.SourceCodec) + + args := []string{ + "-hide_banner", "-nostats", "-loglevel", "error", + } + + // Input seek (before -i) is the fast variant: ffmpeg jumps near the + // requested position before demuxing. ASS can't use it because the + // output needs the [Script Info] header which only sits at offset 0. + // PGS can't either: the client (libpgs) fetches the .sup stream + // exactly once and consumes it whole, so the output must cover the + // complete track from offset 0 with original timestamps — windowing + // would silently drop every cue outside the window. The same logic + // excludes both from the -t duration cap below. + windowable := !IsASS(opts.SourceCodec) && !IsPGS(opts.SourceCodec) + seekApplied := opts.SeekSeconds > 0 && windowable + if seekApplied { + args = append(args, "-ss", strconv.FormatFloat(opts.SeekSeconds, 'f', 3, 64)) + } + + // Duration limit must be an *input* option (placed before -i) so it + // caps how much of the file we read. Placed as an output option, -t + // combined with -copyts stops output when PTS reaches the given + // value — which with a non-zero seek is already in the past, so + // ffmpeg would emit only the WEBVTT header and zero cues. + if opts.DurationSeconds > 0 && windowable { + args = append(args, "-t", strconv.FormatFloat(opts.DurationSeconds, 'f', 3, 64)) + } + + args = append(args, + "-i", opts.InputPath, + "-map", fmt.Sprintf("0:s:%d", opts.TrackIndex), + "-c:s", outCodec, + ) + + // When we seek the input, preserve the absolute source timestamps + // in the output. Without this ffmpeg rebases cues to start at 0, + // which makes every cue play `opts.SeekSeconds` earlier than it + // should — the symptom is subtitles that look "out of sync" with + // the video the player is showing at the same media time. + if seekApplied { + args = append(args, "-copyts", "-avoid_negative_ts", "disabled") + } + + return append(args, + "-f", outFormat, + "pipe:1", + ) +} + // copyAndFlush streams from src to dst in 32KB chunks, calling Flush on // dst after each successful write when dst implements http.Flusher. func copyAndFlush(dst io.Writer, src io.Reader) error { @@ -177,11 +187,16 @@ func copyAndFlush(dst io.Writer, src io.Reader) error { } // streamExtractOutput picks the ffmpeg output codec and muxer format for -// a given source codec. ASS/SSA is copied so styling survives; everything -// else is transmuxed to WebVTT for direct `` consumption. +// a given source codec. ASS/SSA is copied so styling survives; PGS is +// copied into a .sup elementary stream for client-side bitmap rendering +// (libpgs); everything else is transmuxed to WebVTT for direct `` +// consumption. func streamExtractOutput(codec string) (outCodec, outFormat string) { - if IsASS(codec) { + switch { + case IsASS(codec): return "copy", "ass" + case IsPGS(codec): + return "copy", "sup" } return "webvtt", "webvtt" } diff --git a/internal/playback/subtitle_stream_test.go b/internal/playback/subtitle_stream_test.go new file mode 100644 index 00000000..85af1e00 --- /dev/null +++ b/internal/playback/subtitle_stream_test.go @@ -0,0 +1,112 @@ +package playback + +import ( + "slices" + "strings" + "testing" +) + +func TestIsPGS(t *testing.T) { + cases := []struct { + codec string + want bool + }{ + {"pgs", true}, + {"hdmv_pgs_subtitle", true}, + {"HDMV_PGS_SUBTITLE", true}, + {"dvd_subtitle", false}, + {"dvb_subtitle", false}, + {"subrip", false}, + {"ass", false}, + {"", false}, + } + for _, tc := range cases { + if got := IsPGS(tc.codec); got != tc.want { + t.Errorf("IsPGS(%q) = %v, want %v", tc.codec, got, tc.want) + } + } +} + +func TestStreamExtractOutput(t *testing.T) { + cases := []struct { + codec string + wantCodec string + wantFormat string + }{ + {"ass", "copy", "ass"}, + {"ssa", "copy", "ass"}, + {"pgs", "copy", "sup"}, + {"hdmv_pgs_subtitle", "copy", "sup"}, + {"subrip", "webvtt", "webvtt"}, + {"mov_text", "webvtt", "webvtt"}, + } + for _, tc := range cases { + outCodec, outFormat := streamExtractOutput(tc.codec) + if outCodec != tc.wantCodec || outFormat != tc.wantFormat { + t.Errorf("streamExtractOutput(%q) = (%q, %q), want (%q, %q)", + tc.codec, outCodec, outFormat, tc.wantCodec, tc.wantFormat) + } + } +} + +func TestStreamExtractArgs_TextCodecIsWindowed(t *testing.T) { + args := streamExtractArgs(StreamExtractOpts{ + InputPath: "/media/movie.mkv", + TrackIndex: 2, + SourceCodec: "subrip", + SeekSeconds: 120, + DurationSeconds: 600, + }) + + joined := strings.Join(args, " ") + if !strings.Contains(joined, "-ss 120.000") { + t.Fatalf("text extract should seek the input: %s", joined) + } + if !strings.Contains(joined, "-t 600.000") { + t.Fatalf("text extract should cap the read duration: %s", joined) + } + if !strings.Contains(joined, "-copyts") { + t.Fatalf("seeked extract must preserve source timestamps: %s", joined) + } + if !strings.Contains(joined, "-c:s webvtt") || !strings.Contains(joined, "-f webvtt") { + t.Fatalf("text extract should transmux to WebVTT: %s", joined) + } +} + +// ASS and PGS streams are fetched once and consumed whole by their +// client-side renderers, so seek/duration windowing must never apply even +// when the handler passes nonzero values. +func TestStreamExtractArgs_WholeTrackCodecsIgnoreWindow(t *testing.T) { + for _, codec := range []string{"ass", "hdmv_pgs_subtitle"} { + args := streamExtractArgs(StreamExtractOpts{ + InputPath: "/media/movie.mkv", + TrackIndex: 0, + SourceCodec: codec, + SeekSeconds: 120, + DurationSeconds: 600, + }) + + if slices.Contains(args, "-ss") { + t.Errorf("%s extract must not seek the input: %v", codec, args) + } + if slices.Contains(args, "-t") { + t.Errorf("%s extract must not cap the read duration: %v", codec, args) + } + if !slices.Contains(args, "copy") { + t.Errorf("%s extract must copy the source stream: %v", codec, args) + } + } +} + +func TestStreamExtractArgs_PGSProducesSup(t *testing.T) { + args := streamExtractArgs(StreamExtractOpts{ + InputPath: "/media/movie.mkv", + TrackIndex: 1, + SourceCodec: "pgs", + }) + + joined := strings.Join(args, " ") + if !strings.Contains(joined, "-map 0:s:1 -c:s copy -f sup pipe:1") { + t.Fatalf("PGS extract should copy into a sup stream: %s", joined) + } +} diff --git a/internal/playback/subtitles.go b/internal/playback/subtitles.go index a297aebc..f870e1a0 100644 --- a/internal/playback/subtitles.go +++ b/internal/playback/subtitles.go @@ -26,6 +26,20 @@ func NeedsBurnIn(subtitleCodec string) bool { return bitmapSubtitleCodecs[strings.ToLower(subtitleCodec)] } +// pgsSubtitleCodecs lists PGS (Blu-ray bitmap) subtitle codec names. Unlike +// other bitmap codecs, PGS can be extracted losslessly to a .sup elementary +// stream and rendered client-side (libpgs in the web player), so burn-in is +// not the only delivery option. DVD/DVB bitmap subs still require burn-in. +var pgsSubtitleCodecs = map[string]bool{ + "pgs": true, + "hdmv_pgs_subtitle": true, +} + +// IsPGS reports whether the given subtitle codec is PGS format. +func IsPGS(codec string) bool { + return pgsSubtitleCodecs[strings.ToLower(codec)] +} + // assSubtitleCodecs lists subtitle codecs that are ASS/SSA format and support // rich styling (fonts, colors, positioning, karaoke, typesetting). var assSubtitleCodecs = map[string]bool{ diff --git a/internal/proxy/server.go b/internal/proxy/server.go index fd8d20c1..c3d6565f 100644 --- a/internal/proxy/server.go +++ b/internal/proxy/server.go @@ -223,6 +223,31 @@ func (s *Server) handleSubtitle(w http.ResponseWriter, r *http.Request) { return } + // When the URL requests SUP format (e.g. /subtitles/{token}/2.sup), + // stream the PGS track as a raw .sup elementary stream for client-side + // bitmap rendering (libpgs). Unlike the buffered text paths below, this + // streams ffmpeg output directly: the track can be large and the client + // renders progressively as data arrives. + if requestedFormat == "sup" { + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + err := playback.StreamExtractSubtitle(r.Context(), playback.StreamExtractOpts{ + InputPath: claims.MediaPath, + TrackIndex: trackIndex, + SourceCodec: "hdmv_pgs_subtitle", // .sup URLs are only generated for PGS tracks + FFmpegPath: cfg.Playback.FFmpegPath, + Writer: w, + }) + if err != nil && r.Context().Err() == nil { + // Headers already committed — log and let the client see a + // truncated response. + slog.Error("stream subtitle (sup)", "error", err, "track", trackIndex, + "path", claims.MediaPath, "playback_session_id", claims.SessionID) + } + return + } + // When the URL requests ASS format (e.g. /subtitles/{token}/2.ass), // extract as raw ASS to preserve styling for client-side rendering. if requestedFormat == "ass" { diff --git a/web/package.json b/web/package.json index c811d9e0..387e158b 100644 --- a/web/package.json +++ b/web/package.json @@ -33,6 +33,7 @@ "framer-motion": "^12.38.0", "hls.js": "^1.6.15", "jassub": "^2.4.2", + "libpgs": "^0.8.1", "lucide-react": "^0.576.0", "node-unrar-js": "^2.0.2", "pdfjs-dist": "^5.7.284", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 563158f1..48fcd340 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -67,6 +67,9 @@ importers: jassub: specifier: ^2.4.2 version: 2.4.2 + libpgs: + specifier: ^0.8.1 + version: 0.8.1 lucide-react: specifier: ^0.576.0 version: 0.576.0(react@19.2.4) @@ -2244,6 +2247,9 @@ packages: lfa-ponyfill@1.1.0: resolution: {integrity: sha512-YS3/DmyDdywWwoEu1ZacAudqkJ4q7WtKE9+bWlaSuEoVrXva7ChIJHMJYs19zyVc1H198pzqAreQU0r/+YNeew==} + libpgs@0.8.1: + resolution: {integrity: sha512-Z8WCvHRYr37QjU8b2WqiJpCvnoojbYPtoE4FktwdI+m+b1QkNIKqrYQrlPKG1xvYyQWgV1j+16xjFOhun4nBUA==} + lightningcss-android-arm64@1.31.1: resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==} engines: {node: '>= 12.0.0'} @@ -5007,6 +5013,8 @@ snapshots: lfa-ponyfill@1.1.0: {} + libpgs@0.8.1: {} + lightningcss-android-arm64@1.31.1: optional: true diff --git a/web/src/player/components/SubtitleMenu.tsx b/web/src/player/components/SubtitleMenu.tsx index b896c2e2..22420d9b 100644 --- a/web/src/player/components/SubtitleMenu.tsx +++ b/web/src/player/components/SubtitleMenu.tsx @@ -9,7 +9,7 @@ import { SubtitleAppearancePanel } from "./SubtitleAppearancePanel"; import { playerFetch } from "../player-fetch"; import { getLanguageName } from "../utils/languageNames"; import { sortSubtitlesBySource } from "../utils/subtitleSort"; -import { getSubtitleFormatLabel } from "../utils/assSubtitles"; +import { getSubtitleFormatLabel } from "../utils/subtitleCodecs"; interface SubtitleMenuProps { tracks: PlayerSubtitleInfo[]; diff --git a/web/src/player/components/SubtitleTranslateModal.tsx b/web/src/player/components/SubtitleTranslateModal.tsx index 2182ffc7..d533107a 100644 --- a/web/src/player/components/SubtitleTranslateModal.tsx +++ b/web/src/player/components/SubtitleTranslateModal.tsx @@ -5,12 +5,11 @@ import type { PlayerConfig } from "../context/PlayerConfigContext"; import type { PlayerAudioTrack, PlayerSubtitleInfo } from "../types"; import { playerFetch, PlayerFetchError } from "../player-fetch"; import { LANGUAGES, getLanguageName } from "../utils/languageNames"; +import { isBitmapCodec } from "../utils/subtitleCodecs"; import { QUOTA_PERIOD_WINDOW_LABELS } from "@/lib/quotaPeriods"; // Formats the server can parse directly from an external/downloaded file. const TRANSLATABLE_TEXT_CODECS = new Set(["srt", "subrip", "vtt", "webvtt"]); -// Bitmap codecs the server can't extract as text (it would burn them in). -const BITMAP_CODECS = new Set(["pgs", "hdmv_pgs_subtitle", "dvd_subtitle", "dvb_subtitle"]); // Mirror the server's loadSource acceptance: embedded non-bitmap tracks are // extracted to text via ffmpeg, while external/downloaded sources must already @@ -20,7 +19,7 @@ export function isTranslatableSource(track: PlayerSubtitleInfo): boolean { if (track.live) return false; const codec = (track.codec ?? "").toLowerCase(); if (track.source === "embedded") { - return !BITMAP_CODECS.has(codec); + return !isBitmapCodec(codec); } return TRANSLATABLE_TEXT_CODECS.has(codec); } diff --git a/web/src/player/components/VideoPlayer.tsx b/web/src/player/components/VideoPlayer.tsx index df9a8052..a667617f 100644 --- a/web/src/player/components/VideoPlayer.tsx +++ b/web/src/player/components/VideoPlayer.tsx @@ -14,6 +14,7 @@ import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts"; import { useRemuxSeeking } from "../hooks/useRemuxSeeking"; import { useSubtitleTracks } from "../hooks/useSubtitleTracks"; import { useASSSubtitles } from "../hooks/useASSSubtitles"; +import { usePGSSubtitles } from "../hooks/usePGSSubtitles"; import { useSubtitleAppearance } from "../hooks/useSubtitleAppearance"; import { useSubtitlePositionStyle } from "../hooks/useSubtitlePositionStyle"; import { useNextEpisode } from "../hooks/useNextEpisode"; @@ -1613,6 +1614,16 @@ export function VideoPlayer({ subtitleDelayMs, ); + // -- PGS (Blu-ray bitmap) subtitle rendering via libpgs -- + const { isActive: isPGSActive } = usePGSSubtitles( + videoRef, + subtitleUrls, + activeSubtitleIndex, + isDetached, + transcodeQuality.streamOriginSeconds, + subtitleDelayMs, + ); + // -- Auto-select subtitle track based on mode -- useEffect(() => { if (subtitleSelectionWasManualRef.current) { @@ -2274,8 +2285,8 @@ export function VideoPlayer({ style={!isPlayerReady ? { visibility: "hidden" } : undefined} /> - {/* Subtitle overlay — suppressed when JASSUB is rendering ASS subtitles */} - {!isDetached && !isASSActive && activeCueTexts.length > 0 && ( + {/* Subtitle overlay — suppressed when JASSUB (ASS) or libpgs (PGS) is rendering */} + {!isDetached && !isASSActive && !isPGSActive && activeCueTexts.length > 0 && (
> = []; +const disposeSpies: Array> = []; +const renderers: MockPgsRenderer[] = []; + +class MockPgsRenderer { + timeOffset: number; + dispose = vi.fn(); + constructor(opts: Record) { + constructorOpts.push(opts); + this.timeOffset = (opts.timeOffset as number) ?? 0; + disposeSpies.push(this.dispose); + renderers.push(this); + } +} + +vi.mock("libpgs", () => ({ PgsRenderer: MockPgsRenderer })); +vi.mock("libpgs/dist/libpgs.worker.js?url", () => ({ default: "/assets/libpgs.worker.js" })); + +function makeVideoRef(): RefObject { + return { current: document.createElement("video") }; +} + +const pgsTrack: PlayerSubtitleInfo = { + index: 3, + language: "eng", + codec: "hdmv_pgs_subtitle", + label: "English (PGS)", + source: "embedded", + url: "/api/v1/stream/x/subtitles/3.sup?token=abc", +}; + +const otherPgsTrack: PlayerSubtitleInfo = { + index: 4, + language: "ger", + codec: "pgs", + label: "German (PGS)", + source: "embedded", + url: "/api/v1/stream/x/subtitles/4.sup?token=abc", +}; + +const srtTrack: PlayerSubtitleInfo = { + index: 1, + language: "eng", + codec: "subrip", + label: "English", + source: "embedded", + url: "/api/v1/stream/x/subtitles/1.vtt?token=abc", +}; + +const tracks = [srtTrack, pgsTrack, otherPgsTrack]; + +beforeEach(() => { + constructorOpts.length = 0; + disposeSpies.length = 0; + renderers.length = 0; +}); + +describe("usePGSSubtitles", () => { + it("creates a renderer wired to the video and .sup URL for a PGS track", async () => { + const videoRef = makeVideoRef(); + const { result } = renderHook(() => usePGSSubtitles(videoRef, tracks, 3, false, 0, 0)); + + await waitFor(() => expect(constructorOpts).toHaveLength(1)); + + const opts = constructorOpts[0]!; + expect(opts.video).toBe(videoRef.current); + expect(opts.subUrl).toBe(pgsTrack.url); + expect(opts.workerUrl).toBe("/assets/libpgs.worker.js"); + expect(opts.aspectRatio).toBe("contain"); + expect(result.current.isActive).toBe(true); + }); + + it("does nothing for text and ASS tracks", async () => { + const { result, rerender } = renderHook( + ({ index }) => usePGSSubtitles(makeVideoRef(), tracks, index, false, 0, 0), + { initialProps: { index: 1 as number | null } }, + ); + + expect(result.current.isActive).toBe(false); + + rerender({ index: null }); + await Promise.resolve(); + expect(constructorOpts).toHaveLength(0); + expect(result.current.isActive).toBe(false); + }); + + it("applies stream origin and subtracts user delay from the time offset", async () => { + // Positive delay shows subtitles later; libpgs looks up the display set + // at currentTime + timeOffset, so later means a smaller offset. + renderHook(() => usePGSSubtitles(makeVideoRef(), tracks, 3, false, 30, 2000)); + + await waitFor(() => expect(constructorOpts).toHaveLength(1)); + expect(constructorOpts[0]!.timeOffset).toBe(28); + }); + + it("updates the time offset in place without recreating the renderer", async () => { + const videoRef = makeVideoRef(); + const { rerender } = renderHook( + ({ delay }) => usePGSSubtitles(videoRef, tracks, 3, false, 0, delay), + { initialProps: { delay: 0 } }, + ); + + await waitFor(() => expect(renderers).toHaveLength(1)); + + rerender({ delay: 1500 }); + await waitFor(() => expect(renderers[0]!.timeOffset).toBe(-1.5)); + expect(renderers).toHaveLength(1); + expect(disposeSpies[0]!).not.toHaveBeenCalled(); + }); + + it("disposes the renderer when switching to a non-PGS track", async () => { + const videoRef = makeVideoRef(); + const { result, rerender } = renderHook( + ({ index }) => usePGSSubtitles(videoRef, tracks, index, false, 0, 0), + { initialProps: { index: 3 } }, + ); + + await waitFor(() => expect(renderers).toHaveLength(1)); + + rerender({ index: 1 }); + await waitFor(() => expect(disposeSpies[0]!).toHaveBeenCalledTimes(1)); + expect(result.current.isActive).toBe(false); + }); + + it("recreates the renderer when switching between PGS tracks", async () => { + const videoRef = makeVideoRef(); + const { rerender } = renderHook( + ({ index }) => usePGSSubtitles(videoRef, tracks, index, false, 0, 0), + { initialProps: { index: 3 } }, + ); + + await waitFor(() => expect(renderers).toHaveLength(1)); + + rerender({ index: 4 }); + await waitFor(() => expect(renderers).toHaveLength(2)); + expect(disposeSpies[0]!).toHaveBeenCalledTimes(1); + expect(constructorOpts[1]!.subUrl).toBe(otherPgsTrack.url); + }); + + it("disposes the renderer while detached and reports inactive", async () => { + const videoRef = makeVideoRef(); + const { result, rerender } = renderHook( + ({ detached }) => usePGSSubtitles(videoRef, tracks, 3, detached, 0, 0), + { initialProps: { detached: false } }, + ); + + await waitFor(() => expect(renderers).toHaveLength(1)); + + rerender({ detached: true }); + await waitFor(() => expect(disposeSpies[0]!).toHaveBeenCalledTimes(1)); + expect(result.current.isActive).toBe(false); + }); + + it("disposes the renderer on unmount", async () => { + const { unmount } = renderHook(() => usePGSSubtitles(makeVideoRef(), tracks, 3, false, 0, 0)); + + await waitFor(() => expect(renderers).toHaveLength(1)); + + unmount(); + expect(disposeSpies[0]!).toHaveBeenCalled(); + }); +}); diff --git a/web/src/player/hooks/usePGSSubtitles.ts b/web/src/player/hooks/usePGSSubtitles.ts new file mode 100644 index 00000000..5f90b790 --- /dev/null +++ b/web/src/player/hooks/usePGSSubtitles.ts @@ -0,0 +1,127 @@ +import { useEffect, useRef } from "react"; +import type { PgsRenderer } from "libpgs"; +import libpgsWorkerUrl from "libpgs/dist/libpgs.worker.js?url"; +import type { PlayerSubtitleInfo } from "../types"; +import { isPGSCodec } from "../utils/subtitleCodecs"; + +/** + * Manages client-side PGS (Blu-ray bitmap) subtitle rendering via libpgs. + * + * When a PGS-codec subtitle track is active, this hook lazy-loads libpgs and + * creates a PgsRenderer attached to the video element. The renderer's worker + * fetches the .sup stream itself (the URL already carries auth via ?token=), + * decodes display sets progressively as bytes arrive, and draws them onto a + * canvas it positions over the video. When a non-PGS track is selected (or + * subtitles are turned off), the renderer is disposed. + * + * Coordination mirrors useASSSubtitles: the `isActive` return value tells the + * player to suppress the VTT text overlay while bitmap rendering is active. + */ +export function usePGSSubtitles( + videoRef: React.RefObject, + subtitleUrls: PlayerSubtitleInfo[], + activeSubtitleIndex: number | null, + isDetached: boolean, + streamOriginSeconds: number, + subtitleDelayMs: number, +): { isActive: boolean } { + const rendererRef = useRef(null); + const libpgsImportRef = useRef | null>(null); + // libpgs renders the display set whose timestamp matches + // `video.currentTime + timeOffset`, and .sup timestamps are in source media + // time. With HLS PTS rebasing, currentTime + streamOrigin = media time, so + // the origin adds. A positive user delay should show subtitles *later* + // (VTTCue semantics: cues shift forward), which here means looking up an + // *earlier* display set at any instant — so the delay subtracts. + const effectiveOffset = streamOriginSeconds - subtitleDelayMs / 1000; + const offsetRef = useRef(effectiveOffset); + offsetRef.current = effectiveOffset; + + // Resolve the active subtitle track. + const activeSub = + activeSubtitleIndex !== null + ? (subtitleUrls.find((s) => s.index === activeSubtitleIndex) ?? null) + : null; + + const isPGS = activeSub !== null && isPGSCodec(activeSub.codec); + const activeUrl = isPGS ? activeSub.url : null; + + // Main effect: create/destroy the renderer based on the active track. + useEffect(() => { + const video = videoRef.current; + + if (!activeUrl || !video || isDetached) { + if (rendererRef.current) { + rendererRef.current.dispose(); + rendererRef.current = null; + } + return; + } + + let cancelled = false; + + async function initRenderer() { + if (!video || cancelled) return; + + // Lazy-load the libpgs module (only once). + if (!libpgsImportRef.current) { + libpgsImportRef.current = import("libpgs"); + } + const { PgsRenderer: PgsRendererClass } = await libpgsImportRef.current; + if (cancelled) return; + + const renderer = new PgsRendererClass({ + video, + subUrl: activeUrl!, + workerUrl: libpgsWorkerUrl, + timeOffset: offsetRef.current, + // Must match the video element's object-fit so subtitle positions + // stay anchored to the video content box, not the element box. + aspectRatio: "contain", + }); + + // Guard against the effect being cleaned up while the constructor ran. + if (cancelled) { + renderer.dispose(); + return; + } + + rendererRef.current = renderer; + } + + void initRenderer(); + + return () => { + cancelled = true; + // dispose() removes the canvas and terminates the worker, which also + // abandons its in-flight .sup fetch — no separate AbortController. + if (rendererRef.current) { + rendererRef.current.dispose(); + rendererRef.current = null; + } + }; + // videoRef is a stable ref object. effectiveOffset is read from + // offsetRef inside the async function to always get the latest value. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeUrl, isDetached]); + + // Update the renderer's time offset when the media timeline remaps or the + // user nudges subtitle sync — without recreating the renderer. + useEffect(() => { + const renderer = rendererRef.current; + if (!renderer || !activeUrl) return; + renderer.timeOffset = effectiveOffset; + }, [effectiveOffset, activeUrl]); + + // Cleanup on unmount. + useEffect(() => { + return () => { + if (rendererRef.current) { + rendererRef.current.dispose(); + rendererRef.current = null; + } + }; + }, []); + + return { isActive: isPGS && !isDetached }; +} diff --git a/web/src/player/hooks/useSubtitleTracks.ts b/web/src/player/hooks/useSubtitleTracks.ts index 141bc5bf..30f5a7d9 100644 --- a/web/src/player/hooks/useSubtitleTracks.ts +++ b/web/src/player/hooks/useSubtitleTracks.ts @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { parseVTT, type ParsedCue } from "../utils/parseVTT"; import type { PlayerSubtitleInfo } from "../types"; -import { isASSCodec } from "../utils/assSubtitles"; +import { isASSCodec, isPGSCodec } from "../utils/subtitleCodecs"; import { toMediaTime } from "../utils/mediaTimeline"; // Each subtitle fetch covers this many source-time seconds. Matches the @@ -119,8 +119,9 @@ export function useSubtitleTracks( setActiveCueTexts([]); - // Skip entirely for ASS/SSA: JASSUB handles those via useASSSubtitles. - if (isASSCodec(activeCodec)) { + // Skip entirely for ASS/SSA (JASSUB renders those via useASSSubtitles) + // and PGS (libpgs renders those via usePGSSubtitles). + if (isASSCodec(activeCodec) || isPGSCodec(activeCodec)) { return; } // Need either a URL to stream from or a live cue source. diff --git a/web/src/player/utils/subtitleCodecs.test.ts b/web/src/player/utils/subtitleCodecs.test.ts new file mode 100644 index 00000000..17d369d3 --- /dev/null +++ b/web/src/player/utils/subtitleCodecs.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { isASSCodec, isBitmapCodec, isPGSCodec } from "./subtitleCodecs"; + +describe("isPGSCodec", () => { + it("matches PGS codec names case-insensitively", () => { + expect(isPGSCodec("pgs")).toBe(true); + expect(isPGSCodec("hdmv_pgs_subtitle")).toBe(true); + expect(isPGSCodec("HDMV_PGS_SUBTITLE")).toBe(true); + }); + + it("rejects non-PGS codecs", () => { + expect(isPGSCodec("dvd_subtitle")).toBe(false); + expect(isPGSCodec("ass")).toBe(false); + expect(isPGSCodec("subrip")).toBe(false); + expect(isPGSCodec(undefined)).toBe(false); + }); +}); + +describe("isBitmapCodec", () => { + it("covers all image-based codecs", () => { + expect(isBitmapCodec("pgs")).toBe(true); + expect(isBitmapCodec("hdmv_pgs_subtitle")).toBe(true); + expect(isBitmapCodec("dvd_subtitle")).toBe(true); + expect(isBitmapCodec("dvb_subtitle")).toBe(true); + }); + + it("rejects text codecs", () => { + expect(isBitmapCodec("srt")).toBe(false); + expect(isBitmapCodec("ass")).toBe(false); + expect(isBitmapCodec(undefined)).toBe(false); + }); +}); + +describe("isASSCodec", () => { + it("matches ASS/SSA only", () => { + expect(isASSCodec("ass")).toBe(true); + expect(isASSCodec("ssa")).toBe(true); + expect(isASSCodec("pgs")).toBe(false); + expect(isASSCodec(undefined)).toBe(false); + }); +}); diff --git a/web/src/player/utils/assSubtitles.ts b/web/src/player/utils/subtitleCodecs.ts similarity index 54% rename from web/src/player/utils/assSubtitles.ts rename to web/src/player/utils/subtitleCodecs.ts index f8a435ea..0986e5ae 100644 --- a/web/src/player/utils/assSubtitles.ts +++ b/web/src/player/utils/subtitleCodecs.ts @@ -7,6 +7,27 @@ export function isASSCodec(codec: string | undefined): boolean { return ASS_CODECS.has(codec.toLowerCase()); } +/** + * Codecs that indicate PGS (Blu-ray bitmap) subtitles. Rendered client-side + * by libpgs from a .sup stream served by the backend. + */ +const PGS_CODECS = new Set(["pgs", "hdmv_pgs_subtitle"]); + +/** Returns true if the given subtitle codec is PGS format. */ +export function isPGSCodec(codec: string | undefined): boolean { + if (!codec) return false; + return PGS_CODECS.has(codec.toLowerCase()); +} + +/** Bitmap (image-based) subtitle codecs; these carry no extractable text. */ +const BITMAP_CODECS = new Set([...PGS_CODECS, "dvd_subtitle", "dvb_subtitle"]); + +/** Returns true if the given subtitle codec is bitmap-based (PGS/DVD/DVB). */ +export function isBitmapCodec(codec: string | undefined): boolean { + if (!codec) return false; + return BITMAP_CODECS.has(codec.toLowerCase()); +} + /** * Returns a human-readable format label for display in the subtitle menu, * or null if the codec is unknown/unset. diff --git a/web/src/player/utils/subtitleSort.test.ts b/web/src/player/utils/subtitleSort.test.ts index 3271895a..56d3312d 100644 --- a/web/src/player/utils/subtitleSort.test.ts +++ b/web/src/player/utils/subtitleSort.test.ts @@ -469,3 +469,55 @@ describe("resolveSubtitleAutoSelect", () => { }); }); }); + +describe("bitmap (PGS) codec deprioritization", () => { + it("prefers a text track over a PGS track of the same language and source", () => { + const tracks = [ + makeSub({ index: 0, source: "embedded", language: "en", codec: "hdmv_pgs_subtitle" }), + makeSub({ index: 1, source: "embedded", language: "en", codec: "subrip" }), + ]; + expect(findPreferredSubtitleIndex(tracks, "en")).toBe(1); + }); + + it("still prefers a better source even when it is bitmap-free elsewhere", () => { + // External text beats embedded PGS, and embedded text beats embedded PGS, + // but an external PGS-like entry would still beat embedded text — source + // remains the primary key. + const tracks = [ + makeSub({ index: 0, source: "embedded", language: "en", codec: "subrip" }), + makeSub({ index: 1, source: "external", language: "en", codec: "srt" }), + ]; + expect(findPreferredSubtitleIndex(tracks, "en")).toBe(1); + }); + + it("selects a PGS track when it is the only language match", () => { + const tracks = [ + makeSub({ index: 0, source: "embedded", language: "fr", codec: "subrip" }), + makeSub({ index: 1, source: "embedded", language: "en", codec: "pgs" }), + ]; + expect(findPreferredSubtitleIndex(tracks, "en")).toBe(1); + }); + + it("auto-selects a forced PGS track when forced display is enabled", () => { + const tracks = [ + makeSub({ index: 0, source: "embedded", language: "en", codec: "subrip" }), + makeSub({ + index: 1, + source: "embedded", + language: "en", + codec: "hdmv_pgs_subtitle", + forced: true, + }), + ]; + expect( + resolveSubtitleAutoSelect({ + mode: "off", + tracks, + preferredLanguage: null, + audioLanguage: "en", + profileLanguage: "en", + showForcedSubtitles: true, + }), + ).toBe(1); + }); +}); diff --git a/web/src/player/utils/subtitleSort.ts b/web/src/player/utils/subtitleSort.ts index 542b132d..2a73b583 100644 --- a/web/src/player/utils/subtitleSort.ts +++ b/web/src/player/utils/subtitleSort.ts @@ -1,4 +1,5 @@ import type { PlayerSubtitleInfo, PlayerSubtitleTrackSignature, SubtitleMode } from "../types"; +import { isBitmapCodec } from "./subtitleCodecs"; const ORIGINAL_LANGUAGE_SENTINEL = "original"; @@ -8,6 +9,17 @@ const SOURCE_PRIORITY: Record = { embedded: 2, }; +/** + * Auto-select priority for a track: lower is better. Within the same source + * tier, text tracks beat bitmap (PGS) tracks — bitmap is heavier to render + * and can't be styled — while a bitmap track still wins when it's the only + * match for the language. + */ +function trackPriority(track: PlayerSubtitleInfo): number { + const source = SOURCE_PRIORITY[track.source ?? "embedded"] ?? 2; + return source * 2 + (isBitmapCodec(track.codec) ? 1 : 0); +} + const ISO639_TO_3: Record = { en: "eng", es: "spa", @@ -127,7 +139,7 @@ export function findPreferredSubtitleIndex(tracks: PlayerSubtitleInfo[], languag for (const track of tracks) { if (!track || !sameLanguage(track, language)) continue; - const priority = SOURCE_PRIORITY[track.source ?? "embedded"] ?? 2; + const priority = trackPriority(track); if (priority < bestPriority) { bestPriority = priority; bestIdx = track.index; @@ -148,7 +160,7 @@ function findPreferredSubtitleIndexWithSignature( for (const track of tracks) { if (!track || !sameLanguage(track, language)) continue; - const priority = SOURCE_PRIORITY[track.source ?? "embedded"] ?? 2; + const priority = trackPriority(track); const score = scoreSignatureFallback(track, signature); if ( bestTrack === null ||