clone

a desktop tv and program guide for over-the-air broadcasts: what's on air, what your tuner gets, live tv, recordings

main
airwaves / internal / vchan / captions_test.go
captions_test.go 24.4K
1package vchan23import (4 "bytes"5 "context"6 "fmt"7 "io"8 "math"9 "net/http"10 "net/http/httptest"11 "os"12 "os/exec"13 "path/filepath"14 "regexp"15 "slices"16 "strconv"17 "strings"18 "testing"19 "time"2021 "airwaves/internal/cc"22 "airwaves/internal/jellyfin"23 "airwaves/internal/jellyfin/jellyfintest"24 "airwaves/internal/phase"25 "airwaves/internal/stream"26 "airwaves/internal/tuner"27 "airwaves/internal/youtube"28)2930func srtCaptions(srt string) *captions {31 return newCaptions("test", func(context.Context) ([]byte, error) { return []byte(srt), nil })32}3334// TestStreamCarriesCaptions plays a video with captions from a second in,35// as a viewer tuning in late would, and reads the captions back out of the36// stream with ffmpeg.37func TestStreamCarriesCaptions(t *testing.T) {38 ffmpeg, err := exec.LookPath("ffmpeg")39 if err != nil {40 t.Skip("no ffmpeg")41 }42 dir := t.TempDir()43 video := filepath.Join(dir, "v.mp4")44 cmd := exec.Command(ffmpeg, "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc2=s=640x360:r=25:d=8",45 "-f", "lavfi", "-i", "sine=f=440:d=8", "-c:v", "libx264", "-c:a", "aac", video)46 if out, err := cmd.CombinedOutput(); err != nil {47 t.Fatalf("make video: %v: %s", err, out)48 }49 it := item{Path: video, Frames: 8 * loopFPS, Audio: true, Title: "Captioned", Captions: srtCaptions(50 "1\n00:00:01,500 --> 00:00:02,500\nAlready going\n\n" +51 "2\n00:00:03,500 --> 00:00:05,000\nRight on time\n")}52 n := 053 cue := func(time.Time, bool) (item, int64, error) {54 if n++; n == 1 {55 return it, loopFPS, nil // a second in56 }57 return item{Slate: true, Frames: 10 * loopFPS}, 0, nil58 }59 ctx, cancel := context.WithTimeout(context.Background(), 5500*time.Millisecond)60 defer cancel()61 var out bytes.Buffer62 if err := playCues(ctx, &out, ffmpeg, "test", cue); err != nil {63 t.Fatal(err)64 }65 if err := os.WriteFile(filepath.Join(dir, "out.ts"), out.Bytes(), 0o644); err != nil {66 t.Fatal(err)67 }68 extract := exec.Command(ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin",69 "-f", "lavfi", "-i", "movie=out.ts[out0+subcc]", "-map", "0:s", "-f", "srt", "-")70 extract.Dir = dir71 srt, err := extract.Output()72 if err != nil {73 t.Fatalf("extracting captions: %v", err)74 }75 got, err := cc.Parse(srt)76 if err != nil {77 t.Fatalf("%v:\n%s", err, srt)78 }79 t.Logf("captions:\n%s", srt)80 if len(got) != 2 || got[0].Text != "Already going" || got[1].Text != "Right on time" {81 t.Fatalf("captions %q", got)82 }83 // Times count from the stream's start, a second into the video.84 if d := got[1].Start - 2500*time.Millisecond; d < -70*time.Millisecond || d > 70*time.Millisecond {85 t.Errorf("second caption at %v, want 2.5s", got[1].Start)86 }87 if got[0].Start > time.Second {88 t.Errorf("first caption at %v, want by 1s", got[0].Start)89 }90}9192// TestCaptionsLoadWhileTheVideoOpens: captions that take a while to93// fetch don't hold the video's opening up, only its picture going on to94// the encoder, so they're all there from the start.95func TestCaptionsLoadWhileTheVideoOpens(t *testing.T) {96 ffmpeg, err := exec.LookPath("ffmpeg")97 if err != nil {98 t.Skip("no ffmpeg")99 }100 dir := t.TempDir()101 video := filepath.Join(dir, "v.mp4")102 cmd := exec.Command(ffmpeg, "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc2=s=640x360:r=25:d=4",103 "-f", "lavfi", "-i", "sine=f=440:d=4", "-c:v", "libx264", "-c:a", "aac", video)104 if out, err := cmd.CombinedOutput(); err != nil {105 t.Fatalf("make video: %v: %s", err, out)106 }107 slow := newCaptions("slow", func(context.Context) ([]byte, error) {108 time.Sleep(700 * time.Millisecond)109 return []byte("1\n00:00:00,500 --> 00:00:02,000\nFrom the start\n"), nil110 })111 it := item{Path: video, Frames: 4 * loopFPS, Audio: true, Title: "Captioned", Captions: slow}112 n := 0113 cue := func(time.Time, bool) (item, int64, error) {114 if n++; n == 1 {115 return it, 0, nil116 }117 return item{Slate: true, Frames: 10 * loopFPS}, 0, nil118 }119 timer := phase.New("test", "folder")120 ctx, cancel := context.WithTimeout(phase.NewContext(context.Background(), timer), 3*time.Second)121 defer cancel()122 var out bytes.Buffer123 if err := playCues(ctx, &out, ffmpeg, "test", cue); err != nil {124 t.Fatal(err)125 }126 var decoding, captioned time.Duration127 for _, m := range timer.Marks() {128 switch m.Name {129 case "decoding":130 decoding = m.At131 case "captions":132 captioned = m.At133 }134 }135 if decoding == 0 || captioned < 600*time.Millisecond || decoding > captioned {136 t.Errorf("the video waited for its captions to open: %s", timer)137 }138 if err := os.WriteFile(filepath.Join(dir, "out.ts"), out.Bytes(), 0o644); err != nil {139 t.Fatal(err)140 }141 extract := exec.Command(ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin",142 "-f", "lavfi", "-i", "movie=out.ts[out0+subcc]", "-map", "0:s", "-f", "srt", "-")143 extract.Dir = dir144 srt, err := extract.Output()145 if err != nil {146 t.Fatalf("extracting captions: %v", err)147 }148 got, err := cc.Parse(srt)149 if err != nil || len(got) != 1 || got[0].Text != "From the start" || got[0].Start > time.Second {150 t.Errorf("captions %q (%v), want the one from the start:\n%s", got, err, srt)151 }152}153154func TestFeedCountsWholeFrames(t *testing.T) {155 r, w, err := os.Pipe()156 if err != nil {157 t.Fatal(err)158 }159 f := newFeed(w, 4, 10, nil)160 got := make(chan []byte)161 go func() {162 b, _ := readAll(r)163 got <- b164 }()165 for _, data := range []string{"0123456789abcdefghij", "klmno", ""} {166 src, done, err := f.source(nil, nil)167 if err != nil {168 t.Fatal(err)169 }170 src.WriteString(data)171 src.Close()172 if n, want := done(), int64((len(data)+9)/10); n != want {173 t.Errorf("%q: %d units, want %d", data, n, want)174 }175 }176 if f.units != 3 || f.fed() != 3 {177 t.Errorf("units %d, fed %d; want 3", f.units, f.fed())178 }179 f.close()180 if b := <-got; string(b) != "0123456789abcdefghijklmno\x00\x00\x00\x00\x00" {181 t.Errorf("fed %q", b)182 }183 if pad := filler(frameBytes, loopWidth*loopHeight-1); len(pad) != frameBytes-loopWidth*loopHeight+1 || pad[0] != 0x10 || pad[1] != 0x80 {184 t.Errorf("frame filler starts % x", pad[:2])185 }186}187188func readAll(f *os.File) ([]byte, error) {189 defer f.Close()190 var b bytes.Buffer191 _, err := b.ReadFrom(f)192 return b.Bytes(), err193}194195func TestTimeline(t *testing.T) {196 var tl timeline197 if b1, b2 := tl.pair(0); b1 != cc.Padding || b2 != cc.Padding {198 t.Error("an empty timeline sent something")199 }200 a := tl.begin(0)201 tl.set(a, []cc.Cue{{Start: time.Second, End: 2 * time.Second, Text: "Hi"}})202 b := tl.begin(100)203 track := cc.NewTrack([]cc.Cue{{Start: time.Second, End: 2 * time.Second, Text: "Hi"}}, loopFPS)204 empty := cc.NewTrack(nil, loopFPS)205 for f := range int64(200) {206 var w1, w2 byte207 if f < 100 {208 w1, w2 = track.Pair(f)209 } else {210 w1, w2 = empty.Pair(f - 100)211 }212 if g1, g2 := tl.pair(f); g1 != w1 || g2 != w2 {213 t.Fatalf("frame %d: % x, want % x", f, []byte{g1, g2}, []byte{w1, w2})214 }215 }216 // Spans the writer has passed go.217 tl.begin(300)218 if len(tl.spans) != 2 || tl.spans[0] != b {219 t.Errorf("%d spans kept", len(tl.spans))220 }221}222223func TestBurnInGraph(t *testing.T) {224 args := strings.Join(decodeInputs([]string{"-re", "-i", "in.mp4"}, "0:a:1", 300, "/tmp/subs.srt"), " ")225 if !strings.Contains(args, "setsar=1,subtitles=filename=/tmp/subs.srt:force_style='") || !strings.Contains(args, "[0:a:1]aresample") {226 t.Errorf("args %s", args)227 }228 if plain := strings.Join(decodeInputs([]string{"-i", "in.mp4"}, "", 300, ""), " "); strings.Contains(plain, "subtitles") {229 t.Errorf("subtitles without a file: %s", plain)230 }231}232233// TestSubtitleChoosesBurnIn checks which viewers get subtitles burned in:234// HDHomeRun clients, for sound not in English, when ffmpeg can.235func TestSubtitleChoosesBurnIn(t *testing.T) {236 ffmpeg, err := exec.LookPath("ffmpeg")237 if err != nil {238 t.Skip("no ffmpeg")239 }240 srt := "1\n00:00:05,000 --> 00:00:06,000\nBonjour\n"241 for _, c := range []struct {242 lang string243 app bool244 burn bool245 }{246 {"fre", false, true},247 {"fre", true, false},248 {"eng", false, false},249 {"", false, false},250 } {251 out := &output{captions: &timeline{}, app: c.app, video: &feed{unit: frameBytes}}252 it := item{AudioLang: c.lang, Captions: srtCaptions(srt)}253 s := out.captions.begin(0)254 path, settled := subtitle(context.Background(), ffmpeg, it, 2*loopFPS, s, out)255 if settled != nil {256 <-settled257 }258 burnt := path != ""259 if burnt {260 b, _ := os.ReadFile(path)261 os.Remove(path)262 if !strings.Contains(string(b), "00:00:03,000 --> 00:00:04,000\nBonjour") {263 t.Errorf("%s: subtitles not shifted to the start:\n%s", c.lang, b)264 }265 }266 if want := c.burn && canBurn(ffmpeg); burnt != want {267 t.Errorf("%s, app %v: burnt in %v, want %v", c.lang, c.app, burnt, want)268 }269 // Captions not burnt in are closed captions.270 var sent bool271 for f := range int64(200) {272 if b1, _ := s.track.Pair(f); b1 != cc.Padding && f > 2 {273 sent = true274 }275 }276 if sent == burnt {277 t.Errorf("%s, app %v: closed captions %v with burnt in %v", c.lang, c.app, sent, burnt)278 }279 }280}281282func TestFolderPrefersEnglishAndFindsSubtitles(t *testing.T) {283 ffmpeg, err := exec.LookPath("ffmpeg")284 if err != nil {285 t.Skip("no ffmpeg")286 }287 ffprobe, err := exec.LookPath("ffprobe")288 if err != nil {289 t.Skip("no ffprobe")290 }291 dir := t.TempDir()292 mk := func(name string, args ...string) {293 cmd := exec.Command(ffmpeg, append(append([]string{"-hide_banner", "-loglevel", "error"}, args...), filepath.Join(dir, name))...)294 if out, err := cmd.CombinedOutput(); err != nil {295 t.Fatalf("make %s: %v: %s", name, err, out)296 }297 }298 mk("anime.mkv", "-f", "lavfi", "-i", "testsrc2=s=320x240:r=25:d=2", "-f", "lavfi", "-i", "sine=d=2", "-f", "lavfi", "-i", "sine=d=2",299 "-map", "0", "-map", "1", "-map", "2", "-c:v", "libx264", "-c:a", "aac",300 "-metadata:s:a:0", "language=jpn", "-disposition:a:0", "default", "-metadata:s:a:1", "language=eng", "-disposition:a:1", "0")301 mk("movie.mp4", "-f", "lavfi", "-i", "testsrc2=s=320x240:r=25:d=2", "-f", "lavfi", "-i", "sine=d=2",302 "-c:v", "libx264", "-c:a", "aac", "-metadata:s:a:0", "language=fre")303 mk("quiet.mp4", "-f", "lavfi", "-i", "testsrc2=s=320x240:r=25:d=2", "-c:v", "libx264")304 writeFile(t, filepath.Join(dir, "movie.en.srt"), "1\n00:00:00,500 --> 00:00:01,500\nHello\n")305 writeFile(t, filepath.Join(dir, "quiet.srt"), "1\n00:00:00,500 --> 00:00:01,500\n[silence]\n")306307 f := &Folder{Num: "1.9", Title: "Test", Dir: dir, FFmpeg: ffmpeg, FFprobe: ffprobe}308 items := f.list()309 if len(items) != 3 {310 t.Fatalf("%d items", len(items))311 }312 byName := map[string]item{}313 for _, it := range items {314 byName[filepath.Base(it.Path)] = it315 }316 if it := byName["anime.mkv"]; it.AudioStream != "0:a:1" || it.AudioLang != "eng" || it.Captions != nil {317 t.Errorf("anime: sound %q (%q), captions %v", it.AudioStream, it.AudioLang, it.Captions != nil)318 }319 if it := byName["movie.mp4"]; it.AudioStream != "0:a:0" || it.AudioLang != "fre" || it.Captions == nil {320 t.Errorf("movie: sound %q (%q), captions %v", it.AudioStream, it.AudioLang, it.Captions != nil)321 }322 if it := byName["quiet.mp4"]; it.Audio || it.Captions == nil {323 t.Errorf("quiet: audio %v, captions %v", it.Audio, it.Captions != nil)324 }325 cues, err := byName["movie.mp4"].Captions.get(context.Background())326 if err != nil || len(cues) != 1 || cues[0].Text != "Hello" {327 t.Errorf("movie captions %q, %v", cues, err)328 }329 progs := f.Programs(time.Now(), time.Now().Add(time.Minute))330 var flagged []string331 for _, p := range progs {332 if p.Captions {333 flagged = append(flagged, p.Title+" "+p.AudioLang)334 }335 }336 if !slices.Contains(flagged, "movie fre") || slices.Contains(flagged, "anime eng") {337 t.Errorf("programs with captions: %q", flagged)338 }339 // Rescanning keeps the captions fetched.340 f.scanned = time.Time{}341 again := f.list()342 for _, it := range again {343 if filepath.Base(it.Path) == "movie.mp4" && it.Captions != byName["movie.mp4"].Captions {344 t.Error("captions fetched again after a rescan")345 }346 }347}348349func TestJellyfinItemSoundAndCaptions(t *testing.T) {350 srv := jellyfintest.New(t, "subs", "pw",351 jellyfintest.Item{ID: "lib", Type: "CollectionFolder", Name: "Movies"},352 jellyfintest.Item{ID: "akira", Type: "Movie", Name: "Akira", Parent: "lib", Runtime: 124 * time.Minute, Streams: []jellyfintest.Stream{353 {Type: "Audio", Language: "jpn", Codec: "flac", Default: true},354 {Type: "Subtitle", Language: "eng", Codec: "subrip", Forced: true, SRT: "1\n00:00:01,000 --> 00:00:02,000\nSigns\n"},355 }},356 jellyfintest.Item{ID: "dub", Type: "Movie", Name: "Dubbed", Parent: "lib", Runtime: 90 * time.Minute, Streams: []jellyfintest.Stream{357 {Type: "Audio", Language: "jpn", Codec: "flac", Default: true},358 {Type: "Audio", Language: "eng", Codec: "aac"},359 {Type: "Subtitle", Language: "eng", Codec: "subrip", Forced: true, SRT: "forced"},360 }},361 )362 c := jellyfin.New(jellyfin.Account{Server: srv.URL, User: "subs", Password: "pw"}, "dev", nil)363 vs, err := c.Videos(context.Background(), []jellyfin.Item{{ID: "akira", Type: "Movie"}, {ID: "dub", Type: "Movie"}})364 if err != nil || len(vs) != 2 {365 t.Fatalf("videos %v, %v", vs, err)366 }367 akira, _ := jellyfinItem(c, vs[0], 0)368 if akira.AudioLang != "jpn" || akira.AudioStream != "" || akira.Captions == nil {369 t.Fatalf("akira: sound %q %q, captions %v", akira.AudioStream, akira.AudioLang, akira.Captions != nil)370 }371 cues, err := akira.Captions.get(context.Background())372 if err != nil || len(cues) != 1 || cues[0].Text != "Signs" {373 t.Errorf("akira captions %q, %v", cues, err)374 }375 // With English sound, forced subtitles aren't captions.376 dub, _ := jellyfinItem(c, vs[1], 0)377 if dub.AudioLang != "eng" || dub.AudioStream != "0:a:1" || dub.Captions != nil {378 t.Errorf("dub: sound %q %q, captions %v", dub.AudioStream, dub.AudioLang, dub.Captions != nil)379 }380 // A transcode picks the sound on the server.381 if dub, _ := jellyfinItem(c, vs[1], 4); dub.AudioStream != "" || !strings.Contains(strings.Join(decodeArgs(dub, 0), " "), "audioStreamIndex=2") {382 t.Errorf("transcode: %q", decodeArgs(dub, 0))383 }384}385386func TestYouTubeCaptions(t *testing.T) {387 var srv *httptest.Server388 srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {389 switch r.URL.Path {390 case "/manual.vtt":391 fmt.Fprint(w, "WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nBy hand\n")392 case "/manual.json3":393 fmt.Fprint(w, `{"events":[{"tStartMs":1000,"dDurationMs":1000,"segs":[{"utf8":"By hand"}]}]}`)394 case "/asr.json3":395 fmt.Fprint(w, `{"events":[{"tStartMs":1000,"dDurationMs":2000,"segs":[{"utf8":"all"},{"utf8":" right","tOffsetMs":400}]}]}`)396 case "/auto.m3u8":397 fmt.Fprint(w, "#EXTM3U\n#EXT-X-TARGETDURATION:600\n#EXTINF:600,\n/piece?n=1\n#EXTINF:600,\n"+srv.URL+"/piece?n=2\n#EXT-X-ENDLIST\n")398 case "/piece":399 n := r.URL.Query().Get("n")400 fmt.Fprintf(w, "WEBVTT\nKind: captions\nLanguage: en\n\n00:%s0:00.000 --> 00:%s0:02.000\nPiece %s\n", n, n, n)401 default:402 http.NotFound(w, r)403 }404 }))405 defer srv.Close()406 info := youtube.Info{407 Subtitles: map[string][]youtube.Subtitle{408 "de": {{Ext: "vtt", URL: srv.URL + "/de.vtt"}},409 "en-GB": {{Ext: "srv3", URL: srv.URL + "/x"}, {Ext: "vtt", URL: srv.URL + "/manual.vtt"}, {Ext: "json3", URL: srv.URL + "/manual.json3"}},410 },411 AutoCaptions: map[string][]youtube.Subtitle{"en": {{Ext: "vtt", URL: srv.URL + "/auto.m3u8", Protocol: "m3u8_native"}}},412 }413 sub, ok := info.Captions()414 if !ok || sub.URL != srv.URL+"/manual.json3" {415 t.Fatalf("picked %+v", sub)416 }417 raw, err := youtube.FetchCaptions(context.Background(), srv.Client(), sub)418 if cues, _ := cc.Parse(raw); err != nil || len(cues) != 1 || cues[0].Text != "By hand" {419 t.Errorf("manual captions %q, %v", raw, err)420 }421 info.Subtitles = nil422 sub, ok = info.Captions()423 if !ok || sub.Protocol != "m3u8_native" {424 t.Fatalf("picked %+v", sub)425 }426 raw, err = youtube.FetchCaptions(context.Background(), srv.Client(), sub)427 cues, _ := cc.Parse(raw)428 if err != nil || len(cues) != 2 || cues[0].Text != "Piece 1" || cues[1].Start != 20*time.Minute {429 t.Errorf("automatic captions %q, %v", cues, err)430 }431 // Speech recognition's, as json3, time each word.432 info.AutoCaptions["en-orig"] = []youtube.Subtitle{{Ext: "vtt", URL: srv.URL + "/x"}, {Ext: "json3", URL: srv.URL + "/asr.json3"}}433 sub, _ = info.Captions()434 raw, err = youtube.FetchCaptions(context.Background(), srv.Client(), sub)435 cues, _ = cc.Parse(raw)436 if err != nil || len(cues) != 1 || cues[0].Text != "all right" || len(cues[0].Words) != 2 || cues[0].Words[1].Start != 1400*time.Millisecond {437 t.Errorf("speech recognition's captions %+v, %v", cues, err)438 }439 if _, ok := (youtube.Info{AutoCaptions: map[string][]youtube.Subtitle{"fr": {{Ext: "vtt", URL: "x"}}}}).Captions(); ok {440 t.Error("picked French captions")441 }442 if _, err := youtube.FetchCaptions(context.Background(), srv.Client(), youtube.Subtitle{URL: srv.URL + "/gone?signature=secret"}); err == nil || strings.Contains(err.Error(), "secret") {443 t.Errorf("error %v", err)444 }445 dubbed := youtube.Info{Language: "ja", RequestedFormats: []youtube.Format{{URL: "v", VCodec: "avc1", ACodec: "none"}, {URL: "a", VCodec: "none", ACodec: "mp4a", Language: "en-US"}}}446 if l := dubbed.AudioLang(); l != "en-US" {447 t.Errorf("dubbed sound %q", l)448 }449 if l := (youtube.Info{Language: "ja"}).AudioLang(); l != "ja" {450 t.Errorf("sound %q", l)451 }452}453454// TestBurnInDraws burns subtitles into a black picture where ffmpeg has455// libass, as the server's does.456func TestBurnInDraws(t *testing.T) {457 ffmpeg, err := exec.LookPath("ffmpeg")458 if err != nil || !canBurn(ffmpeg) {459 t.Skip("no ffmpeg with the subtitles filter")460 }461 subs, err := writeSubtitles([]cc.Cue{{Start: 0, End: 5 * time.Second, Text: "Burned in"}})462 if err != nil {463 t.Fatal(err)464 }465 defer os.Remove(subs)466 bright := func(subtitles string) int {467 t.Helper()468 dir := t.TempDir()469 video, _ := os.Create(filepath.Join(dir, "v"))470 audio, _ := os.Create(filepath.Join(dir, "a"))471 cmd := exec.Command(ffmpeg, decodeInputs([]string{"-f", "lavfi", "-i", "color=c=black:s=640x360:r=30:d=1"}, "", 15, subtitles)...)472 cmd.ExtraFiles = []*os.File{video, audio}473 if out, err := cmd.CombinedOutput(); err != nil {474 t.Fatalf("ffmpeg: %v: %s", err, out)475 }476 video.Close()477 audio.Close()478 raw, _ := os.ReadFile(filepath.Join(dir, "v"))479 if len(raw) != 15*frameBytes {480 t.Fatalf("%d bytes of picture, want 15 frames", len(raw))481 }482 n := 0483 for _, y := range raw[14*frameBytes : 14*frameBytes+loopWidth*loopHeight] {484 if y > 128 {485 n++486 }487 }488 return n489 }490 if n := bright(""); n != 0 {491 t.Errorf("%d bright pixels without subtitles", n)492 }493 if n := bright(subs); n < 500 {494 t.Errorf("%d bright pixels with subtitles burned in", n)495 }496}497498// TestAppStreamSubtitles plays a captioned video to the app's HLS stream,499// and checks its WebVTT rendition places each caption where the closed500// captions in its picture show it.501func TestAppStreamSubtitles(t *testing.T) {502 ffmpeg, err := exec.LookPath("ffmpeg")503 if err != nil {504 t.Skip("no ffmpeg")505 }506 ffprobe, err := exec.LookPath("ffprobe")507 if err != nil {508 t.Skip("no ffprobe")509 }510 if testing.Short() {511 t.Skip("short")512 }513 dir := t.TempDir()514 video := filepath.Join(dir, "v.mp4")515 cmd := exec.Command(ffmpeg, "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc2=s=640x360:r=25:d=30",516 "-f", "lavfi", "-i", "sine=f=440:d=30", "-c:v", "libx264", "-preset", "ultrafast", "-c:a", "aac", video)517 if out, err := cmd.CombinedOutput(); err != nil {518 t.Fatalf("make video: %v: %s", err, out)519 }520 srt := "1\n00:00:01,000 --> 00:00:02,500\nCaption one\n\n" +521 "2\n00:00:04,000 --> 00:00:05,500\nCaption two\n\n" +522 "3\n00:00:07,000 --> 00:00:08,500\nCaption three\n\n" +523 "4\n00:00:10,000 --> 00:00:11,500\nCaption four\n"524 it := item{Path: video, Frames: 30 * loopFPS, Audio: true, Title: "Captioned", Captions: srtCaptions(srt)}525 script := &cc.Script{}526 srv, err := stream.NewServer(true)527 if err != nil {528 t.Fatal(err)529 }530 defer srv.Close()531 in := tuner.Input{532 Args: []string{"-f", "mpegts", "-i", "pipe:0"}, Video: "0:v:0", Audio: []tuner.AudioTrack{{Map: "0:a:0"}},533 Captions: true, Subtitles: script,534 Source: func(ctx context.Context, w io.Writer) error {535 return playCues(ForApp(ctx, script), w, ffmpeg, "test", func(time.Time, bool) (item, int64, error) { return it, 0, nil })536 },537 }538 pb, err := srv.Start(context.Background(), "test", in)539 if err != nil {540 t.Fatal(err)541 }542 time.Sleep(10 * time.Second) // past the last caption, from four seconds in543 base := strings.TrimSuffix(pb.URL, "master.m3u8")544 get := func(name string) []byte {545 t.Helper()546 resp, err := http.Get(base + name)547 if err != nil {548 t.Fatal(err)549 }550 defer resp.Body.Close()551 b, _ := io.ReadAll(resp.Body)552 if resp.StatusCode != http.StatusOK {553 t.Fatalf("GET %s: %s", name, resp.Status)554 }555 return b556 }557 if m := string(get("master.m3u8")); !strings.Contains(m, `TYPE=SUBTITLES`) || !strings.Contains(m, `SUBTITLES="subs"`) {558 t.Fatalf("master:\n%s", m)559 }560 segments := func(list []byte) []string {561 var out []string562 for _, l := range strings.Split(string(list), "\n") {563 if l != "" && !strings.HasPrefix(l, "#") {564 out = append(out, l)565 }566 }567 return out568 }569 subs := get("subs.m3u8")570 videos := segments(get("stream_0.m3u8"))571 vtts := segments(subs)572 if len(vtts) < 5 || len(vtts) > len(videos) {573 t.Fatalf("%d subtitle segments for %d video:\n%s", len(vtts), len(videos), subs)574 }575 t.Logf("subs.m3u8:\n%s", subs)576577 // The video, and where its first picture is.578 var all []byte579 for _, v := range videos {580 all = append(all, get(v)...)581 }582 if err := os.WriteFile(filepath.Join(dir, "hls.ts"), all, 0o644); err != nil {583 t.Fatal(err)584 }585 if err := os.WriteFile(filepath.Join(dir, "seg0.ts"), get(videos[0]), 0o644); err != nil {586 t.Fatal(err)587 }588 out, err := exec.Command(ffprobe, "-v", "error", "-select_streams", "v", "-show_entries", "packet=pts",589 "-of", "csv=p=0", filepath.Join(dir, "seg0.ts")).Output()590 if err != nil {591 t.Fatal(err)592 }593 initPTS := int64(math.MaxInt64)594 for _, l := range strings.Fields(strings.ReplaceAll(string(out), ",", " ")) {595 if n, err := strconv.ParseInt(l, 10, 64); err == nil {596 initPTS = min(initPTS, n)597 }598 }599600 // Where a player puts each WebVTT cue, through its segment's map.601 mapRe := regexp.MustCompile(`X-TIMESTAMP-MAP=MPEGTS:(\d+),LOCAL:([\d:.]+)`)602 cueRe := regexp.MustCompile(`([\d:.]+) --> ([\d:.]+)\n(.+)`)603 secs := func(v string) float64 {604 parts := strings.Split(v, ":")605 h, _ := strconv.ParseFloat(parts[0], 64)606 m, _ := strconv.ParseFloat(parts[1], 64)607 s, _ := strconv.ParseFloat(parts[2], 64)608 return h*3600 + m*60 + s609 }610 placed := map[string]float64{}611 for _, v := range vtts {612 body := string(get(v))613 m := mapRe.FindStringSubmatch(body)614 if m == nil {615 t.Fatalf("%s:\n%s", v, body)616 }617 mpegts, _ := strconv.ParseInt(m[1], 10, 64)618 offset := float64(mpegts-initPTS)/90000 - secs(m[2])619 if math.Abs(offset) > 0.001 {620 t.Errorf("%s maps %s to its local %s, %.3fs from the video", v, m[1], m[2], offset)621 }622 for _, c := range cueRe.FindAllStringSubmatch(body, -1) {623 at := secs(c[1]) + offset624 if p, ok := placed[c[3]]; ok && math.Abs(p-at) > 0.001 {625 t.Errorf("%q placed at %.3f and %.3f", c[3], p, at)626 }627 placed[c[3]] = at628 }629 }630 // The closed captions in the picture, from the same start.631 extract := exec.Command(ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin",632 "-f", "lavfi", "-i", "movie=hls.ts[out0+subcc]", "-map", "0:s", "-f", "srt", "-")633 extract.Dir = dir634 srtOut, err := extract.Output()635 if err != nil {636 t.Fatalf("extracting captions: %v", err)637 }638 decoded, err := cc.Parse(srtOut)639 if err != nil {640 t.Fatal(err)641 }642 if len(decoded) < 4 {643 t.Fatalf("closed captions:\n%s", srtOut)644 }645 for _, c := range decoded {646 text := regexp.MustCompile(`<[^>]*>|\{[^}]*\}`).ReplaceAllString(c.Text, "")647 at, ok := placed[text]648 if !ok {649 t.Errorf("%q in the picture but not the WebVTT (%v)", text, placed)650 continue651 }652 t.Logf("%q: WebVTT %.3fs, closed captions %.3fs", text, at, c.Start.Seconds())653 if d := at - c.Start.Seconds(); math.Abs(d) > 0.07 {654 t.Errorf("%q: WebVTT at %.3fs, closed captions at %.3fs", text, at, c.Start.Seconds())655 }656 }657}658659// The script, which the app's WebVTT comes from, gets one screen at a660// time, timed from the stream's start.661func TestTimelineScriptScreens(t *testing.T) {662 script := &cc.Script{}663 tl := timeline{script: script}664 tl.begin(0)665 s := tl.begin(30 * loopFPS) // a video from 30 s in666 word := func(sec float64, w string) cc.Word {667 return cc.Word{Start: time.Duration(sec * float64(time.Second)), Text: w}668 }669 tl.set(s, []cc.Cue{670 {Start: 1 * time.Second, End: 5 * time.Second, Text: "one two", Words: []cc.Word{word(1, "one"), word(1.5, "two")}},671 {Start: 3 * time.Second, End: 7 * time.Second, Text: "three four", Words: []cc.Word{word(3, "three"), word(3.5, "four")}},672 })673 got := script.Between(0, time.Hour)674 if len(got) != 2 || got[0].Start != 31*time.Second || got[0].End != 33*time.Second || got[1].Text != "one two\nthree four" || got[1].Start != 33*time.Second {675 t.Errorf("script %+v", got)676 }677}