package media import ( "context" "errors" "math" "os" "os/exec" "path/filepath" "sort" "strconv" "strings" "testing" "time" ) // The real thing, when ffmpeg is here: a generated clip goes through as H.264 and comes out // as a playlist of segments, the status follows it to the end, and closing removes it all. func TestStreamWithFFmpeg(t *testing.T) { if _, err := exec.LookPath("ffmpeg"); err != nil { t.Skip("no ffmpeg") } c := Probe(context.Background()) if c.Error != "" { t.Skip(c.Error) } if EngineFor(c, "h264") == "" { t.Skip("nothing here encodes h264") } dir := t.TempDir() clip := filepath.Join(dir, "clip.mp4") out, err := exec.Command("ffmpeg", "-nostdin", "-v", "error", "-f", "lavfi", "-i", "testsrc=size=640x360:rate=25:duration=9", "-f", "lavfi", "-i", "sine=frequency=440:duration=9", "-c:v", "mpeg4", "-q:v", "5", "-c:a", "aac", "-shortest", clip).CombinedOutput() if err != nil { t.Skipf("could not make a clip: %s", out) } src, err := Inspect(context.Background(), c, clip, false) if err != nil || src.Video == nil || src.Audio == nil || src.Video.Codec != "mpeg4" || src.Duration < 8 { t.Fatalf("inspect: %+v %v", src, err) } m := NewStreamer(dir, "hamlab", 2) s, err := m.Open(context.Background(), c, false, clip, "h264", "480p", src) if err != nil { t.Fatal(err) } if s.Width != 640 || s.Height != 360 || s.Engine == "" || !s.Seekable { t.Fatalf("stream: %+v", s) } if _, ok := s.File("index.m3u8"); !ok { t.Fatal("no playlist") } if pl := string(s.Playlist()); !strings.Contains(pl, "#EXT-X-PLAYLIST-TYPE:VOD") || !strings.HasSuffix(pl, "#EXT-X-ENDLIST\n") || strings.Count(pl, "#EXTINF") != 3 { t.Fatalf("playlist for 9 s:\n%s", pl) } if !exists(s.segPath(0)) { t.Fatal("Open returned before the first segment") } for _, bad := range []string{"../x", "progress", "seg1.m4s", "index.m3u8/", "init.mp4.tmp"} { if _, ok := s.File(bad); ok { t.Fatalf("%q must not be served", bad) } } // the same request is the same stream if again, err := m.Open(context.Background(), c, false, clip, "h264", "480p", src); err != nil || again.ID != s.ID { t.Fatal("not reused") } var st StreamStatus for i := 0; i < 120; i++ { st = s.Status() if st.Done || st.Error != "" { break } time.Sleep(250 * time.Millisecond) } if !st.Done || st.Error != "" || st.Segments != 3 || st.Progress < 8 { t.Fatalf("status at the end: %+v", st) } if init, _ := s.File("init.mp4"); !exists(init) { t.Fatal("no init segment") } m.Close(s.ID) if _, ok := m.Find(s.ID); ok || exists(s.dir) { t.Fatal("close must forget and remove") } // an unplayable source is a DecodeError, and leaves nothing behind bad := filepath.Join(dir, "bad.mkv") _ = os.WriteFile(bad, []byte("not a video"), 0o644) if _, err := Inspect(context.Background(), c, bad, false); err == nil { t.Fatal("inspect of garbage") } if left, _ := os.ReadDir(m.dir); len(left) != 0 { t.Fatalf("left behind: %v", left) } } // A seek ahead of the encode: the run moves there, the segment comes from a run that started // at it, the gaps are filled afterwards, and the segments of the different runs, joined, are // one film with no hole and nothing twice beyond a frame at a join. func TestSeekRestartsTheEncode(t *testing.T) { if _, err := exec.LookPath("ffmpeg"); err != nil { t.Skip("no ffmpeg") } c := Probe(context.Background()) if c.Error != "" || EngineFor(c, "h264") == "" { t.Skip("nothing here encodes h264") } dir := t.TempDir() clip := filepath.Join(dir, "clip.mkv") // big and busy enough that the first run is still near the start when the seek comes out, err := exec.Command("ffmpeg", "-nostdin", "-v", "error", "-f", "lavfi", "-i", "testsrc2=size=1920x1080:rate=30000/1001:duration=64", "-f", "lavfi", "-i", "sine=frequency=440:duration=64", "-c:v", "libx264", "-preset", "ultrafast", "-g", "250", "-c:a", "libopus", "-shortest", clip).CombinedOutput() if err != nil { t.Skipf("could not make a clip: %s", out) } src, err := Inspect(context.Background(), c, clip, false) if err != nil { t.Fatal(err) } m := NewStreamer(dir, "hamlab", 2) s, err := m.Open(context.Background(), c, false, clip, "h264", "1080p", src) if err != nil { t.Fatal(err) } defer m.Close(s.ID) if s.n != 16 { t.Fatalf("%d segments for 64 s", s.n) } ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() p, err := s.Segment(ctx, 12) if err != nil { t.Fatal(err) } s.mu.Lock() from := s.run.from s.mu.Unlock() if from != 12 { t.Fatalf("the run after the seek starts at segment %d, not 12 (the first run was already there?)", from) } if got := firstVideoPTS(t, filepath.Join(s.dir, "init.mp4"), p); math.Abs(got-(48+tsBase)) > 0.05 { t.Fatalf("segment 12 starts at %.3f, want %d", got, 48+tsBase) } if _, err := s.Segment(ctx, 16); !errors.Is(err, ErrNotInFilm) { t.Fatalf("past the end: %v", err) } var st StreamStatus for i := 0; i < 480; i++ { if st = s.Status(); st.Done || st.Error != "" { break } time.Sleep(250 * time.Millisecond) } if !st.Done || st.Segments != 16 { t.Fatalf("not filled in: %+v", st) } // the joined film: every segment in order behind the one init segment joined := filepath.Join(dir, "joined.mp4") f, _ := os.Create(joined) for _, n := range append([]string{"init.mp4"}, segNames(16)...) { b, err := os.ReadFile(filepath.Join(s.dir, n)) if err != nil { t.Fatal(err) } _, _ = f.Write(b) } f.Close() if out, err := exec.Command("ffmpeg", "-v", "error", "-i", joined, "-f", "null", "-").CombinedOutput(); err != nil || strings.Contains(string(out), "error") { t.Fatalf("the joined film doesn't decode cleanly: %v %s", err, out) } pts := videoPTS(t, joined) if len(pts) < 1900 || pts[0] > tsBase+0.1 || pts[len(pts)-1] < tsBase+63.5 { t.Fatalf("%d frames, %.3f .. %.3f", len(pts), pts[0], pts[len(pts)-1]) } for i := 1; i < len(pts); i++ { if d := pts[i] - pts[i-1]; d > 0.07 { // a frame is 0.033; a join may lose up to one t.Fatalf("a hole of %.3f s at %.3f", d, pts[i-1]) } } } func segNames(n int) []string { var out []string for i := 0; i < n; i++ { out = append(out, "seg"+strconv.FormatInt(int64(100000+i), 10)[1:]+".m4s") } return out } func videoPTS(t *testing.T, file string) []float64 { out, err := exec.Command("ffprobe", "-v", "error", "-select_streams", "v", "-show_entries", "packet=pts_time", "-of", "csv=p=0", file).Output() if err != nil { t.Fatal(err) } var pts []float64 seen := map[string]bool{} for _, l := range strings.Fields(string(out)) { if seen[l] { // the same frame from both runs at a join continue } seen[l] = true if v, err := strconv.ParseFloat(l, 64); err == nil { pts = append(pts, v) } } sort.Float64s(pts) return pts } func firstVideoPTS(t *testing.T, init, seg string) float64 { dir := t.TempDir() j := filepath.Join(dir, "one.mp4") a, _ := os.ReadFile(init) b, _ := os.ReadFile(seg) _ = os.WriteFile(j, append(a, b...), 0o644) pts := videoPTS(t, j) if len(pts) == 0 { t.Fatal("no frames in the segment") } return pts[0] }