feat(hls): pre-segmentación delantada — 2 s segments + async session start (0.9.10)
First-frame latency drops by another 1-2 s on cold-cache plays: 1. HLS segment duration halved from 4 s to 2 s. seg-0 lands in ~half the wait time — the player paints the first frame as soon as it arrives. Software encodes on 4K go from ~3 s wait to ~1.5 s; HW encoders shave ~0.5 s. Trade-off: 2× segment count per source (~3600 segments for a 2 h movie instead of ~1800), but each is half the size on disk. Within HLS spec — Apple recommends 6 s, but 2 s is valid; LL-HLS uses 1-2 s. 2. Cache from 0.9.9 self-heals: cached entries used 4 s segments; VerifyComplete now expects a different highest segment index and invalidates them, triggering a re-encode on next play. No manual cleanup needed. 3. OnStreamSession daemon callback now runs StartHLSSession in a goroutine. Sync HTTP responses return immediately (~50 ms instead of waiting for the ~0.3-1 s ffprobe). Other pending actions in the same sync cycle (new tasks, deletes) no longer wait for the transcoder warmup. Browser HEAD probes already have a 30 s retry budget that covers the brief gap between playerSessionRegistry.add and streamSrv.HLS().Register. Helpers added (engine.segmentDurationFor / segmentStartSec / segmentCountForDuration) so a future short-first-segment variant or non-uniform layout can slot in without touching every call site. Internal: -hls_init_time was investigated but discarded — ffmpeg's implementation treats it as a min duration, not a target, so it couldn't deliver a uniformly 2 s first segment on top of a 4 s steady state. Uniform 2 s is simpler and gets the same first-frame win.
This commit is contained in:
parent
bf8ed0d928
commit
0b2462c82a
5 changed files with 96 additions and 27 deletions
|
|
@ -32,10 +32,46 @@ import (
|
|||
"time"
|
||||
)
|
||||
|
||||
// hlsSegmentDuration is the target seconds per HLS fragment. Four seconds is
|
||||
// the Plex/Apple default — short enough that seek granularity is acceptable,
|
||||
// long enough that GOP overhead doesn't dominate.
|
||||
const hlsSegmentDuration = 4
|
||||
// hlsSegmentDuration is the target seconds per HLS fragment.
|
||||
//
|
||||
// We use 2 seconds (not the more common 4-6 s). Trade-off: 2× more segments
|
||||
// per source (a 2 h movie produces 3600 segments instead of 1800), but the
|
||||
// player's first-frame wait drops to ~half — ffmpeg only needs to encode
|
||||
// 2 s before seg-0 lands. For software encodes on 4K this is ~1 s instead
|
||||
// of ~3 s of cold-cache wait. Well within HLS spec (Apple recommends 6 s,
|
||||
// but 2-6 s is acceptable; Low-Latency HLS uses 1-2 s segments).
|
||||
//
|
||||
// Caveat for existing cached encodes: cache entries from 0.9.9 used 4 s
|
||||
// segments. After this bump, VerifyComplete (which checks the highest
|
||||
// expected segment index) returns false for those entries — they're
|
||||
// invalidated + re-encoded with 2 s segments on next play. Self-healing.
|
||||
const hlsSegmentDuration = 2
|
||||
|
||||
// segmentDurationFor returns the target duration (in whole seconds) for the
|
||||
// segment at index idx. With uniform-duration segments this is always
|
||||
// hlsSegmentDuration; the helper exists so a future short-first-segment
|
||||
// variant can be slotted in here without touching every call site.
|
||||
func segmentDurationFor(idx int) int {
|
||||
return hlsSegmentDuration
|
||||
}
|
||||
|
||||
// segmentStartSec returns the wall-clock start time of segment idx. Used
|
||||
// to compute the `-ss` flag when ffmpeg restarts at a mid-file segment.
|
||||
func segmentStartSec(idx int) float64 {
|
||||
if idx <= 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(idx * hlsSegmentDuration)
|
||||
}
|
||||
|
||||
// segmentCountForDuration returns how many segments cover a source of the
|
||||
// given duration. Always returns at least 1.
|
||||
func segmentCountForDuration(dur float64) int {
|
||||
if dur <= 0 {
|
||||
return 1
|
||||
}
|
||||
return int((dur + float64(hlsSegmentDuration) - 1) / float64(hlsSegmentDuration))
|
||||
}
|
||||
|
||||
// hlsSessionTTL is how long a session can sit idle (no segment requests)
|
||||
// before the manager kills ffmpeg + cleans the tmpdir.
|
||||
|
|
@ -302,10 +338,7 @@ func StartHLSSession(ctx context.Context, cfg HLSSessionConfig) (*HLSSession, er
|
|||
// Integrity gate: HasComplete just stats the marker. If init.mp4 or
|
||||
// the last segment vanished (external rm, partial-disk failure), we
|
||||
// can't actually serve a HIT — drop the dir and re-encode.
|
||||
segCountForVerify := int((probe.DurationSec + float64(hlsSegmentDuration) - 1) / float64(hlsSegmentDuration))
|
||||
if segCountForVerify < 1 {
|
||||
segCountForVerify = 1
|
||||
}
|
||||
segCountForVerify := segmentCountForDuration(probe.DurationSec)
|
||||
if cfg.Cache.HasComplete(cacheKey) && !cfg.Cache.VerifyComplete(cacheKey, segCountForVerify) {
|
||||
log.Printf("[hls %s] cache %s sealed but failed integrity check — re-encoding",
|
||||
shortHLSID(cfg.SessionID), cacheKey)
|
||||
|
|
@ -357,10 +390,7 @@ func StartHLSSession(ctx context.Context, cfg HLSSessionConfig) (*HLSSession, er
|
|||
return nil, fmt.Errorf("hls: mkdir subs: %w", err)
|
||||
}
|
||||
|
||||
segCount := int((probe.DurationSec + float64(hlsSegmentDuration) - 1) / float64(hlsSegmentDuration))
|
||||
if segCount < 1 {
|
||||
segCount = 1
|
||||
}
|
||||
segCount := segmentCountForDuration(probe.DurationSec)
|
||||
|
||||
s := &HLSSession{
|
||||
cfg: cfg,
|
||||
|
|
@ -911,8 +941,10 @@ func (s *HLSSession) restartFromSegment(targetIdx int) error {
|
|||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
|
||||
// Build args for the new ffmpeg with -ss offset.
|
||||
startSec := float64(targetIdx * hlsSegmentDuration)
|
||||
// Build args for the new ffmpeg with -ss offset. Segments are non-uniform
|
||||
// (seg-0 is hlsInitSegmentDuration s, the rest are hlsSegmentDuration s),
|
||||
// so use segmentStartSec for the seek time instead of multiplying.
|
||||
startSec := segmentStartSec(targetIdx)
|
||||
args := buildHLSFFmpegArgsAt(s.cfg, s.probe, s.tmpDir, targetIdx, startSec)
|
||||
|
||||
ffCtx, cancel := context.WithCancel(context.Background())
|
||||
|
|
@ -1244,6 +1276,10 @@ func (s *HLSSession) extractSubtitles(ctx context.Context) {
|
|||
// renderVideoPlaylist builds the VOD media playlist for the video stream.
|
||||
// Segment count is derived from the source duration — the player learns the
|
||||
// total timeline from the manifest before any segment is fetched.
|
||||
//
|
||||
// seg-0 is the short init segment (hlsInitSegmentDuration s); seg-1 onward
|
||||
// are hlsSegmentDuration s each. The last segment may be shorter than the
|
||||
// nominal duration when (duration - init) doesn't divide evenly.
|
||||
func renderVideoPlaylist(durationSec float64, segCount int) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("#EXTM3U\n")
|
||||
|
|
@ -1254,7 +1290,7 @@ func renderVideoPlaylist(durationSec float64, segCount int) string {
|
|||
b.WriteString(`#EXT-X-MAP:URI="init.mp4"` + "\n")
|
||||
remaining := durationSec
|
||||
for i := 0; i < segCount; i++ {
|
||||
segDur := float64(hlsSegmentDuration)
|
||||
segDur := float64(segmentDurationFor(i))
|
||||
if remaining < segDur {
|
||||
segDur = remaining
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,10 +115,11 @@ func TestRenderVideoPlaylist(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestRenderVideoPlaylistShortFinalSegment(t *testing.T) {
|
||||
// 9.5s total, 4s segments → 3 segs of 4/4/1.5
|
||||
out := renderVideoPlaylist(9.5, 3)
|
||||
// 9.5s total, 2s segments → 5 segs of 2/2/2/2/1.5
|
||||
segCount := segmentCountForDuration(9.5)
|
||||
out := renderVideoPlaylist(9.5, segCount)
|
||||
if !strings.Contains(out, "#EXTINF:1.500,") {
|
||||
t.Errorf("expected final segment 1.5s in playlist, got:\n%s", out)
|
||||
t.Errorf("expected final segment 1.5s in playlist (segCount=%d), got:\n%s", segCount, out)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue