From f531e1055cb525cb2077b8e9ad28d3074864ee3c Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Wed, 24 Jun 2026 12:06:10 -0700 Subject: [PATCH] docs(spf): decide the single presentation-level live timeline anchor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../decisions/live-presentation-anchor.md | 121 ++++++++++++++++++ internal/decisions/live-timeline-anchoring.md | 15 ++- .../design/spf/live-presentation-modeling.md | 31 +++-- 3 files changed, 149 insertions(+), 18 deletions(-) create mode 100644 internal/decisions/live-presentation-anchor.md diff --git a/internal/decisions/live-presentation-anchor.md b/internal/decisions/live-presentation-anchor.md new file mode 100644 index 00000000..09932a22 --- /dev/null +++ b/internal/decisions/live-presentation-anchor.md @@ -0,0 +1,121 @@ +--- +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 (pin-once — it surfaces drift rather than +masking it; the parser's PDT carry-forward maintains the timeline across +reloads). Before any A/V track has buffer ground truth, the manifest estimate +(the existing bootstrap) supplies an *estimated* anchor of the same shape, which +the buffer pin later upgrades. + +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-live-tracks` 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-live-tracks`).** 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 + +Not yet implemented. The implementation converts `anchor-live-tracks` into a +reactor (`unanchored → anchored`; a single entry establishes the shared anchor +once an A/V track has buffer ground truth, then positions all selected tracks) +— tracked separately. Verification will cover: one A/V pin placing audio + text +by PDT; first-track-wins; pre-pin estimate → buffer-pin upgrade; pin-once (no +re-pin across reloads); inert when no PDT / no resolved track. + +## 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-live-tracks` behavior that implements this. diff --git a/internal/decisions/live-timeline-anchoring.md b/internal/decisions/live-timeline-anchoring.md index bd3ef084..68695f6f 100644 --- a/internal/decisions/live-timeline-anchoring.md +++ b/internal/decisions/live-timeline-anchoring.md @@ -19,11 +19,13 @@ seconds) used for two things: `targetDuration × offset`. Scope of this decision: the **anchor source** is PDT. The parser *surfaces* -`Segment.programDateTime` (landed; see Verification). How that anchor drives a -shared presentation timeline — the cross-track adjuster / per-track -`presentationTimeOffset` derivation, and whether the model holds a single -rolling anchor vs. per-segment measured times — is downstream and not fixed -here. +`Segment.programDateTime` (landed; see Verification). Whether the model holds a +single rolling anchor vs. per-segment measured times is **fixed in +[live-presentation-anchor](./live-presentation-anchor.md)** — a single +presentation-level `(media-time ↔ PDT)` anchor, learned from the first A/V +track to buffer and applied to all tracks (incl. text) by PDT. The remaining +per-track `presentationTimeOffset` derivation for the skew case is downstream +and not fixed here. This resolves open questions **[4] sync anchor** and **turnover `startTime` recovery** in @@ -86,6 +88,9 @@ cross-track adjuster are not yet implemented. ## See also +- [live-presentation-anchor](./live-presentation-anchor.md) — fixes how this + anchor drives a single shared presentation timeline (the deferred + single-rolling-anchor question). - [mse-timestamp-offset](./mse-timestamp-offset.md) — the buffer-layer half; the manifest→buffer offset this decision's anchor feeds into. - [live-presentation-modeling](../design/spf/live-presentation-modeling.md) — diff --git a/internal/design/spf/live-presentation-modeling.md b/internal/design/spf/live-presentation-modeling.md index 77cd7279..364dcf54 100644 --- a/internal/design/spf/live-presentation-modeling.md +++ b/internal/design/spf/live-presentation-modeling.md @@ -197,10 +197,13 @@ count); a parts-only LL-HLS update is invisible to it, which `EXT-X-PROGRAM-DATE-TIME` is captured per-segment in the snapshot. It is the **wall-clock anchor common across separate renditions** — the spec's designated mechanism for keeping demuxed audio and video aligned. The -model's job here is to *capture* PDT; the presentation-level anchor -derivation and *how it drives per-track `timestampOffset`* is the -deferred A/V-sync decision (see [Open questions](#open-questions) and -[non-zero-pts-support](./features/non-zero-pts-support.md)). PDT alone +model's job here is to *capture* PDT; the **presentation-level anchor +derivation is now decided** — a single `(media-time ↔ PDT)` anchor learned +from the first A/V track to buffer and applied to all tracks (incl. text) by +PDT (see [live-presentation-anchor](../../decisions/live-presentation-anchor.md)). +Only *how that anchor drives per-track `timestampOffset` in the skew case* +remains the deferred A/V-sync decision (see [Open questions](#open-questions) +and [non-zero-pts-support](./features/non-zero-pts-support.md)). PDT alone suffices as a sync anchor under the no-mid-stream-discontinuity assumption this foundation makes; `EXT-X-DISCONTINUITY(-SEQUENCE)` is deferred (it is not live-exclusive — ad-stitched VOD has it too — and is @@ -338,15 +341,17 @@ prematurely fix. the timeline forward per fetch; `anchorLiveTracks` aligns renditions by PDT). So fetch scheduling is independent without the timeline reconciliation being forced independent, as this question anticipated. -- **[4] How captured PDT feeds the A/V-sync anchor — DECIDED (anchor source).** - Resolved in [live-timeline-anchoring](../../decisions/live-timeline-anchoring.md): - align demuxed audio/video by equal PDT (same real instant), not by - sequence number or per-track zeroing. The parser now surfaces - `Segment.programDateTime`. Still open downstream: how that anchor drives a - shared presentation timeline (the cross-track adjuster / per-track - `presentationTimeOffset`), and the separate manifest→buffer offset - ([mse-timestamp-offset](../../decisions/mse-timestamp-offset.md)). Couples - back to discontinuity handling when that lands. +- **[4] How captured PDT feeds the A/V-sync anchor — DECIDED.** Anchor *source* + resolved in [live-timeline-anchoring](../../decisions/live-timeline-anchoring.md) + (align demuxed audio/video by equal PDT, not sequence number or per-track + zeroing); the parser surfaces `Segment.programDateTime`. Anchor *structure* + resolved in [live-presentation-anchor](../../decisions/live-presentation-anchor.md): + a single presentation-level `(media-time ↔ PDT)` anchor, learned from the first + A/V track to buffer and applied to all tracks (incl. text) by PDT. Still open + downstream: the per-track `presentationTimeOffset` derivation for the *skew* + case, and the separate manifest→buffer offset + ([mse-timestamp-offset](../../decisions/mse-timestamp-offset.md)). Couples back + to discontinuity handling when that lands. - **Parser output shape — resurrect `MediaPlaylistInfo` or hang fields on `Track`?** The snapshot/merge model wants a faithful per-fetch representation; today's parser merges into `Track`. This is the parser