Files
hamlab/internal/media/hls_test.go
T
Mitchell-JohnsonandClaude Opus 5.5 450a9d3327 files: seek anywhere in a re-encode; it restarts from there
A re-encode's playlist is now the whole film on a fixed 4 s grid. A segment
that isn't on disk and isn't coming soon moves the encode there; runs stop
short of segments already made, and gaps are filled after. Every run is cut
the same way (-ss, -output_ts_offset start+10, frag_discont), so segments
from different runs join. Repackaging keeps its growing playlist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:08:52 -07:00

221 lines
7.0 KiB
Go

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]
}