Files

270 lines
9.1 KiB
Go
Raw Permalink Normal View History

package playback
import (
"net/url"
"slices"
"strings"
"testing"
)
func TestIsPGS(t *testing.T) {
cases := []struct {
codec string
want bool
}{
{"pgs", true},
{"hdmv_pgs_subtitle", true},
{"pgssub", 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"},
{"pgssub", "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 by default 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)
}
}
}
// A client that opts in via AllowWindow gets a seeked, duration-capped PGS
// extract with -copyts preserving absolute source timestamps — the -ss must
// be an input option (before -i) so ffmpeg uses the container index.
func TestStreamExtractArgs_WindowedPGS(t *testing.T) {
args := streamExtractArgs(StreamExtractOpts{
InputPath: "/media/movie.mkv",
TrackIndex: 1,
SourceCodec: "hdmv_pgs_subtitle",
SeekSeconds: 1200,
DurationSeconds: 3600,
AllowWindow: true,
})
joined := strings.Join(args, " ")
if !strings.Contains(joined, "-ss 1200.000") {
t.Fatalf("windowed PGS extract should seek the input: %s", joined)
}
ssIdx := slices.Index(args, "-ss")
inIdx := slices.Index(args, "-i")
if ssIdx < 0 || inIdx < 0 || ssIdx > inIdx {
t.Fatalf("-ss must be an input option (before -i): %s", joined)
}
if !strings.Contains(joined, "-t 3600.000") {
t.Fatalf("windowed PGS extract should cap the read duration: %s", joined)
}
if !strings.Contains(joined, "-copyts") {
t.Fatalf("windowed PGS extract must preserve source timestamps: %s", joined)
}
if !strings.Contains(joined, "-c:s copy") || !strings.Contains(joined, "-f sup pipe:1") {
t.Fatalf("windowed PGS extract should still copy into a sup stream: %s", joined)
}
}
// A windowed extract whose input is a cached full-track .sup must force the
// sup demuxer (the elementary stream has no container header to probe from
// arbitrary offsets), remap to the file's sole stream regardless of the
// original container's track ordinal, and still seek/window with -copyts so
// the cached stream's absolute timestamps survive into the output.
func TestStreamExtractArgs_ExtractedSupInput(t *testing.T) {
args := streamExtractArgs(StreamExtractOpts{
InputPath: "/transcode/subtitle-cache/abc-s3-1-2.sup",
TrackIndex: 3,
SourceCodec: "hdmv_pgs_subtitle",
SeekSeconds: 1200,
DurationSeconds: 3600,
AllowWindow: true,
InputIsExtractedSup: true,
})
joined := strings.Join(args, " ")
if !strings.Contains(joined, "-f sup -i /transcode/subtitle-cache/abc-s3-1-2.sup") {
t.Fatalf("cached sup input must force the sup demuxer before -i: %s", joined)
}
if !strings.Contains(joined, "-map 0:s:0") {
t.Fatalf("cached sup holds exactly one stream; must map 0:s:0: %s", joined)
}
if strings.Contains(joined, "0:s:3") {
t.Fatalf("original container track ordinal must not leak into sup input mapping: %s", joined)
}
if !strings.Contains(joined, "-ss 1200.000") || !strings.Contains(joined, "-t 3600.000") {
t.Fatalf("cached sup extract must still window the input: %s", joined)
}
ssIdx := slices.Index(args, "-ss")
inIdx := slices.Index(args, "-i")
if ssIdx < 0 || inIdx < 0 || ssIdx > inIdx {
t.Fatalf("-ss must be an input option (before -i): %s", joined)
}
if !strings.Contains(joined, "-copyts") {
t.Fatalf("cached sup extract must preserve absolute timestamps: %s", joined)
}
if !strings.Contains(joined, "-c:s copy") || !strings.Contains(joined, "-f sup pipe:1") {
t.Fatalf("cached sup extract should copy into a sup stream: %s", joined)
}
}
// AllowWindow must not override the ASS guard — its [Script Info] header
// only exists at stream offset 0, so a seeked extract would be broken.
func TestStreamExtractArgs_ASSIgnoresAllowWindow(t *testing.T) {
args := streamExtractArgs(StreamExtractOpts{
InputPath: "/media/movie.mkv",
TrackIndex: 0,
SourceCodec: "ass",
SeekSeconds: 120,
DurationSeconds: 600,
AllowWindow: true,
})
if slices.Contains(args, "-ss") {
t.Errorf("ass extract must not seek the input even with AllowWindow: %v", args)
}
if slices.Contains(args, "-t") {
t.Errorf("ass extract must not cap the read duration even with AllowWindow: %v", args)
}
}
// Absent the explicit ?windowed=1 opt-in the request must not window, no
// matter what other params are present — existing clients (Apple, Android,
// jellycompat) send no param and rely on whole-track extraction.
func TestPGSWindowRequest(t *testing.T) {
cases := []struct {
name string
query string
wantAllow bool
wantSeek float64
wantDuration float64
}{
{"no params", "", false, 0, 0},
{"position without opt-in", "position=120&duration=600", false, 0, 0},
{"windowed off", "windowed=0&position=120", false, 0, 0},
{"opt-in with position and duration", "windowed=1&position=120.5&duration=3600", true, 120.5, 3600},
{"opt-in without position", "windowed=1", true, 0, 0},
{"opt-in negative position ignored", "windowed=1&position=-5&duration=600", true, 0, 600},
{"opt-in duration over cap ignored", "windowed=1&position=10&duration=7200", true, 10, 0},
{"opt-in invalid values ignored", "windowed=1&position=abc&duration=xyz", true, 0, 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
q, err := url.ParseQuery(tc.query)
if err != nil {
t.Fatalf("ParseQuery(%q): %v", tc.query, err)
}
allow, seek, duration := PGSWindowRequest(q)
if allow != tc.wantAllow || seek != tc.wantSeek || duration != tc.wantDuration {
t.Errorf("PGSWindowRequest(%q) = (%v, %v, %v), want (%v, %v, %v)",
tc.query, allow, seek, duration, tc.wantAllow, tc.wantSeek, tc.wantDuration)
}
})
}
}
// A forced "vtt" target applies only to text sources: bitmap codecs carry no
// text for ffmpeg's webvtt encoder, so the override must fall back to the
// source-driven mapping instead of building a command that always fails.
func TestStreamExtractOutput_TargetFormatVTTGatedToTextSources(t *testing.T) {
cases := []struct {
codec string
wantCodec string
wantFormat string
}{
{"subrip", "webvtt", "webvtt"},
{"mov_text", "webvtt", "webvtt"},
{"ass", "webvtt", "webvtt"},
{"pgs", "copy", "sup"},
{"hdmv_pgs_subtitle", "copy", "sup"},
}
for _, tc := range cases {
outCodec, outFormat := streamExtractOutput(tc.codec, "vtt")
if outCodec != tc.wantCodec || outFormat != tc.wantFormat {
t.Errorf("streamExtractOutput(%q, \"vtt\") = (%q, %q), want (%q, %q)",
tc.codec, outCodec, outFormat, tc.wantCodec, tc.wantFormat)
}
}
}
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)
}
}