From fadd8ff4567ea36c981167f329e50e457d1b675d Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Wed, 10 Jun 2026 20:18:29 -0400 Subject: [PATCH] feat(player): native PGS subtitle rendering via libpgs (#129) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * 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 * 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 --------- Co-authored-by: Claude Fable 5 --- internal/api/handlers/playback.go | 13 +- .../handlers/playback_subtitle_urls_test.go | 81 +++++++++ internal/api/handlers/stream.go | 30 +++- internal/playback/subtitle_stream.go | 109 ++++++----- internal/playback/subtitle_stream_test.go | 112 ++++++++++++ internal/playback/subtitles.go | 14 ++ internal/proxy/server.go | 25 +++ web/package.json | 1 + web/pnpm-lock.yaml | 8 + web/src/player/components/SubtitleMenu.tsx | 2 +- .../components/SubtitleTranslateModal.tsx | 5 +- web/src/player/components/VideoPlayer.tsx | 15 +- web/src/player/hooks/useASSSubtitles.ts | 2 +- web/src/player/hooks/usePGSSubtitles.test.tsx | 170 ++++++++++++++++++ web/src/player/hooks/usePGSSubtitles.ts | 127 +++++++++++++ web/src/player/hooks/useSubtitleTracks.ts | 7 +- web/src/player/utils/subtitleCodecs.test.ts | 41 +++++ .../{assSubtitles.ts => subtitleCodecs.ts} | 21 +++ web/src/player/utils/subtitleSort.test.ts | 52 ++++++ web/src/player/utils/subtitleSort.ts | 16 +- 20 files changed, 783 insertions(+), 68 deletions(-) create mode 100644 internal/api/handlers/playback_subtitle_urls_test.go create mode 100644 internal/playback/subtitle_stream_test.go create mode 100644 web/src/player/hooks/usePGSSubtitles.test.tsx create mode 100644 web/src/player/hooks/usePGSSubtitles.ts create mode 100644 web/src/player/utils/subtitleCodecs.test.ts rename web/src/player/utils/{assSubtitles.ts => subtitleCodecs.ts} (54%) 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 ||