feat(spf): hls live#1715
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
✅ Deploy Preview for vjs10-site ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
📦 Bundle Size Report🎨 @videojs/html
Presets (7)
Media (10)
Players (5)
Skins (30)
UI Components (38)
Sizes are marginal over the root entry point. ⚛️ @videojs/react
Presets (7)
Media (9)
Skins (27)
UI Components (32)
Sizes are marginal over the root entry point. 🧩 @videojs/core
Entries (14)
🏷️ @videojs/element — no changesEntries (2)
📦 @videojs/store — no changesEntries (3)
🔧 @videojs/utils — no changesEntries (10)
📦 @videojs/spf
Entries (4)
ℹ️ How to interpretJS sizes are initial static graph totals (minified + brotli). Lazy dynamic chunks are shown separately when present.
Run |
c20c1bc to
7ead200
Compare
Companion to presentation-modeling.md for streams that change over time (live / DVR / event). Spine is a category decomposition that keeps data separated by change-rate and consumer: content snapshot (snapshot/merge segment identity via media-sequence + URL/byteRange), streamType, completeness-as-duration, refetch policy as its own category, the PROGRAM-DATE-TIME sync anchor, and the derived consumer surface. Captures the debated decisions and open questions; scopes to sliding-window live (LL-HLS / DVR / discontinuity / timestampOffset deferred). Adds the reciprocal companion link in presentation-modeling.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
POC spike for live HLS at the playlist layer only (no MSE/segments/DOM): - parser surfaces target-duration / media-sequence / playlist-type / endlist into a generic Ham.metadata bag (getMediaPlaylistMetadata accessor), with stable media-sequence-derived segment ids - parseMediaPlaylist(text, previous) takes a resolved-or-unresolved prior track and carries the timeline forward across reloads (media-sequence overlap + actual durations); first load anchors at 0; targetDuration used only as the no-overlap fallback - duration -> Infinity for unended live (finite once VOD/ENDLIST) - streamType on Presentation, derived from PLAYLIST-TYPE alone - reloadTrack behavior + createLivePlaylistSpikeEngine composition WIP: startTime/timestampOffset basis and cross-track (A/V) sync are still under research; PROGRAM-DATE-TIME capture and the cross-track alignment strategy are not yet implemented. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…parser Surface absolute per-segment program-date-time (epoch seconds) on Segment, interpolated forward via EXTINF and re-anchored on explicit tags. This is the cross-track sync anchor for demuxed audio/video — where per-track relative startTime disagrees, equal PDT identifies the same presentation instant — and the exact recovery value on a full live-window turnover. Surfacing only; PDT-anchored placement / the cross-track adjuster is a follow-up. Adds synthetic unit tests plus sanitized real Mux live snapshots (TS non-uniform slide; CMAF/LL-HLS demuxed A/V) as fixtures. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ions Two decision docs for the live-HLS timeline work: - mse-timestamp-offset — native-PTS default for live; timestampOffset reserved for relocation (the manifest→buffer / encoded layer). - live-timeline-anchoring — align demuxed tracks and recover turnover via PDT, not sequence number (the manifest layer). Resolve open questions [4] (sync anchor) and turnover startTime recovery in live-presentation-modeling, pointing at the decisions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add Track.startDate — the wall-clock time (epoch seconds) at a track's timeline origin, derived in the parser as programDateTime − startTime and stable as the window slides. Provisional from the manifest (refined later from the buffer). Add alignTrackTimelines(tracks): a pure model transform that re-bases demuxed tracks onto a common wall-clock origin by their startDate delta, so segments with equal programDateTime get equal startTime — the cross-track A/V sync step. Verified against the real demuxed Mux CMAF fixtures, where the 2s audio/video skew resolves. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add anchorTrackToSequenceOrigin: re-bases a track's timeline to an estimated stream start (the segment at startSequence, default 0) so startTime reads as elapsed-since-stream-start and startDate becomes the wall clock at that origin. The unseen earlier segments' duration is estimated from the observed segments' average (more reliable than EXT-X-TARGETDURATION, a spec ceiling); present segments keep their actual spacing. Rough and provisional — refined later from the buffer; per-track. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The HLS-tag-shaped name doesn't fit the format-neutral media model. startDate parallels startTime (a segment carries both) and Track.startDate, while staying protocol-neutral. Pure rename — no behavior change. (Open question, deferred: whether a per-segment wall-clock field is needed at all once Track.startDate is the anchor, or only re-emerges for discontinuities.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Generalize the video-only reload spike into a per-type factory mirroring resolve-track: reloadVideoTrack / reloadAudioTrack / reloadTextTrack, each gating on its own selected*TrackId + track type so demuxed audio and video reload independently. Inject fetchResolvableText via config (parity with resolve-track, and testable). Update the live-playlist spike to reloadVideoTrack. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add createLiveHlsEngine: reuses the VoD engine's MSE/segment/ABR behaviors, swaps one-shot resolve* for the per-type live reload loop, defaults resolveDuration to Infinity, and adds anchorLiveTracks — a behavior that applies the per-track stream-origin anchor to the selected tracks so segment.startTime ≈ native PTS (what the segment loader matches currentTime against). Cross-track alignment is intentionally not composed yet (it would fight the per-track anchor in a re-firing effect); residual skew is absorbed by native-PTS A/V sync. Demuxed audio+video; text/discontinuity out of scope. Not yet wired to a DOM element — engine composition + anchor behavior only, both typecheck/build green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Live segments append at native PTS, so the buffered range sits at a large timestamp while currentTime starts at 0 — no data at 0, readyState never advances, playback can't start. seekToLiveEdge watches for the buffered range and seeks the playhead into it (window start) once present. Listens to events that fire without data at currentTime (loadedmetadata/durationchange/progress); once per source. Composed into createLiveHlsEngine. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the ./live-hls subpath export (build entry + package export) and a LiveHlsEngineSignals type. Add a bare sandbox template (live-hls-engine) that wires createLiveHlsEngine to a raw <video> — no player/skin — and logs playback state, for end-to-end live-playback verification. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…seek) The buffered-driven version couldn't fire at cold-start: live segments append at native PTS, so currentTime=0 is outside the buffered window, but the loader only fetches segments overlapping [currentTime, currentTime+bufferDuration], so nothing loads until the playhead is in the window — a deadlock (no buffer → no seek → no load). Now derive the window from the selected video track's anchored timeline: setLiveSeekableRange(windowStart, windowEnd) (re-declared as the window slides) and seek currentTime to the window start once. Verified end-to-end against a live Mux CMAF/LL-HLS stream: automatic playback, native-PTS buffered range matches the seq-0-anchored model (Mux tfdt is stream-relative). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
For live, presentation.duration is Infinity as soon as the presentation resolves — before any segment appends. updateMediaSourceDuration is composed before the buffer actors, so its entry runs while the MediaSource is freshly open and empty: write Infinity synchronously there (no buffered clamp needed, Infinity ≥ any range). This gets ahead of the first append, which would otherwise set duration to the buffered end (MSE coded-frame-processing) and pin the live stream to a finite, live-edge duration — the async wait-for-idle path loses that race because a live loader appends continuously. VoD keeps the existing wait-open/idle + clamp + write-once-while-NaN path. Verified against a live Mux CMAF stream: mediaSource.duration is Infinity from the start, no finite transient. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The behavior's mechanism — pin one track from buffer ground truth, derive one shared offset, stamp every track onto it — is format-neutral; only the offset source (PDT) is live-specific. Rename it and its published signal (liveAnchor → presentationAnchor, matching the PresentationAnchor type it holds) so the name reflects the general role, and note the non-zero-PTS reuse path through the existing resolveBufferedAnchor seam. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ppend off-by-one bufferedAnchorFor paired the newest appended segment with maxBufferedEnd, but appendSegment streams one appendBuffer per chunk, so the newest segment's bytes are only partway into `buffered` mid-append — mis-reading its native start by up to a full segment, skewing the derived anchor ~2s and shifting the whole model timeline. seg0 went negative on a window anchored at the origin, so setLiveSeekableRange threw every reload and back-seek stalled in the gap. Pin to the trailing edge instead (earliest settled segment + minBufferedStart), which only moves on eviction, and exclude partial (still-appending) segments — the actor already flags them. Intermittent and live-only; exposed by DVR/EVENT growing windows, masked at the live edge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tion churn liveWindowFromState keyed the live window on the *selected* timeline-bearing track, so when an ABR/user switch briefly left the newly-selected rendition unresolved (a shell, no segments), the window blinked to null. That flipped seekToLiveEdge out of `live` and stalled the seekable-range writer. The live window is a presentation-level property — all renditions are time-aligned and share the anchor — so fall back to any resolved track of the timeline-bearing type when the selected one isn't resolved yet, rather than returning null. A deselected track's window may trail live by up to one reload during the switch gap; the selected track's fresh window resumes the moment it resolves. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The initial "jump near the live edge" was modeled as a reactor entry, so it re-fired on every entry into `live`. A transient precondition flip (e.g. the live window briefly unknown mid track-switch) re-enters `live` for the same source and yanked a viewer who had scrubbed back into the DVR window. The actual rule is "on initial load of a source, jump near the edge — once," so latch it on the source url: a genuine source change re-seeks; a transient re-entry does not. The continuous window-exit guard is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
10cdb42 to
7a6517b
Compare
…t-loading Adds a Live/DVR panel (stream type, window classification, live gap, DVR window length, buffer-ahead, presentation anchor + wall-clock) and seek controls (seek-to-live, -10/-30s, jump to window start) to the SPF segment-loading harness. All derived sandbox-side from presentation streamType + the native video; degrades cleanly to on-demand. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The window-exit guard in seekToLiveEdge bailed whenever `mediaElement.seeking` was true, to avoid fighting an in-flight DVR scrub-back. But a seek whose target sits behind the window start can never complete — the data has slid out of the window and been evicted — so `seeking` stays true forever and the playhead strands permanently, the very stall the guard exists to rescue. Drop the `seeking` bail. The `currentTime < windowStart` test is itself the precise discriminator: an in-window scrub-back lands at `currentTime >= windowStart` and never trips it, so only a stuck out-of-window seek is repositioned to the live edge. Verified live (Mux sliding-window LL-HLS): repeated seeks to the window start now snap back to the edge within ~1.5s instead of stalling, while mid-window DVR scrub-back still holds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…r-coupling follow-ups - live-stream-support.md: correct the window-exit guard description and test matrix to match the shipped fix (guard no longer bails on `seeking`); add the resolved out-of-window-seek-stall note; add Open questions for the seekable-range "lip", clamp-to-seekable as the general mechanism, and back-of-window yank-to-edge on discrete window slides. Remove stray EOF tags. - live-presentation-modeling.md: add Open question flagging the model <-> anchor <-> resolve-track coupling for a holistic revisit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…estrator apps/sandbox/scripts/live/ — spin up an ephemeral Mux live source for exercising the live path in any HLS-capable sandbox page: - create-stream.sh: create a low-latency Mux live stream (creds via --token-id/--token-secret flags or MUX_TOKEN_ID/MUX_TOKEN_SECRET env; --quiet for scripting) - broadcast.sh: push an ffmpeg testsrc + tone feed to the stream - dvr-url.sh: resolve active_asset_id to the DVR/EVENT playback URL - live-test.sh: orchestrate create -> broadcast -> wait -> open the page, parameterized by --page (renderer-agnostic), --flavor sliding|dvr|both, --port, --params, --latency; deletes the stream on exit unless --keep - README.md: quick-start, manual steps, and cross-renderer reuse notes Credentials are read only from flags/env; none are committed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surfaces the HLS media-playlist metadata accessor (and MediaPlaylistMetadata
type) on @videojs/spf/hls, including playlistType ('VOD' | 'EVENT'). Lets
consumers distinguish an EVENT / DVR source from sliding-window live directly
from the manifest instead of inferring it from the seekable window size.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Live/DVR panel guessed DVR from a 60s seekable-window threshold, so an
EVENT source read as 'sliding live' until it had aggregated enough segments.
Classify from the parsed playlistType ('EVENT') via getMediaPlaylistMetadata
instead. Gate on metadata presence (show 'live (resolving…)' until a timeline
track has metadata) so an EVENT source no longer flickers through 'sliding
live' on load/reload before the playlist resolves.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The DVR-vs-live distinction open question's PLAYLIST-TYPE:EVENT option is available today: playlistType is exposed via getMediaPlaylistMetadata on @videojs/spf/hls and used by the sandbox. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…IO group When an EXT-X-STREAM-INF lists an audio codec (e.g. CODECS="avc1,mp4a") but declares no AUDIO group, the audio is muxed into that rendition's segments. The parser was discarding the audio codec and keeping only the video codec, so the SourceBuffer mimetype omitted audio and the muxed segment failed to append (CHUNK_DEMUXER_ERROR_APPEND_FAILED: audio object type does not match the mimetype). Retain both codecs in that case so the mimetype matches the muxed media. The guard keys on an audio codec actually being present in CODECS, not on the mere absence of an AUDIO group — audioless video (CODECS="avc1") stays video-only, and demuxed sources (with an AUDIO group) are unchanged. Verified live: a muxed CMAF source (mediastreamsegmenter) now appends and plays in SPF where it previously errored. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
local-live.sh generates a sliding-window demuxed CMAF/fMP4 live stream with ffmpeg's HLS muxer (master + EXT-X-MEDIA audio group + fMP4 init/segments) to a temp dir, served with CORS and no caching — a no-creds local live source for the SPF engine and the hls.js/native sandbox pages. Documents the no-cache requirement (a positive max-age on a live playlist freezes the client's window — a server misconfiguration, not a player bug). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want higher recall? High effort reviews run extra passes and find more bugs. A team admin can switch effort levels in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 553061a. Configure here.
| export function liveWindowFromState(state: LiveWindowState): LiveWindow | null { | ||
| const presentation = state.presentation.get(); | ||
|
|
||
| // Prefer the selected timeline-bearing track when it's resolved. | ||
| const selected = liveWindowFor(presentation, liveTrackId(state)); | ||
| if (selected) return selected; | ||
|
|
||
| // The selected track isn't resolved yet (e.g. briefly mid ABR / user switch). | ||
| // The live window is a presentation-level property — all renditions are | ||
| // time-aligned and share the anchor — so fall back to any resolved track of the | ||
| // timeline-bearing type rather than letting the window blink to `null`. A null | ||
| // blink would flip `seekToLiveEdge` out of `live` and re-fire its one-time edge | ||
| // seek (yanking a viewer who has scrubbed back), and stall the seekable-range | ||
| // writer. A deselected track's window may trail live by up to one reload during | ||
| // the switch gap — acceptable, and the selected track's fresh window resumes the | ||
| // moment it resolves. | ||
| if (!presentation) return null; | ||
| const type = state.selectedVideoTrackId?.get() ? 'video' : 'audio'; | ||
| for (const track of getTracksByType(presentation, type)) { | ||
| const window = liveWindowFor(presentation, track.id); | ||
| if (window) return window; | ||
| } | ||
| return null; | ||
| } |
There was a problem hiding this comment.
nit(non-blocking): could if (!presentation) return null; be moved to after const presentation = state.presentation.get();?
| // off the anchor for good (anchoring is pin-once). Correctness | ||
| // rests on a run-to-completion invariant: NOTHING may yield | ||
| // (await) between this re-read and the write below, so no writer | ||
| // can interleave. `parseMediaPlaylist` is synchronous — keep it |
There was a problem hiding this comment.
parseMediaPlaylistis synchronous — keep it that way, or move the read into the updater.
note: this should probably be stated in the parseMediaPlaylist too
| const live = peek(state.presentation); | ||
| const previous = live ? findTrackToResolve(live, trackId) : undefined; | ||
| if (!previous) throw new Error('resolve-track: selected track not found'); | ||
| const mediaTrack = parseMediaPlaylist(text, previous); |
There was a problem hiding this comment.
question: I'm not sure I understand these changes (or maybe just the variable names ?) How does this change from vod only support to live?
| const presentation = state.presentation.get(); | ||
| if (!isResolvedPresentation(presentation)) return; | ||
| const anchor = deriveBufferAnchor(presentation); | ||
| if (isUndefined(anchor)) return; | ||
| update(state.presentation as Signal<MaybeResolvedPresentation>, (current) => | ||
| isResolvedPresentation(current) ? positionAllTracksToAnchor(current, anchor) : current | ||
| ); | ||
| // Publish after stamping, so a consumer reacting to the anchor (e.g. | ||
| // seekToLiveEdge) sees the already-shifted window. | ||
| state.presentationAnchor.set(anchor); |
There was a problem hiding this comment.
question(non-blocking): Still learning about signals, why do we check isResolvedPresentation(current) again on the update function
| it('keeps Track.startDate stable as the window slides', () => { | ||
| const first = `#EXTM3U | ||
| #EXT-X-MEDIA-SEQUENCE:0 | ||
| #EXT-X-PROGRAM-DATE-TIME:2026-01-01T00:00:00.000Z | ||
| #EXTINF:4, | ||
| s0.ts | ||
| #EXT-X-PROGRAM-DATE-TIME:2026-01-01T00:00:04.000Z | ||
| #EXTINF:4, | ||
| s1.ts`; | ||
| const prev = parseMediaPlaylist(first, videoShell); | ||
| expect(prev.startDate).toBe(epoch('2026-01-01T00:00:00.000Z')); | ||
|
|
||
| // Window slid by one: s0 rolled off, s1 is now first (startTime carried to 4). | ||
| const reload = `#EXTM3U | ||
| #EXT-X-MEDIA-SEQUENCE:1 | ||
| #EXT-X-PROGRAM-DATE-TIME:2026-01-01T00:00:04.000Z | ||
| #EXTINF:4, | ||
| s1.ts | ||
| #EXT-X-PROGRAM-DATE-TIME:2026-01-01T00:00:08.000Z | ||
| #EXTINF:4, | ||
| s2.ts`; | ||
| const next = parseMediaPlaylist(reload, prev); | ||
| expect(next.segments[0]?.startTime).toBe(4); // window advanced | ||
| expect(next.startDate).toBe(epoch('2026-01-01T00:00:00.000Z')); // origin unchanged | ||
| }); |
There was a problem hiding this comment.
question(non-blocking): I'm not sure yet how we use Track.startDate, but what would happen if a viewer joins in late and doesn't have the original Track.startDate? Do we infer it? I'm assuming it's server responsibility to send pdt every n segments, and more importantly for it to be consistent, just curious.


