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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ff1373e838
commit
450a9d3327
+139
-4
@@ -2,9 +2,14 @@ package media
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -38,12 +43,16 @@ func TestStreamWithFFmpeg(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Width != 640 || s.Height != 360 || s.Engine == "" {
|
||||
if s.Width != 640 || s.Height != 360 || s.Engine == "" || !s.Seekable {
|
||||
t.Fatalf("stream: %+v", s)
|
||||
}
|
||||
if p, ok := s.File("index.m3u8"); !ok {
|
||||
if _, ok := s.File("index.m3u8"); !ok {
|
||||
t.Fatal("no playlist")
|
||||
} else if !hasSegments(p) {
|
||||
}
|
||||
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"} {
|
||||
@@ -63,7 +72,7 @@ func TestStreamWithFFmpeg(t *testing.T) {
|
||||
}
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
}
|
||||
if !st.Done || st.Error != "" || st.Segments < 2 || st.Progress < 8 {
|
||||
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) {
|
||||
@@ -83,3 +92,129 @@ func TestStreamWithFFmpeg(t *testing.T) {
|
||||
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]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user