Commit Graph
681 Commits
Author SHA1 Message Date
Christian PillsburyandClaude Opus 4.8 cf8aaca45b feat(spf): add mp4 box parser and decode-time origin extraction
Minimal ISO-BMFF box walker (media/mp4/box.ts) plus decode-time origin
extraction (timestamp-origin.ts) for the non-zero-PTS timestampOffset
relocation spike. Relocating a source by tfdt.baseMediaDecodeTime (a DTS)
over mdhd.timescale keeps the earliest DTS >= 0, so a Chromium
negative-DTS append failure is impossible by construction.

Two variants share the box walker and leaf field-readers:

- Presumptive (readFirstMediaTimescale / readFirstBaseMediaDecodeTime):
  the first mdhd timescale + first tfdt baseMediaDecodeTime, no track_id,
  no iteration. For single-media-track sources.
- Track-selected (findMediaTrack by hdlr handler / readBaseMediaDecodeTime
  by tfhd track_id): the track_id joins one track's timescale to the same
  track's baseMediaDecodeTime -- required when a source muxes CEA-608/708
  captions into the same moov/moof (each track carries its own timescale
  and baseMediaDecodeTime).

Split into separate exports so caption-free platforms tree-shake the
matching machinery away (~37% smaller minified than the track-selected
pair).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 09:34:24 -07:00
Christian PillsburyandClaude Opus 4.8 7b0d8bbdcb feat(spf): parse VTT X-TIMESTAMP-MAP for text-segment metadata
Add a header-only X-TIMESTAMP-MAP scraper (parseVttTimestampMap) plus
metadata-aware VTT resolution — the mechanism-independent foundation for
correlating non-zero-PTS subtitle cues with the media presentation
timeline.

resolveVttSegment keeps its exact signature; the new
resolveVttSegmentMetadata (raw fetch + header scrape, no VTT parser) and
resolveVttSegmentWithMetadata (Promise.all of the two) are additive, and
nothing requests metadata yet — the control of when/how to apply the map
stays external pending the non-zero-PTS mechanism decision.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:52:25 -07:00
Christian PillsburyandClaude Opus 4.8 434390916f fix(spf): retain the muxed audio codec on video tracks without an AUDIO 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>
2026-06-25 14:54:40 -07:00
Christian PillsburyandClaude Opus 4.8 53d99493d6 feat(spf): export getMediaPlaylistMetadata from the hls entry
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>
2026-06-25 14:20:40 -07:00
Christian PillsburyandClaude Opus 4.8 88edc78cb2 fix(spf): rescue a playhead stranded by an out-of-window seek
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>
2026-06-25 11:38:28 -07:00
Christian PillsburyandClaude Opus 4.8 7a6517bfae fix(spf): seek to the live edge once per source
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>
2026-06-25 10:00:47 -07:00
Christian PillsburyandClaude Opus 4.8 4572730e0b fix(spf): derive the live window from any resolved track during selection 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>
2026-06-25 10:00:47 -07:00
Christian PillsburyandClaude Opus 4.8 7c99f266a6 fix(spf): pin the buffer anchor to the trailing edge to avoid a mid-append 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>
2026-06-25 10:00:46 -07:00
Christian PillsburyandClaude Opus 4.8 dbddbf67bb refactor(spf): rename anchorLiveTracks to anchorPresentationTimeline
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>
2026-06-25 10:00:46 -07:00
Christian PillsburyandClaude Opus 4.8 6bbbc23961 fix(spf): keep the live anchor sticky per source
The anchor reactor's monitor re-derived buffer ground truth on every reload, so a
transient loss of it — a buffer underrun, flush, or seek that momentarily empties
both SourceBuffers — flipped the reactor back to `unanchored` and cleared
`liveAnchor`. The anchor value itself is idempotent, but `seekToLiveEdge` gates
its `live` state on `liveAnchor` being set: clearing it drove `live → inactive →
live` and re-fired the one-time live-edge seek, jumping the playhead forward.

Make the anchor sticky per source: once `liveAnchor` is published, stay
`anchored` while the presentation stays resolved; only a source change (the
presentation reset to an unresolved value) reverts it. This matches the
"established once per source" intent in live-presentation-anchor.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:00:46 -07:00
Christian PillsburyandClaude Opus 4.8 3983195661 fix(spf): re-read track timeline after fetch to avoid clobbering the live anchor
A track reload read its `previous` (the prior window it carries the timeline
forward from) before the playlist fetch await, then parsed against that stale
snapshot after. When anchor-live-tracks established the shared live anchor during
the fetch — stamping every track's timeline via positionAllTracksToAnchor — the
in-flight reload wrote a window built on the pre-stamp (un-anchored) snapshot,
clobbering the stamp. Anchoring is pin-once, so the track was never re-corrected:
its model timeline sat seconds off the anchor and the loader stopped fetching it
near currentTime. Observed live on Mux LL-HLS as the selected video track
stranded ~hundreds of seconds off the anchor while its un-reloaded ABR-shell
siblings stayed anchored — video never buffered, playback stalled.