TL;DR
Adds live HLS to the SPF engine: runner-driven media-playlist reload, a single presentation-timeline anchor (PDT ↔ native-PTS), a live-window seekable range with seek-to-live-edge + a window-exit guard, and
Infinity-duration /ENDLIST-termination handling — all folded intocreateSimpleHlsEnginewith no live-vs-VOD branch (live behaviors go inert for VOD via finite-duration guards). Validated live against Mux LL-HLS, a Mux DVR/EVENT source, and a local ffmpeg source. Internal/alpha; no external API break.For reviewers — how to read this PR
Bucket 1 — Runtime (focus here)
Read fully (new building blocks):
seek-to-live-edge.ts(+218) —inactive ↔ livereactor: one-time entry seek to the edge + the window-exit guard (window-update re-fire + aplaylistener); also rescues a seek stranded behind the window start (it can't settle, soseekingisn't a veto)anchor-presentation-timeline.ts,presentation-anchor.ts,buffered-anchor.ts(+198 / +133 / +52) — one shared(media-time ↔ PDT)anchor learned from the first buffered track's native PTS; trailing-edge buffer pin (the leading edge is racy by up to a segment during chunked append → off-by-one); stamped onto every trackreload-policy.ts,core/tasks/delayed-reschedule.ts(+80 / +33) — reload cadence (target-duration;nullstops once duration is finite) + the injected reschedulelive-window.ts(media +41, playback/primitives +97) — geometric window derivation +getLiveEdge(window geometry + the injectedresolveLiveLatencyseam)Targeted careful read (diffs into existing — named concerns):
parse-media-playlist.ts(+237) — PDT capture;placeOnPreviousTimelinecarry-forward (media-sequence overlap → PDT bridge on full turnover, not the lossy target-duration estimate);duration = InfinityuntilENDLIST/PLAYLIST-TYPE:VODcore/tasks/task.ts(+204) —RecurringRunneris now a self-recursiveschedule()(reschedule required);Taskcarries memoizedrun()+previous+signal; fixes orphan rejectionsresolve-track.ts(+78) — runner-driven reload; run-to-completion invariant —previousis re-read after the fetch so a concurrent anchor write isn't clobbered (nothing mayawaitbetween that read and the state write)engine.ts(+82) — composes the live behaviors unconditionally; VOD inertness is finite-duration guards, not a branchsync-live-seekable-range.ts(+87) —setLiveSeekableRangere-declared on each window slide (incl. while paused); intentionally noclearLiveSeekableRange()on termination (UA ignores the live range oncedurationis finite)update-mediasource-duration.ts(+66) — solemediaSource.durationwriter (Infinityfor live → finite onENDLIST)media/types/index.ts(+93) —streamType(live/on-demand; orthogonal to completeness),playlistType(VOD/EVENT),getMediaPlaylistMetadataparse-multivariant.ts(+8) — retain the muxed audio codec when a STREAM-INF lists one with noAUDIOgroup; gated on the codec being present, so audioless video stays video-onlySkim structure only:
end-of-stream.ts(gate on completeness, not duration math),align-track-timelines.ts,resolve-buffered-anchor.ts,hls/index.ts(additivegetMediaPlaylistMetadataexport),utils/.../sleep.ts; sandbox —spf-segment-loading(Live/DVR panel + seek controls),live-hls-engine(?src=harness);scripts/live/*(Mux + local-ffmpeg test-stream tooling — dev harness, no shipped code)Skim file: Tests (19 files, ~2.5k lines) + fixtures (
live-cmaf-*,live-ts-*) — trust the suite; verify assertion shape across reload cadence, anchor establishment (first-track-wins, trailing-edge), the window guard matrix, parse timeline/PDT carry-forward, and duration/terminationBucket 2 — Design docs (skim)
Skim file:
live-stream-support.md(+497) — the spine: what shipped vs. what's deferred (naive vs. full depth). The three decision docs carry the "why":mse-timestamp-offset.md(native-PTS + seek-into-window),live-presentation-anchor.md(one presentation-level anchor),live-timeline-anchoring.md(PDT alignment).Skim or skip:
live-presentation-modeling.md(data-model design); feature-doc cascades (dvr-event-stream-support,ll-hls-support,buffer-stall-recovery);presentation-modeling.md;use-cases/README.md.Smoke test
No stable public live URL exists, so the reviewer path is a local ffmpeg source (no creds, no Mux account):
apps/sandbox/scripts/live/local-live.sh # serves http://localhost:8080/master.m3u8Sandbox:
/spf-segment-loading/?src=http://localhost:8080/master.m3u8&muted=true&autoplay=true&preload=autopnpm dev(the script serves the stream; Vite serves the page).sliding livewith a small, stable live gap and an advancing seekable end + wall-clock.For a DVR/EVENT source, point
?src=at an active Mux LL-HLS stream (seescripts/live/README) — the panel then readsDVR / EVENT.What changed — by surface
RecurringRunnerpaced bydelayedReschedule(mediaPlaylistReloadDelay)— full target-duration on a window change, half on an unchanged window,null(stop) once the playlist completes. Replaced an earlier epoch-signal scheduler; retry-on-error was dropped (transient-failure recovery belongs at the fetch layer).(media-time ↔ PDT)anchor is learned once per source from the first buffered track's native PTS (trailing-edge pin) and stamped onto every track, so renditions and successive reloads stay time-aligned. Cross-reload turnover is bridged by PDT rather than a target-duration estimate.liveWindowForderives the window;sync-live-seekable-rangemirrors it ontoMediaSource.setLiveSeekableRange;seek-to-live-edge(a reactor) seeks near the edge on load and repositions the playhead when it falls behind the window start.updateMediaSourceDurationis the sole duration writer —Infinitywhile live, finite once#EXT-X-ENDLISTlands;end-of-streamgates on completeness so live duration staysInfinity.getMediaPlaylistMetadatais exported so consumers (and the sandbox panel) can distinguish EVENT/DVR from sliding live via the manifest.Notable design decisions
RecurringRunnerlocalizes recurrence + abort + reschedule in one place. Alternative: the epoch-signal scheduler (the initial spike). Rejected because the signal approach scattered scheduling across effects and made abort/teardown implicit; the runner makes "reschedule until the policy stops" explicit and testable.(media-time ↔ PDT)reference; it's learned from the first buffered native-PTS, not estimated. Seelive-presentation-anchor.md/mse-timestamp-offset.md.maxBufferedEnd. Rejected because chunkedappendBuffergrowsbufferedincrementally, so the leading edge is racy by up to a full segment → an intermittent ~2s off-by-one; the trailing edge only moves on eviction.Reviewer callouts — known limitations
#EXT-X-ENDLIST/PLAYLIST-TYPE:VODonly (sufficient for conformant content, e.g. Mux); the miss-counter fallback for non-conformant servers and thundering-herd jitter/backoff are deferred.ll-hls-support) and DVR-aware back-buffer + start-in-place (dvr-event-stream-support) are separate features.scripts/live/is a dev harness only — no shipped/library code; drift is low-stakes.Breaking changes
No external break (SPF is alpha). Internally, live support folds into
createSimpleHlsEngineand the redundantlive-hls/live-playlist-spikeexperiment engines were dropped;@videojs/spf/hlsgains an additivegetMediaPlaylistMetadataexport.Test plan
@videojs/spfsuite green (1174 passing) — reload cadence, anchor establishment, window guard, parse/PDT carry-forward, duration/termination, muxed-codec parse.active_asset_id, back-seek), and a local ffmpeg demuxed-CMAF source (continuous playback, no-cache).Note
Medium Risk
Touches core SPF live playback (reload loop, timeline anchoring, live seeks, and MSE seekable range) with broad unit coverage; regressions would affect live/VOD edge cases rather than security or data handling.
Overview
Adds end-to-end HLS live support to
createSimpleHlsEngine: per-track media-playlist reload viaRecurringRunner+ RFC 8216bis cadence (mediaPlaylistReloadDelay), sliding-window merge inparseMediaPlaylist(media-sequence overlap, PDT on turnover),Infinityduration /ENDLISTtermination,setLiveSeekableRange, live-edge seek with hold-back latency, and a window-exit playhead guard inseekToLiveEdge. Timeline work includes PDT capture,alignTrackTimelines, buffer-derived presentation anchoring (anchorPresentationTimeline/presentationAnchorgating the initial live seek), andgetMediaPlaylistMetadatafor EVENT vs sliding classification.Task layer:
Taskmemoization/clone/previous, plusRecurringRunneranddelayedReschedulefor start-anchored reload scheduling.Sandbox:
apps/sandbox/scripts/live(Mux orchestration + local CMAFlocal-live.sh),live-hls-engineharness, and Live / DVR UI onspf-segment-loading(panel + seek-to-live / back-seek controls).Docs: New decisions (
live-timeline-anchoring,live-presentation-anchor,mse-timestamp-offset) and expandedlive-stream-support/live-presentation-modeling; DVR doc notesplaylistTypeviagetMediaPlaylistMetadata.Parser fix: Muxed
STREAM-INFkeeps both video and audio codecs when there is noAUDIOgroup (MSE append compatibility).Tests cover reload policy, live playlist parsing (incl. Mux fixtures), recurring tasks, and anchoring; feature docs call out follow-ups (#1743–#1746) for seekable lip, clamp-to-seekable, and model/reload coupling.
Reviewed by Cursor Bugbot for commit 553061a. Bugbot is set up for automated code reviews on this repo. Configure here.