liveWindowFor is now {start, end}; the holdback rule lives in liveLatencyFor
and reaches seek-to-live-edge via the resolveLiveLatency seam + getLiveEdge.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
22 KiB
status, date, definition
| status | date | definition |
|---|---|---|
| draft | 2026-06-23 | sketched |
Live stream support
The engine's foundation for playing live HLS sources: periodic
media-playlist refetch, sliding-window segment tracking, target-duration
pacing, Infinity duration semantics, live-edge seek, and termination
detection (transitioning out of live mode when the stream ends). Distinct from
sibling capabilities for low-latency live (LL-HLS) and DVR / event streams —
those are extensions on top of this foundation, tracked as separate features.
A Media-src feature in the framing from clusters.md § Feature classification axes: without it, live HLS sources don't play correctly.
Status
- Composition: implemented in
createSimpleHlsEngineat naive depth on branchfeat/spf-hls-live. The engine composes uniformly across VOD and live — there is no live-vs-VOD composition branch; live behaviors are inert for VOD via finite-duration guards (Number.isFinite(track.duration)). The reload loop, sliding-window tracking,Infinityduration semantics, live-edge seek +setLiveSeekableRange, and termination-on-#EXT-X-ENDLISTall land. The live-window playhead guard (reposition-on-window-exit) is now implemented inseek-to-live-edge. Not yet implemented: the edge-onlyon-resumereposition policy (a future use-case). Termination is#EXT-X-ENDLIST-based (the naive tier — sufficient for conformant content); the miss-counter fallback and reload jitter are deferred full-depth. - Definition depth: sketched — grounded in the implementation on
feat/spf-hls-live. Source material: SPF Epics Working Doc — Live Stream Support (epic #2) (cluster A foundation, eng size L, validation M). - Foundational for the manifest-reload-loop cluster — ll-hls-support and dvr-event-stream-support build on this feature.
Phases of complexity
Capability slices for the foundational live-stream-support feature. Each phase below is part of "live works (and terminates) at all"; richer live variants (LL-HLS, DVR) sit in sibling features.
| Phase | Status | What / how |
|---|---|---|
| Manifest reload loop | ✅ Implemented | Periodic media-playlist refetch keyed off #EXT-X-TARGETDURATION. Each resolveXTrack owns a RecurringRunner (resolve-track.ts) rescheduled by delayedReschedule(mediaPlaylistReloadDelay) (wired in engine.ts). Cadence in reload-policy.ts: full target-duration on window change / first reload, half target-duration on unchanged window; start-anchored per RFC 8216bis §6.3.4; returns null (stops) once duration is finite |
| Sliding-window segment tracking | ✅ Implemented | placeOnPreviousTimeline (parse-media-playlist.ts) carries the new window onto the established timeline via media-sequence overlap (PDT bridge on full turnover). Segment-loader planTasks re-evaluates the mutating track.segments on each load dispatch; back-buffer keeps last 2 segments. No explicit "no-longer-in-playlist" eviction signal — the keep-count heuristic + list shrink handle roll-off |
| Live duration semantics | ✅ Implemented | Parser sets Track.duration = Infinity for unended live; calculatePresentationDuration (default resolver getResolvedSelectedTrackDuration) writes it; updateMediaSourceDuration propagates mediaSource.duration = Infinity once MS is open and buffers idle |
Live edge tracking + setLiveSeekableRange |
✅ Implemented (option b) | The live window (or null for VOD/ended) is derived once by the pure liveWindowFor (media/live-window.ts), consumed by two separate behaviors: sync-live-seekable-range.ts declares setLiveSeekableRange(start, end) reactively on each window slide (runs while paused too), and seek-to-live-edge.ts does the one-time seek to liveEdgeStart (the live latency behind the edge, via the getLiveEdge primitive). Separate from the reload loop (option b). Inert for VOD via liveWindowFor returning null. No clearLiveSeekableRange() on termination — unnecessary: the UA consults the live range only while duration === Infinity, so once endOfStream sets a finite duration it's ignored |
| Live-window playhead guard | ✅ Implemented (window-exit) |
While playing (!paused && !seeking && readyState > 0), reposition currentTime to liveEdgeStart = max(windowStart, windowEnd − liveLatency) (the latency from the injected resolveLiveLatency seam — HLS HOLD-BACK, 3× target duration) when the playhead falls outside the sliding window — covering paused-too-long (Scenario A; fires on the playing resume) and fell-behind-on-poor-network (Scenario B; fires on the reload / window-update re-fire of the effect, since timeupdate stops during a stall). Within-window pause and scrub-back are left untouched (DVR model). Lives in seek-to-live-edge so the live playhead position has a single owner (the one-time initial seek + the ongoing guard); secondary triggers are playing / timeupdate / seeked listeners. Reposition policy is a seam (repositionPolicy, default 'window-exit'); the edge-only 'on-resume' branch is not yet implemented. Deferred: playback-rate latency catch-up, MSE gap-jumping |
| Reload jitter / backoff | ✅ Naive only | Target-duration cadence with unchanged-window throttle (half target). No thundering-herd jitter, no backoff on repeated identical-playlist responses (full depth, not implemented) |
| Per-type reload coordination | ✅ Independent | Audio / video / text each own their RecurringRunner and reload on their own #EXT-X-TARGETDURATION. Resolved as extended resolveXTrack (same setupTrackResolution handles one-shot VOD and recurring live via the RecurringRunner abstraction) — no separate reloadXTrack family |
| Termination detection | ✅ Naive depth | #EXT-X-ENDLIST recognized and surfaced (MediaPlaylistMetadata.endList); parser flips Track.duration finite (on ENDLIST or PLAYLIST-TYPE:VOD), which stops the reload loop. ENDLIST-only is the sanctioned naive tier (per clusters.md) and sufficient for conformant content (Mux always emits ENDLIST); the miss-counter fallback for non-conformant servers is deferred full-depth |
| Terminated state transition | ✅ Implemented (one verify-later item) | Reload stops (policy returns null); the endOfStream gate unblocks once Track.duration is finite and the last segment is appended; per-type independent (waits for all active tracks). clearLiveSeekableRange() is intentionally not called — the MSE spec consults the live range only while duration === Infinity, so once endOfStream sets a finite duration the UA ignores it; clearing on the ENDLIST→finite transition would be premature (duration still Infinity) and shrink seekable to buffered-only |
What's not implemented
Within this feature:
- Edge-only
on-resumereposition policy — therepositionPolicyseam exists (default'window-exit', implemented), but the'on-resume'(always-snap-to-edge / no-DVR) branch is inert. It lands as the[live-edge-only-mode]use-case, not here.
Naive depth (sufficient for conformant content; full depth deferred):
- Termination detection —
#EXT-X-ENDLIST/PLAYLIST-TYPE:VODonly, the sanctioned naive tier per clusters.md. Full depth adds a miss-counter fallback for non-conformant servers that stop updating withoutENDLIST(Mux always emits it). - Reload jitter / backoff — target-duration cadence only; full depth adds thundering-herd jitter + backoff on identical-playlist responses.
Out of scope (separate Media-src features):
- ll-hls-support — blocking reload, partial segments, delta playlists, preload hints. Builds on this feature's reload loop.
- dvr-event-stream-support — growing
(non-sliding) window, back-seek through history. Extension with different
windowing semantics; shares the reload loop and the
setLiveSeekableRangewriter shape. Sits at the maximal-back-seek end of the same seekable spectrum the guard'srepositionPolicyexposes (edge-only ↔ in-window scrub-back ↔ full-history back-seek). - non-zero-pts-support — live PTS advances from stream start, far from zero; the time-mapping primitive live consumes is a separate cluster B feature.
- buffer-stall-recovery — mid-stream stall detection + unstick-in-place recovery (nudge → flush → reset). Coordinates with this feature's planned guard: an in-window stall is buffer-stall-recovery's territory; the window-exit reposition is the live-specific terminal action.
Likely cross-cutting impact
The foundation is implemented, so most prior cross-cutting concerns are now realized. The remaining forward-looking impact is concentrated in the planned guard:
repositionPolicycomposition seam. The guard's reposition condition is a policy point:'window-exit'(default — DVR model; reposition only when the playhead is outside the window, in-window scrub-back preserved) vs'on-resume'(edge-only — always snap to the live edge on resume). Per clusters.md § Composition vs Policy vs middle pattern,'window-exit'ships as the default config consumed by the guard behavior; the edge-only variant (stop fetching segments while paused + always-snap on resume + narrowed seekable) is a future Composition (alternative-default-config + add + alternative-impl), tracked as a[live-edge-only-mode]use-case rather than a runtime branch. Leaving the seam in place costs only the policy indirection now and keeps the edge-only mode a composition rather than a rewrite later.- Single owner of the live playhead position. The guard writes
currentTime(a seek).seek-to-live-edgealready does the one-time initial seek; folding the guard into it keeps one writer of "where the live playhead belongs" (initial seek = first firing) rather than two behaviors racing seeks. No new state slot — the guard reads existingpresentation.duration(finiteness guard),selectedVideoTrackId, andtrack.segments(window), plus thecurrentTimemirror. - Primary trigger is the window-update signal, not
timeupdate. Scenario B's fall-behind happens during a stall, whentimeupdatehas stopped but the window keeps sliding via reloads — so the guard must re-evaluate on the reload / window-update signal.playing/timeupdate/seekedare secondary triggers (resume case, prompt in-playback detection, overran-edge).
Implementation surface
Behaviors:
| Behavior | File | Live responsibility |
|---|---|---|
liveWindowFor (pure helper) |
media/live-window.ts |
Derive the live window {start,end} from the track with the given id (type-agnostic via findTrackById), or null (VOD/ended/unresolved). Purely geometric — no delivery-format metadata. Centralizes all inertness so consumers don't re-derive the window. |
liveWindowFromState / getLiveEdge (primitives) |
playback/primitives/live-window.ts |
The state-reading call sites the live behaviors use. liveWindowFromState picks the timeline-bearing track — selectedVideoTrackId ?? selectedAudioTrackId (video positions both A/V; audio-only falls back to audio) — and calls liveWindowFor. getLiveEdge({state,config}) adds the target playhead position (liveEdgeStart = end − live latency, clamped to start), bundling window geometry with the format-specific config.resolveLiveLatency policy so the behavior consumes one edge. Reads signals lazily (call inside an effect). |
syncLiveSeekableRange |
behaviors/dom/sync-live-seekable-range.ts |
Consume liveWindowFromState; setLiveSeekableRange(start, end) reactively on each window slide, including while paused. Duration is owned solely by updateMediaSourceDuration. Composed before seekToLiveEdge. |
seekToLiveEdge |
behaviors/dom/seek-to-live-edge.ts |
Consume getLiveEdge; one-time seek to liveEdgeStart + the live-window playhead guard (reposition-on-window-exit, repositionPolicy seam). Format-neutral — the live latency comes from the injected resolveLiveLatency seam, never read here. Gates on mediaSource open so the seek lands in the declared range. |
anchorLiveTracks |
behaviors/anchor-live-tracks.ts |
Pin live track timelines to the SourceBuffer's native-PTS ground truth (first appended segment) or manifest estimate; re-pin per reload as the window slides |
resolveVideoTrack / resolveAudioTrack / resolveTextTrack |
behaviors/resolve-track.ts |
Own the reload loop via RecurringRunner; reschedule defaults to mediaPlaylistReloadDelay; per-type independent |
calculatePresentationDuration |
behaviors/calculate-presentation-duration.ts |
Populate presentation.duration via the config resolver (Infinity for unended live) |
updateMediaSourceDuration |
behaviors/dom/update-mediasource-duration.ts |
Propagate presentation.duration to mediaSource.duration once per MediaSource (uniform across variants) |
endOfStream |
behaviors/dom/end-of-stream.ts |
Gate on Track.duration finiteness — inert for ongoing live, reachable once terminated |
Actors: SegmentLoaderActor (actors/dom/segment-loader.ts) —
planTasks / getSegmentsToLoad iterate the live-mutating segment list within
the forward/back-buffer windows; SourceBufferActor
(actors/dom/source-buffer.ts) — append/remove + tracks appended segment IDs
for the endOfStream gate.
State / derived: presentation.duration (Infinity for live; written by
calculatePresentationDuration, read by updateMediaSourceDuration /
seek-to-live-edge / endOfStream — distinct decision domains, not a
multi-writer conflict). presentation.streamType ('live'). Per-track
segments (live-mutating; written by reload parse, read by segment-loader).
Live edge and the planned liveEdgeStart are derived from
track.segments, not state slots.
Engine composition: engines/hls/engine.ts composes the live behaviors
unconditionally (anchorLiveTracks, calculatePresentationDuration,
updateMediaSourceDuration, the resolveXTrack family with
reschedule: delayedReschedule(mediaPlaylistReloadDelay), seekToLiveEdge,
endOfStream); VOD inertness comes from finite-duration guards, not a branch.
Config surface
| Constant | File | Value | Purpose |
|---|---|---|---|
HOLD_BACK_TARGET_MULTIPLIER |
media/hls/reload-policy.ts |
3 |
HLS default HOLD-BACK as a multiple of target duration. liveLatencyFor(track) applies it; the engine injects it as seekToLiveEdge's format-neutral resolveLiveLatency seam. Initial-seek + guard reposition target = live edge − live latency, clamped to window start |
REPOSITION_TOLERANCE |
behaviors/dom/seek-to-live-edge.ts |
0.1 |
Guard's boundary tolerance (s) — avoids jitter seeks at the window edges |
FALLBACK_TARGET_DURATION |
media/hls/reload-policy.ts |
6 |
Reload cadence (s) — and HOLD-BACK basis — when no #EXT-X-TARGETDURATION |
resolveLiveLatency |
seekToLiveEdge config (engine-injected) |
liveLatencyFor (HLS) |
Format-neutral seam for the seconds the playhead trails the live edge. HLS injects HOLD-BACK (3× target duration); a DASH engine would inject suggestedPresentationDelay. Keeps seek-to-live-edge free of delivery-format specifics |
DEFAULT_FORWARD_BUFFER_CONFIG.bufferDuration |
media/buffer/forward-buffer.ts |
30 |
Seconds ahead of playhead to load |
DEFAULT_BACK_BUFFER_CONFIG.keepSegments |
media/buffer/back-buffer.ts |
2 |
Segments kept behind playhead |
repositionPolicy |
seek-to-live-edge config (behavior-scoped) |
'window-exit' |
Reposition condition seam. 'window-exit' implemented; 'on-resume' (edge-only) is inert pending the [live-edge-only-mode] use-case, which adds the public engine-config surface |
Verification
Existing (implemented phases):
media/hls/tests/reload-policy.test.ts— cadence:nullfor finite duration, full/half target-duration, 6s fallback.media/hls/tests/parse-media-playlist.test.ts—Infinityfor unended live;endListon#EXT-X-ENDLIST; finite forPLAYLIST-TYPE:VOD; PDT capture + carry-forward.behaviors/tests/anchor-live-tracks.test.ts— pin to buffer ground truth; PDT carry-forward; sequence-origin bootstrap.behaviors/tests/resolve-track.test.ts— live reload re-resolves; stops on finite duration; source-change abort.behaviors/dom/tests/seek-to-live-edge.test.ts— seeks toliveEdgeStart(latency injected viaresolveLiveLatency); no-op for finite/absent tracks. Plus the live-window guard matrix (see below).playback/primitives/tests/live-window.test.ts—liveWindowFromState/liveTrackIdtrack-pick;getLiveEdgelatency placement + window-start clamp.media/hls/tests/reload-policy.test.ts— alsoliveLatencyFor(3× target duration; fallback).behaviors/tests/calculate-presentation-duration.test.ts—Infinityfor live resolver.engines/hls/tests/engine.test.ts— end-to-end live setup / reload / window slide / termination.- Sandbox:
apps/sandbox/templates/live-hls-engine,SOURCES['hls-live'].
Live-window playhead guard — behaviors/dom/tests/seek-to-live-edge.test.ts
(describe('live-window playhead guard'), event-capable fake media element):
playing-inside-window → no seek; currentTime < windowStart (playing) → seek to
liveEdgeStart; currentTime > windowEnd (playing) → seek; paused-out-of-window
→ no seek, then seek on playing resume; DVR mid-window scrub-back → no yank;
seeking in flight → defer to seeked; sub-tolerance boundary → no jitter seek,
beyond-tolerance → seek; window slides past frozen paused playhead across reloads
→ no seek while paused, snaps in on resume; stalled-behind-window → repositions on
the window-update re-fire; on-resume policy → inert (not yet implemented).
Out of scope / deferred: E2E (Chromium) — a local synthetic sliding-window
stream (short target-duration) driving pause-beyond-window → resume-snaps-near-edge
and a CDP Network.emulateNetworkConditions buffer-drain → reposition-and-recover.
Deferred: needs new synthetic-sliding-window HLS fixture infra; the unit matrix
covers the guard logic deterministically.
Open questions
- Guard reposition target under truly-insufficient bandwidth. If the network can't sustain even the lowest rate, the guard can loop (reposition → stall → window slides → reposition). v1 lean: accept the loop (the stream is below its playable floor; the jumps are honest) and document it; cheap alternative is deepening the holdback on repeated exits rather than touching playback rate.
- Miss-counter threshold (if pursued). How many identical-manifest reloads constitute termination?
Resolved during guard implementation:
repositionPolicyseam shape → behavior-scoped config onseek-to-live-edge(default'window-exit'), not a publicSimpleHlsEngineConfigfield. Avoids a speculative engine-config surface with no current consumer (perconventions/config.md); the[live-edge-only-mode]use-case adds the public field when it implements'on-resume'.- Guard placement → extends
seek-to-live-edge(single owner of the live playhead position), not a sibling behavior.
Related features
- ll-hls-support — builds on this feature's reload loop; adds blocking reload, partial segments, delta playlists, preload hints.
- dvr-event-stream-support — growing
window + full-history back-seek on the same reload loop; the maximal-back-seek
end of the seekable spectrum the guard's
repositionPolicysits on (edge-only ↔ in-window scrub-back ↔ full history). - buffer-stall-recovery — in-window stall detection + unstick-in-place recovery; coordinates with this feature's planned guard (window-exit reposition is the live-specific terminal action).
- non-zero-pts-support — live PTS starts far
from zero; cluster B foundation live consumes for correct
currentTime/seekable. - mse-mms-pipeline —
Infinityduration viaconfig.resolveDuration; theendOfStreamgate uses segment + currentTime, not duration finiteness. - buffer-management — sliding-window tracking interacts with back-buffer eviction; the planner's currentTime-driven shape applies with the list mutating mid-flight.
- video-abr / audio-playback / subtitles — per-type consumers read the resolved track that now changes over time (segments append / roll off).
- source-replacement — orthogonal; live → live source change tears down and rebuilds the reload loop via the same in-place cascade.
[live-edge-only-mode](future use-case) — the edge-only composition therepositionPolicy: 'on-resume'seam enables.
See also
- clusters.md § Manifest reload loop — cluster A description; this feature is the foundation.
- clusters.md § Composition vs Policy vs middle pattern
— the
repositionPolicyseam / edge-only composition framing. - live-timeline-anchoring — PDT
anchor that places the sliding-window timeline
anchorLiveTracksconsumes. - mse-timestamp-offset — native-PTS
default,
setLiveSeekableRangein native coords, one-time seek into the window on load. - presentation-modeling.md — the reload loop
sits below
resolvePresentationand re-usesparseMediaPlaylistper cycle. - SPF Epics Working Doc — cluster A epic candidates.
- Mux Video Permutations Matrix — Stream Type section.