Re-read `previous` from a fresh peek after the fetch and parse against that. With
no yield between the re-read and the write, no concurrent writer can interleave,
so the stamp is carried forward instead of lost.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:00:46 -07:00
Christian PillsburyandClaude Opus 4.8 0cb33bb532 refactor(spf): establish the live anchor from buffer truth and gate the seek on it
Rework live anchoring around a single shared anchor established only from
authoritative buffer ground truth, dropping the unreliable manifest estimate:

- anchorLiveTracks establishes the anchor once per source from the first
  actually-buffered A/V track (the buffer actor's `initTrackId`, not the
  selection), stamps it onto every track via `positionAllTracksToAnchor`
  (resolved tracks shift; not-yet-resolved shells get `startDate` for the
  parser's `placeOnAnchor`), and publishes it as `liveAnchor`.
- seekToLiveEdge gates its live-edge seek on `liveAnchor`: with no estimate the
  pre-anchor window is the raw one, and seeking there would strand the playhead
  when the pin later shifts the window. Gating holds the seek until the timeline
  is anchored, so it targets the final native-PTS window. (Smoke-tested on Mux
  LL-HLS: single seek to the live edge after the pin, clean startup.)
- The resolveBufferedAnchor seam becomes `(deps) -> { trackId, segmentId,
  actualStart }`, reading the buffer actor directly (video-first).

An intermittent ~2 s audio model-`startDate` offset (buffers stay aligned, so
playback is synced) remains, tracked as a separate startup race.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:00:46 -07:00
Christian PillsburyandClaude Opus 4.8 2007a173b3 feat(spf): honor a pre-applied anchor when first-resolving a media playlist
parseMediaPlaylist now places a first-resolved window relative to a
`startDate` set on the unresolved shell (each segment at PDT − anchor)
instead of always anchoring at the local base 0. This lets the shared
presentation anchor pre-position not-yet-resolved tracks so they resolve
already on the shared timeline — no separate positioning pass needed.

Inert until a caller sets `startDate` on a shell; a playlist with no PDT
falls back to the local base, and the recomputed track `startDate` reads
back as the anchor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:00:45 -07:00
Christian PillsburyandClaude Opus 4.8 2b42ff48ef refactor(spf): remove the superseded per-track anchor primitives
anchorTrackToBufferedSegment and anchorTrackToSequenceOrigin implemented
the per-track pinning that the shared presentation anchor replaces; they
have no remaining callers. Drop them and their tests, repoint
buffered-anchor's doc link to presentationAnchorFromBuffer, and remove
the now-moot bridge test in presentation-anchor.test.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:00:45 -07:00
Christian PillsburyandClaude Opus 4.8 7cde2ea28d refactor(spf): hold one shared presentation anchor for all live tracks
Convert anchor-live-tracks from N per-track buffer pins to a single
shared (media-time <-> PDT) anchor applied to every selected track —
video, audio, and now text. A two-state reactor (unanchored -> anchored)
positions from the manifest estimate until a selected A/V track has
SourceBuffer ground truth, then establishes the shared anchor once
(first track to buffer wins) and positions each track onto it by PDT,
leaving it to the parser's carry-forward thereafter.

The resolveBufferedAnchor seam now takes the standard (track, deps)
setup arguments instead of closing over engine scope; the HLS engine's
implementation lives in its own generic module (resolve-buffered-anchor)
so a future audio-only-live engine can reuse it. anchorLiveTracks is now
a makeAnchorLiveTracks<Context>() factory mirroring makeShareSignals.

Realizes internal/decisions/live-presentation-anchor.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:00:45 -07:00
Christian PillsburyandClaude Opus 4.8 3a9d1faca0 feat(spf): add presentation-anchor primitives for the shared live anchor
Introduce media/presentation-anchor.ts realizing the single presentation-level
anchor decision (internal/decisions/live-presentation-anchor.md):
presentationAnchorFromBuffer / presentationAnchorEstimate derive the shared
(media-time ↔ PDT) anchor — buffer-pinned, or the pre-buffer estimate — and
positionTrackToAnchor re-origins any track onto it by its own per-segment PDT.
Tested to generalize anchorTrackToBufferedSegment. Consumed by the
anchor-live-tracks reactor conversion (next), which replaces the per-track
anchor primitives.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:00:45 -07:00
Christian PillsburyandClaude Opus 4.8 bfe12eedf9 refactor(spf): extract resolveLiveLatency out of the engine closure
The HLS live-latency bridge was defined inside createSimpleHlsEngine's closure
despite capturing nothing — a fresh allocation per engine, and pure HLS logic
stranded where it can't be tested. Move it to media/hls/reload-policy.ts as a
pure resolveLiveLatency(presentation, trackId) next to liveLatencyFor; the
engine injects the import. Adds direct coverage for the bridge (previously only
stubbed via seek-to-live-edge's injected resolver).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:00:45 -07:00
Christian PillsburyandClaude Opus 4.8 de2e1f823f refactor(spf): drop the redundant readyState check in sync-live-seekable-range
context.mediaSource is published only while open (setupMediaSource is the sole
writer; cleared on detach), and a non-null live window means the timeline-bearing
track is still Infinity-duration, so endOfStream hasn't ended the MS. Present +
live window ⟹ open, so the explicit `readyState !== 'open'` guard before
setLiveSeekableRange (which throws off-open) is redundant. The "until open" test
becomes a publish transition.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:00:44 -07:00
Christian PillsburyandClaude Opus 4.8 c60fbb5606 refactor(spf): drive the live-window guard off window updates + a play listener
Rework seek-to-live-edge's guard effect: read the live edge first (the only
tracked dep) so window-update re-fires survive the paused bail, reposition on
each window slide (catches a stall, where timeupdate is silent), and add a
single `play` listener for immediate reposition on resume — `play`, not
`playing`, since after a long pause the playhead sits behind the window at an
unseekable position where `playing` never fires. Drop the now-unused
repositionPolicy seam; the live-edge-only use-case reintroduces it when real.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:00:22 -07:00
Christian PillsburyandClaude Opus 4.8 2fa3e5c311 refactor(spf): convert seek-to-live-edge to a reactor
Replace the single effect with a two-state machine (inactive ↔ live) gated on
deriveState (mediaElement + published-hence-open MediaSource + a derivable live
edge). The one-time seek becomes the live state's entry; the window-exit guard
becomes its effects. Entry-once-per-live-entry dissolves the closure `seeked`
latch — a source change drops to inactive and re-entry re-seeks. Drop the
MediaSource readyState check: setupMediaSource publishes the signal only once
open, so the signal's presence is the open gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:00:22 -07:00
Christian PillsburyandClaude Opus 4.8 62787a611d refactor(spf): derive the live edge from an injected latency seam
Unwind the HLS holdback assumption from seek-to-live-edge so the live
behaviors stay format-neutral. liveWindowFor returns a purely geometric
{start, end}; the 3×targetDuration HOLD-BACK rule moves to liveLatencyFor
in the HLS reload-policy, injected by the engine as seek-to-live-edge's
resolveLiveLatency seam. A new getLiveEdge primitive bundles the window
with the resolved latency into {start, end, liveEdgeStart}, so the behavior
consumes one edge and never reads delivery-format metadata. A DASH engine
injects its own resolver (suggestedPresentationDelay) with no model change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:00:22 -07:00
Christian PillsburyandClaude Opus 4.8 57a426db6b docs(spf): explain why syncLiveSeekableRange doesn't clear on termination
Reframe the "clearLiveSeekableRange() on termination" follow-up as a
deliberate non-action rather than a gap. Per the W3C MSE spec, the live
seekable range is consulted only while duration === Infinity; when a live
stream ends, endOfStream() sets a finite duration and the UA derives seekable
from buffered + duration, ignoring the live range — so clearing is unnecessary.
Clearing on the ENDLIST->finite-Track.duration transition would also be
premature (duration is still Infinity until endOfStream) and shrink seekable to
buffered-only. No logic change; the existing behavior (bail on null window) is
already correct, and the 'no-ops for a complete playlist' test locks it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:00:21 -07:00
Christian PillsburyandClaude Opus 4.8 0a7187eb22 fix(spf): derive the live window for audio-only sources
liveWindowFor hardcoded the selected *video* track, so audio-only live (no
video track / no selectedVideoTrackId) derived no window — leaving both
seek-to-live-edge and sync-live-seekable-range inert. Make liveWindowFor
track-type-agnostic (findTrackById instead of findTrack('video')), and add a
liveWindowFromState primitive that picks the timeline-bearing track:
selectedVideoTrackId ?? selectedAudioTrackId (video positions both A/V; audio-only
falls back to audio). Both behaviors now share that single call site, removing
the two brittle, must-stay-identical liveWindowFor(...) call sites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:00:21 -07:00
Christian PillsburyandClaude Opus 4.8 02af12e9d9 refactor(spf): drop vestigial try/catch in syncLiveSeekableRange
setLiveSeekableRange throws only on a non-'open' readyState or an invalid
range. The readyState is checked synchronously immediately before the call
(no await between, so it can't change), and liveWindowFor guarantees
0 <= start <= end — neither throw vector is reachable. The try/catch was
load-bearing pre-split (it also wrapped a duration write that could throw
mid-append); that write is gone, leaving the catch dead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:00:21 -07:00
Christian PillsburyandClaude Opus 4.8 3859da3dbd refactor(spf): make updateMediaSourceDuration the sole duration writer
syncLiveSeekableRange dropped its defensive mediaSource.duration = Infinity
write — it duplicated updateMediaSourceDuration (the canonical owner). Per the
W3C MSE spec, setLiveSeekableRange requires only readyState === 'open', not a
set duration, so this behavior never needed it. Verified live (Mux LL-HLS):
with the write removed, duration is Infinity and the initial seek lands at the
holdback from the first frame — updateMediaSourceDuration's async write resolves
fast at startup (buffers idle), ahead of seekToLiveEdge's seek.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:00:20 -07:00
Christian PillsburyandClaude Opus 4.8 24fa36e695 refactor(spf): split seek-to-live-edge into window derivation + seekable writer
Decompose the seek-to-live-edge behavior (which coupled three concerns) into:
- liveWindowFor (media/live-window.ts) — a pure derivation of the live window
  {start,end,targetDuration}, or null for VOD/ended/unresolved. The single
  source of truth, centralizing all inertness so consumers don't re-derive.
- syncLiveSeekableRange (behaviors/dom) — declares setLiveSeekableRange on each
  window slide, including while paused (the seekable range must stay current
  regardless of play state).
- seekToLiveEdge — now just the one-time HOLD-BACK seek + the window-exit guard,
  consuming liveWindowFor; sheds the seekable/duration writes (keeps the
  mediaSource open-gate so the seek lands in the declared range).

Composed syncLiveSeekableRange before seekToLiveEdge to preserve the
declare-before-seek ordering (a seek outside seekable is clamped). Behavior-
preserving; tests redistributed (7 liveWindowFor + 14 seekToLiveEdge + 5
syncLiveSeekableRange). Deferred follow-ups: clearLiveSeekableRange on
termination; duration-ownership cleanup vs updateMediaSourceDuration; B's
seeked-latch source-reset; window-derives-from-accumulated-segments-while-paused.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:00:20 -07:00
Christian PillsburyandClaude Opus 4.8 85e300e999 feat(spf): reposition the live playhead to the edge when it exits the window
Extend seek-to-live-edge with a live-window playhead guard: while playing
(!paused && !seeking && readyState>0), reposition currentTime to the live edge
(holdback) when it falls outside the sliding window [windowStart, windowEnd]
(0.1s tolerance). Covers a paused-too-long playhead the window slid past
(caught on the playing resume) and playback that fell behind on poor network
(caught on the effect's window-update re-fire, since timeupdate stops once a
stall freezes currentTime). In-window pause and DVR scrub-back are untouched.

The one-time initial seek is preserved (latched) and the guard reuses the same
liveEdgeStart, keeping the live playhead position single-owned. repositionPolicy
is a behavior-scoped seam (default 'window-exit'); the edge-only 'on-resume'
branch is inert, reserved for a future live-edge-only-mode use-case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:00:20 -07:00
Christian PillsburyandClaude Opus 4.8 b32783fbd7 fix(spf): pin the live track timeline to the buffer so streams end cleanly
The model timeline (an averageDuration×sequence estimate) could drift from
the SourceBuffer's native-PTS timeline across reloads. At ENDLIST the drifted
model placed the final segments behind the playhead, so the loader skipped
them and end-of-stream's isLastSegmentAppended never satisfied — playback
stalled in `waiting`, duration stuck at Infinity, `ended` never fired.

Pin the model to ground truth: once a segment is buffered, re-origin the track
onto where it actually landed (anchorTrackToBufferedSegment), correlated via
bufferedAnchorFor over the buffer actor's DOM-free snapshot. Pin once per track
(the offset is constant under no-discontinuity); the parser's now-PDT-exact
carry-forward maintains it. The sequence estimate stays the pre-buffer
bootstrap. anchorLiveTracks stays DOM-free — the engine injects the resolver
from the buffer actors; no timestampOffset (preserves A/V sync). The same pin
serves non-zero-PTS VOD.

Smoke-tested on a live Mux LL-HLS stream: durationchange → finite duration →
plays out → `ended`, no stall.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:59:45 -07:00
Christian PillsburyandClaude Opus 4.8 2a1fe85c28 fix(spf): bridge media-playlist turnover via PDT, not the target-duration estimate
When a live reload skips the whole window (no media-sequence overlap),
placeOnPreviousTimeline bridged the gap with a target-duration estimate,
which drifts whenever actual segment durations differ from the declared
ceiling. PROGRAM-DATE-TIME is the spec-consistent cross-reload reference and
places the turnover window exactly; use it when present, falling back to the
estimate only when PDT is absent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:59:45 -07:00
Christian PillsburyandClaude Opus 4.8 bc617eb1e7 refactor(spf): inline the resolve task, keep resolve-track close to main
The createResolveTask factory was a single-call hoist (the RecurringRunner
clones the task itself), so inline the task back into runner.schedule(...) as
on main, and inline the shouldLoadTrack gate the same way. Restore the
surrounding comments and formatting to main's, deviating only where the
refactor changed meaning: the gate now reloads resolved-but-incomplete (live)
windows (resolved→complete), and the obsolete scheduler/commit-time-id-check
notes are dropped. Net diff against main: 131+/70- down to 33+/18-.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:59:44 -07:00
Christian PillsburyandClaude Opus 4.8 c7abf7df8e refactor(spf): rename startSequence engine config to presumedStartSequence
startSequence read ambiguously. Rename to presumedStartSequence across the
hls engine config, the anchorLiveTracks behavior, and the (HLS-agnostic)
anchorTrackToSequenceOrigin media primitive — conveying that it's the media
sequence presumed to be the stream origin. Kept HLS-free so the media-layer
primitive stays layering-clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:59:44 -07:00
Christian PillsburyandClaude Opus 4.8 9b973b8e66 refactor(spf): drop redundant live-hls and live-playlist-spike engines
Live playback was folded into createSimpleHlsEngine (VoD + live, one
composition), making the separate createLiveHlsEngine — which only wrapped
the same behaviors plus the hls engine itself — redundant, and leaving
live-playlist-spike an orphaned experiment with no exports or consumers.
Delete both, drop the ./live-hls package export and tsdown entry, and point
the sandbox live harness at createSimpleHlsEngine.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:59:44 -07:00
Christian PillsburyandClaude Opus 4.8 2ad9f01954 test(spf): pin live reload cadence intervals
The resolve-track live-reload tests stub the reschedule, so they never
assert the interval. Drive the real composition the engine wires —
RecurringRunner + delayedReschedule + mediaPlaylistReloadDelay + Task.clone
previous-threading — under fake timers and assert: a sliding window reloads
at the full target duration (per RFC 8216 §6.3.4), a stale window floors at
half target and never faster, and a completed playlist stops after one run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:59:25 -07:00
Christian PillsburyandClaude Opus 4.8 5aa99674e9 refactor(spf): drop the vestigial reschedule retry-on-error path
The RecurringRunner propagates a rejected run as the recurrence's failure
(Promise.all short-circuits before any retry verdict lands), so the
retry-on-error affordance in delayedReschedule and mediaPlaylistReloadDelay
was dead code. Remove it: delayedReschedule awaits the run directly (a
rejection now rejects the reschedule), and mediaPlaylistReloadDelay takes a
non-optional current track. Transient-fetch-failure recovery belongs at the
fetch layer, not in the cadence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:59:25 -07:00
Christian PillsburyandClaude Opus 4.8 2520373f2f refactor(spf): make RecurringRunner a self-recursive schedule(), require reschedule
Replace the imperative `#loop()` with a single chained promise: each cycle's
`.then` returns the next cycle's `schedule(clone())`, so the recurrence is the
method calling itself. The slot is released just before re-scheduling the same-id
clone so the call advances rather than dedup-returning; ownership is tracked by
`#active === task`.

Error handling moves downstream — the runner no longer invents a retry policy:
- A genuine run/reschedule failure rejects schedule()'s promise (propagates to
  the caller); no swallowing.
- The runner's own cancellation (abort/supersede/destroy) is not a failure, so an
  aborted recurrence settles quietly — callers don't `.catch` routine teardown.

Consequences:
- `reschedule` is now required; `runOnce` expresses run-exactly-once explicitly
  (a missing reschedule is a bug, not a silent run-once).
- Reschedule-driven retry-on-transient-error is dropped (a rejected run is
  terminal). The retry logic in delayedReschedule / mediaPlaylistReloadDelay is
  now vestigial — to be cleaned up or relocated to the fetch layer next.
- `resolve-track` catches schedule()'s promise (abort settles quietly; genuine
  resolve failures end the recurrence, TODO surface to state).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:59:25 -07:00
