diff --git a/pkg/llhls/window.go b/pkg/llhls/window.go index 694a29c9..ce917035 100644 --- a/pkg/llhls/window.go +++ b/pkg/llhls/window.go @@ -104,22 +104,24 @@ type PartIdentity struct { } type Window struct { - mu sync.Mutex - presentation string - tracks map[string]*track - maxSegments int - maxBytes int - bytes int - changed chan struct{} - videoConfig VideoConfig - audioConfig AudioConfig - programDateTime time.Time - programDateTimeStart time.Duration - targetDuration int64 - partTarget time.Duration - configuredTarget int64 - configuredPartTarget time.Duration - completionHold time.Duration + mu sync.Mutex + presentation string + tracks map[string]*track + maxSegments int + maxBytes int + bytes int + changed chan struct{} + videoConfig VideoConfig + audioConfig AudioConfig + programDateTime time.Time + programDateTimeStart time.Duration + targetDuration int64 + partTarget time.Duration + configuredTarget int64 + configuredPartTarget time.Duration + partHoldBack time.Duration + configuredPartHoldBack time.Duration + completionHold time.Duration } type track struct { @@ -153,6 +155,16 @@ type Option func(*Window) func WithMaxSegments(n int) Option { return func(w *Window) { w.maxSegments = n } } func WithMaxBytes(n int) Option { return func(w *Window) { w.maxBytes = n } } +// WithPartHoldBack sets the advertised live edge distance for LL-HLS parts. +// Values below the HLS minimum are raised to three part targets. +func WithPartHoldBack(d time.Duration) Option { + return func(w *Window) { + if d > 0 { + w.partHoldBack = d + } + } +} + // WithSegmentCompletionDelay delays parent completion visibility by d. This // gives blocking reloads time to observe the final part of an open segment. func WithSegmentCompletionDelay(d time.Duration) Option { @@ -183,8 +195,12 @@ func NewWindow(opts ...Option) *Window { for _, opt := range opts { opt(w) } + if minimum := minimumPartHoldBack(w.partTarget); w.partHoldBack < minimum { + w.partHoldBack = minimum + } w.configuredTarget = w.targetDuration w.configuredPartTarget = w.partTarget + w.configuredPartHoldBack = w.partHoldBack return w } @@ -214,6 +230,7 @@ func (w *Window) Observe(ev Event) error { w.programDateTimeStart = 0 w.targetDuration = w.configuredTarget w.partTarget = w.configuredPartTarget + w.partHoldBack = w.configuredPartHoldBack } t := w.tracks[ev.Track] @@ -652,8 +669,7 @@ func (w *Window) playlist(presentation, trackID string, partURI func(uint64, uin return "" } includeParts := mode == playlistWithParts - targetSeconds, partTarget := w.playlistDurations() - partHoldBack := 3*partTarget + partHoldBackMargin + targetSeconds, partTarget, partHoldBack := w.playlistDurations() var b strings.Builder fmt.Fprintf(&b, "#EXTM3U\n#EXT-X-VERSION:10\n#EXT-X-TARGETDURATION:%d\n", targetSeconds) if includeParts { @@ -729,11 +745,15 @@ func allSegmentsIndependent(segments []SegmentSnapshot) bool { return true } -func (w *Window) playlistDurations() (targetSeconds int64, partTarget time.Duration) { +func (w *Window) playlistDurations() (targetSeconds int64, partTarget, partHoldBack time.Duration) { w.mu.Lock() - targetSeconds, partTarget = w.targetDuration, w.partTarget + targetSeconds, partTarget, partHoldBack = w.targetDuration, w.partTarget, w.partHoldBack w.mu.Unlock() - return targetSeconds, partTarget + return targetSeconds, partTarget, partHoldBack +} + +func minimumPartHoldBack(partTarget time.Duration) time.Duration { + return 3*partTarget + partHoldBackMargin } type renditionReport struct { diff --git a/pkg/llhls/window_test.go b/pkg/llhls/window_test.go index 6e7a6c0c..c7b7f4fa 100644 --- a/pkg/llhls/window_test.go +++ b/pkg/llhls/window_test.go @@ -627,6 +627,40 @@ func TestPlaylistUsesConfiguredPartTargetWithoutGrowing(t *testing.T) { } } +func TestPlaylistUsesConfiguredLiveHoldBack(t *testing.T) { + w := NewWindow(WithPlaylistDurations(2*time.Second, 1100*time.Millisecond), WithPartHoldBack(5500*time.Millisecond)) + observeEvent(t, w, Event{Kind: Init, Presentation: "p", Track: "video", Generation: 1}) + observeEvent(t, w, Event{Kind: Part, Presentation: "p", Track: "video", Generation: 1, MSN: 1, Part: 0, Duration: time.Second, Data: []byte("part")}) + + playlist := w.Playlist("p", "video", func(uint64, uint32) string { return "part.m4s" }, func(uint64) string { return "segment.m4s" }, "init.mp4", nil) + if !strings.Contains(playlist, "#EXT-X-TARGETDURATION:2") { + t.Fatalf("playlist did not use the configured parent target:\n%s", playlist) + } + if !strings.Contains(playlist, "PART-HOLD-BACK=5.500000") { + t.Fatalf("playlist did not use the configured part holdback:\n%s", playlist) + } + if !strings.Contains(playlist, "HOLD-BACK=6.000000") { + t.Fatalf("playlist did not derive holdback from the parent target:\n%s", playlist) + } + + observeEvent(t, w, Event{Kind: Init, Presentation: "next", Track: "video", Generation: 1}) + playlist = w.Playlist("next", "video", func(uint64, uint32) string { return "part.m4s" }, func(uint64) string { return "segment.m4s" }, "init.mp4", nil) + if !strings.Contains(playlist, "PART-HOLD-BACK=5.500000") || !strings.Contains(playlist, "#EXT-X-TARGETDURATION:2") { + t.Fatalf("configured holdback did not survive a presentation reset:\n%s", playlist) + } +} + +func TestPlaylistRaisesShortHoldBackToPartMinimum(t *testing.T) { + w := NewWindow(WithPlaylistDurations(2*time.Second, 1100*time.Millisecond), WithPartHoldBack(time.Second)) + observeEvent(t, w, Event{Kind: Init, Presentation: "p", Track: "video", Generation: 1}) + observeEvent(t, w, Event{Kind: Part, Presentation: "p", Track: "video", Generation: 1, MSN: 1, Part: 0, Duration: time.Second, Data: []byte("part")}) + + playlist := w.Playlist("p", "video", func(uint64, uint32) string { return "part.m4s" }, func(uint64) string { return "segment.m4s" }, "init.mp4", nil) + if !strings.Contains(playlist, "PART-HOLD-BACK=3.301000") { + t.Fatalf("playlist advertised a holdback below the part minimum:\n%s", playlist) + } +} + func TestPlaylistDurationContractRoundsAndResetsPerPresentation(t *testing.T) { w := NewWindow(WithPlaylistDurations(2500*time.Millisecond, 750*time.Millisecond)) observeEvent(t, w, Event{Kind: Init, Presentation: "first", Track: "v", Generation: 1}) diff --git a/pkg/media/live_window.go b/pkg/media/live_window.go index 84767c41..40650f55 100644 --- a/pkg/media/live_window.go +++ b/pkg/media/live_window.go @@ -20,8 +20,11 @@ const liveWindowSize = 12 // to drive count-based eviction. const liveWindowRetention = 30 * time.Second -const llhlsWindowSegments = 30 -const llhlsWindowBytes = 64 << 20 +const ( + llhlsWindowSegments = 30 + llhlsWindowBytes = 64 << 20 + llhlsLivePartHoldBack = 5 * llhlsPartTarget +) // llhlsCompletionHold keeps a finished parent open briefly so a blocking // reload for its final part can observe and fetch that part before completion @@ -33,7 +36,13 @@ func (mm *MediaManager) llWindow(did string) *llhls.Window { defer mm.llWindowsMut.Unlock() w := mm.llWindows[did] if w == nil { - w = llhls.NewWindow(llhls.WithMaxSegments(llhlsWindowSegments), llhls.WithMaxBytes(llhlsWindowBytes), llhls.WithSegmentCompletionDelay(llhlsCompletionHold)) + w = llhls.NewWindow( + llhls.WithMaxSegments(llhlsWindowSegments), + llhls.WithMaxBytes(llhlsWindowBytes), + llhls.WithPlaylistDurations(llhlsParentDuration, llhlsPartTarget), + llhls.WithPartHoldBack(llhlsLivePartHoldBack), + llhls.WithSegmentCompletionDelay(llhlsCompletionHold), + ) mm.llWindows[did] = w } return w diff --git a/pkg/media/live_window_ll_test.go b/pkg/media/live_window_ll_test.go index 935ff0d9..22493aad 100644 --- a/pkg/media/live_window_ll_test.go +++ b/pkg/media/live_window_ll_test.go @@ -2,6 +2,7 @@ package media import ( "testing" + "time" "github.com/stretchr/testify/require" "stream.place/streamplace/pkg/llhls" @@ -14,3 +15,24 @@ func TestLLWindowIsKeyedByStreamerDID(t *testing.T) { require.Same(t, window, mm.GetLLWindow("did:plc:streamer")) require.Nil(t, mm.GetLLWindow("did:key:signing-key")) } + +func TestLLWindowUsesMobilePlaybackHoldBack(t *testing.T) { + mm := &MediaManager{llWindows: map[string]*llhls.Window{}} + w := mm.llWindow("did:plc:streamer") + require.NoError(t, w.Observe(llhls.Event{Kind: llhls.Init, Presentation: "p", Track: "video", Generation: 1})) + require.NoError(t, w.Observe(llhls.Event{ + Kind: llhls.Part, + Presentation: "p", + Track: "video", + Generation: 1, + MSN: 1, + Part: 0, + Duration: time.Second, + Data: []byte("part"), + })) + + playlist := w.Playlist("p", "video", func(uint64, uint32) string { return "part.m4s" }, func(uint64) string { return "segment.m4s" }, "init.mp4", nil) + require.Contains(t, playlist, "#EXT-X-TARGETDURATION:2") + require.Contains(t, playlist, "PART-HOLD-BACK=5.500000") + require.Contains(t, playlist, "HOLD-BACK=6.000000") +}