* 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>
113 lines
3.0 KiB
Go
113 lines
3.0 KiB
Go
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)
|
|
}
|
|
}
|