From e3c00f1db97ac9d5cf22e436f3bb2f6a96f9a454 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Tue, 23 Jun 2026 09:31:16 -0700 Subject: [PATCH] docs(spf): reconcile live-stream-support to implemented state, document live-window guard Bump live-stream-support to sketched depth grounded in feat/spf-hls-live (reload loop, sliding-window tracking, Infinity duration, live-edge seek, ENDLIST termination all implemented); resolve its 5 open questions. Add the planned live-window playhead guard (reposition on window-exit, covering paused-too-long and fallen-behind) with a repositionPolicy seam for a future edge-only composition. Cascade: register [live-edge-only-mode] use-case candidate; cross-ref buffer-stall-recovery (in-window stall vs window-exit reposition) and dvr-event-stream-support (seekable spectrum). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../spf/features/buffer-stall-recovery.md | 9 +- .../spf/features/dvr-event-stream-support.md | 6 +- .../spf/features/live-stream-support.md | 430 +++++++++--------- internal/design/spf/use-cases/README.md | 1 + 4 files changed, 235 insertions(+), 211 deletions(-) diff --git a/internal/design/spf/features/buffer-stall-recovery.md b/internal/design/spf/features/buffer-stall-recovery.md index d159af5c..825c4a31 100644 --- a/internal/design/spf/features/buffer-stall-recovery.md +++ b/internal/design/spf/features/buffer-stall-recovery.md @@ -196,7 +196,14 @@ Things this feature probably forces decisions on, not just additions: lifecycle. - **[live-stream-support](./live-stream-support.md)** — primary consumer in the sense that live triggers stall detection more - often. Reload-loop interruption is one stall cause. + often. Reload-loop interruption is one stall cause. Division of + labor with that feature's live-window playhead guard: this feature + owns in-window stalls (nudge → flush → reset, the playhead is still + inside the sliding window); the guard owns the window-*exit* + reposition (the playhead fell behind the window start → seek to the + live edge). A Scenario-B poor-network stall may surface here first, + then escalate to the guard's reposition once the window slides past + the frozen playhead. - **`[multi-signal-abr]`** — page visibility signal should suppress stall detection during hidden state. - **`[discontinuity-handling]`** *(deferred candidate)* — diff --git a/internal/design/spf/features/dvr-event-stream-support.md b/internal/design/spf/features/dvr-event-stream-support.md index 92cd7ada..9f01bfbc 100644 --- a/internal/design/spf/features/dvr-event-stream-support.md +++ b/internal/design/spf/features/dvr-event-stream-support.md @@ -214,7 +214,11 @@ Things this feature probably forces decisions on, not just additions: `Infinity` duration semantics, termination detection (`ENDLIST` + miss-counter), and the `setLiveSeekableRange` writer-behavior shape. DVR/event-stream supplies the windowing variance and the - variant-specific seekable-range start producer. + variant-specific seekable-range start producer. Its live-window + playhead guard's DVR `repositionPolicy` (preserve in-window + scrub-back on the sliding window) is the analog of this feature's + full-history back-seek; the guard's edge-only `repositionPolicy` + variant is the no-scrub-back opposite end of that spectrum. - **[ll-hls-support](./ll-hls-support.md)** — orthogonal cluster A extension. DVR + LL-HLS compose; both extensions sit atop the same reload-loop foundation but address different concerns diff --git a/internal/design/spf/features/live-stream-support.md b/internal/design/spf/features/live-stream-support.md index 94b5ea06..e52da8d9 100644 --- a/internal/design/spf/features/live-stream-support.md +++ b/internal/design/spf/features/live-stream-support.md @@ -1,18 +1,17 @@ --- status: draft -date: 2026-05-20 -definition: technical +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, 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 candidate features. +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): @@ -20,232 +19,245 @@ without it, live HLS sources don't play correctly. ## Status -- **Composition:** not implemented in `createSimpleHlsEngine`. Today's - engine assumes VOD: `resolvePresentation` fetches the manifest once; - media playlists are fetched once per resolved track; no reload loop. -- **Definition depth:** technical — scope and constraints articulated; - no implementation. Source material: [SPF Epics Working Doc — Live - Stream Support (epic #2)](https://www.notion.so/35f97a7f89d08123a13fecab1ca1cac4) +- **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. **Not yet implemented:** the live-window playhead guard (see the + planned phase below). Termination is `#EXT-X-ENDLIST`-based (the naive tier — + sufficient for conformant content); the miss-counter fallback and reload + jitter are deferred full-depth, and `clearLiveSeekableRange()` on termination + is a low-risk verify-later item. +- **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. + [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. +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 | What | Notes | +| Phase | Status | What / how | |---|---|---| -| Manifest reload loop | Periodic media-playlist refetch keyed off `#EXT-X-TARGETDURATION` pacing per HLS spec. Each selected track's media playlist reloads independently as long as the source is live | The core primitive; cluster A foundation | -| Sliding-window segment tracking | Engine handles segments dropping off the start of the playlist as the window slides forward. Already-buffered segments past the window are still playable; un-fetched segments past the window are no longer fetchable | Affects the segment-loader's planning + back-buffer policy | -| Live duration semantics | `presentation.duration = Infinity` flows through `config.resolveDuration` (already pluggable). Downstream `updateMediaSourceDuration` propagates to `mediaSource.duration = Infinity` per MSE spec for live | The pluggable `resolveDuration` hook from `mse-mms-pipeline` is the surface; no new state slot needed | -| Live edge tracking | Engine tracks the latest segment available in the current playlist snapshot. Distinct from `currentTime` (the playhead); the gap between them is the buffer + the user's distance from live edge. DOM exposure via `mediaSource.setLiveSeekableRange(start, end)` so the browser's `HTMLMediaElement.seekable` reflects the live window (without it, `seekable` is empty under `duration === Infinity`) | Likely a derived signal (computed) rather than a new state slot | -| Reload jitter / backoff | Pacing variations under server delays or slow networks. Naive: poll on target-duration; full: jitter to avoid thundering herd, backoff on consecutive identical-playlist responses | Naive depth matches what hls.js does; full depth adds vendor-specific tuning | -| Per-type reload coordination | Audio / video / text media playlists each reload independently. Today the per-type `resolveXTrack` family is one-shot; live requires extending or replacing it with a reloading variant | Open question: extend in place, add a sibling `reloadXTrack` behavior, or compose differently | -| Termination detection (manifest signal) | Recognize when the reload loop should stop. **Naive**: `#EXT-X-ENDLIST` recognition only (assumes spec-compliant servers). Today's parser matches the literal `#EXT-X-ENDLIST` line but doesn't surface the value to the track output — the parser-side fix is part of this phase. **Full**: ENDLIST + unchanged-playlist miss-counter as a fallback for servers that stop updating without emitting `ENDLIST` | Naive vs Full depth per [clusters.md § Feature classification axes](./clusters.md#naive-vs-full-implementation-depth) | -| Terminated state transition | Engine flips out of live mode for the affected track. Reload loop stops scheduling that track's playlist. The track's segment list stops mutating, which makes the existing `endOfStream` gate naturally reachable (last segment now exists permanently). Per-type independence: audio / video can terminate at different times | The state transition is the only new orchestration; `endOfStream` doesn't need new code, just the playlist to stabilize. `clearLiveSeekableRange()` pairs with the transition so the browser's `seekable` returns to buffer-derived semantics | +| 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**) | Dedicated behavior `seek-to-live-edge.ts` reads the selected video track's timeline, calls `setLiveSeekableRange(windowStart, windowEnd)` reactively on each window slide, and does a one-time seek to the HOLD-BACK position. Separate from the reload loop (option b), so other consumers of the derived live-edge signal can plug in without coupling to polling. Inert for VOD via finite-duration guard. **Gap:** no `clearLiveSeekableRange()` on termination | +| Live-window playhead guard | 🔲 Planned | While playing (`!paused && !seeking && readyState > 0`), reposition `currentTime` to `liveEdgeStart = max(windowStart, windowEnd − HOLD_BACK_TARGET_MULTIPLIER×targetDuration)` 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 signal, since `timeupdate` stops during a stall). Within-window pause and scrub-back are left untouched (DVR model). Extends `seek-to-live-edge` so the live playhead position has a single owner (the initial seek becomes this guard's first firing). Reposition policy is a seam — see Likely cross-cutting impact. **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 **not** called on the transition — low-risk (a terminated window's stale live range ~matches its buffered/duration-derived seekable); verify on a real terminating stream | -## What's in scope vs out of scope +## What's not implemented -**In scope:** -- All phases above for HLS live VOD content with `#EXT-X-TARGETDURATION` pacing -- Standard sliding-window behavior (segments roll off the start) -- `Infinity` duration semantics through MSE -- Naive reload pacing (target-duration interval; no jitter) -- `#EXT-X-ENDLIST` recognition + unchanged-playlist miss-counter fallback for termination detection +**Within this feature:** + +- **Live-window playhead guard** (planned phase above) — the substantive + unimplemented work. Today the playhead is never repositioned after the + one-time initial seek, so a paused-too-long or fallen-behind playhead simply + stalls outside the window. +- **`clearLiveSeekableRange()` on termination** — not called; low-risk (a + terminated window's stale live range ~matches its buffered/duration-derived + seekable). Verify on a real terminating stream; not a blocker. + +**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):** -**Out of scope (separate Media-src candidate features):** - **[ll-hls-support](./ll-hls-support.md)** — blocking reload, partial - segments, delta playlists, preload hints. Largest single live-related - gap per the permutation matrix; builds directly on this feature's - reload loop. -- **[dvr-event-stream-support](./dvr-event-stream-support.md)** — - DVR / event streams: growing playlist (non-sliding); user can seek - backwards through history. Extension of this feature with different - windowing semantics. - -**Out of scope (related but separate concerns):** -- **[non-zero-pts-support](./non-zero-pts-support.md)** — live streams' PTS advances continuously - from stream start, typically far from zero. Live needs this for - correct `currentTime` / `seekable` semantics, but the time-mapping - primitive itself is a separate cluster B feature. -- **`[buffer-stall-recovery]`** — affects live more than VoD due to - ingest variability, but is a separate borderline feature. -- **`[viewer-rate-limiting-audit]`** — reload-loop pacing must respect - server-side rate limiting; the audit itself is a separate borderline - feature. + 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 -Things this feature probably forces decisions on, not just additions: +The foundation is implemented, so most prior cross-cutting concerns are now +realized. The remaining forward-looking impact is concentrated in the planned +guard: -- **`resolvePresentation` reload variant** — today's behavior is - one-shot: parse manifest → write resolved presentation. Live requires - re-fetching the *media playlists* (not the multivariant), so the - reload loop sits below `resolvePresentation` rather than replacing - it. Most likely a per-type `reloadXTrack` family alongside the - existing `resolveXTrack` family, or an extension to the existing - `resolve-track.ts` shape. Touches the `parseMediaPlaylist` direct - import (see [presentation-modeling.md](../presentation-modeling.md)) - — same parser, but called repeatedly per track. -- **Segment-loader sliding-window awareness** — today's planner - iterates over all segments in `track.segments`. Live needs the - planner to handle the segment list mutating mid-flight (segments - appended at the live edge, segments removed from the start). The - existing back-buffer eviction policy may need extension to honor - "no longer in the playlist" as an eviction signal independent of - `keepSegments` count. -- **`bandwidthState` resume semantics** — already preserved across - source resets (see [video-abr.md](./video-abr.md)). For live, - bandwidth-aware ABR continues to function, but bandwidth measurement - during live is more variable (network conditions matter more without - the full-buffer-ahead cushion). -- **Time mapping for live edge** — `state.currentTime` is the playhead; - the live edge is a derived value computed from the playlist's last - segment. Tools like "seek to live edge" or "is at live edge" would - consume this derived signal. Doesn't necessarily need its own state - slot. -- **End-of-stream handling** — `endOfStream` today gates on - `isLastSegmentAppended` + `currentTime >= lastSegStart`, *not* on - `presentation.duration` finiteness. For live, the gate naturally - doesn't fire because the playlist keeps growing — no segment is - permanently "the last." Once termination commits via this feature's - termination-detection phases, the last segment stabilizes and the - gate becomes reachable for normal reasons. **Subtlety:** there's a - possible race if reload pacing lags playhead consumption — the - current last segment could meet the gate before the next reload - appends a new one, firing `endOfStream` spuriously. Whether this - happens in practice depends on reload pacing relative to - forward-buffer depth. -- **Parser-side ENDLIST surfacing** — `parseMediaPlaylist` currently - recognizes `#EXT-X-ENDLIST` (skips the line) but doesn't extract the - value. The `MediaPlaylistInfo.endList: boolean` type field exists - but is orphaned (the parser returns a `Track`, not - `MediaPlaylistInfo`). The termination-detection phases need the - parser to surface the value to the track output. -- **`mediaSource.*` third-writer pattern — `setLiveSeekableRange`** — - `mediaSource.duration` already has two non-overlapping writers - (`updateMediaSourceDuration` for the initial `Infinity` write; - `endOfStream` for the deterministic final value — see - [mse-mms-pipeline.md](./mse-mms-pipeline.md) on DOM-property - multi-writer). Live introduces a structurally *different* third - writer on the same `mediaSource` resource: ongoing reactive - `setLiveSeekableRange(start, end)` calls keyed off live-edge updates, - plus `clearLiveSeekableRange()` paired with the terminated-state - transition. Distinct from the existing two writers along all three - characterization axes — decision domain (derived from playlist - snapshot vs. presentation / buffered), trigger (ongoing reactive vs. - one-shot transitions), and method (range setter rather than - `.duration` assignment). `start` = earliest still-fetchable segment - (sliding-window-aware); `end` = live edge. **Lives as a new live-only - behavior composed into the live engine variant**, not as a runtime - branch inside an existing MSE behavior — live vs VoD is a - composition-time distinction, and `updateMediaSourceDuration` is - deliberately uniform-across-variants (see - [conventions/behaviors.md](../conventions/behaviors.md) → *Inverse: - behaviors that operate uniformly across tracks* and the - `updateMediaSourceDuration` worked example). Two SPF-shaped options - for placement within the live variant: (a) baked into the - (yet-to-exist) live track-polling / reload-loop behavior that - produces edge data, or (b) a separate behavior reading presentation / - segment state and writing to DOM. Current lean: (b) — single-purpose - composition keeps other consumers of the same derived live-edge - signal (above-engine "seek to live edge," ABR live-edge-distance if - ever introduced) pluggable on the same data without coupling to the - polling behavior. Gates on MediaSource `'open'`; buffers-idle gating - is an implementation detail for the behavior itself. +- **`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 | +|---|---|---| +| `seekToLiveEdge` | `behaviors/dom/seek-to-live-edge.ts` | Compute the live window from the selected video track, `setLiveSeekableRange(start, end)` reactively, one-time seek to HOLD-BACK. Inert for VOD. **(Planned)** host the live-window playhead guard | +| `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` | `behaviors/dom/seek-to-live-edge.ts` | `3` | Initial-seek (and planned guard) target = live edge − 3×target-duration, clamped to window start | +| `FALLBACK_TARGET_DURATION` | `media/hls/reload-policy.ts` | `6` | Reload cadence (s) when no `#EXT-X-TARGETDURATION` | +| `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` *(planned)* | guard behavior | `'window-exit'` | Reposition condition seam; `'on-resume'` opt-in drives the edge-only composition | + +## 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` — declares window; seeks to + HOLD-BACK; no-op for finite/absent tracks. +- `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']`. + +**Planned (live-window playhead guard):** unit (fake media element + injected +window signal) — within-window/playing → no seek; `currentTime < windowStart` +→ seek to `liveEdgeStart`; out-back while paused → no seek, then seek on +`playing`; `currentTime > windowEnd` → seek; DVR mid-window scrub-back → +**no** yank; target = `max(windowStart, windowEnd − 3×td)`; boundary within +tolerance → no jitter; window slides past frozen paused playhead across N +reloads → single seek on resume; `seeking` in flight → defer to `seeked`. E2E +(Chromium) — local synthetic sliding-window stream (short target-duration): +pause-beyond-window → resume snaps near `seekable.end`; CDP +`Network.emulateNetworkConditions` to drain buffer → reposition + recover. ## Open questions -- **Per-type reload coordination.** Audio / video / text media - playlists each have their own `EXT-X-TARGETDURATION`. Reload them - independently per their own pacing, or coordinate (e.g., reload all - on the shortest target duration)? The Epics doc doesn't take a - position; HLS spec allows independent. Practical question: does the - engine want symmetric coordination for stalls (one playlist behind → - block segment-loader from advancing) or independent (each playlist - paces itself)? -- **Reload behavior extension vs new behavior.** Extending the - existing `resolveXTrack` family to keep reloading vs adding a - sibling `reloadXTrack` family vs composing differently. Affects - cleanup-cascade semantics — the existing family ties cancellation - to source identity via reactor state-exit; a reload variant needs - to honor the same contract. -- **Default `defaultResolveDuration` already handles VoD; does live - need a different wired default in `createSimpleHlsEngine`, or do - live consumers wire their own?** The `resolveDuration` hook is - already pluggable; the only question is whether the default engine - variant covers live or requires opt-in. -- **Miss-counter threshold.** Heuristic feature — how many identical- - manifest reloads constitute termination? hls.js uses some count; - SPF needs its own choice. Threshold affects false-positive vs - false-negative rate. -- **Per-type termination semantics.** When audio terminates before - video, what's the engine's consumer-facing surface? "Live until all - tracks terminate" or "terminated when any track terminates"? Likely - the former, but worth confirming the precedent. Aligns with the - per-type reload-coordination question above. -- **`endOfStream` race under live reload pacing.** Could the gate fire - spuriously between reloads on a heavily-buffered live source? - Likely not in practice (reload pace beats consumption pace) but - worth verifying. -- **`setLiveSeekableRange` behavior shape within the live variant.** - Two SPF-shaped options — (a) call from inside the live track-polling - / reload-loop behavior that produces edge data, or (b) a separate - behavior reacting to presentation / track state changes. Lean: (b), - for single-purpose composition and so other consumers of the same - derived live-edge signal can plug in alongside without coupling to - the polling behavior. Revisit once the polling behavior's shape - lands. +- **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? ## 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. Largest live-related gap. -- **[dvr-event-stream-support](./dvr-event-stream-support.md)** — - different windowing semantics on top of the same reload loop. - Growing playlist + back-seek through history; resolves this doc's - prior "DVR / event boundary" decomposition question (DVR is its - own feature, not a phase here). -- **[non-zero-pts-support](./non-zero-pts-support.md)** — live PTS - starts far from zero. Live without non-zero PTS handling means - `currentTime` is wrong. Cluster B foundation that live consumes. -- **mse-mms-pipeline** — `Infinity` duration via `config.resolveDuration` - is already supported there; live writes the value, MSE pipeline - propagates it. The `endOfStream` gate uses segment + currentTime, - not duration finiteness; it naturally doesn't fire for live (growing - playlist) and becomes reachable once termination commits. -- **buffer-management** — sliding-window segment tracking interacts - with back-buffer eviction. The planner's currentTime-driven plan - shape applies; the playlist itself mutating mid-flight is new. -- **video-abr** / **audio-playback** / **subtitles** — all per-type - consumers continue to work in live, but each reads the resolved - track which now changes over time (segments append / roll off). - Quality-aware buffer planning preserves buffered higher-quality - content; for live this still applies but the windowing changes the - buffered-set turnover rate. -- **source-replacement** — orthogonal; live and VoD source changes - use the same in-place cascade. Live → live source change tears - down and rebuilds the reload loop along with everything else. +- **[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 § Feature classification axes](./clusters.md#feature-classification-axes) - — the Media-src feature framing this doc instantiates -- [presentation-modeling.md](../presentation-modeling.md) — - architectural deep-dive; the reload loop sits below - `resolvePresentation` and re-uses `parseMediaPlaylist` per cycle. - When `parseMediaPlaylist` pluggability arrives (see that doc's - Open questions), live + format support intersect +- [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) - — source material; cluster A epic candidates and decompositions + — cluster A epic candidates. - [Mux Video Permutations Matrix](https://www.notion.so/32c97a7f89d08191b84dd30f06685490) - — Stream Type section; SPF column shows ⚠️ for live + DVR (manifest - re-polling unverified), 🔲 for LL-HLS + — Stream Type section. + + diff --git a/internal/design/spf/use-cases/README.md b/internal/design/spf/use-cases/README.md index fbca928a..467d97fd 100644 --- a/internal/design/spf/use-cases/README.md +++ b/internal/design/spf/use-cases/README.md @@ -141,6 +141,7 @@ Initially empty; populated as docs land. Candidates flagged in source material ( - [`audio-only-mode-override`](./audio-only-mode-override.md) *(partial — Phase 1 landed)* — audio-only delivery. Covers both truly-audio-only HLS sources and mixed-manifest sources delivered as audio-only via the same shared engine factory (`createHlsAudioOnlyEngine`). Subsumes what Notion originally framed as separate epics #4a (Basic Audio-only) and #4b (Audio-only Mode Override). - [`video-only-mode-override`](./video-only-mode-override.md) *(coarse)* — video-only delivery. Inverse-axis sibling of [`audio-only-mode-override`](./audio-only-mode-override.md); same shape. Subsumes Notion epics NEW-A (Basic Video-only) and NEW-B (Video-only Composition). - `[background-video]` — Mux's background-video product scenario: loop + autoplay-muted + GPU/thermal-aware caps + likely silent-video delivery. **Distinct from `video-only-mode-override`** despite shared Mux consumer context; both may share constituent features but address different delivery scenarios. *[GitHub #873](https://github.com/videojs/v10/issues/873); [`mux-background-video`](https://github.com/muxinc/mux-background-video) prior art.* +- `[live-edge-only-mode]` — pure-live delivery with no DVR scrub-back: always snap the playhead to the live edge on resume rather than preserving in-window position, optionally stop fetching segments while paused, and narrow the declared seekable range to the edge. The opposite end of the seekable spectrum from [`dvr-event-stream-support`](../features/dvr-event-stream-support.md). Composes on [`live-stream-support`](../features/live-stream-support.md) via its `repositionPolicy: 'on-resume'` seam (alternative-default-config) + a pause-fetch-gate behavior (add) + a narrowed-seekable producer (alternative-impl). Scope it only when a concrete consumer asks for edge-only. - Further candidates surfaced in source material but not yet scoped: picture-in-picture, short-form / shorts-player, audio-podcast mode, cast/remote-display compositions, ambient/decorative video. ## See also