1package cc23import (4 "bytes"5 "math/rand/v2"6 "os"7 "os/exec"8 "path/filepath"9 "strings"10 "testing"11 "time"12)1314// TestCaptionsReachFFmpeg captions a real H.264 stream from libx264 (with15// B-frames, as the channel encoder makes) and reads the captions back16// with ffmpeg, before and after the re-encode the Airwaves app's streams17// go through.18func TestCaptionsReachFFmpeg(t *testing.T) {19 ffmpeg, err := exec.LookPath("ffmpeg")20 if err != nil {21 t.Skip("no ffmpeg")22 }23 if testing.Short() {24 t.Skip("short")25 }26 dir := t.TempDir()27 run := func(args ...string) {28 t.Helper()29 cmd := exec.Command(ffmpeg, append([]string{"-hide_banner", "-loglevel", "error", "-nostdin", "-y"}, args...)...)30 cmd.Dir = dir31 if out, err := cmd.CombinedOutput(); err != nil {32 t.Fatalf("ffmpeg %s: %v\n%s", strings.Join(args, " "), err, out)33 }34 }35 run("-f", "lavfi", "-i", "testsrc2=size=320x240:rate=30", "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000",36 "-t", "8", "-c:v", "libx264", "-preset", "veryfast", "-g", "60", "-pix_fmt", "yuv420p",37 "-c:a", "aac", "-f", "mpegts", "in.ts")38 in, err := os.ReadFile(filepath.Join(dir, "in.ts"))39 if err != nil {40 t.Fatal(err)41 }42 cues := []Cue{43 {Start: 1 * time.Second, End: 2500 * time.Millisecond, Text: "Hello there."},44 {Start: 3 * time.Second, End: 4500 * time.Millisecond, Text: "A second caption\nover two lines"},45 {Start: 4500 * time.Millisecond, End: 6 * time.Second, Text: "♪ Straight after ♪"},46 }47 track := NewTrack(cues, 30)48 var out bytes.Buffer49 w := NewWriter(&out, 30, track.Pair)50 rng := rand.New(rand.NewPCG(1, 2))51 for p := in; len(p) > 0; {52 n := min(len(p), 1+rng.IntN(1000))53 if _, err := w.Write(p[:n]); err != nil {54 t.Fatal(err)55 }56 p = p[n:]57 }58 if err := w.Flush(); err != nil {59 t.Fatal(err)60 }61 if err := os.WriteFile(filepath.Join(dir, "out.ts"), out.Bytes(), 0o644); err != nil {62 t.Fatal(err)63 }64 // The app's streams are re-encoded, carrying captions over.65 run("-i", "out.ts", "-map", "0:v", "-c:v", "libx264", "-preset", "veryfast", "-a53cc", "1", "-f", "mpegts", "again.ts")6667 for _, name := range []string{"out.ts", "again.ts"} {68 cmd := exec.Command(ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-y",69 "-f", "lavfi", "-i", "movie="+name+"[out0+subcc]", "-map", "0:s", "-f", "srt", "-")70 cmd.Dir = dir71 srt, err := cmd.Output()72 if err != nil {73 t.Fatalf("%s: extracting captions: %v", name, err)74 }75 got, err := Parse(srt)76 if err != nil {77 t.Fatalf("%s: %v\n%s", name, err, srt)78 }79 t.Logf("%s:\n%s", name, srt)80 if len(got) != len(cues) {81 t.Fatalf("%s: %d captions, want %d:\n%s", name, len(got), len(cues), srt)82 }83 // ffmpeg times captions from the stream's first timestamp.84 offset := got[0].Start - cues[0].Start85 for i, c := range cues {86 g := got[i]87 if norm(g.Text) != norm(c.Text) {88 t.Errorf("%s: caption %d = %q, want %q", name, i, g.Text, c.Text)89 }90 if d := g.Start - offset - c.Start; d < -70*time.Millisecond || d > 70*time.Millisecond {91 t.Errorf("%s: caption %d starts at %v, want %v", name, i, g.Start-offset, c.Start)92 }93 if d := g.End - offset - c.End; d < -70*time.Millisecond || d > 70*time.Millisecond {94 t.Errorf("%s: caption %d ends at %v, want %v", name, i, g.End-offset, c.End)95 }96 }97 if offset < 0 || offset > 2*time.Second {98 t.Errorf("%s: captions offset by %v", name, offset)99 }100 }101 // The stream still decodes cleanly.102 cmd := exec.Command(ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-i", "out.ts", "-f", "null", "-")103 cmd.Dir = dir104 if msg, err := cmd.CombinedOutput(); err != nil || len(msg) > 0 {105 t.Errorf("decoding the captioned stream: %v\n%s", err, msg)106 }107}108109// norm compares caption text the way ffmpeg gives it back: lines as110// spaces, and spaces collapsed.111func norm(s string) string { return strings.Join(strings.Fields(s), " ") }112113// TestRollUpReachesFFmpeg sends YouTube's word-timed captions rolling up,114// and reads them back with ffmpeg: every word, in order.115func TestRollUpReachesFFmpeg(t *testing.T) {116 ffmpeg, err := exec.LookPath("ffmpeg")117 if err != nil {118 t.Skip("no ffmpeg")119 }120 if testing.Short() {121 t.Skip("short")122 }123 raw, err := os.ReadFile("testdata/asr.json3")124 if err != nil {125 t.Fatal(err)126 }127 cues, err := Parse(raw)128 if err != nil {129 t.Fatal(err)130 }131 var spoken []string132 for _, c := range cues {133 if c.End > 12*time.Second {134 break135 }136 for _, w := range c.Words {137 spoken = append(spoken, w.Text)138 }139 }140 dir := t.TempDir()141 cmd := exec.Command(ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-y",142 "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=30", "-t", "16", "-c:v", "libx264", "-preset", "veryfast",143 "-pix_fmt", "yuv420p", "-f", "mpegts", "in.ts")144 cmd.Dir = dir145 if out, err := cmd.CombinedOutput(); err != nil {146 t.Fatalf("ffmpeg: %v\n%s", err, out)147 }148 in, err := os.ReadFile(filepath.Join(dir, "in.ts"))149 if err != nil {150 t.Fatal(err)151 }152 track := NewTrack(cues, 30)153 var out bytes.Buffer154 w := NewWriter(&out, 30, track.Pair)155 w.Write(in)156 w.Flush()157 if err := os.WriteFile(filepath.Join(dir, "out.ts"), out.Bytes(), 0o644); err != nil {158 t.Fatal(err)159 }160 extract := exec.Command(ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin",161 "-f", "lavfi", "-i", "movie=out.ts[out0+subcc]", "-map", "0:s", "-f", "srt", "-")162 extract.Dir = dir163 srt, err := extract.Output()164 if err != nil {165 t.Fatalf("extracting captions: %v", err)166 }167 got, err := Parse(srt)168 if err != nil {169 t.Fatalf("%v:\n%s", err, srt)170 }171 t.Logf("%d captions, from %q", len(got), got[0].Text)172 // Each roll shows what's on screen; the words come through in order.173 var seen []string174 for _, c := range got {175 // ffmpeg shows 608's apostrophe as a closing quote.176 text := strings.NewReplacer(`\h`, " ", "\u2019", "'").Replace(markup.ReplaceAllString(c.Text, ""))177 for _, line := range strings.Split(text, "\n") {178 if line = norm(line); line != "" && (len(seen) == 0 || seen[len(seen)-1] != line) && !strings.HasSuffix(strings.Join(seen, " "), line) {179 seen = append(seen, line)180 }181 }182 }183 all := " " + strings.Join(seen, " ") + " "184 pos := 0185 for _, word := range spoken {186 i := strings.Index(all[pos:], " "+fold(word))187 if i < 0 {188 t.Fatalf("%q missing after %q", word, all[max(0, pos-60):pos])189 }190 pos += i + 1191 }192}