Christian PillsburyandClaude Opus 4.8 5154c424f9 refactor(spf): simplify Reschedule to single-arg by carrying previous/signal on Task
Collapse `Reschedule<T>` from `(task, previous, signal) => ...` to
`(task) => PromiseLike<boolean>`. One `delayedReschedule` instance is shared
across the video + audio runners, so per-recurrence state can't live in its
closure — the task is the only per-recurrence carrier, so both dropped params
move onto it:

- `task.signal` exposes the task's composed signal. RecurringRunner drops its
  separate `#abort` AbortController: the task is now the sole cancellation
  channel and loop ownership is task-identity, not an AbortController token.
- `task.previous` is carried by `clone()` as lineage
  (`#previous = this.#value ?? this.#previous`), reproducing the last-*successful*
  value semantics (an errored cycle inherits the prior good value) with no
  bookkeeping in the runner — `#loop` no longer threads previous/result.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:59:24 -07:00
Christian PillsburyandClaude Opus 4.8 73f0ef711a refactor(spf): memoize Task.run via a named #execute, fixing orphan rejections
Memoize the run-machinery promise itself (`#promise ??= this.#execute()`) rather
than reassigning `#promise` to fresh `Promise.resolve(value)` / `Promise.reject(error)`
on settle. The reassigned promises were never awaited, so an errored or aborted
task that's not re-run (the norm — the RecurringRunner moves on to a clone) left
an unhandled rejection. The memoized promise is the one callers await, so it's
always handled.

