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>
liveWindowFor is now {start, end}; the holdback rule lives in liveLatencyFor
and reaches seek-to-live-edge via the resolveLiveLatency seam + getLiveEdge.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
Update the Implementation surface + live-edge phase row to attribute
setLiveSeekableRange to syncLiveSeekableRange and the window derivation to the
pure liveWindowFor helper, rather than the (now leaner) seekToLiveEdge.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
Promote the live-window playhead guard phase from planned to implemented
(window-exit reposition in seek-to-live-edge): update the Status block, phase
row, What's-not-implemented, Implementation/Config surface, and Verification
(landed unit matrix; e2e deferred). Record the resolved seam-shape decision
(behavior-scoped repositionPolicy, not a public engine-config field) and guard
placement (extends seek-to-live-edge).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bump live-stream-support to sketched depth grounded in feat/spf-hls-live
(reload loop, sliding-window tracking, Infinity duration, live-edge seek,
ENDLIST termination all implemented); resolve its 5 open questions. Add the
planned live-window playhead guard (reposition on window-exit, covering
paused-too-long and fallen-behind) with a repositionPolicy seam for a future
edge-only composition.
Cascade: register [live-edge-only-mode] use-case candidate; cross-ref
buffer-stall-recovery (in-window stall vs window-exit reposition) and
dvr-event-stream-support (seekable spectrum).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
live-presentation-modeling: category [3] refetch policy is realized as the
pure §6.3.4 cadence (mediaPlaylistReloadDelay) + delayedReschedule +
RecurringRunner, fetch scheduling per-track. Record the decided error
semantics — a failed reload's rejection propagates and ends the recurrence;
transient-fetch retry is deferred to network-resilience, kept out of the
cadence — and mark the [3] placement open question decided.
ll-hls-support: note that regular-live change-detection keys off the
full-segment signature, so a parts-only update reads as unchanged; LL-HLS
must fold part progression into change-detection or switch to blocking reload.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Live test streams are ephemeral; let the harness target any m3u8 via a query
param (default kept) so it can be pointed at a fresh stream without editing code.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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 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>
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>
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 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>
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>
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>
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>
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>
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>
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>
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>
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>