mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
The behavior's mechanism — pin one track from buffer ground truth, derive one shared offset, stamp every track onto it — is format-neutral; only the offset source (PDT) is live-specific. Rename it and its published signal (liveAnchor → presentationAnchor, matching the PresentationAnchor type it holds) so the name reflects the general role, and note the non-zero-PTS reuse path through the existing resolveBufferedAnchor seam. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
152 lines
8.1 KiB
Markdown
152 lines
8.1 KiB
Markdown
---
|
||
status: decided
|
||
date: 2026-06-24
|
||
---
|
||
|
||
# Single presentation-level live timeline anchor
|
||
|
||
## Decision
|
||
|
||
Hold **one** rolling presentation-level anchor — a `(media-time ↔ PDT)`
|
||
correspondence — and apply it to **every** selected track, rather than pinning
|
||
each track to its own buffer.
|
||
|
||
The anchor is **learned from whichever selected audio/video track first has
|
||
SourceBuffer ground truth**: the buffer pin (`resolveBufferedAnchor`) reports a
|
||
buffered segment's actual native-PTS start `M₀`, paired with that segment's PDT
|
||
`P₀`. From that one pair, any track's segment is placed by its own PDT:
|
||
|
||
```
|
||
segment.startTime = M₀ + (segment.programDateTime − P₀)
|
||
```
|
||
|
||
Established **once** per source, on first buffer ground truth (pin-once — it
|
||
surfaces drift rather than masking it; we don't re-correct, and the parser's PDT
|
||
carry-forward maintains the timeline across reloads). On establishment the anchor
|
||
is stamped onto **every** track in one pass: resolved tracks shift their segment
|
||
timeline onto it; not-yet-resolved shells get it as `startDate`, so the
|
||
media-playlist parser places their segments on the shared timeline at first
|
||
resolve (`placeOnAnchor`). So a track selected later — an ABR rung, another audio
|
||
language, late captions — resolves already anchored, with no per-track
|
||
positioning pass. The established anchor is also published as a presentation-level
|
||
`presentationAnchor`, which `seekToLiveEdge` gates its live-edge seek on (see below).
|
||
|
||
No pre-buffer estimate. Until ground truth exists a track rides its raw parser
|
||
timeline — a valid timeline for fetching the first segments (the same segments
|
||
are fetched either way), so the loader bootstraps the buffer, and thus the pin,
|
||
without it. The one thing the estimate originally covered: `seekToLiveEdge` must
|
||
not seek before the pin, or it targets the raw window and the pin's later shift
|
||
strands the playhead off-window (confirmed by smoke test). We address that by
|
||
**gating** that seek on the published anchor (`presentationAnchor`, below) rather than
|
||
bootstrapping it off a manifest estimate — the estimate's ~27 s turnover drift
|
||
made it an unreliable seek target anyway. An earlier design kept the estimate as
|
||
bootstrap; it's dropped in favor of the gate.
|
||
|
||
This anchor covers **text tracks too** — they have no SourceBuffer to pin, so
|
||
the shared anchor is the *only* way to place them. WebVTT cues are assumed
|
||
already time-aligned (the analog of assuming encoded A/V samples are aligned);
|
||
what we align is the text track's **segment** timeline, by PDT, exactly as for
|
||
A/V. (That cues are sparse/overlapping — a cue may begin or end outside its
|
||
segment's nominal bounds — is a text *loader/eviction* concern, not an
|
||
anchoring one.)
|
||
|
||
Scope: this fixes the **single rolling anchor vs. per-segment measured times**
|
||
question deferred by [live-timeline-anchoring](./live-timeline-anchoring.md),
|
||
and realizes the *manifest → buffer* half of that decision's Boundary as a
|
||
single shared anchor.
|
||
|
||
## Context
|
||
|
||
[live-timeline-anchoring](./live-timeline-anchoring.md) settled the anchor
|
||
*source* — PDT, the universal wall clock shared across renditions — but left
|
||
open how that anchor drives a shared presentation timeline. Today's
|
||
`anchor-presentation-timeline` pins each selected A/V track to its own buffer
|
||
independently, and doesn't anchor text at all.
|
||
|
||
Two facts make a single shared anchor sufficient — and per-track pinning
|
||
redundant:
|
||
|
||
1. **MSE plays all SourceBuffers on one element timeline.** There is a single
|
||
`currentTime`; audio and video are coerced onto one native-PTS coordinate.
|
||
2. **Native-PTS default** ([mse-timestamp-offset](./mse-timestamp-offset.md)) —
|
||
no `timestampOffset`, so every track shares the encoder's PTS clock.
|
||
|
||
Under (1) + (2) there is no inter-track skew, so one track's `(media-time ↔
|
||
PDT)` pair describes all tracks; further pins would only restate it. And text —
|
||
which has no buffer to pin — can be placed *only* by a shared anchor.
|
||
|
||
**Assumption committed:** no inter-track PTS skew. This is the existing
|
||
live-model assumption (native-PTS default); per-track skew correction remains
|
||
deferred to
|
||
[non-zero-pts-support](../design/spf/features/non-zero-pts-support.md). If skew
|
||
ever needs handling it layers a per-track offset on top of the shared anchor —
|
||
it does not require returning to per-track pinning.
|
||
|
||
## Alternatives Considered
|
||
|
||
- **Per-track buffer pins (today's `anchor-presentation-timeline`).** Pin each selected
|
||
A/V track to its own buffered segment. Rejected: under the no-skew assumption
|
||
the pins necessarily *agree*, so the extra pins are redundant restatement;
|
||
and the approach structurally **cannot anchor text**, which has no
|
||
SourceBuffer — keeping it would force a separate, divergent text path for the
|
||
same concern.
|
||
|
||
- **Per-track PDT estimate only (no buffer truth).** Place every track from the
|
||
manifest estimate (`sequence × avgDuration` + PDT) and never pin. Rejected:
|
||
drops the authoritative correction — the estimate is duration-variance-prone
|
||
(it produced an observed ~27 s drift at window turnover), and the buffer pin
|
||
is what makes the timeline exact. The estimate is the right *bootstrap*, not
|
||
the final answer.
|
||
|
||
## Rationale
|
||
|
||
PDT is already the cross-track anchor source; this decision commits to **one**
|
||
anchor instead of N. The win is uniformity: audio, video, and text all position
|
||
by the same `(media-time ↔ PDT)` rule, so the behavior has one code path, text
|
||
stops being a special case, and "first track to buffer wins" makes the anchor
|
||
available as early as possible. Pinning once (not per reload) keeps the
|
||
drift-surfacing property: if a track's PDT disagrees with the established
|
||
anchor, it shows up as a visible desync rather than being silently re-corrected
|
||
every reload.
|
||
|
||
This promotes open question **[4] sync anchor** in
|
||
[live-presentation-modeling](../design/spf/live-presentation-modeling.md) from
|
||
"per-segment / per-track" to "presentation-level," as that doc anticipated.
|
||
|
||
## Verification
|
||
|
||
Implemented. `anchor-presentation-timeline` is a reactor that, on first buffer ground truth
|
||
(entry to `anchored`), establishes the shared anchor once and stamps it onto
|
||
every track via `positionAllTracksToAnchor` — resolved tracks shifted, shells
|
||
given `startDate` for the parser's `placeOnAnchor` to honor at first resolve. No
|
||
estimate, no per-track positioning pass. The old per-track pin primitives
|
||
(`anchorTrackToBufferedSegment` / `anchorTrackToSequenceOrigin`) are removed.
|
||
|
||
Unit-covered: the parser honors a pre-applied anchor (`parse-media-playlist.test.ts`);
|
||
`positionAllTracksToAnchor` shifts resolved tracks + stamps shells, identity-
|
||
preserving (`presentation-anchor.test.ts`); the behavior establishes from the
|
||
buffered video track and stamps all tracks, first-track-wins, establishes once
|
||
across reloads, and is inert without buffer truth or a selected track
|
||
(`anchor-presentation-timeline.test.ts`); `seekToLiveEdge` holds its seek until `presentationAnchor`
|
||
is published (`seek-to-live-edge.test.ts`). Live A/V init smoke-tested on an
|
||
ephemeral Mux LL-HLS stream: with no estimate, the seek is gated until the pin
|
||
lands, then fires once to the live edge — clean startup, no stranding (an
|
||
un-gated build stranded the playhead at the raw-window seek). Text anchoring
|
||
against a live subtitled source is still unverified end-to-end; an intermittent
|
||
~2 s audio *model* offset (buffers stay aligned) is tracked as a separate startup
|
||
race.
|
||
|
||
## See also
|
||
|
||
- [live-timeline-anchoring](./live-timeline-anchoring.md) — the anchor *source*
|
||
(PDT); this decision resolves its deferred "single rolling anchor vs.
|
||
per-segment measured times" question.
|
||
- [mse-timestamp-offset](./mse-timestamp-offset.md) — native-PTS default; the
|
||
manifest → buffer offset half this anchor realizes.
|
||
- [non-zero-pts-support](../design/spf/features/non-zero-pts-support.md) — where
|
||
per-track skew correction lands if the no-skew assumption ever breaks.
|
||
- [live-presentation-modeling](../design/spf/live-presentation-modeling.md) —
|
||
open question [4], promoted to presentation-level here.
|
||
- [live-stream-support](../design/spf/features/live-stream-support.md) — the
|
||
`anchor-presentation-timeline` behavior that implements this.
|