Also closes a sync-throw gap: a `#runFn` that throws synchronously is now captured
as a rejected memoized promise instead of leaving `#promise` unset (which would
re-execute on the next run()).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:59:24 -07:00
Christian PillsburyandClaude Opus 4.8 557d8bd72f refactor(spf): drive live reload via a RecurringRunner instead of an epoch signal (WIP)
Replace the signal-as-event live-reload scheduler with a runner-driven model.
The `resolveTrack` loader schedules its resolve work on a new `RecurringRunner`
that re-runs the task on an injected `reschedule` policy; the separate
`scheduleTrackReload` behavior and its per-type reload-epoch signals are deleted.

Core (`core/tasks`):
- `Task.run()` is now memoized (runs once, shares the result across calls) and
  gains `clone()` (fresh, pending, structurally identical) — added to `TaskLike`.
- `RecurringRunner`: single-slot, id-keyed (dedup same id / abort-and-replace on
  new id), time-free. Each cycle runs `Promise.all([task.run(), reschedule(task,
  previous, signal)])` and re-runs a `clone()` while reschedule resolves `true`.
- `Reschedule<T> = (task, previous, signal) => PromiseLike<boolean>` — invoked
  concurrently with the run, observes it via the memoized `run()`, owns its delay.
- `delayedReschedule(cadence)` builds a Reschedule from a pure ms-cadence fn,
  start-anchored (subtracts the run's elapsed) so reloads are measured from
  load-start per RFC 8216 §6.3.4, preserving half-on-unchanged.

Supporting:
- `@videojs/utils/time`: add cancellable `sleep(ms, signal)`.
- `media/hls/reload-policy`: `mediaPlaylistReloadDelay` (pure cadence; relocated
  scheduler logic — target-duration, half-on-unchanged, stop-on-ENDLIST, retry).
- `resolve-track`: baked universal completeness gate + injected `reschedule`;
  engine composes `delayedReschedule(mediaPlaylistReloadDelay)`.

WIP: not fully validated end-to-end against a live stream through this path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:59:24 -07:00
Christian PillsburyandClaude Opus 4.8 1d51582d8c refactor(spf): inject the track-load gate so resolve-track stays live-agnostic
Replace the inline completeness check (and the `state[reloadEpochKey].get()`
ping read) in the loader's effect with an injected `shouldLoadTrack(track,
params)` gate, mirroring track-switching's rule pattern. The core
(`setupTrackResolution`) now knows nothing about reload epochs or completeness:
its effect just runs `if (!track || !shouldLoadTrack(track, params)) return;`.

- Default gate `loadIfUnresolved` (`!isResolvedTrack`) is the live-agnostic
  one-shot resolve. Each `resolve*` variant injects `shouldLoadLiveTrack`, which
  reloads incomplete windows and subscribes the effect to the scheduler's
  per-type reload-epoch ping (read for subscription only; value unused).
- `ResolveTrackState`, the variants' `stateKeys`, and `TrackResolutionConfig`
  drop the epoch slots + `reloadEpochKey`. The live gate reads its slot
  defensively off an optional `ReloadEpochStateView` (the `bandwidthState?`
  pattern), with the per-type key closured in at the variant — so only the gate
  names the ping, never the core. The scheduler still materializes the slots.
- `setupTrackResolution` takes `params` whole (destructured in the body, not the
  signature) and threads it through to the gate, so a future gate can reach
  `context` without changing the seam.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:59:24 -07:00
Christian PillsburyandClaude Opus 4.8 6125c93216 refactor(spf): gate track reload on completeness, not a serviced-epoch counter
Replace the resolve-track loader's fused reload gate (`isResolvedTrack(track)
&& epoch <= lastLoadedEpoch`) and its `let lastLoadedEpoch` closure-local with
a pure `shouldResolveTrack` predicate keyed off `Track.duration` completeness:
unresolved → load, resolved-but-incomplete → reload, resolved + complete →
reuse. A switch to a resolved-but-incomplete live track now eagerly re-fetches
(its window may have slid past the playhead), which is what makes the
last-serviced memory unnecessary — every effect re-fire is a legitimate load,
with in-flight dedup and the peeked presentation preventing redundancy.

The reload-epoch slot becomes purely a re-fire ping: subscribed to, never
compared. Completeness via `Number.isFinite(track.duration)` is the existing
single source of truth, so a live stream that hits ENDLIST stops reloading with
no engine- or stream-type config.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:59:23 -07:00
Christian PillsburyandClaude Opus 4.8 e5d98649ff refactor(spf): use Track.duration as the single completeness source of truth
The live-vs-complete decision was being re-derived in three places from
`metadata.endList`, which diverges from how the parser itself computes
completeness (`endList || PLAYLIST-TYPE:VOD`) — and the parser already bakes
that result into `Track.duration` (finite EXTINF sum when complete, Infinity
while it can still grow). So read that instead of re-deriving:

- resolveDuration default reverts to `getResolvedSelectedTrackDuration` (returns
  `Track.duration`, already Infinity for live by construction). Drops the
  redundant `resolveSelectedTrackDuration`, which also keyed off `endList` alone
  and thus wrongly returned Infinity for a complete PLAYLIST-TYPE:VOD playlist
  lacking #EXT-X-ENDLIST.
- end-of-stream and seek-to-live-edge guards switch from
  `metadata.endList` to `Number.isFinite(track.duration)`.

Net: `Presentation.duration === Track.duration` by construction, and all three
behaviors share the parser's one completeness predicate. Removes code. Full spf
suite green (1070 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:59:23 -07:00
Christian PillsburyandClaude Opus 4.8 54f126bd9f fix(spf): gate end-of-stream on playlist completeness so live duration stays Infinity
endOfStream's deriveState treated "the last currently-known segment is appended"
as "the stream ended", with no completeness check. For VoD that's correct, but
for live the last segment is only the rolling edge — so once it was appended the
behavior called mediaSource.endOfStream() and set duration to the buffered
(live-edge) end, the next reload's appends flipped the MediaSource back to
'open', and it re-fired on a loop. The visible symptom was mediaSource.duration
flipping from Infinity to a finite, growing value (and a stream that could stall
once the window slid past it).

Add a completeness guard: a track only reaches 'eos-ready' when its playlist is
complete (#EXT-X-ENDLIST) and its last segment is appended. Ongoing live (no
endList) stays inert, so duration remains Infinity; a live stream that genuinely
ends appends #EXT-X-ENDLIST, which opens the guard and ends it gracefully. Keys
off completeness, consistent with resolveSelectedTrackDuration and seekToLiveEdge.

Verified live playback through createSimpleHlsEngine: duration now holds at
Infinity. Full spf suite green (1073 passed); two new tests cover the
live-no-fire and graceful-endList-fire cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:59:23 -07:00
Christian PillsburyandClaude Opus 4.8 bfb5ef7aae feat(spf): fold live HLS support into createSimpleHlsEngine
Compose the live behaviors into the VoD engine so one composition handles
both VoD and live, keying every live-vs-VoD decision off playlist
completeness rather than streamType:

- resolveSelectedTrackDuration: new completeness-based duration resolver, now
  the engine default. Returns the resolved track's finite duration when its
  playlist is complete (#EXT-X-ENDLIST), else Infinity (still growing → live).
  Keys off completeness because deriveStreamType marks any playlist lacking
  #EXT-X-PLAYLIST-TYPE:VOD as 'live', which would wrongly force Infinity on a
  plain VoD stream that only carries #EXT-X-ENDLIST.
- seekToLiveEdge: guarded to no-op for complete playlists, so it's inert for
  VoD when composed unconditionally.
- createSimpleHlsEngine: composes scheduleVideoTrackReload/Audio/Text,
  anchorLiveTracks, and guarded seekToLiveEdge — all inert for VoD (complete
  playlists never reload; the anchor is a no-op without PDT / shift 0). Adds
  the startSequence config knob.

Also make updateMediaSourceDuration's live write defer to a non-updating
SourceBuffer instant: Infinity needn't precede appends (it overrides any finite
live-edge value an append pinned), but the MSE spec forbids setting duration
while a buffer is updating, so a racing synchronous write threw.

Verified live playback end-to-end through createSimpleHlsEngine (real-time,
presentation.duration Infinity, seeked to edge) against a Mux LL-HLS stream;
full VoD suite green (1071 passed). The separate live engine is retained for
now. Known follow-up: mediaSource.duration can stay finite during initial
buffer fill (continuous appends leave no idle instant) — to be fixed by
writing Infinity synchronously at sourceopen, ahead of the buffer actors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:59:23 -07:00
Christian PillsburyandClaude Opus 4.8 e268f5a350 refactor(spf): split track loading from live-reload scheduling
Decompose the live media-playlist reload into two single-responsibility
behaviors, matching the content-snapshot ([1]) vs refetch-policy ([3])
category split in live-presentation-modeling.md:

- resolve-track (loader, [1]) now also services live reloads: it watches a
  per-type reload-epoch slot and re-fetches when it advances (gate: load if
  unresolved OR epoch > last serviced), carries the prior snapshot's timeline
  forward, and sets presentation.streamType. ConcurrentRunner id-dedup +
  recording the epoch at schedule time gives drop-if-busy coalescing.
- schedule-track-reload (scheduler, [3]) is what remains of reload-track once
  the fetching is removed: it bumps the reload epoch on a target-duration
  cadence (half on unchanged), inert for VoD via #EXT-X-ENDLIST, and enters on
  track *selection* so its bumps also drive first-resolve retries.

reload-track is removed; the live-hls and live-playlist-spike engines compose
resolve* + schedule* in its place.

Also fix updateMediaSourceDuration for live: it only wrote Infinity when the
MediaSource was already open at entry and returned without scheduling a wait
otherwise. The presentation can resolve to Infinity before setupMediaSource
opens the MediaSource, so the eager write was missed and the first append
pinned a finite live-edge duration — stalling once the window slid past it.
Now it defers to sourceopen when not yet open.

Verified live playback end-to-end (real-time, Infinity duration, seeked to
edge) against a Mux LL-HLS stream.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:59:22 -07:00
Christian PillsburyandClaude Opus 4.8 0f10a280e5 feat(spf): add live HLS media-element adapter
WHATWG media-element adapter (src/preload/play/attach/destroy) over
createLiveHlsEngine, mirroring SimpleHlsMediaMixin — the foundation a
<live-hls-video> element wraps. Exported from @videojs/spf/live-hls as
LiveHlsMediaMixin / LiveHlsMediaElement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:59:22 -07:00
Christian PillsburyandClaude Opus 4.8 cf69a7b7eb feat(spf): start live playback near the edge (HOLD-BACK)
seekToLiveEdge seeked to the window start (~full DVR window behind live).
Start HOLD-BACK behind the edge instead — windowEnd − 3 × TARGETDURATION (the
HLS spec default when the playlist omits HOLD-BACK), clamped to the window
start. The full DVR window stays seekable; only the initial position moves
toward the edge. Verified against a live Mux CMAF stream: starts ~edge instead
of ~20s back.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:59:22 -07:00
Christian PillsburyandClaude Opus 4.8 fb5e6cbf95 fix(spf): keep the live reload loop alive across transient fetch failures
The reload loop's try/catch wrapped the while, so a single fetch/parse failure
(e.g. a transient "TypeError: Failed to fetch", a CDN blip) ended the loop —
the live playlist would stop refreshing and playback would eventually stall at
the last-known window. Move the try inside the loop: on a non-abort error, log
and retry on the next cadence; only abort (source change / destroy) exits.

Adds a fake-timer test asserting the loop retries after a rejected fetch and
resolves on the next attempt. Verified against a live Mux CMAF stream (window
keeps advancing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:59:22 -07:00
Christian PillsburyandClaude Opus 4.8 b4c7db2653 fix(spf): set mediaSource.duration = Infinity eagerly for live
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>
2026-06-25 09:59:21 -07:00
Christian PillsburyandClaude Opus 4.8 6780a44ccb fix(spf): make seekToLiveEdge model-driven (declare seekable range + 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>
2026-06-25 09:59:21 -07:00
Christian PillsburyandClaude Opus 4.8 4a58e85015 feat(spf): export ./live-hls + sandbox live-engine harness
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>
2026-06-25 09:59:21 -07:00