mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
2007a173b3
commit
0cb33bb532
@@ -20,11 +20,27 @@ buffered segment's actual native-PTS start `M₀`, paired with that segment's PD
|
||||
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.
|
||||
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
|
||||
`liveAnchor`, 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 (`liveAnchor`, 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
|
||||
@@ -99,17 +115,26 @@ This promotes open question **[4] sync anchor** in
|
||||
|
||||
## Verification
|
||||
|
||||
Implemented. `anchor-live-tracks` is a two-state reactor (`unanchored →
|
||||
anchored`): `unanchored` positions every selected track from the manifest
|
||||
estimate; entering `anchored` establishes the shared anchor once from the first
|
||||
selected A/V track with buffer ground truth, then positions all selected tracks
|
||||
(incl. text) onto it. The old per-track pin primitives
|
||||
Implemented. `anchor-live-tracks` 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 (`anchor-live-tracks.test.ts`): one A/V pin placing audio + text by
|
||||
PDT; first-track-wins (video preferred); pre-pin estimate → buffer-pin upgrade;
|
||||
pin-once (no re-pin across reloads); inert when no PDT / no resolved track. Live
|
||||
end-to-end (a real stream with subtitles) is not yet smoke-tested.
|
||||
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-live-tracks.test.ts`); `seekToLiveEdge` holds its seek until `liveAnchor`
|
||||
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
|
||||
|
||||
|
||||
@@ -136,8 +136,8 @@ realized. What remains is forward-looking:
|
||||
| `liveWindowFor` *(pure helper)* | `media/live-window.ts` | Derive the live window `{start,end}` from the track with the given id (type-agnostic via `findTrackById`), or `null` (VOD/ended/unresolved). Purely geometric — no delivery-format metadata. Centralizes all inertness so consumers don't re-derive the window. |
|
||||
| `liveWindowFromState` / `getLiveEdge` *(primitives)* | `playback/primitives/live-window.ts` | The state-reading call sites the live behaviors use. `liveWindowFromState` picks the timeline-bearing track — `selectedVideoTrackId ?? selectedAudioTrackId` (video positions both A/V; audio-only falls back to audio) — and calls `liveWindowFor`. `getLiveEdge({state,config})` adds the target playhead position (`liveEdgeStart = end − live latency`, clamped to start), bundling window geometry with the format-specific `config.resolveLiveLatency` policy so the behavior consumes one edge. Reads signals lazily (call inside an effect). |
|
||||
| `syncLiveSeekableRange` | `behaviors/dom/sync-live-seekable-range.ts` | Consume `liveWindowFromState`; `setLiveSeekableRange(start, end)` reactively on each window slide, including while paused. Duration is owned solely by `updateMediaSourceDuration`. Composed before `seekToLiveEdge`. |
|
||||
| `seekToLiveEdge` | `behaviors/dom/seek-to-live-edge.ts` | A reactor (`inactive ↔ live`) consuming `getLiveEdge`. `live` `entry` does the one-time seek to `liveEdgeStart`; `live` `effects` runs the window-exit guard (window-update re-fire + `play` listener). Format-neutral — the live latency comes from the injected `resolveLiveLatency` seam, never read here. The `mediaSource`-open precondition orders the entry seek after `sync-live-seekable-range` declares the range, so the seek lands in-window. |
|
||||
| `anchorLiveTracks` | `behaviors/anchor-live-tracks.ts` | A reactor (`unanchored → anchored`) holding **one** shared presentation anchor for all selected tracks (video, audio, *and* text). `unanchored` positions from the manifest estimate; entering `anchored` establishes the anchor once from the first selected A/V track's SourceBuffer ground truth (first-track-wins) and positions each track onto it by PDT, then leaves it to the parser's carry-forward (pin-once surfaces drift). See [live-presentation-anchor](../../../decisions/live-presentation-anchor.md). |
|
||||
| `seekToLiveEdge` | `behaviors/dom/seek-to-live-edge.ts` | A reactor (`inactive ↔ live`) consuming `getLiveEdge`. `live` `entry` does the one-time seek to `liveEdgeStart`; `live` `effects` runs the window-exit guard (window-update re-fire + `play` listener). Format-neutral — the live latency comes from the injected `resolveLiveLatency` seam, never read here. The `mediaSource`-open precondition orders the entry seek after `sync-live-seekable-range` declares the range, so the seek lands in-window. Also gated on `liveAnchor` (published by `anchorLiveTracks`): the seek waits until the timeline is buffer-anchored, so it targets the final native-PTS window rather than the raw pre-anchor one (which the pin's later shift would strand). |
|
||||
| `anchorLiveTracks` | `behaviors/anchor-live-tracks.ts` | A reactor that establishes **one** shared presentation anchor once per source — on first buffer ground truth, first-track-wins — and stamps it onto every track via `positionAllTracksToAnchor`: resolved tracks shift onto it, not-yet-resolved shells get `startDate` for the parser's `placeOnAnchor` to honor at first resolve. Covers video, audio, and text; any track selected later (ABR / audio language / late captions) resolves already anchored. No pre-buffer estimate. See [live-presentation-anchor](../../../decisions/live-presentation-anchor.md). |
|
||||
| `resolveVideoTrack` / `resolveAudioTrack` / `resolveTextTrack` | `behaviors/resolve-track.ts` | Own the reload loop via `RecurringRunner`; reschedule defaults to `mediaPlaylistReloadDelay`; per-type independent |
|
||||
| `calculatePresentationDuration` | `behaviors/calculate-presentation-duration.ts` | Populate `presentation.duration` via the config resolver (`Infinity` for unended live) |
|
||||
| `updateMediaSourceDuration` | `behaviors/dom/update-mediasource-duration.ts` | Propagate `presentation.duration` to `mediaSource.duration` once per MediaSource (uniform across variants) |
|
||||
@@ -183,9 +183,9 @@ unconditionally (`anchorLiveTracks`, `calculatePresentationDuration`,
|
||||
- `media/hls/tests/parse-media-playlist.test.ts` — `Infinity` for unended
|
||||
live; `endList` on `#EXT-X-ENDLIST`; finite for `PLAYLIST-TYPE:VOD`; PDT
|
||||
capture + carry-forward.
|
||||
- `behaviors/tests/anchor-live-tracks.test.ts` — estimate bootstrap; one A/V
|
||||
pin placing audio + text by PDT; first-track-wins; estimate → buffer-pin
|
||||
upgrade; pin-once across reloads.
|
||||
- `behaviors/tests/anchor-live-tracks.test.ts` — establishes from the buffered
|
||||
video track and stamps all tracks (incl. an unresolved text shell);
|
||||
first-track-wins; establishes once across reloads; inert without buffer truth.
|
||||
- `behaviors/tests/resolve-track.test.ts` — live reload re-resolves; stops on
|
||||
finite duration; source-change abort.
|
||||
- `behaviors/dom/tests/seek-to-live-edge.test.ts` — seeks to `liveEdgeStart`
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isUndefined } from '@videojs/utils/predicate';
|
||||
import { getMediaPlaylistMetadata, type Track } from './types';
|
||||
import { getMediaPlaylistMetadata, isResolvedTrack, type Presentation, type Track } from './types';
|
||||
|
||||
/**
|
||||
* The presentation's live timeline anchor: the wall-clock (PDT, epoch seconds)
|
||||
@@ -96,3 +96,38 @@ export function positionTrackToAnchor<Tracks extends Track>(track: Tracks, ancho
|
||||
segments: track.segments.map((segment) => ({ ...segment, startTime: segment.startTime + shift })),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Position **every** track in a presentation onto the shared `anchor`, in one
|
||||
* pass. A resolved track has its segment timeline shifted
|
||||
* ({@link positionTrackToAnchor}); a not-yet-resolved shell gets the anchor
|
||||
* stamped as `startDate`, so the media-playlist parser places its segments on
|
||||
* the shared timeline at first resolve (`placeOnAnchor`). Because it covers
|
||||
* unselected renditions too, any track selected later — an ABR rung, another
|
||||
* audio language, late captions — is already anchored without a per-track
|
||||
* positioning pass.
|
||||
*
|
||||
* Identity-preserving: returns the same presentation when nothing moved (every
|
||||
* track already on the anchor), so an idempotent re-establish writes no new
|
||||
* reference.
|
||||
*/
|
||||
export function positionAllTracksToAnchor(presentation: Presentation, anchor: PresentationAnchor): Presentation {
|
||||
let changed = false;
|
||||
const selectionSets = presentation.selectionSets.map((selectionSet) => ({
|
||||
...selectionSet,
|
||||
switchingSets: selectionSet.switchingSets.map((switchingSet) => ({
|
||||
...switchingSet,
|
||||
tracks: switchingSet.tracks.map((track) => {
|
||||
// Resolved → shift segments; shell → stamp the anchor for the parser.
|
||||
const next = isResolvedTrack(track)
|
||||
? positionTrackToAnchor(track, anchor)
|
||||
: track.startDate === anchor
|
||||
? track
|
||||
: { ...track, startDate: anchor };
|
||||
if (next !== track) changed = true;
|
||||
return next;
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
return changed ? ({ ...presentation, selectionSets } as Presentation) : presentation;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
positionAllTracksToAnchor,
|
||||
positionTrackToAnchor,
|
||||
presentationAnchorEstimate,
|
||||
presentationAnchorFromBuffer,
|
||||
} from '../presentation-anchor';
|
||||
import { MEDIA_PLAYLIST_METADATA_KEY, type Track } from '../types';
|
||||
import { MEDIA_PLAYLIST_METADATA_KEY, type Presentation, type Track } from '../types';
|
||||
|
||||
/** Minimal live track: a window starting at `startTime`, 2s segments, PDT from `startDate`. */
|
||||
function track(
|
||||
@@ -89,3 +90,45 @@ describe('positionTrackToAnchor', () => {
|
||||
expect(positionTrackToAnchor(noPdt, 900)).toBe(noPdt);
|
||||
});
|
||||
});
|
||||
|
||||
describe('positionAllTracksToAnchor', () => {
|
||||
// A resolved video track plus an unselected, not-yet-resolved audio shell.
|
||||
const audioShell = {
|
||||
type: 'audio',
|
||||
id: 'a-1',
|
||||
url: 'a.m3u8',
|
||||
mimeType: 'audio/mp4',
|
||||
codecs: ['mp4a.40.2'],
|
||||
bandwidth: 128_000,
|
||||
groupId: 'aud',
|
||||
name: 'English',
|
||||
sampleRate: 48_000,
|
||||
channels: 2,
|
||||
};
|
||||
const presentation = {
|
||||
id: 'p',
|
||||
url: 'm.m3u8',
|
||||
startTime: 0,
|
||||
selectionSets: [
|
||||
{ id: 'v', type: 'video', switchingSets: [{ id: 'vs', type: 'video', tracks: [track()] }] },
|
||||
{ id: 'a', type: 'audio', switchingSets: [{ id: 'as', type: 'audio', tracks: [audioShell] }] },
|
||||
],
|
||||
} as unknown as Presentation;
|
||||
|
||||
it('shifts resolved tracks and stamps startDate on unresolved shells, in one pass', () => {
|
||||
const positioned = positionAllTracksToAnchor(presentation, 900);
|
||||
const video = positioned.selectionSets[0]!.switchingSets[0]!.tracks[0]!;
|
||||
const audio = positioned.selectionSets[1]!.switchingSets[0]!.tracks[0]!;
|
||||
// Resolved video shifted onto the anchor (startDate 1000 → 900, startTime 100 → 200).
|
||||
expect(video.startDate).toBe(900);
|
||||
expect(video.startTime).toBe(200);
|
||||
// Unresolved audio shell: anchor stamped, no segments materialized.
|
||||
expect(audio.startDate).toBe(900);
|
||||
expect(audio.segments).toBeUndefined();
|
||||
});
|
||||
|
||||
it('is identity-preserving when everything is already on the anchor', () => {
|
||||
const once = positionAllTracksToAnchor(presentation, 900);
|
||||
expect(positionAllTracksToAnchor(once, 900)).toBe(once);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,69 +1,78 @@
|
||||
/**
|
||||
* Position every selected track's timeline so model coordinates coincide with
|
||||
* the SourceBuffer's native-PTS coordinates — the loader matches `currentTime`
|
||||
* (a native-PTS value, since segments append unmodified) against each segment's
|
||||
* `startTime`, so the two timelines must agree.
|
||||
* Establish the live presentation's shared timeline anchor — the wall clock
|
||||
* (PDT) at media-time 0 — once per source, and stamp it onto every track so the
|
||||
* model's coordinates coincide with the SourceBuffer's native-PTS coordinates
|
||||
* (the loader matches `currentTime`, a native-PTS value since segments append
|
||||
* unmodified, against each segment's `startTime`).
|
||||
*
|
||||
* One **shared presentation anchor** — a `(media-time ↔ PDT)` correspondence —
|
||||
* drives all selected tracks (video, audio, *and* text); each track positions
|
||||
* itself onto it by its own per-segment PDT. See
|
||||
* [live-presentation-anchor](../../../../internal/decisions/live-presentation-anchor.md).
|
||||
* A two-state reactor holds it:
|
||||
* One shared anchor drives all tracks. It's learned from whichever A/V track is
|
||||
* first **actually buffered**: an injected `resolveBufferedAnchor` reads the
|
||||
* buffer actor's snapshot — the track it's buffering (`initTrackId`) and where a
|
||||
* segment landed (native PTS) — and the behavior reads that track's segment PDT
|
||||
* from the presentation, so `presentationAnchorFromBuffer` yields the shared
|
||||
* `(media-time ↔ PDT)` anchor. Established **once**, on entry to `anchored`; a
|
||||
* source change re-enters and re-establishes. (The buffered track id comes from
|
||||
* the actor, not the selection — it's what's *actually* buffered, which during a
|
||||
* switch is more reliable than the intended selection.)
|
||||
*
|
||||
* - **`unanchored` (bootstrap).** Before any A/V track has buffer ground truth
|
||||
* there's no authoritative anchor, so the manifest-only estimate
|
||||
* (`presentationAnchorEstimate`, `averageDuration × sequence`) supplies a
|
||||
* provisional one. Re-applied each reload — provisional, ungated — until the
|
||||
* buffer upgrades it.
|
||||
* - **`anchored` (authoritative).** Once a selected A/V track is buffered, an
|
||||
* injected `resolveBufferedAnchor` reports where a segment *actually* landed
|
||||
* (native PTS); `presentationAnchorFromBuffer` turns that into the shared
|
||||
* anchor, established **once** on entry (first track to buffer wins). Each
|
||||
* selected track is then positioned onto it (`positionTrackToAnchor`) exactly
|
||||
* once — including text and tracks selected later — and thereafter left to the
|
||||
* parser's PDT-exact carry-forward. Re-positioning a pinned track every reload
|
||||
* would *mask* a drifting baseline; positioning once *surfaces* it.
|
||||
* On establishment, `positionAllTracksToAnchor` stamps the anchor 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
|
||||
* them on the shared timeline at first resolve (`placeOnAnchor`). This covers
|
||||
* unselected renditions too, so any track selected later — an ABR rung, another
|
||||
* audio language, late captions — resolves already anchored, with no per-track
|
||||
* positioning pass. Text included: it has no SourceBuffer to pin, so the shared
|
||||
* anchor is the only way to place it.
|
||||
*
|
||||
* Text has no SourceBuffer to pin, so the shared anchor is the *only* way to
|
||||
* place it; A/V and text run one code path. Cross-track A/V skew is intentionally
|
||||
* *not* corrected here — under the native-PTS default all tracks share the
|
||||
* encoder's PTS clock, so one anchor describes them all (see the decision doc).
|
||||
* 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), and nothing is playing yet, so nothing is
|
||||
* mispositioned; the buffer pin supersedes it the moment a segment appends.
|
||||
*
|
||||
* DOM-free: the buffered ground truth arrives via the injected resolver (the
|
||||
* engine wires it from the buffer actor's `bufferedRanges`), so this behavior
|
||||
* never touches `HTMLMediaElement`.
|
||||
* engine wires it from the buffer actor's snapshot), so this behavior never
|
||||
* touches `HTMLMediaElement`. Cross-track A/V skew is intentionally not corrected
|
||||
* here — under the native-PTS default all tracks share the encoder's PTS clock,
|
||||
* so one anchor describes them all (see the decision doc).
|
||||
*/
|
||||
|
||||
import { isUndefined } from '@videojs/utils/predicate';
|
||||
import type { Behavior, BehaviorDeps, ContextSignals } from '../../core/composition/create-composition';
|
||||
import { createMachineReactor, type Reactor } from '../../core/reactors/create-machine-reactor';
|
||||
import { type ReadonlySignal, type Signal, update } from '../../core/signals/primitives';
|
||||
import { type Signal, update } from '../../core/signals/primitives';
|
||||
import type { BufferedAnchor } from '../../media/buffered-anchor';
|
||||
import {
|
||||
type PresentationAnchor,
|
||||
positionTrackToAnchor,
|
||||
presentationAnchorEstimate,
|
||||
positionAllTracksToAnchor,
|
||||
presentationAnchorFromBuffer,
|
||||
} from '../../media/presentation-anchor';
|
||||
import {
|
||||
isResolvedPresentation,
|
||||
isResolvedTrack,
|
||||
type MaybeResolvedPresentation,
|
||||
type ResolvedTrack,
|
||||
type TrackType,
|
||||
} from '../../media/types';
|
||||
import { findTrack, updateTrackInPresentation } from '../../media/utils/tracks';
|
||||
import { isResolvedPresentation, isResolvedTrack, type MaybeResolvedPresentation } from '../../media/types';
|
||||
import { findTrackById } from '../../media/utils/tracks';
|
||||
|
||||
export interface AnchorLiveTracksState {
|
||||
presentation?: MaybeResolvedPresentation;
|
||||
selectedVideoTrackId?: string;
|
||||
selectedAudioTrackId?: string;
|
||||
selectedTextTrackId?: string;
|
||||
/**
|
||||
* The established shared anchor (wall clock at media-time 0), published once
|
||||
* the buffer pin lands; `undefined` until then. Owned here; read by
|
||||
* `seekToLiveEdge` to gate its live-edge seek until the timeline is anchored —
|
||||
* seeking on the pre-anchor (raw) timeline would strand the playhead when the
|
||||
* pin later shifts the window.
|
||||
*/
|
||||
liveAnchor?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A buffered anchor paired with the id of the track it was read from (the buffer
|
||||
* actor's `initTrackId`), so the behavior can resolve that track's segment PDT
|
||||
* from the presentation.
|
||||
*/
|
||||
export interface BufferedTrackAnchor extends BufferedAnchor {
|
||||
trackId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The standard behavior setup deps (`{ state, context, config }`) passed to the
|
||||
* `resolveBufferedAnchor` factory. Generic over the engine's `Context` so this
|
||||
* `resolveBufferedAnchor` seam. Generic over the engine's `Context` so this
|
||||
* behavior stays DOM-free — the engine (DOM boundary) names the concrete buffer
|
||||
* actors; here `Context` is opaque.
|
||||
*/
|
||||
@@ -74,28 +83,19 @@ export type AnchorLiveTracksDeps<Context extends object> = BehaviorDeps<
|
||||
>;
|
||||
|
||||
export interface AnchorLiveTracksConfig<Context extends object = object> {
|
||||
/**
|
||||
* Sequence number assumed to be the stream origin (time 0) for the bootstrap
|
||||
* estimate. Default 0 — see `presentationAnchorEstimate`.
|
||||
*/
|
||||
presumedStartSequence?: number;
|
||||
/**
|
||||
* Buffered-ground-truth resolver, injected by the engine (the DOM boundary).
|
||||
* Reports where a buffered segment actually sits in native PTS, or `undefined`
|
||||
* before anything is buffered. Receives the behavior's setup deps (rather than
|
||||
* closing over engine scope) so the engine reads its buffer actors from
|
||||
* `context`. Absent → estimate-only (e.g. non-DOM tests).
|
||||
* Reads the first A/V buffer actor with ground truth and reports where a
|
||||
* buffered segment landed (native PTS) plus which track it belongs to, or
|
||||
* `undefined` before anything is buffered. Receives the behavior's setup deps
|
||||
* (rather than closing over engine scope) so the engine reads its buffer actors
|
||||
* from `context`. Absent → never anchors (e.g. non-DOM tests with no buffer).
|
||||
*/
|
||||
resolveBufferedAnchor?: (track: ResolvedTrack, deps: AnchorLiveTracksDeps<Context>) => BufferedAnchor | undefined;
|
||||
resolveBufferedAnchor?: (deps: AnchorLiveTracksDeps<Context>) => BufferedTrackAnchor | undefined;
|
||||
}
|
||||
|
||||
type AnchorFsmState = 'unanchored' | 'anchored';
|
||||
|
||||
// The shared anchor is learned from a buffered A/V track; text has none.
|
||||
const ANCHOR_SOURCE_TYPES = ['video', 'audio'] as const;
|
||||
// All selected tracks ride the shared anchor — text included.
|
||||
const POSITIONED_TYPES = ['video', 'audio', 'text'] as const;
|
||||
|
||||
function anchorLiveTracksSetup<Context extends object>({
|
||||
state,
|
||||
context,
|
||||
@@ -103,128 +103,57 @@ function anchorLiveTracksSetup<Context extends object>({
|
||||
}: {
|
||||
state: {
|
||||
presentation: Signal<AnchorLiveTracksState['presentation']>;
|
||||
selectedVideoTrackId?: ReadonlySignal<AnchorLiveTracksState['selectedVideoTrackId']>;
|
||||
selectedAudioTrackId?: ReadonlySignal<AnchorLiveTracksState['selectedAudioTrackId']>;
|
||||
selectedTextTrackId?: ReadonlySignal<AnchorLiveTracksState['selectedTextTrackId']>;
|
||||
liveAnchor: Signal<AnchorLiveTracksState['liveAnchor']>;
|
||||
};
|
||||
context: ContextSignals<Context>;
|
||||
config?: AnchorLiveTracksConfig<Context>;
|
||||
}): Reactor<AnchorFsmState | 'destroying' | 'destroyed'> {
|
||||
const { presumedStartSequence = 0 } = config;
|
||||
// The deps handed to the injected resolver, so the engine reads its buffer
|
||||
// actors from `context` — no pre-composition closure over engine scope.
|
||||
const deps: AnchorLiveTracksDeps<Context> = { state, context, config };
|
||||
|
||||
// The shared anchor, established once from buffer ground truth on entry to
|
||||
// `anchored`. `undefined` while unanchored — the estimate supplies a
|
||||
// provisional anchor of the same shape instead.
|
||||
let bufferAnchor: PresentationAnchor | undefined;
|
||||
// Track ids already positioned to `bufferAnchor`. Positioned once, then left
|
||||
// to the parser's carry-forward — re-positioning would mask a drifting
|
||||
// baseline rather than surface it. The estimate phase is ungated (provisional).
|
||||
const positioned = new Set<string>();
|
||||
|
||||
const selectedId = (type: TrackType): string | undefined =>
|
||||
type === 'video'
|
||||
? state.selectedVideoTrackId?.get()
|
||||
: type === 'audio'
|
||||
? state.selectedAudioTrackId?.get()
|
||||
: state.selectedTextTrackId?.get();
|
||||
|
||||
function selectedTrack(presentation: MaybeResolvedPresentation, type: TrackType): ResolvedTrack | undefined {
|
||||
const id = selectedId(type);
|
||||
if (!id) return undefined;
|
||||
const track = findTrack(presentation, type, id);
|
||||
return track && isResolvedTrack(track) ? track : undefined;
|
||||
}
|
||||
|
||||
// First selected A/V track with buffer ground truth wins (video preferred).
|
||||
// The shared anchor from the first actually-buffered A/V track: the resolver
|
||||
// reports the buffered segment + its track id; that track's segment carries the
|
||||
// PDT the anchor is computed from. `undefined` until something is buffered.
|
||||
function deriveBufferAnchor(presentation: MaybeResolvedPresentation): PresentationAnchor | undefined {
|
||||
for (const type of ANCHOR_SOURCE_TYPES) {
|
||||
const track = selectedTrack(presentation, type);
|
||||
const anchor = track && config.resolveBufferedAnchor?.(track, deps);
|
||||
if (!track || !anchor) continue;
|
||||
const presentationAnchor = presentationAnchorFromBuffer(track, anchor.segmentId, anchor.actualStart);
|
||||
if (!isUndefined(presentationAnchor)) return presentationAnchor;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function deriveEstimate(presentation: MaybeResolvedPresentation): PresentationAnchor | undefined {
|
||||
for (const type of ANCHOR_SOURCE_TYPES) {
|
||||
const track = selectedTrack(presentation, type);
|
||||
const estimate = track && presentationAnchorEstimate(track, { presumedStartSequence });
|
||||
if (!isUndefined(estimate)) return estimate;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// `gate` true (authoritative): position each track once, then leave it. `gate`
|
||||
// false (estimate): re-apply unconditionally — provisional, settles to a no-op
|
||||
// once the estimate is stable.
|
||||
function positionSelectedTracks(presentation: MaybeResolvedPresentation, anchor: PresentationAnchor, gate: boolean) {
|
||||
const next: ResolvedTrack[] = [];
|
||||
for (const type of POSITIONED_TYPES) {
|
||||
const track = selectedTrack(presentation, type);
|
||||
// No PDT yet → can't place it; retry next reload (don't mark positioned).
|
||||
if (!track || isUndefined(track.startDate)) continue;
|
||||
if (gate) {
|
||||
if (positioned.has(track.id)) continue;
|
||||
positioned.add(track.id);
|
||||
}
|
||||
const positionedTrack = positionTrackToAnchor(track, anchor);
|
||||
// Identity-equal when nothing moved (already on the anchor).
|
||||
if (positionedTrack !== track) next.push(positionedTrack);
|
||||
}
|
||||
if (next.length === 0) return;
|
||||
|
||||
update(state.presentation as Signal<MaybeResolvedPresentation>, (current) => {
|
||||
if (!isResolvedPresentation(current)) return current;
|
||||
let result = current;
|
||||
for (const track of next) result = updateTrackInPresentation(result, track);
|
||||
return result;
|
||||
});
|
||||
const buffered = config.resolveBufferedAnchor?.(deps);
|
||||
if (!buffered) return undefined;
|
||||
const track = findTrackById(presentation, buffered.trackId);
|
||||
if (!track || !isResolvedTrack(track)) return undefined;
|
||||
return presentationAnchorFromBuffer(track, buffered.segmentId, buffered.actualStart);
|
||||
}
|
||||
|
||||
return createMachineReactor<AnchorFsmState>({
|
||||
initial: 'unanchored',
|
||||
// Re-checks buffer availability on each reload / selection change (the
|
||||
// resolver is read untracked by the engine, so reloads — not buffer ticks —
|
||||
// drive the transition). An unresolved presentation drops back to bootstrap.
|
||||
// Re-checks buffer availability on each reload (the resolver is read untracked
|
||||
// by the engine, so reloads — not buffer ticks — drive the transition). An
|
||||
// unresolved presentation drops back to idle.
|
||||
monitor: () => {
|
||||
const presentation = state.presentation.get();
|
||||
if (!isResolvedPresentation(presentation)) return 'unanchored';
|
||||
return isUndefined(deriveBufferAnchor(presentation)) ? 'unanchored' : 'anchored';
|
||||
},
|
||||
states: {
|
||||
unanchored: {
|
||||
// Reset per source so a new source re-bootstraps from its own estimate.
|
||||
entry: () => {
|
||||
bufferAnchor = undefined;
|
||||
positioned.clear();
|
||||
},
|
||||
effects: () => {
|
||||
const presentation = state.presentation.get();
|
||||
if (!isResolvedPresentation(presentation)) return;
|
||||
const estimate = deriveEstimate(presentation);
|
||||
if (isUndefined(estimate)) return;
|
||||
positionSelectedTracks(presentation, estimate, false);
|
||||
},
|
||||
},
|
||||
// Reset the published anchor per source so a new source re-gates the seek.
|
||||
unanchored: { entry: () => state.liveAnchor.set(undefined) },
|
||||
anchored: {
|
||||
// Establish the shared anchor once (first track to buffer wins), and
|
||||
// clear `positioned` so every selected track re-positions onto the
|
||||
// authoritative anchor, superseding the estimate.
|
||||
// Establish the shared anchor once and stamp it onto every track. Runs
|
||||
// once per entry; a source change exits to `unanchored`, so the next
|
||||
// source re-establishes. Re-deriving the same buffer anchor is idempotent
|
||||
// (segment PDT and native-PTS start are stable), and
|
||||
// `positionAllTracksToAnchor` writes no new reference when nothing moved —
|
||||
// so a transient re-entry is a no-op.
|
||||
entry: () => {
|
||||
const presentation = state.presentation.get();
|
||||
if (!isResolvedPresentation(presentation)) return;
|
||||
bufferAnchor = deriveBufferAnchor(presentation);
|
||||
positioned.clear();
|
||||
},
|
||||
effects: () => {
|
||||
const presentation = state.presentation.get();
|
||||
if (!isResolvedPresentation(presentation) || isUndefined(bufferAnchor)) return;
|
||||
positionSelectedTracks(presentation, bufferAnchor, true);
|
||||
const anchor = deriveBufferAnchor(presentation);
|
||||
if (isUndefined(anchor)) return;
|
||||
update(state.presentation as Signal<MaybeResolvedPresentation>, (current) =>
|
||||
isResolvedPresentation(current) ? positionAllTracksToAnchor(current, anchor) : current
|
||||
);
|
||||
// Publish after stamping, so a consumer reacting to the anchor (e.g.
|
||||
// seekToLiveEdge) sees the already-shifted window.
|
||||
state.liveAnchor.set(anchor);
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -232,20 +161,21 @@ function anchorLiveTracksSetup<Context extends object>({
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual `Behavior<>` literal (like `shareSignals`): declares only `presentation`
|
||||
* in stateKeys while reading the `selected*TrackId` slots defensively, so the
|
||||
* behavior stays composable in variants that wire selection differently. Generic
|
||||
* over `Context` (like `makeShareSignals`) so the engine — which names the
|
||||
* concrete buffer actors the `resolveBufferedAnchor` factory reads — supplies the
|
||||
* context type while this behavior stays DOM-free.
|
||||
* Manual `Behavior<>` literal (like `shareSignals`), generic over `Context` (like
|
||||
* `makeShareSignals`) so the engine — which names the concrete buffer actors the
|
||||
* `resolveBufferedAnchor` seam reads — supplies the context type while this
|
||||
* behavior stays DOM-free.
|
||||
*/
|
||||
export function makeAnchorLiveTracks<Context extends object = object>(): Behavior<
|
||||
{ presentation: Signal<AnchorLiveTracksState['presentation']> },
|
||||
{
|
||||
presentation: Signal<AnchorLiveTracksState['presentation']>;
|
||||
liveAnchor: Signal<AnchorLiveTracksState['liveAnchor']>;
|
||||
},
|
||||
ContextSignals<Context>,
|
||||
AnchorLiveTracksConfig<Context>
|
||||
> {
|
||||
return {
|
||||
stateKeys: ['presentation'],
|
||||
stateKeys: ['presentation', 'liveAnchor'],
|
||||
contextKeys: [],
|
||||
setup: anchorLiveTracksSetup,
|
||||
};
|
||||
|
||||
@@ -53,6 +53,13 @@ export interface SeekToLiveEdgeState {
|
||||
presentation?: MaybeResolvedPresentation;
|
||||
selectedVideoTrackId?: string;
|
||||
selectedAudioTrackId?: string;
|
||||
/**
|
||||
* The shared live anchor, published by `anchorLiveTracks` once the buffer pin
|
||||
* lands (`undefined` until then). Gates the live-edge seek: seeking before the
|
||||
* timeline is anchored would target the raw (pre-anchor) window, and the pin's
|
||||
* later shift would strand the playhead off-window.
|
||||
*/
|
||||
liveAnchor?: number;
|
||||
}
|
||||
|
||||
export interface SeekToLiveEdgeContext {
|
||||
@@ -75,14 +82,18 @@ type SeekToLiveEdgeFsmState = 'inactive' | 'live';
|
||||
|
||||
/**
|
||||
* `'live'` once the seek preconditions hold: a media element, a (published →
|
||||
* open) MediaSource, and a derivable live edge. `'inactive'` otherwise.
|
||||
* open) MediaSource, a derivable live edge, and an established live anchor
|
||||
* (`anchorLiveTracks` has buffer-pinned the timeline — so the edge we seek to is
|
||||
* the final native-PTS one, not the raw pre-anchor window). `'inactive'`
|
||||
* otherwise.
|
||||
*/
|
||||
function deriveState(
|
||||
mediaElement: HTMLMediaElement | undefined,
|
||||
mediaSource: MediaSource | undefined,
|
||||
edge: LiveEdge | null
|
||||
edge: LiveEdge | null,
|
||||
anchored: boolean
|
||||
): SeekToLiveEdgeFsmState {
|
||||
return mediaElement && mediaSource && edge ? 'live' : 'inactive';
|
||||
return mediaElement && mediaSource && edge && anchored ? 'live' : 'inactive';
|
||||
}
|
||||
|
||||
function seekToLiveEdgeSetup({
|
||||
@@ -94,6 +105,7 @@ function seekToLiveEdgeSetup({
|
||||
presentation: ReadonlySignal<SeekToLiveEdgeState['presentation']>;
|
||||
selectedVideoTrackId?: ReadonlySignal<SeekToLiveEdgeState['selectedVideoTrackId']>;
|
||||
selectedAudioTrackId?: ReadonlySignal<SeekToLiveEdgeState['selectedAudioTrackId']>;
|
||||
liveAnchor?: ReadonlySignal<SeekToLiveEdgeState['liveAnchor']>;
|
||||
};
|
||||
context: {
|
||||
mediaElement: ReadonlySignal<SeekToLiveEdgeContext['mediaElement']>;
|
||||
@@ -102,7 +114,12 @@ function seekToLiveEdgeSetup({
|
||||
config?: SeekToLiveEdgeConfig;
|
||||
}): Reactor<SeekToLiveEdgeFsmState | 'destroying' | 'destroyed'> {
|
||||
const derivedStateSignal = computed(() =>
|
||||
deriveState(context.mediaElement.get(), context.mediaSource.get(), getLiveEdge({ state, config }))
|
||||
deriveState(
|
||||
context.mediaElement.get(),
|
||||
context.mediaSource.get(),
|
||||
getLiveEdge({ state, config }),
|
||||
state.liveAnchor?.get() !== undefined
|
||||
)
|
||||
);
|
||||
|
||||
return createMachineReactor<SeekToLiveEdgeFsmState>({
|
||||
|
||||
@@ -78,13 +78,17 @@ function run(opts: {
|
||||
mediaElement?: HTMLMediaElement;
|
||||
mediaSource?: MediaSource;
|
||||
config?: SeekToLiveEdgeConfig;
|
||||
liveAnchor?: number;
|
||||
}) {
|
||||
// Built as vars (not inline literals) so the defensively-read
|
||||
// `selectedVideoTrackId` isn't rejected by the excess-property check against
|
||||
// the behavior's declared `{ presentation }` state slice.
|
||||
// the behavior's declared `{ presentation }` state slice. `liveAnchor` defaults
|
||||
// to a defined value (the timeline is anchored) so the seek gate is open;
|
||||
// pass `liveAnchor: undefined` to exercise the pre-anchor gate.
|
||||
const state = {
|
||||
presentation: signal<MaybeResolvedPresentation | undefined>(opts.presentation),
|
||||
selectedVideoTrackId: signal<string | undefined>(opts.trackId),
|
||||
liveAnchor: signal<number | undefined>('liveAnchor' in opts ? opts.liveAnchor : 1000),
|
||||
};
|
||||
const context = {
|
||||
mediaElement: signal<HTMLMediaElement | undefined>(opts.mediaElement),
|
||||
@@ -132,6 +136,26 @@ describe('seekToLiveEdge', () => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('does not seek until the timeline is anchored (liveAnchor published)', () => {
|
||||
const ms = fakeMediaSource();
|
||||
const el = fakeMediaElement();
|
||||
|
||||
// Pre-anchor: anchorLiveTracks hasn't buffer-pinned yet, so the window is the
|
||||
// raw (pre-shift) one. Seeking now would strand the playhead when the pin
|
||||
// later shifts the window; the gate holds the seek until `liveAnchor` lands.
|
||||
const { cleanup } = run({
|
||||
presentation: makePresentation(),
|
||||
trackId: 'v-1',
|
||||
mediaElement: el,
|
||||
mediaSource: ms,
|
||||
liveAnchor: undefined,
|
||||
});
|
||||
|
||||
expect(el.currentTime).toBe(0);
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('no-ops for a complete (finite-duration) playlist — VoD / ended live', () => {
|
||||
const ms = fakeMediaSource();
|
||||
const el = fakeMediaElement();
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { signal } from '../../../core/signals/primitives';
|
||||
import {
|
||||
type AudioTrack,
|
||||
isResolvedTrack,
|
||||
type MaybeResolvedPresentation,
|
||||
MEDIA_PLAYLIST_METADATA_KEY,
|
||||
type PartiallyResolvedTextTrack,
|
||||
type Presentation,
|
||||
type ResolvedTrack,
|
||||
type TextTrack,
|
||||
type VideoTrack,
|
||||
} from '../../../media/types';
|
||||
import { findTrack } from '../../../media/utils/tracks';
|
||||
@@ -43,8 +43,7 @@ function makeAudioTrack(): AudioTrack {
|
||||
initialization: { url: 'https://example.com/audio-init.mp4' },
|
||||
duration: Number.POSITIVE_INFINITY,
|
||||
startTime: 0,
|
||||
// First audio segment's PDT trails video's by 2s — placement is by PDT, so
|
||||
// this offset must survive anchoring.
|
||||
// First audio segment's PDT trails video's by 2s — placement is by PDT.
|
||||
startDate: 1002,
|
||||
segments: [{ id: 'audio-85', url: 'https://example.com/a85.m4s', duration: 4, startTime: 0, startDate: 1002 }],
|
||||
groupId: 'aud',
|
||||
@@ -55,25 +54,21 @@ function makeAudioTrack(): AudioTrack {
|
||||
};
|
||||
}
|
||||
|
||||
function makeTextTrack(): TextTrack {
|
||||
// Not-yet-resolved text shell (no segments) — exercises the stamp-the-anchor path.
|
||||
function makeTextShell(): PartiallyResolvedTextTrack {
|
||||
return {
|
||||
type: 'text',
|
||||
id: 't-1',
|
||||
url: 'https://example.com/subs.m3u8',
|
||||
mimeType: 'text/vtt',
|
||||
bandwidth: 0,
|
||||
duration: Number.POSITIVE_INFINITY,
|
||||
startTime: 0,
|
||||
startDate: 1000,
|
||||
segments: [{ id: 'text-85', url: 'https://example.com/t85.vtt', duration: 4, startTime: 0, startDate: 1000 }],
|
||||
groupId: 'sub',
|
||||
label: 'English',
|
||||
kind: 'subtitles',
|
||||
metadata: META,
|
||||
};
|
||||
}
|
||||
|
||||
function makePresentation(tracks: ResolvedTrack[]): Presentation {
|
||||
function makePresentation(tracks: (ResolvedTrack | PartiallyResolvedTextTrack)[]): Presentation {
|
||||
const selectionSets = (['video', 'audio', 'text'] as const).flatMap((type) => {
|
||||
const typed = tracks.filter((track) => track.type === type);
|
||||
return typed.length
|
||||
@@ -85,21 +80,10 @@ function makePresentation(tracks: ResolvedTrack[]): Presentation {
|
||||
return { id: 'pres-1', url: 'https://example.com/master.m3u8', startTime: 0, selectionSets } as Presentation;
|
||||
}
|
||||
|
||||
function run(opts: {
|
||||
presentation?: MaybeResolvedPresentation;
|
||||
videoId?: string;
|
||||
audioId?: string;
|
||||
textId?: string;
|
||||
config?: AnchorLiveTracksConfig;
|
||||
}) {
|
||||
// Built as a var (not an inline literal) so the defensively-read
|
||||
// `selected*TrackId` slots aren't rejected by the excess-property check
|
||||
// against the behavior's declared `{ presentation }` state slice.
|
||||
function run(opts: { presentation?: MaybeResolvedPresentation; config?: AnchorLiveTracksConfig }) {
|
||||
const state = {
|
||||
presentation: signal<MaybeResolvedPresentation | undefined>(opts.presentation),
|
||||
selectedVideoTrackId: signal<string | undefined>(opts.videoId),
|
||||
selectedAudioTrackId: signal<string | undefined>(opts.audioId),
|
||||
selectedTextTrackId: signal<string | undefined>(opts.textId),
|
||||
liveAnchor: signal<number | undefined>(undefined),
|
||||
};
|
||||
// The manual `Behavior<>` literal widens the setup return to `BehaviorCleanup`;
|
||||
// narrow back to the reactor's destroy handle for teardown.
|
||||
@@ -109,7 +93,7 @@ function run(opts: {
|
||||
return { cleanup: () => reactor.destroy(), state };
|
||||
}
|
||||
|
||||
// Let the reactor's effects re-run after a signal write (they re-run on a microtask).
|
||||
// Let the reactor's monitor re-run after a signal write (effects re-run on a microtask).
|
||||
const flush = () => Promise.resolve();
|
||||
|
||||
function resolved(presentation: MaybeResolvedPresentation, type: ResolvedTrack['type'], id: string) {
|
||||
@@ -119,152 +103,73 @@ function resolved(presentation: MaybeResolvedPresentation, type: ResolvedTrack['
|
||||
}
|
||||
|
||||
describe('anchorLiveTracks', () => {
|
||||
it('anchors the selected track to the estimated stream origin', () => {
|
||||
const { cleanup, state } = run({ presentation: makePresentation([makeVideoTrack()]), videoId: 'v-1' });
|
||||
|
||||
const track = resolved(state.presentation.get()!, 'video', 'v-1');
|
||||
// origin offset = (85 − 0) × 4 = 340; idempotent (no double-application).
|
||||
expect(track.startTime).toBe(340);
|
||||
expect(track.segments[0]?.startTime).toBe(340);
|
||||
// startDate re-based to the seq-0 wall clock: 1000 − 340 = 660.
|
||||
expect(track.startDate).toBe(660);
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('no-ops when the track carries no startDate', () => {
|
||||
const track = makeVideoTrack();
|
||||
track.startDate = undefined;
|
||||
track.segments = [{ id: 'segment-85', url: 'https://example.com/85.m4s', duration: 4, startTime: 0 }];
|
||||
const { cleanup, state } = run({ presentation: makePresentation([track]), videoId: 'v-1' });
|
||||
|
||||
expect(findTrack(state.presentation.get()!, 'video', 'v-1')?.startTime).toBe(0);
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('no-ops without a selected track', () => {
|
||||
it('does nothing until a track has buffer ground truth', () => {
|
||||
// No resolveBufferedAnchor → never anchors; the track keeps its raw timeline.
|
||||
const { cleanup, state } = run({ presentation: makePresentation([makeVideoTrack()]) });
|
||||
|
||||
expect(findTrack(state.presentation.get()!, 'video', 'v-1')?.startTime).toBe(0);
|
||||
expect(resolved(state.presentation.get()!, 'video', 'v-1').startTime).toBe(0);
|
||||
expect(state.liveAnchor.get()).toBeUndefined();
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('buffer pin', () => {
|
||||
it('pins the track onto the actual buffered position, overriding the estimate', () => {
|
||||
const { cleanup, state } = run({
|
||||
presentation: makePresentation([makeVideoTrack()]),
|
||||
videoId: 'v-1',
|
||||
config: { resolveBufferedAnchor: () => ({ segmentId: 'segment-85', actualStart: 500 }) },
|
||||
});
|
||||
|
||||
const track = resolved(state.presentation.get()!, 'video', 'v-1');
|
||||
// Buffer wins over the estimate (which would place it at 340).
|
||||
expect(track.startTime).toBe(500);
|
||||
expect(track.segments[0]?.startTime).toBe(500);
|
||||
|
||||
cleanup();
|
||||
it('establishes the shared anchor from the buffered track and stamps every track', () => {
|
||||
const { cleanup, state } = run({
|
||||
presentation: makePresentation([makeVideoTrack(), makeAudioTrack(), makeTextShell()]),
|
||||
// The resolver reports the buffered video segment + which track it's from;
|
||||
// the shared anchor (PDT 500 ↔ media-0) then places audio and stamps text.
|
||||
config: { resolveBufferedAnchor: () => ({ trackId: 'v-1', segmentId: 'segment-85', actualStart: 500 }) },
|
||||
});
|
||||
|
||||
it('places audio and text from one A/V buffer pin, each by its own PDT', () => {
|
||||
const { cleanup, state } = run({
|
||||
presentation: makePresentation([makeVideoTrack(), makeAudioTrack(), makeTextTrack()]),
|
||||
videoId: 'v-1',
|
||||
audioId: 'a-1',
|
||||
textId: 't-1',
|
||||
// Only video has a SourceBuffer; the shared anchor (PDT 500 ↔ media-0)
|
||||
// places audio and text too.
|
||||
config: {
|
||||
resolveBufferedAnchor: (track) =>
|
||||
track.type === 'video' ? { segmentId: 'segment-85', actualStart: 500 } : undefined,
|
||||
},
|
||||
});
|
||||
const presentation = state.presentation.get()!;
|
||||
// Shared anchor: video seg PDT 1000 − actualStart 500 = 500.
|
||||
expect(resolved(presentation, 'video', 'v-1').startTime).toBe(500);
|
||||
// Audio's first segment PDT 1002 → 1002 − 500 = 502 (the 2s offset survives).
|
||||
expect(resolved(presentation, 'audio', 'a-1').startTime).toBe(502);
|
||||
// Unresolved text shell: anchor stamped as startDate (no segments materialized),
|
||||
// so it resolves already on the shared timeline.
|
||||
const text = findTrack(presentation, 'text', 't-1');
|
||||
expect(text?.startDate).toBe(500);
|
||||
expect(isResolvedTrack(text!)).toBe(false);
|
||||
// The anchor is published for seekToLiveEdge to gate on.
|
||||
expect(state.liveAnchor.get()).toBe(500);
|
||||
|
||||
const presentation = state.presentation.get()!;
|
||||
// Shared anchor: video seg PDT 1000 − actualStart 500 = 500 (PDT at media-0).
|
||||
expect(resolved(presentation, 'video', 'v-1').startTime).toBe(500);
|
||||
// Audio's first segment PDT is 1002 → 1002 − 500 = 502 (the 2s offset survives).
|
||||
expect(resolved(presentation, 'audio', 'a-1').startTime).toBe(502);
|
||||
// Text PDT 1000 → 1000 − 500 = 500.
|
||||
expect(resolved(presentation, 'text', 't-1').startTime).toBe(500);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
cleanup();
|
||||
it('is inert when the buffered track id is not in the presentation', () => {
|
||||
const { cleanup, state } = run({
|
||||
presentation: makePresentation([makeVideoTrack()]),
|
||||
config: { resolveBufferedAnchor: () => ({ trackId: 'gone', segmentId: 'segment-85', actualStart: 500 }) },
|
||||
});
|
||||
|
||||
it('first selected A/V track to buffer wins (video preferred over audio)', () => {
|
||||
const { cleanup, state } = run({
|
||||
presentation: makePresentation([makeVideoTrack(), makeAudioTrack()]),
|
||||
videoId: 'v-1',
|
||||
audioId: 'a-1',
|
||||
// Both report buffer truth, disagreeing: video → anchor 500, audio → 602.
|
||||
config: {
|
||||
resolveBufferedAnchor: (track) =>
|
||||
track.type === 'video'
|
||||
? { segmentId: 'segment-85', actualStart: 500 }
|
||||
: { segmentId: 'audio-85', actualStart: 400 },
|
||||
},
|
||||
});
|
||||
expect(resolved(state.presentation.get()!, 'video', 'v-1').startTime).toBe(0);
|
||||
|
||||
const presentation = state.presentation.get()!;
|
||||
// Video wins: shared anchor 500, so audio rides it (1002 − 500 = 502), not
|
||||
// its own (1002 − 400 = 602).
|
||||
expect(resolved(presentation, 'video', 'v-1').startTime).toBe(500);
|
||||
expect(resolved(presentation, 'audio', 'a-1').startTime).toBe(502);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
cleanup();
|
||||
it('establishes once — a later reload is left to the parser (no re-establish even if buffer drifts)', async () => {
|
||||
let actualStart = 500;
|
||||
const { cleanup, state } = run({
|
||||
presentation: makePresentation([makeVideoTrack()]),
|
||||
config: { resolveBufferedAnchor: () => ({ trackId: 'v-1', segmentId: 'segment-85', actualStart }) },
|
||||
});
|
||||
|
||||
it('upgrades from the estimate to the buffer pin once ground truth arrives', async () => {
|
||||
let bufferReady = false;
|
||||
const { cleanup, state } = run({
|
||||
presentation: makePresentation([makeVideoTrack()]),
|
||||
videoId: 'v-1',
|
||||
config: {
|
||||
resolveBufferedAnchor: () => (bufferReady ? { segmentId: 'segment-85', actualStart: 500 } : undefined),
|
||||
},
|
||||
});
|
||||
expect(resolved(state.presentation.get()!, 'video', 'v-1').startTime).toBe(500);
|
||||
|
||||
// Bootstrap: estimate places it at 340.
|
||||
expect(resolved(state.presentation.get()!, 'video', 'v-1').startTime).toBe(340);
|
||||
// Reload carrying the anchored timeline forward; the resolver now disagrees.
|
||||
actualStart = 600;
|
||||
const carried = makeVideoTrack();
|
||||
carried.startTime = 500;
|
||||
carried.startDate = 500;
|
||||
carried.segments = [{ ...carried.segments[0]!, startTime: 500, startDate: 1000 }];
|
||||
state.presentation.set(makePresentation([carried]));
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
// Buffer ground truth appears; a reload re-checks it and upgrades the anchor.
|
||||
bufferReady = true;
|
||||
state.presentation.set(makePresentation([makeVideoTrack()]));
|
||||
await flush();
|
||||
await flush();
|
||||
// Stays at 500 (entry doesn't re-fire while still anchored), not re-pinned to 400.
|
||||
expect(resolved(state.presentation.get()!, 'video', 'v-1').startTime).toBe(500);
|
||||
|
||||
expect(resolved(state.presentation.get()!, 'video', 'v-1').startTime).toBe(500);
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('pins once — a later reload is left to the parser (no re-pin even if the anchor drifts)', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
let actualStart = 500;
|
||||
const { cleanup, state } = run({
|
||||
presentation: makePresentation([makeVideoTrack()]),
|
||||
videoId: 'v-1',
|
||||
config: { resolveBufferedAnchor: () => ({ segmentId: 'segment-85', actualStart }) },
|
||||
});
|
||||
|
||||
expect(resolved(state.presentation.get()!, 'video', 'v-1').startTime).toBe(500);
|
||||
|
||||
// Reload carrying the pinned timeline forward; the resolver now disagrees.
|
||||
actualStart = 600;
|
||||
const carried = makeVideoTrack();
|
||||
carried.startTime = 500;
|
||||
carried.startDate = 500;
|
||||
carried.segments = [{ ...carried.segments[0]!, startTime: 500, startDate: 1000 }];
|
||||
state.presentation.set(makePresentation([carried]));
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
// Maintain mode: stays at 500 (the parser owns carry-forward), not re-pinned to 600.
|
||||
expect(resolved(state.presentation.get()!, 'video', 'v-1').startTime).toBe(500);
|
||||
|
||||
cleanup();
|
||||
warn.mockRestore();
|
||||
});
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -117,6 +117,12 @@ export interface SimpleHlsEngineState {
|
||||
failedCdns?: string[];
|
||||
currentTime?: number;
|
||||
loadActivated?: boolean;
|
||||
/**
|
||||
* The shared live timeline anchor (wall clock at media-time 0), published by
|
||||
* `anchorLiveTracks` once the buffer pin lands. `seekToLiveEdge` gates its
|
||||
* live-edge seek on it. Absent for VoD / until the first segment buffers.
|
||||
*/
|
||||
liveAnchor?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { untrack } from '../../../core/signals/primitives';
|
||||
import { type BufferedAnchor, bufferedAnchorFor } from '../../../media/buffered-anchor';
|
||||
import type { ResolvedTrack } from '../../../media/types';
|
||||
import { bufferedAnchorFor } from '../../../media/buffered-anchor';
|
||||
import type { SourceBufferActor } from '../../actors/dom/source-buffer';
|
||||
import type { AnchorLiveTracksDeps } from '../../behaviors/anchor-live-tracks';
|
||||
import type { AnchorLiveTracksDeps, BufferedTrackAnchor } from '../../behaviors/anchor-live-tracks';
|
||||
|
||||
/**
|
||||
* The engine context this resolver reads — the per-type SourceBuffer actors.
|
||||
@@ -17,25 +16,30 @@ export interface BufferActorContext {
|
||||
|
||||
/**
|
||||
* An engine's implementation of `anchorLiveTracks`' `resolveBufferedAnchor` seam.
|
||||
* Reads the buffer actors from the behavior's `context` deps — the actors'
|
||||
* DOM-free snapshot data (appended segments + native-PTS `bufferedRanges`) — to
|
||||
* report where a segment actually landed, so the model timeline can be pinned to
|
||||
* ground truth. Reads are untracked: the pin re-checks each reload, with no need
|
||||
* to re-fire on every buffer tick.
|
||||
* Reads the first A/V buffer actor with ground truth (video preferred) from the
|
||||
* behavior's `context` deps — the actor knows which track it's buffering
|
||||
* (`initTrackId`) and exposes DOM-free snapshot data (appended segments +
|
||||
* native-PTS `bufferedRanges`) — and reports where a segment actually landed so
|
||||
* the model timeline can be pinned to ground truth. Reads are untracked: the pin
|
||||
* re-checks each reload, with no need to re-fire on every buffer tick.
|
||||
*
|
||||
* Generic over the engine `Context` so it stays engine-agnostic; the only
|
||||
* requirement is the per-type buffer-actor slots (`BufferActorContext`).
|
||||
*/
|
||||
export function resolveBufferedAnchor<Context extends BufferActorContext>(
|
||||
track: ResolvedTrack,
|
||||
{ context }: AnchorLiveTracksDeps<Context>
|
||||
): BufferedAnchor | undefined {
|
||||
export function resolveBufferedAnchor<Context extends BufferActorContext>({
|
||||
context,
|
||||
}: AnchorLiveTracksDeps<Context>): BufferedTrackAnchor | undefined {
|
||||
return untrack(() => {
|
||||
const actor = (
|
||||
track.type === 'video' ? context.videoBufferActor : track.type === 'audio' ? context.audioBufferActor : undefined
|
||||
)?.get();
|
||||
if (!actor) return undefined;
|
||||
const { context: bufferContext } = actor.snapshot.get();
|
||||
return bufferedAnchorFor(bufferContext.segments, bufferContext.bufferedRanges);
|
||||
// Video preferred; under the no-skew assumption both agree, so this is just
|
||||
// a tiebreak for which actor supplies the (shared) anchor.
|
||||
for (const ref of [context.videoBufferActor, context.audioBufferActor]) {
|
||||
const actor = ref?.get();
|
||||
if (!actor) continue;
|
||||
const { initTrackId, segments, bufferedRanges } = actor.snapshot.get().context;
|
||||
if (!initTrackId) continue;
|
||||
const anchor = bufferedAnchorFor(segments, bufferedRanges);
|
||||
if (anchor) return { ...anchor, trackId: initTrackId };
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user