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>
221 lines
7.0 KiB
Go
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]
|
|
}
|