Files
v10/internal/design/spf/features/live-stream-support.md
T
Christian PillsburyandClaude Opus 4.8 ada3100209 docs(spf): reflect the live-latency seam in live-stream-support
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>
2026-06-25 10:00:22 -07:00

287 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
status: draft
date: 2026-06-23
definition: 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](./clusters.md#feature-classification-axes):
without it, live HLS sources don't play correctly.
## Status
- **Composition:** implemented in `createSimpleHlsEngine` at naive depth on
branch `feat/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, `Infinity` duration semantics,
live-edge seek + `setLiveSeekableRange`, and termination-on-`#EXT-X-ENDLIST`
all land. The **live-window playhead guard** (reposition-on-window-exit) is
now implemented in `seek-to-live-edge`. **Not yet implemented:** the edge-only
`on-resume` reposition 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)](https://www.notion.so/35f97a7f89d08123a13fecab1ca1cac4)
(cluster A foundation, eng size L, validation M).
- **Foundational** for the manifest-reload-loop cluster —
[ll-hls-support](./ll-hls-support.md) and
[dvr-event-stream-support](./dvr-event-stream-support.md) 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](./clusters.md#naive-vs-full-implementation-depth)) 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-resume` reposition policy** — the `repositionPolicy` seam
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:VOD` only, the
sanctioned naive tier per
[clusters.md](./clusters.md#naive-vs-full-implementation-depth). Full depth
adds a miss-counter fallback for non-conformant servers that stop updating
without `ENDLIST` (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](./ll-hls-support.md)** — blocking reload, partial
segments, delta playlists, preload hints. Builds on this feature's reload
loop.
- **[dvr-event-stream-support](./dvr-event-stream-support.md)** — growing
(non-sliding) window, back-seek through history. Extension with different
windowing semantics; shares the reload loop and the `setLiveSeekableRange`
writer shape. Sits at the maximal-back-seek end of the same seekable
spectrum the guard's `repositionPolicy` exposes (edge-only ↔ in-window
scrub-back ↔ full-history back-seek).
- **[non-zero-pts-support](./non-zero-pts-support.md)** — live PTS advances
from stream start, far from zero; the time-mapping primitive live consumes is
a separate cluster B feature.
- **[buffer-stall-recovery](./buffer-stall-recovery.md)** — 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:
- **`repositionPolicy` composition 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](./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-edge` already 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 existing `presentation.duration`
(finiteness guard), `selectedVideoTrackId`, and `track.segments` (window),
plus the `currentTime` mirror.
- **Primary trigger is the window-update signal, not `timeupdate`.** Scenario
B's fall-behind happens *during a stall*, when `timeupdate` has stopped but
the window keeps sliding via reloads — so the guard must re-evaluate on the
reload / window-update signal. `playing` / `timeupdate` / `seeked` are
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: `null` for finite
duration, full/half target-duration, 6s fallback.
- `media/hls/tests/parse-media-playlist.test.ts``Infinity` for unended
live; `endList` on `#EXT-X-ENDLIST`; finite for `PLAYLIST-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 to `liveEdgeStart`
(latency injected via `resolveLiveLatency`); no-op for finite/absent tracks.
Plus the live-window guard matrix (see below).
- `playback/primitives/tests/live-window.test.ts``liveWindowFromState` /
`liveTrackId` track-pick; `getLiveEdge` latency placement + window-start clamp.
- `media/hls/tests/reload-policy.test.ts` — also `liveLatencyFor` (3× target
duration; fallback).
- `behaviors/tests/calculate-presentation-duration.test.ts``Infinity` for
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:**
- **`repositionPolicy` seam shape** → behavior-scoped config on
`seek-to-live-edge` (default `'window-exit'`), **not** a public
`SimpleHlsEngineConfig` field. Avoids a speculative engine-config surface with
no current consumer (per `conventions/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](./ll-hls-support.md)** — builds on this feature's reload
loop; adds blocking reload, partial segments, delta playlists, preload hints.
- **[dvr-event-stream-support](./dvr-event-stream-support.md)** — growing
window + full-history back-seek on the same reload loop; the maximal-back-seek
end of the seekable spectrum the guard's `repositionPolicy` sits on (edge-only
↔ in-window scrub-back ↔ full history).
- **[buffer-stall-recovery](./buffer-stall-recovery.md)** — 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](./non-zero-pts-support.md)** — live PTS starts far
from zero; cluster B foundation live consumes for correct `currentTime` /
`seekable`.
- **mse-mms-pipeline** — `Infinity` duration via `config.resolveDuration`; the
`endOfStream` gate 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
the `repositionPolicy: 'on-resume'` seam enables.
## See also
- [clusters.md § Manifest reload loop](./clusters.md#manifest-reload-loop) —
cluster A description; this feature is the foundation.
- [clusters.md § Composition vs Policy vs middle pattern](./clusters.md#composition-vs-policy-vs-middle-pattern)
— the `repositionPolicy` seam / edge-only composition framing.
- [live-timeline-anchoring](../../../decisions/live-timeline-anchoring.md) — PDT
anchor that places the sliding-window timeline `anchorLiveTracks` consumes.
- [mse-timestamp-offset](../../../decisions/mse-timestamp-offset.md) — native-PTS
default, `setLiveSeekableRange` in native coords, one-time seek into the
window on load.
- [presentation-modeling.md](../presentation-modeling.md) — the reload loop
sits below `resolvePresentation` and re-uses `parseMediaPlaylist` per cycle.
- [SPF Epics Working Doc](https://www.notion.so/35f97a7f89d08123a13fecab1ca1cac4)
— cluster A epic candidates.
- [Mux Video Permutations Matrix](https://www.notion.so/32c97a7f89d08191b84dd30f06685490)
— Stream Type section.
</content>
</invoke>