Reinstate text-cue relocation (dropped with relocation.ts) and, in the
same pass, rearchitect the text-segment loader onto the v/a loader's
composed-step model so relocation is a pipeline step, not a resolver
wrapper.
Text loader gains TextFrame / TextLoadStep / TextStepDeps /
TextMessagePipelines mirroring segment-loader, with base resolveCuesStep
+ dispatchCuesStep (default: resolveCues -> dispatchCues). makeLoadTask
now just runs the pipeline. The resolver reverts to a pure (url) => cues
host primitive (the text analog of fetchBytes) -- composition-awareness
moves entirely into the injected step, so composition state threads via
compositionDeps into TextStepDeps.
Relocation supplies relocatingTextPipelines (resolveWithMetadata ->
relocateCues -> dispatchCues): cues + X-TIMESTAMP-MAP fetched in
parallel, cues shifted by (mapCorrection - startMediaTime) using the
primary A/V track's startMediaTime (video ?? audio, awaited). The engine
bakes textMessagePipelines, mirroring video/audioMessagePipelines.
Base steps are generic arrow consts (TextStepDeps is contravariant in C
via textTracksActor.send). Sandbox-verified 0-based against the Mux
asset_start_time=60 clip: active cue brackets the 0-based playhead.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Key mediaContainerData by track *type* (video/audio), not track id: one
init+media pair per type, ABR rungs share the origin. relocationMessagePipelines
-> relocationPipelinesFor(type); the engines pass per-type.
- #4 fix: record the discovered segment's 0-based startTime and derive the origin
as baseMediaDecodeTime/timescale - segmentStartTime, so relocation is correct when
the first loaded segment isn't the 0th (non-zero initial currentTime, live/DVR).
Adds segmentStartTime to MediaContainerData.
- Rename derivePerTrackStartMediaTime -> derivePerTypeStartMediaTime; stampTracks
applies each type's origin to every track of that type.
- Add establish-start-media-time derive unit test.
Unit-verified (segmentStartTime=100 still yields origin 60) and end-to-end
sandbox-verified: start-at-300 buffers around the playhead (0-based); default
start-at-0 A/V + audio-only remain 0-based.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rework the segment loader's per-message execution into composable step
pipelines (fetch → dispatch by default); relocation injects discover/stamp
steps between them, so loader + SourceBuffer actor stay relocation-oblivious
and Tier 0 imports no relocation code.
- segment-loader: Frame/LoadStep/StepDeps/MessagePipelines, fetchStep/
dispatchStep (deps as 3rd arg), step-runner makeLoadTask + inFlight wrapper.
- source-buffer: drop CreateAppendMeta; keep idempotent timestampOffset guard.
- relocation.ts / origin-discoverer.ts: TRANSITIONAL — slated for rebuild as
the establishStartMediaTime reactor.
WIP checkpoint; outstanding work tracked in
.claude/plans/spf-non-zero-pts-timestamp-offset-spike.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Opt-in relocateTimestampOrigin on the HLS engine. The video loader reads
each track's decode-time origin (tfdt baseMediaDecodeTime / mdhd timescale)
from the init + first media segment, then relocates the buffer to 0-based
via SourceBuffer.timestampOffset = -origin, so currentTime/seekable/duration
stay 0-based with no adapter translation. Off by default (zero-PTS VOD pays
nothing).
The established offset is published on the segment-loader snapshot so the
text path can rebase cues onto the same timeline: cueFinal = cueNative +
timestampOffset, where cueNative applies the X-TIMESTAMP-MAP correction
(Apple) or the absolute cue time (map-less Mux, which can't self-derive the
origin and reads the A/V-established one).
Checkpoint before the composition-driven refactor.
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>