Skip to content

feat(spf): hls live#1715

Open
cjpillsbury wants to merge 76 commits into
mainfrom
feat/spf-hls-live
Open

feat(spf): hls live#1715
cjpillsbury wants to merge 76 commits into
mainfrom
feat/spf-hls-live

Conversation

@cjpillsbury

@cjpillsbury cjpillsbury commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

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 into createSimpleHlsEngine with 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 ↔ live reactor: one-time entry seek to the edge + the window-exit guard (window-update re-fire + a play listener); also rescues a seek stranded behind the window start (it can't settle, so seeking isn'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 track
  • reload-policy.ts, core/tasks/delayed-reschedule.ts (+80 / +33) — reload cadence (target-duration; null stops once duration is finite) + the injected reschedule
  • live-window.ts (media +41, playback/primitives +97) — geometric window derivation + getLiveEdge (window geometry + the injected resolveLiveLatency seam)

Targeted careful read (diffs into existing — named concerns):

  • parse-media-playlist.ts (+237) — PDT capture; placeOnPreviousTimeline carry-forward (media-sequence overlap → PDT bridge on full turnover, not the lossy target-duration estimate); duration = Infinity until ENDLIST/PLAYLIST-TYPE:VOD
  • core/tasks/task.ts (+204) — RecurringRunner is now a self-recursive schedule() (reschedule required); Task carries memoized run() + previous + signal; fixes orphan rejections
  • resolve-track.ts (+78) — runner-driven reload; run-to-completion invariantprevious is re-read after the fetch so a concurrent anchor write isn't clobbered (nothing may await between that read and the state write)
  • engine.ts (+82) — composes the live behaviors unconditionally; VOD inertness is finite-duration guards, not a branch
  • sync-live-seekable-range.ts (+87) — setLiveSeekableRange re-declared on each window slide (incl. while paused); intentionally no clearLiveSeekableRange() on termination (UA ignores the live range once duration is finite)
  • update-mediasource-duration.ts (+66) — sole mediaSource.duration writer (Infinity for live → finite on ENDLIST)
  • media/types/index.ts (+93) — streamType (live/on-demand; orthogonal to completeness), playlistType (VOD/EVENT), getMediaPlaylistMetadata
  • parse-multivariant.ts (+8) — retain the muxed audio codec when a STREAM-INF lists one with no AUDIO group; gated on the codec being present, so audioless video stays video-only

Skim structure only: end-of-stream.ts (gate on completeness, not duration math), align-track-timelines.ts, resolve-buffered-anchor.ts, hls/index.ts (additive getMediaPlaylistMetadata export), 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/termination

Bucket 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.m3u8

Sandbox: /spf-segment-loading/?src=http://localhost:8080/master.m3u8&muted=true&autoplay=true&preload=auto

  • Load via local pnpm dev (the script serves the stream; Vite serves the page).
  • Observe:
    • Playback tracks the live edge continuously — the window slides, no stall after the first segments.
    • The Live/DVR panel reads sliding live with a small, stable live gap and an advancing seekable end + wall-clock.
    • Seek to live returns to the edge after a DVR back-seek (−10s / −30s holds inside the window, doesn't bounce).

For a DVR/EVENT source, point ?src= at an active Mux LL-HLS stream (see scripts/live/ README) — the panel then reads DVR / EVENT.

What changed — by surface

  • Runner-driven live reload. Per-type media-playlist refetch is owned by a RecurringRunner paced by delayedReschedule(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).
  • Presentation-timeline anchoring. A single presentation-level (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.
  • Live window + playhead. liveWindowFor derives the window; sync-live-seekable-range mirrors it onto MediaSource.setLiveSeekableRange; seek-to-live-edge (a reactor) seeks near the edge on load and repositions the playhead when it falls behind the window start.
  • Duration & termination. updateMediaSourceDuration is the sole duration writer — Infinity while live, finite once #EXT-X-ENDLIST lands; end-of-stream gates on completeness so live duration stays Infinity.
  • Muxed-codec retention + DVR/EVENT surface. The multivariant parser stops discarding a muxed rendition's audio codec; getMediaPlaylistMetadata is exported so consumers (and the sandbox panel) can distinguish EVENT/DVR from sliding live via the manifest.

Notable design decisions

  • Runner-driven reload over an epoch-signal scheduler. The RecurringRunner localizes 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.
  • One presentation-level anchor from buffer ground truth. Alternative: per-track zeroing or per-segment PDT positioning. Rejected because cross-track A/V sync and cross-reload continuity need a single shared (media-time ↔ PDT) reference; it's learned from the first buffered native-PTS, not estimated. See live-presentation-anchor.md / mse-timestamp-offset.md.
  • Trailing-edge buffer pin. Alternative: pin to the newest appended segment + maxBufferedEnd. Rejected because chunked appendBuffer grows buffered incrementally, 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.
  • Muxed audio codec gated on codec presence, not AUDIO-group absence. Keeping both codecs only when the STREAM-INF actually lists an audio codec avoids fabricating audio for a genuinely audioless video rendition.

Reviewer callouts — known limitations

  • [Deferred] Naive depth on termination + reload jitter. #EXT-X-ENDLIST / PLAYLIST-TYPE:VOD only (sufficient for conformant content, e.g. Mux); the miss-counter fallback for non-conformant servers and thundering-herd jitter/backoff are deferred.
  • [Deferred] LL-HLS, DVR/EVENT hardening. Partial segments / blocking reload / delta playlists (ll-hls-support) and DVR-aware back-buffer + start-in-place (dvr-event-stream-support) are separate features.
  • [Deferred] Muxed audio-only + bare media playlist — muxed video+audio works; muxed-audio-only and media-playlist-only (no multivariant) sources are tracked in Feature: Muxed (audio+video) HLS Support #1748.
  • [Deferred] Live/DVR seekable + anchor follow-ups tracked under epic Architecture: Live/DVR Seekable and Anchor Hardening #1742 (seekable-range "lip", clamp-to-seekable, model↔anchor↔resolve-track coupling).
  • [Author] 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 createSimpleHlsEngine and the redundant live-hls / live-playlist-spike experiment engines were dropped; @videojs/spf/hls gains an additive getMediaPlaylistMetadata export.

Test plan

  • Full @videojs/spf suite green (1174 passing) — reload cadence, anchor establishment, window guard, parse/PDT carry-forward, duration/termination, muxed-codec parse.
  • Live validated end-to-end: Mux LL-HLS (sliding), Mux DVR/EVENT (active_asset_id, back-seek), and a local ffmpeg demuxed-CMAF source (continuous playback, no-cache).
  • E2E (Chromium) deferred — needs a synthetic sliding-window fixture harness; the unit matrix covers the guard/anchor/reload logic deterministically.

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 via RecurringRunner + RFC 8216bis cadence (mediaPlaylistReloadDelay), sliding-window merge in parseMediaPlaylist (media-sequence overlap, PDT on turnover), Infinity duration / ENDLIST termination, setLiveSeekableRange, live-edge seek with hold-back latency, and a window-exit playhead guard in seekToLiveEdge. Timeline work includes PDT capture, alignTrackTimelines, buffer-derived presentation anchoring (anchorPresentationTimeline / presentationAnchor gating the initial live seek), and getMediaPlaylistMetadata for EVENT vs sliding classification.

Task layer: Task memoization/clone/previous, plus RecurringRunner and delayedReschedule for start-anchored reload scheduling.

Sandbox: apps/sandbox/scripts/live (Mux orchestration + local CMAF local-live.sh), live-hls-engine harness, and Live / DVR UI on spf-segment-loading (panel + seek-to-live / back-seek controls).

Docs: New decisions (live-timeline-anchoring, live-presentation-anchor, mse-timestamp-offset) and expanded live-stream-support / live-presentation-modeling; DVR doc notes playlistType via getMediaPlaylistMetadata.

Parser fix: Muxed STREAM-INF keeps both video and audio codecs when there is no AUDIO group (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.

@vercel

vercel Bot commented Jun 22, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
v10-sandbox Ready Ready Preview, Comment Jun 25, 2026 10:47pm

Request Review

@netlify

netlify Bot commented Jun 22, 2026

Copy link
Copy Markdown

Deploy Preview for vjs10-site ready!

Name Link
🔨 Latest commit 553061a
🔍 Latest deploy log https://app.netlify.com/projects/vjs10-site/deploys/6a3daffc3f292c000803f086
😎 Deploy Preview https://deploy-preview-1715--vjs10-site.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@github-actions

github-actions Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

📦 Bundle Size Report

🎨 @videojs/html

Path Base initial PR initial Diff % Lazy
/media/simple-hls-audio-only 17.15 kB 18.13 kB +1003 B +5.7% 🔺
/media/simple-hls-video 18.69 kB 20.67 kB +1.98 kB +10.6% 🔴
Presets (7)
Entry Initial
/video (default) 44.32 kB
/video (default + hls) 183.79 kB
/video (minimal) 43.98 kB
/video (minimal + hls) 183.63 kB
/audio (default) 37.83 kB
/audio (minimal) 36.61 kB
/background 4.20 kB
Media (10)
Entry Initial
/media/background-video 1.14 kB
/media/container 1.72 kB
/media/dash-video 214.74 kB
/media/hls-video 141.27 kB
/media/mux-audio 163.91 kB
/media/mux-video 163.68 kB
/media/native-hls-video 9.05 kB
/media/simple-hls-audio-only 18.13 kB
/media/simple-hls-video 20.67 kB
/media/vimeo-video 12.31 kB
Players (5)
Entry Initial
/video/player 8.07 kB
/audio/player 5.38 kB
/background/player 3.92 kB
/live-video/player 7.64 kB
/live-audio/player 5.39 kB
Skins (30)
Entry Type Initial
/video/minimal-skin.css css 5.45 kB
/video/skin.css css 5.43 kB
/video/minimal-skin js 44.01 kB
/video/minimal-skin.tailwind js 44.58 kB
/video/skin js 44.28 kB
/video/skin.tailwind js 44.98 kB
/audio/minimal-skin.css css 3.60 kB
/audio/skin.css css 3.53 kB
/audio/minimal-skin js 36.62 kB
/audio/minimal-skin.tailwind js 37.04 kB
/audio/skin js 37.80 kB
/audio/skin.tailwind js 38.25 kB
/background/skin.css css 133 B
/background/skin js 1.14 kB
/live-video/minimal-skin.css css 5.45 kB
/live-video/skin.css css 5.43 kB
/live-video/minimal-skin js 43.12 kB
/live-video/minimal-skin.tailwind js 43.58 kB
/live-video/skin js 43.08 kB
/live-video/skin.tailwind js 43.58 kB
/live-audio/minimal-skin.css css 3.60 kB
/live-audio/skin.css css 3.53 kB
/live-audio/minimal-skin js 29.73 kB
/live-audio/minimal-skin.tailwind js 29.20 kB
/live-audio/skin js 31.04 kB
/live-audio/skin.tailwind js 30.66 kB
/global.css css 176 B
/shared.css css 88 B
/tailwind.css css 228 B
/skin-element js 1.44 kB
UI Components (38)
Entry Initial
/ui/airplay-button 2.29 kB
/ui/alert-dialog 2.45 kB
/ui/alert-dialog-close 2.15 kB
/ui/alert-dialog-description 2.18 kB
/ui/alert-dialog-title 2.17 kB
/ui/buffering-indicator 2.24 kB
/ui/captions-button 2.37 kB
/ui/captions-radio-group 2.73 kB
/ui/cast-button 2.25 kB
/ui/compounds 2.85 kB
/ui/controls 2.58 kB
/ui/error-dialog 2.61 kB
/ui/fullscreen-button 2.29 kB
/ui/hotkey 2.31 kB
/ui/menu 2.63 kB
/ui/mute-button 2.27 kB
/ui/pip-button 2.28 kB
/ui/play-button 2.28 kB
/ui/playback-rate-button 2.36 kB
/ui/playback-rate-radio-group 2.78 kB
/ui/popover 2.64 kB
/ui/poster 2.14 kB
/ui/quality-radio-group 2.75 kB
/ui/seek-button 2.27 kB
/ui/seek-indicator 2.29 kB
/ui/seek-indicator-value 465 B
/ui/slider 2.62 kB
/ui/status-announcer 2.28 kB
/ui/status-indicator 2.38 kB
/ui/status-indicator-value 467 B
/ui/thumbnail 2.09 kB
/ui/time 2.60 kB
/ui/time-slider 2.64 kB
/ui/tooltip 2.59 kB
/ui/volume-indicator 2.36 kB
/ui/volume-indicator-fill 410 B
/ui/volume-indicator-value 426 B
/ui/volume-slider 2.64 kB

Sizes are marginal over the root entry point.

⚛️ @videojs/react

Path Base initial PR initial Diff % Lazy
/media/simple-hls-audio-only 15.56 kB 16.49 kB +952 B +6.0% 🔺
/media/simple-hls-video 17.15 kB 19.10 kB +1.94 kB +11.3% 🔴
Presets (7)
Entry Initial
/video (default) 36.83 kB
/video (default + hls) 175.18 kB
/video (minimal) 36.91 kB
/video (minimal + hls) 175.13 kB
/audio (default) 29.97 kB
/audio (minimal) 30.04 kB
/background 754 B
Media (9)
Entry Initial
/media/background-video 575 B
/media/dash-video 213.24 kB
/media/hls-video 139.80 kB
/media/mux-audio 162.26 kB
/media/mux-video 162.26 kB
/media/native-hls-video 7.39 kB
/media/simple-hls-audio-only 16.49 kB
/media/simple-hls-video 19.10 kB
/media/vimeo-video 10.58 kB
Skins (27)
Entry Type Initial
/tailwind.css css 228 B
/video/minimal-skin.css css 5.37 kB
/video/skin.css css 5.34 kB
/video/minimal-skin js 36.81 kB
/video/minimal-skin.tailwind js 42.58 kB
/video/skin js 36.73 kB
/video/skin.tailwind js 42.51 kB
/audio/minimal-skin.css css 3.47 kB
/audio/skin.css css 3.39 kB
/audio/minimal-skin js 29.98 kB
/audio/minimal-skin.tailwind js 31.76 kB
/audio/skin js 29.91 kB
/audio/skin.tailwind js 33.75 kB
/background/skin.css css 90 B
/background/skin js 272 B
/live-video/minimal-skin.css css 5.37 kB
/live-video/skin.css css 5.34 kB
/live-video/minimal-skin js 32.56 kB
/live-video/minimal-skin.tailwind js 38.22 kB
/live-video/skin js 32.58 kB
/live-video/skin.tailwind js 38.24 kB
/live-audio/minimal-skin.css css 3.47 kB
/live-audio/skin.css css 3.39 kB
/live-audio/minimal-skin js 21.74 kB
/live-audio/minimal-skin.tailwind js 24.64 kB
/live-audio/skin js 21.78 kB
/live-audio/skin.tailwind js 24.78 kB
UI Components (32)
Entry Initial
/ui/airplay-button 2.23 kB
/ui/alert-dialog 2.20 kB
/ui/buffering-indicator 2.10 kB
/ui/captions-button 2.16 kB
/ui/captions-radio-group 2.09 kB
/ui/cast-button 2.19 kB
/ui/controls 2.05 kB
/ui/error-dialog 2.24 kB
/ui/fullscreen-button 2.26 kB
/ui/gesture 2.25 kB
/ui/hotkey 2.27 kB
/ui/live-button 2.12 kB
/ui/menu 2.43 kB
/ui/mute-button 2.24 kB
/ui/pip-button 2.26 kB
/ui/play-button 2.19 kB
/ui/playback-rate 2.06 kB
/ui/playback-rate-button 2.25 kB
/ui/popover 2.64 kB
/ui/poster 2.09 kB
/ui/quality 2.10 kB
/ui/seek-button 2.20 kB
/ui/seek-indicator 2.19 kB
/ui/slider 2.26 kB
/ui/status-announcer 2.11 kB
/ui/status-indicator 2.15 kB
/ui/thumbnail 2.00 kB
/ui/time 2.07 kB
/ui/time-slider 2.29 kB
/ui/tooltip 2.59 kB
/ui/volume-indicator 2.18 kB
/ui/volume-slider 2.30 kB

Sizes are marginal over the root entry point.

🧩 @videojs/core

Path Base initial PR initial Diff % Lazy
/dom/media/simple-hls 16.47 kB 18.44 kB +1.97 kB +12.0% 🔴
/dom/media/simple-hls-audio-only 14.92 kB 15.89 kB +1001 B +6.6% 🔺
Entries (14)
Entry Initial
. 9.08 kB
/dom 17.02 kB
/dom/media/custom-media-element 2.09 kB
/dom/media/dash 209.07 kB
/dom/media/google-cast 4.04 kB
/dom/media/hls 135.63 kB
/dom/media/media-host 1.25 kB
/dom/media/media-played-ranges 576 B
/dom/media/mux 151.26 kB
/dom/media/native-hls 3.05 kB
/dom/media/simple-hls 18.44 kB
/dom/media/simple-hls-audio-only 15.89 kB
/dom/media/vimeo 9.86 kB
/media/predicate 573 B
🏷️ @videojs/element — no changes
Entries (2)
Entry Initial
. 996 B
/context 943 B
📦 @videojs/store — no changes
Entries (3)
Entry Initial
. 1.39 kB
/html 696 B
/react 360 B
🔧 @videojs/utils — no changes
Entries (10)
Entry Initial
/array 104 B
/dom 2.26 kB
/events 319 B
/function 327 B
/object 275 B
/predicate 265 B
/string 231 B
/style 190 B
/time 582 B
/number 158 B

📦 @videojs/spf

Path Base initial PR initial Diff % Lazy
/hls 15.37 kB 17.36 kB +1.99 kB +13.0% 🔴
/background-video 12.85 kB 13.79 kB +968 B +7.4% 🔺
Entries (4)
Entry Initial
. 4.53 kB
/dom 6.39 kB
/hls 17.36 kB
/background-video 13.79 kB

ℹ️ How to interpret

JS sizes are initial static graph totals (minified + brotli). Lazy dynamic chunks are shown separately when present.

Icon Meaning
No change
🔺 Increased ≤ 10%
🔴 Increased > 10%
🔽 Decreased
🆕 New (no baseline)

Run pnpm size locally to check current initial sizes.

cjpillsbury and others added 13 commits June 25, 2026 09:58
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>
cjpillsbury and others added 4 commits June 25, 2026 10:00
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>
cjpillsbury and others added 3 commits June 25, 2026 10:55
…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>
Add issue refs to the live/DVR Open-questions bullets (#1743-#1746) and
a Status pointer to the Live/DVR Seekable and Anchor Hardening epic
(#1742), so the design docs and the issue tracker are bidirectional.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
cjpillsbury and others added 5 commits June 25, 2026 13:52
…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>
@cjpillsbury cjpillsbury marked this pull request as ready for review June 25, 2026 22:53

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread packages/spf/src/playback/behaviors/resolve-track.ts
Comment on lines +34 to +57
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;
}

@spuppo-mux spuppo-mux Jul 1, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseMediaPlaylist is 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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment on lines +163 to +172
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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question(non-blocking): Still learning about signals, why do we check isResolvedPresentation(current) again on the update function

Comment on lines +690 to +714
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
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@spuppo-mux

spuppo-mux commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

Tried this out from Vercel Preview and streaming with the provided script. I got a couple of stalls when initializing the video/ when seeking to live, but i haven't been able to reproduce them consistently yet. Seems to happen mainly if I click play before the stream starts; segments load fine, yet they never enter the buffer.

image

Also, seeing some odd behavior when seeking back. For example, on 352 I seek back 10, it jumps to 342 (correct), but then stutters to 350.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants