Commit Graph
1143 Commits
Author SHA1 Message Date
Christian PillsburyandClaude Opus 4.8 8a9e2911fd wip(spf): relocate text cues via a composed step pipeline (#6)
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>
2026-07-10 10:29:35 -07:00
Christian PillsburyandClaude Opus 4.8 a304067c53 wip(spf): cast instead of re-guarding resolved presentation in the derive effect
#5 review: the isResolvedPresentation guard in the derive effect's update() was
runtime-redundant — the monitor gates `monitoring` on a resolved presentation and
transitions before effects re-run, so `current` is always resolved there. Drop the
guard and cast to `Presentation`, with a comment citing the invariant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 07:30:30 -07:00
Christian PillsburyandClaude Opus 4.8 b820064b77 wip(spf): per-type relocation keying + non-0th-segment origin fix
- 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>
2026-07-10 07:20:43 -07:00
Christian PillsburyandClaude Opus 4.8 0b99687db4 wip(spf): wire non-zero-PTS relocation into the audio-only engine
Compose establishStartMediaTime + bake audioMessagePipelines + the
mediaContainerData slot + deriveStartMediaTime config into
createHlsAudioOnlyEngine, comment-marked, mirroring the default engine. The
reactor's optional/defensive selection composes with only audio in scope.

Audio relocates to a 0-based timeline — sandbox-verified on the Mux
asset_start_time=60 clip via the html-simple-hls-audio-only harness (audio
buffered/seekable 0-based, duration 540).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 14:31:15 -07:00
Christian PillsburyandClaude Opus 4.8 05a0a6452d wip(spf): non-zero-PTS relocation as establishStartMediaTime reactor + relocation steps
Rebuild relocation from the config-bundle (createRelocation + self-discriminating
origin-discoverer) into a DOM-free per-source reactor plus DOM config-pipeline
steps, per the presentation-timeline design + review.

- behaviors/establish-start-media-time.ts (new, DOM-free): the reactor — 3 states
  (inactive clears the transient slot per source / monitoring runs the injected
  deriveStartMediaTime seam -> writes Track.startMediaTime / established disables it).
  Owns mediaContainerData on state; selection optional/defensive; never touches pipelines.
- behaviors/dom/relocation-steps.ts (new): relocationMessagePipelines — a plain
  config step array. discover writes state.mediaContainerData; stamp reads the
  track's own discovered origin (Tier 1) -> timestampOffset. Steps read composition
  state from call-time deps; coordinate with the reactor only through the slot.
- segment-loader: StepDeps widened with the composition {state,context,config}
  (opaque conduit; the loader never reads them).
- primitives/head-peek.ts (new): generic eager head-peek (replaces origin-discoverer).
- media/types: Track.startMediaTime + MediaContainerData.
- setup-buffer-actors: relocation pipelines from config; threads composition deps.
- engine: composes the reactor + bakes relocationMessagePipelines + mediaContainerData
  + deriveStartMediaTime config, comment-marked for easy removal.
- Deleted relocation.ts, origin-discoverer.ts. Doc + backlog updated.

Deferred (backlog): per-type keying; non-0th-segment origin (#4 — non-zero starts
currently mis-relocate); tier-agnostic apply; text-cue relocation; Tier 2 min;
audio-only wiring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 14:05:36 -07:00
Christian PillsburyandClaude Opus 4.8 ae1b0beeec wip(docs): reactor-based relocation design + spike backlog
Rearchitect presentation-timeline-model.md around the establishStartMediaTime
reactor (mediaContainerData slot, pure deriveStartMediaTime seam,
Track.startMediaTime consume, abortable awaitDefined apply); resolve the
reduce-hinge and startMediaTime-storage open questions.

Add a living Status & backlog to the spike plan so commits never bury
outstanding refactor/review work.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 12:04:42 -07:00
Christian PillsburyandClaude Opus 4.8 041cecca6a wip(spf): relocation via messagePipelines step model
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>
2026-07-09 12:04:23 -07:00
Christian Pillsbury 6a20bade9b feat(spf): relocate non-zero-PTS origin to a 0-based timeline (spike)
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.
2026-07-08 10:13:20 -07:00
Christian PillsburyandClaude Opus 4.8 555f9fdacc docs(spf): add presentation timeline coordinate model design doc
Consolidates the design-discovery for the timestampOffset-relocation
approach to non-zero-PTS: the three-timeline coordinate model
(media / presentation / wall-clock, with timestampOffset derived from
startTime - startMediaTime), the orthogonal capability axes
(relocation / parser / coordination), the discover -> reduce -> apply ->
consume architecture with branch-free always-present actors, and the open
hinges -- chief among them generalizing the live establishment behavior
for VOD. Draft; spike-stage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 12:50:28 -07:00
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 51caeb7d95 docs(plan): orienting note for the timestampOffset non-zero-PTS spike
Two-path context (native-PTS on feat/spf-non-zero-pts vs timestampOffset
relocation here), the four problems to keep in view, the load-behavior
coordinate-assumption audit, and TODOs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 07:50:07 -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 553061a30b test(sandbox): add ffmpeg-based non-Mux local live HLS source
local-live.sh generates a sliding-window demuxed CMAF/fMP4 live stream with
ffmpeg's HLS muxer (master + EXT-X-MEDIA audio group + fMP4 init/segments) to a
temp dir, served with CORS and no caching — a no-creds local live source for the
SPF engine and the hls.js/native sandbox pages. Documents the no-cache
requirement (a positive max-age on a live playlist freezes the client's window —
a server misconfiguration, not a player bug).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:46:26 -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 d4b21954bc docs(spf): note EVENT detection is now consumer-reachable
The DVR-vs-live distinction open question's PLAYLIST-TYPE:EVENT option is
available today: playlistType is exposed via getMediaPlaylistMetadata on
@videojs/spf/hls and used by the sandbox.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:20:47 -07:00
Christian PillsburyandClaude Opus 4.8 3cbfdace6c fix(sandbox): classify DVR/EVENT from playlist type, not window size
The Live/DVR panel guessed DVR from a 60s seekable-window threshold, so an
EVENT source read as 'sliding live' until it had aggregated enough segments.
Classify from the parsed playlistType ('EVENT') via getMediaPlaylistMetadata
instead. Gate on metadata presence (show 'live (resolving…)' until a timeline
track has metadata) so an EVENT source no longer flickers through 'sliding
live' on load/reload before the playlist resolves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:20:44 -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 da33b10952 test(sandbox): add ffmpeg + Mux live/DVR test-stream scripts and orchestrator
apps/sandbox/scripts/live/ — spin up an ephemeral Mux live source for
exercising the live path in any HLS-capable sandbox page:
- create-stream.sh: create a low-latency Mux live stream (creds via
  --token-id/--token-secret flags or MUX_TOKEN_ID/MUX_TOKEN_SECRET env;
  --quiet for scripting)
- broadcast.sh: push an ffmpeg testsrc + tone feed to the stream
- dvr-url.sh: resolve active_asset_id to the DVR/EVENT playback URL
- live-test.sh: orchestrate create -> broadcast -> wait -> open the page,
  parameterized by --page (renderer-agnostic), --flavor sliding|dvr|both,
  --port, --params, --latency; deletes the stream on exit unless --keep
- README.md: quick-start, manual steps, and cross-renderer reuse notes

Credentials are read only from flags/env; none are committed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:52:21 -07:00
Christian PillsburyandClaude Opus 4.8 78f33556dc docs(spf): link live/DVR hardening follow-ups to tracking issues
Add issue refs to the live/DVR Open-questions bullets (#1743-#1746) and
a Status pointer to the Live/DVR Seekable and Anchor Hardening epic
(#1742), so the design docs and the issue tracker are bidirectional.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:01:58 -07:00
Christian PillsburyandClaude Opus 4.8 2820b844b8 docs(spf): record live-window-guard fix and live/DVR seekable + anchor-coupling follow-ups
- live-stream-support.md: correct the window-exit guard description and
  test matrix to match the shipped fix (guard no longer bails on
  `seeking`); add the resolved out-of-window-seek-stall note; add Open
  questions for the seekable-range "lip", clamp-to-seekable as the
  general mechanism, and back-of-window yank-to-edge on discrete window
  slides. Remove stray EOF tags.
- live-presentation-modeling.md: add Open question flagging the
  model <-> anchor <-> resolve-track coupling for a holistic revisit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:52:15 -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 6888b83abe feat(sandbox): add live/DVR diagnostics + seek controls to spf-segment-loading
Adds a Live/DVR panel (stream type, window classification, live gap,
DVR window length, buffer-ahead, presentation anchor + wall-clock) and
seek controls (seek-to-live, -10/-30s, jump to window start) to the SPF
segment-loading harness. All derived sandbox-side from presentation
streamType + the native video; degrades cleanly to on-demand.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:55:15 -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 f531e1055c docs(spf): decide the single presentation-level live timeline anchor
Capture the decision to hold one rolling (media-time ↔ PDT) anchor — learned
from the first A/V track to obtain SourceBuffer ground truth and applied to all
selected tracks (incl. text) by per-segment PDT — replacing today's per-track
buffer pins, which are redundant under the no-skew assumption and can't anchor
text (no SourceBuffer). New decision doc live-presentation-anchor.md; resolves
the deferred single-rolling-anchor question in live-timeline-anchoring and
promotes [4] in live-presentation-modeling to presentation-level.

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 5e161837d8 docs(spf): drop stale repositionPolicy references after seam removal
The repositionPolicy seam was removed when seek-to-live-edge's guard became
reload-driven. Reframe the sibling references: the guard does window-exit, and
the edge-only [live-edge-only-mode] use-case supplies its own reposition
behavior (alternative-impl) rather than flipping a seam.

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 fb4b6b05fb docs(spf): reconcile live-stream-support to the reactor guard
seek-to-live-edge is now a reactor (inactive ↔ live): entry does the one-time
seek, effects runs the window-exit guard (window-update re-fire + a single
`play` listener — `play`, not `playing`, since a slid-past playhead is at an
unseekable position where `playing` never fires). The repositionPolicy seam
was removed once the guard became reload-driven (nothing consumed it); the
live-edge-only use-case defines its own reposition behavior when built.

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 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 ada3100209 docs(spf): reflect the live-latency seam in live-stream-support
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>
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 81fb61b354 docs(spf): note liveWindowFromState + audio-only live in live-stream-support
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 0d7208d2d4 docs(spf): note duration sole-ownership in live-stream-support
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 7ccac9168e docs(spf): reflect seek-to-live-edge split in live-stream-support
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>
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