feat(player): native PGS subtitle rendering via libpgs (#129)

* feat(playback): add IsPGS helper and sup streaming extract path

PGS (Blu-ray bitmap) subtitle tracks can be copied losslessly into a .sup
elementary stream for client-side rendering, so they no longer have to be
burned in. streamExtractOutput maps PGS to (copy, sup), and the seek/-t
windowing now skips PGS like ASS: both formats are fetched once and
consumed whole by their client-side renderers.

This also fixes a pre-existing truncation bug: the -t duration cap was
applied unconditionally, cutting embedded ASS extracts off at the default
600s window even though the ASS client fetches the full track.

Extract the ffmpeg argument construction into streamExtractArgs for
testability, following the buildFFmpegArgs pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(api): expose PGS subtitle tracks as .sup stream URLs

PGS tracks were filtered out of /playback/start subtitle_urls entirely,
so the web player showed no subtitles for PGS-only files (#34). Include
them with a .sup URL extension; DVD/DVB bitmap tracks stay hidden since
they still have no non-burn-in delivery path.

HandleSubtitle streams the full PGS track as application/octet-stream.
The seek/duration window is forced to zero for sup: subtitleSeekPosition
falls back to the session's last reported position even without a
?position= query, which would otherwise start the extract mid-file. The
proxy-node subtitle handler gets the same sup branch, streaming ffmpeg
output directly instead of buffering like its text paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(player): consolidate subtitle codec helpers into subtitleCodecs.ts

Rename assSubtitles.ts to subtitleCodecs.ts — the module already labeled
every codec, not just ASS — and add isPGSCodec/isBitmapCodec. Replace the
duplicated BITMAP_CODECS set in SubtitleTranslateModal with the shared
helper so codec lists live in one place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(player): native PGS subtitle rendering via libpgs

Render PGS subtitle tracks client-side instead of leaving them
unavailable (#34). usePGSSubtitles mirrors the JASSUB hook: when a PGS
track is active it lazy-loads libpgs, which fetches the .sup stream in a
worker, decodes display sets progressively as bytes arrive, and draws
them onto a canvas positioned over the video.

The renderer looks up the display set at currentTime + timeOffset, so
the HLS stream origin adds and the user-facing delay subtracts — a
positive delay shows subtitles later, matching VTT semantics. Offset
changes apply through the timeOffset setter without recreating the
renderer; track switches, PiP detach, and unmount dispose it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(player): prefer text over bitmap tracks in subtitle auto-select

With PGS tracks now listed, an earlier PGS track would win auto-select
over a later same-language SRT/ASS track. Deprioritize bitmap codecs
within the same source tier — text is lighter to render and styleable —
while a PGS track still wins when it is the only language match, and
forced-PGS auto-select now works.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Quick
2026-06-10 20:18:29 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent a17529fd6f
commit fadd8ff456
20 changed files with 783 additions and 68 deletions
+10 -3
View File
@@ -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
}
@@ -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)
}
}
+24 -6
View File
@@ -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", "*")
+62 -47
View File
@@ -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 `<track>` 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 `<track>`
// 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"
}
+112
View File
@@ -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)
}
}
+14
View File
@@ -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{
+25
View File
@@ -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" {
+1
View File
@@ -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",
+8
View File
@@ -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
+1 -1
View File
@@ -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[];
@@ -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);
}
+13 -2
View File
@@ -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 && (
<div
className="pointer-events-none absolute inset-x-0 z-20 flex flex-col items-center gap-1"
style={{ ...containerStyle, ...subtitlePositionStyle }}
+1 -1
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef } from "react";
import type JASSUB from "jassub";
import type { PlayerSubtitleInfo } from "../types";
import { isASSCodec } from "../utils/assSubtitles";
import { isASSCodec } from "../utils/subtitleCodecs";
import {
fallbackFontForSubtitle,
forceASSFontFamily,
@@ -0,0 +1,170 @@
import type { RefObject } from "react";
import { renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { usePGSSubtitles } from "./usePGSSubtitles";
import type { PlayerSubtitleInfo } from "../types";
// Capture every constructed renderer so tests can assert options and
// lifecycle (libpgs fetches subUrl inside its worker — no fetch mocking).
const constructorOpts: Array<Record<string, unknown>> = [];
const disposeSpies: Array<ReturnType<typeof vi.fn>> = [];
const renderers: MockPgsRenderer[] = [];
class MockPgsRenderer {
timeOffset: number;
dispose = vi.fn();
constructor(opts: Record<string, unknown>) {
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<HTMLVideoElement | null> {
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();
});
});
+127
View File
@@ -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<HTMLVideoElement | null>,
subtitleUrls: PlayerSubtitleInfo[],
activeSubtitleIndex: number | null,
isDetached: boolean,
streamOriginSeconds: number,
subtitleDelayMs: number,
): { isActive: boolean } {
const rendererRef = useRef<PgsRenderer | null>(null);
const libpgsImportRef = useRef<Promise<typeof import("libpgs")> | 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 };
}
+4 -3
View File
@@ -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.
@@ -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);
});
});
@@ -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.
+52
View File
@@ -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);
});
});
+14 -2
View File
@@ -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<string, number> = {
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<string, string> = {
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 ||