mirror of
https://github.com/zoriya/v10.git
synced 2026-08-14 18:04:49 +00:00
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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
3a9d1faca0
commit
7cde2ea28d
@@ -99,12 +99,17 @@ This promotes open question **[4] sync anchor** in
|
||||
|
||||
## 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.
|
||||
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
|
||||
(`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.
|
||||
|
||||
## See also
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ realized. What remains is forward-looking:
|
||||
| `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` | Pin live track timelines to the SourceBuffer's native-PTS ground truth (first appended segment) or manifest estimate; re-pin per reload as the window slides |
|
||||
| `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). |
|
||||
| `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,8 +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` — pin to buffer ground truth;
|
||||
PDT carry-forward; sequence-origin bootstrap.
|
||||
- `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/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,44 +1,56 @@
|
||||
/**
|
||||
* Position the selected tracks' timelines 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
|
||||
* 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.
|
||||
*
|
||||
* Two anchors, by precedence:
|
||||
* 1. **Buffer pin (authoritative).** Once a segment is buffered, an injected
|
||||
* `resolveBufferedAnchor` reports where it *actually* landed (native PTS); the
|
||||
* track re-origins onto that exactly (`anchorTrackToBufferedSegment`). The
|
||||
* offset is constant (no-mid-stream-discontinuity assumption), so we pin
|
||||
* **once** per track and then leave it — the parser's PDT-exact carry-forward
|
||||
* (`placeOnPreviousTimeline`) maintains the buffer-aligned timeline across
|
||||
* reloads. Re-pinning every reload would *mask* a drifting baseline; pinning
|
||||
* once *surfaces* it.
|
||||
* 2. **Sequence estimate (bootstrap).** Before anything is buffered there's no
|
||||
* ground truth, so `anchorTrackToSequenceOrigin` positions from the manifest
|
||||
* alone (`averageDuration × sequence`) — close enough to start playback, then
|
||||
* superseded by the pin.
|
||||
* 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:
|
||||
*
|
||||
* - **`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.
|
||||
*
|
||||
* 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).
|
||||
*
|
||||
* 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`. The same shape serves non-zero-PTS VOD, where
|
||||
* the model is zero-based and the buffer holds the original (large) PTS.
|
||||
*
|
||||
* Cross-track A/V alignment is intentionally *not* composed here — the buffer pin
|
||||
* already lands each track on the shared native-PTS timeline.
|
||||
* never touches `HTMLMediaElement`.
|
||||
*/
|
||||
|
||||
import { isUndefined } from '@videojs/utils/predicate';
|
||||
import type { Behavior } from '../../core/composition/create-composition';
|
||||
import { effect } from '../../core/signals/effect';
|
||||
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 { anchorTrackToBufferedSegment } from '../../media/anchor-track-to-buffered-segment';
|
||||
import { anchorTrackToSequenceOrigin } from '../../media/anchor-track-to-sequence-origin';
|
||||
import type { BufferedAnchor } from '../../media/buffered-anchor';
|
||||
import {
|
||||
type PresentationAnchor,
|
||||
positionTrackToAnchor,
|
||||
presentationAnchorEstimate,
|
||||
presentationAnchorFromBuffer,
|
||||
} from '../../media/presentation-anchor';
|
||||
import {
|
||||
isResolvedPresentation,
|
||||
isResolvedTrack,
|
||||
type MaybeResolvedPresentation,
|
||||
type ResolvedTrack,
|
||||
type TrackType,
|
||||
} from '../../media/types';
|
||||
import { findTrack, updateTrackInPresentation } from '../../media/utils/tracks';
|
||||
|
||||
@@ -46,102 +58,195 @@ export interface AnchorLiveTracksState {
|
||||
presentation?: MaybeResolvedPresentation;
|
||||
selectedVideoTrackId?: string;
|
||||
selectedAudioTrackId?: string;
|
||||
selectedTextTrackId?: string;
|
||||
}
|
||||
|
||||
export interface AnchorLiveTracksConfig {
|
||||
/**
|
||||
* The standard behavior setup deps (`{ state, context, config }`) passed to the
|
||||
* `resolveBufferedAnchor` factory. 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.
|
||||
*/
|
||||
export type AnchorLiveTracksDeps<Context extends object> = BehaviorDeps<
|
||||
{ presentation: Signal<AnchorLiveTracksState['presentation']> },
|
||||
ContextSignals<Context>,
|
||||
AnchorLiveTracksConfig<Context>
|
||||
>;
|
||||
|
||||
export interface AnchorLiveTracksConfig<Context extends object = object> {
|
||||
/**
|
||||
* Sequence number assumed to be the stream origin (time 0) for the bootstrap
|
||||
* estimate. Default 0 — see `anchorTrackToSequenceOrigin`.
|
||||
* estimate. Default 0 — see `presentationAnchorEstimate`.
|
||||
*/
|
||||
presumedStartSequence?: number;
|
||||
/**
|
||||
* Buffered-ground-truth resolver, injected by the engine (the DOM boundary).
|
||||
* Returns where a buffered segment actually sits in native PTS, or `undefined`
|
||||
* before anything is buffered. Absent → estimate-only (e.g. non-DOM tests).
|
||||
* 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).
|
||||
*/
|
||||
resolveBufferedAnchor?: (track: ResolvedTrack) => BufferedAnchor | undefined;
|
||||
resolveBufferedAnchor?: (track: ResolvedTrack, deps: AnchorLiveTracksDeps<Context>) => BufferedAnchor | undefined;
|
||||
}
|
||||
|
||||
function anchorLiveTracksSetup({
|
||||
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,
|
||||
config = {},
|
||||
}: {
|
||||
state: {
|
||||
presentation: Signal<AnchorLiveTracksState['presentation']>;
|
||||
selectedVideoTrackId?: ReadonlySignal<AnchorLiveTracksState['selectedVideoTrackId']>;
|
||||
selectedAudioTrackId?: ReadonlySignal<AnchorLiveTracksState['selectedAudioTrackId']>;
|
||||
selectedTextTrackId?: ReadonlySignal<AnchorLiveTracksState['selectedTextTrackId']>;
|
||||
};
|
||||
config?: AnchorLiveTracksConfig;
|
||||
}): () => void {
|
||||
const { presumedStartSequence = 0, resolveBufferedAnchor } = config;
|
||||
// Track ids pinned to the buffer. Pinned once; thereafter the parser's
|
||||
// PDT-exact carry-forward maintains the alignment — re-pinning every reload
|
||||
// would mask a drifting baseline rather than surface it.
|
||||
const pinned = new Set<string>();
|
||||
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 };
|
||||
|
||||
function position(track: ResolvedTrack): ResolvedTrack {
|
||||
// Already pinned → leave it to the parser's carry-forward.
|
||||
if (pinned.has(track.id)) return track;
|
||||
// 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>();
|
||||
|
||||
// Buffer ground truth available → pin once (authoritative). Only when the
|
||||
// anchor's segment is actually in this track; otherwise fall through to the
|
||||
// estimate and retry next reload.
|
||||
const anchor = resolveBufferedAnchor?.(track);
|
||||
if (anchor && track.segments.some((s) => s.id === anchor.segmentId)) {
|
||||
pinned.add(track.id);
|
||||
return anchorTrackToBufferedSegment(track, anchor.segmentId, anchor.actualStart);
|
||||
}
|
||||
const selectedId = (type: TrackType): string | undefined =>
|
||||
type === 'video'
|
||||
? state.selectedVideoTrackId?.get()
|
||||
: type === 'audio'
|
||||
? state.selectedAudioTrackId?.get()
|
||||
: state.selectedTextTrackId?.get();
|
||||
|
||||
// Pre-buffer bootstrap: the manifest-only sequence estimate.
|
||||
return anchorTrackToSequenceOrigin(track, { presumedStartSequence });
|
||||
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;
|
||||
}
|
||||
|
||||
return effect(() => {
|
||||
const presentation = state.presentation.get();
|
||||
if (!isResolvedPresentation(presentation)) {
|
||||
// Source unloaded/changing — drop pins so the next source re-pins.
|
||||
pinned.clear();
|
||||
return;
|
||||
// First selected A/V track with buffer ground truth wins (video preferred).
|
||||
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;
|
||||
}
|
||||
|
||||
const videoId = state.selectedVideoTrackId?.get();
|
||||
const audioId = state.selectedAudioTrackId?.get();
|
||||
|
||||
const selected = [
|
||||
videoId ? findTrack(presentation, 'video', videoId) : undefined,
|
||||
audioId ? findTrack(presentation, 'audio', audioId) : undefined,
|
||||
];
|
||||
|
||||
const positioned: ResolvedTrack[] = [];
|
||||
for (const track of selected) {
|
||||
if (!track || !isResolvedTrack(track) || isUndefined(track.startDate)) continue;
|
||||
const next = position(track);
|
||||
// Identity-equal when nothing moved (already aligned / maintain mode).
|
||||
if (next !== track) positioned.push(next);
|
||||
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;
|
||||
}
|
||||
if (positioned.length === 0) return;
|
||||
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 positioned) result = updateTrackInPresentation(result, track);
|
||||
for (const track of next) result = updateTrackInPresentation(result, track);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
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.
|
||||
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);
|
||||
},
|
||||
},
|
||||
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.
|
||||
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);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual `Behavior<>` literal (like `calculatePresentationDuration`): declares
|
||||
* only `presentation` in stateKeys while reading the `selected*TrackId` slots
|
||||
* defensively, so the behavior stays composable in variants that wire selection
|
||||
* differently.
|
||||
* 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.
|
||||
*/
|
||||
export const anchorLiveTracks: Behavior<
|
||||
export function makeAnchorLiveTracks<Context extends object = object>(): Behavior<
|
||||
{ presentation: Signal<AnchorLiveTracksState['presentation']> },
|
||||
Record<string, never>,
|
||||
AnchorLiveTracksConfig
|
||||
> = {
|
||||
stateKeys: ['presentation'],
|
||||
contextKeys: [],
|
||||
setup: anchorLiveTracksSetup,
|
||||
};
|
||||
ContextSignals<Context>,
|
||||
AnchorLiveTracksConfig<Context>
|
||||
> {
|
||||
return {
|
||||
stateKeys: ['presentation'],
|
||||
contextKeys: [],
|
||||
setup: anchorLiveTracksSetup,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { signal } from '../../../core/signals/primitives';
|
||||
import {
|
||||
type AudioTrack,
|
||||
isResolvedTrack,
|
||||
type MaybeResolvedPresentation,
|
||||
MEDIA_PLAYLIST_METADATA_KEY,
|
||||
type Presentation,
|
||||
type ResolvedTrack,
|
||||
type TextTrack,
|
||||
type VideoTrack,
|
||||
} from '../../../media/types';
|
||||
import { findTrack } from '../../../media/utils/tracks';
|
||||
import { anchorLiveTracks } from '../anchor-live-tracks';
|
||||
import { type AnchorLiveTracksConfig, makeAnchorLiveTracks } from '../anchor-live-tracks';
|
||||
|
||||
const META = { [MEDIA_PLAYLIST_METADATA_KEY]: { mediaSequence: 85, targetDuration: 5, endList: false } };
|
||||
|
||||
function makeVideoTrack(): VideoTrack {
|
||||
return {
|
||||
@@ -23,33 +28,101 @@ function makeVideoTrack(): VideoTrack {
|
||||
startTime: 0,
|
||||
startDate: 1000,
|
||||
segments: [{ id: 'segment-85', url: 'https://example.com/85.m4s', duration: 4, startTime: 0, startDate: 1000 }],
|
||||
metadata: {
|
||||
[MEDIA_PLAYLIST_METADATA_KEY]: { mediaSequence: 85, targetDuration: 5, endList: false },
|
||||
},
|
||||
metadata: META,
|
||||
};
|
||||
}
|
||||
|
||||
function makePresentation(track: VideoTrack): Presentation {
|
||||
function makeAudioTrack(): AudioTrack {
|
||||
return {
|
||||
id: 'pres-1',
|
||||
url: 'https://example.com/master.m3u8',
|
||||
type: 'audio',
|
||||
id: 'a-1',
|
||||
url: 'https://example.com/audio.m3u8',
|
||||
mimeType: 'audio/mp4',
|
||||
codecs: ['mp4a.40.2'],
|
||||
bandwidth: 128_000,
|
||||
initialization: { url: 'https://example.com/audio-init.mp4' },
|
||||
duration: Number.POSITIVE_INFINITY,
|
||||
startTime: 0,
|
||||
selectionSets: [{ id: 'video-set', type: 'video', switchingSets: [{ id: 'vs', type: 'video', tracks: [track] }] }],
|
||||
// First audio segment's PDT trails video's by 2s — placement is by PDT, so
|
||||
// this offset must survive anchoring.
|
||||
startDate: 1002,
|
||||
segments: [{ id: 'audio-85', url: 'https://example.com/a85.m4s', duration: 4, startTime: 0, startDate: 1002 }],
|
||||
groupId: 'aud',
|
||||
name: 'English',
|
||||
sampleRate: 48_000,
|
||||
channels: 2,
|
||||
metadata: META,
|
||||
};
|
||||
}
|
||||
|
||||
function makeTextTrack(): TextTrack {
|
||||
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 {
|
||||
const selectionSets = (['video', 'audio', 'text'] as const).flatMap((type) => {
|
||||
const typed = tracks.filter((track) => track.type === type);
|
||||
return typed.length
|
||||
? [{ id: `${type}-set`, type, switchingSets: [{ id: `${type}-ss`, type, tracks: typed }] }]
|
||||
: [];
|
||||
});
|
||||
// The flatMap widens each set's `type` to the union; cast back to the
|
||||
// discriminated `Presentation` (the constituent tracks are already typed).
|
||||
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.
|
||||
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),
|
||||
};
|
||||
// The manual `Behavior<>` literal widens the setup return to `BehaviorCleanup`;
|
||||
// narrow back to the reactor's destroy handle for teardown.
|
||||
const reactor = makeAnchorLiveTracks().setup({ state, context: {}, config: opts.config ?? {} }) as {
|
||||
destroy: () => void;
|
||||
};
|
||||
return { cleanup: () => reactor.destroy(), state };
|
||||
}
|
||||
|
||||
// Let the reactor's effects re-run after a signal write (they re-run on a microtask).
|
||||
const flush = () => Promise.resolve();
|
||||
|
||||
function resolved(presentation: MaybeResolvedPresentation, type: ResolvedTrack['type'], id: string) {
|
||||
const track = findTrack(presentation, type, id);
|
||||
expect(track && isResolvedTrack(track)).toBe(true);
|
||||
return track as ResolvedTrack;
|
||||
}
|
||||
|
||||
describe('anchorLiveTracks', () => {
|
||||
it('anchors the selected track to the estimated stream origin', () => {
|
||||
const state = {
|
||||
presentation: signal<MaybeResolvedPresentation | undefined>(makePresentation(makeVideoTrack())),
|
||||
selectedVideoTrackId: signal<string | undefined>('v-1'),
|
||||
};
|
||||
const { cleanup, state } = run({ presentation: makePresentation([makeVideoTrack()]), videoId: 'v-1' });
|
||||
|
||||
const cleanup = anchorLiveTracks.setup({ state, context: {}, config: {} }) as () => void;
|
||||
|
||||
const track = findTrack(state.presentation.get()!, 'video', 'v-1');
|
||||
expect(track && isResolvedTrack(track)).toBe(true);
|
||||
if (!track || !isResolvedTrack(track)) return;
|
||||
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);
|
||||
@@ -63,12 +136,7 @@ describe('anchorLiveTracks', () => {
|
||||
const track = makeVideoTrack();
|
||||
track.startDate = undefined;
|
||||
track.segments = [{ id: 'segment-85', url: 'https://example.com/85.m4s', duration: 4, startTime: 0 }];
|
||||
const state = {
|
||||
presentation: signal<MaybeResolvedPresentation | undefined>(makePresentation(track)),
|
||||
selectedVideoTrackId: signal<string | undefined>('v-1'),
|
||||
};
|
||||
|
||||
const cleanup = anchorLiveTracks.setup({ state, context: {}, config: {} }) as () => void;
|
||||
const { cleanup, state } = run({ presentation: makePresentation([track]), videoId: 'v-1' });
|
||||
|
||||
expect(findTrack(state.presentation.get()!, 'video', 'v-1')?.startTime).toBe(0);
|
||||
|
||||
@@ -76,12 +144,7 @@ describe('anchorLiveTracks', () => {
|
||||
});
|
||||
|
||||
it('no-ops without a selected track', () => {
|
||||
const state = {
|
||||
presentation: signal<MaybeResolvedPresentation | undefined>(makePresentation(makeVideoTrack())),
|
||||
selectedVideoTrackId: signal<string | undefined>(undefined),
|
||||
};
|
||||
|
||||
const cleanup = anchorLiveTracks.setup({ state, context: {}, config: {} }) as () => void;
|
||||
const { cleanup, state } = run({ presentation: makePresentation([makeVideoTrack()]) });
|
||||
|
||||
expect(findTrack(state.presentation.get()!, 'video', 'v-1')?.startTime).toBe(0);
|
||||
|
||||
@@ -90,20 +153,13 @@ describe('anchorLiveTracks', () => {
|
||||
|
||||
describe('buffer pin', () => {
|
||||
it('pins the track onto the actual buffered position, overriding the estimate', () => {
|
||||
const state = {
|
||||
presentation: signal<MaybeResolvedPresentation | undefined>(makePresentation(makeVideoTrack())),
|
||||
selectedVideoTrackId: signal<string | undefined>('v-1'),
|
||||
};
|
||||
|
||||
const cleanup = anchorLiveTracks.setup({
|
||||
state,
|
||||
context: {},
|
||||
const { cleanup, state } = run({
|
||||
presentation: makePresentation([makeVideoTrack()]),
|
||||
videoId: 'v-1',
|
||||
config: { resolveBufferedAnchor: () => ({ segmentId: 'segment-85', actualStart: 500 }) },
|
||||
}) as () => void;
|
||||
});
|
||||
|
||||
const track = findTrack(state.presentation.get()!, 'video', 'v-1');
|
||||
expect(track && isResolvedTrack(track)).toBe(true);
|
||||
if (!track || !isResolvedTrack(track)) return;
|
||||
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);
|
||||
@@ -111,31 +167,101 @@ describe('anchorLiveTracks', () => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('pins once — a later reload is left to the parser (no re-pin even if the anchor drifts)', () => {
|
||||
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 (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();
|
||||
});
|
||||
|
||||
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 },
|
||||
},
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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),
|
||||
},
|
||||
});
|
||||
|
||||
// Bootstrap: estimate places it at 340.
|
||||
expect(resolved(state.presentation.get()!, 'video', 'v-1').startTime).toBe(340);
|
||||
|
||||
// Buffer ground truth appears; a reload re-checks it and upgrades the anchor.
|
||||
bufferReady = true;
|
||||
state.presentation.set(makePresentation([makeVideoTrack()]));
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
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 state = {
|
||||
presentation: signal<MaybeResolvedPresentation | undefined>(makePresentation(makeVideoTrack())),
|
||||
selectedVideoTrackId: signal<string | undefined>('v-1'),
|
||||
};
|
||||
|
||||
const cleanup = anchorLiveTracks.setup({
|
||||
state,
|
||||
context: {},
|
||||
const { cleanup, state } = run({
|
||||
presentation: makePresentation([makeVideoTrack()]),
|
||||
videoId: 'v-1',
|
||||
config: { resolveBufferedAnchor: () => ({ segmentId: 'segment-85', actualStart }) },
|
||||
}) as () => void;
|
||||
});
|
||||
|
||||
expect((findTrack(state.presentation.get()!, 'video', 'v-1') as { startTime: number }).startTime).toBe(500);
|
||||
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.segments = [{ ...carried.segments[0]!, startTime: 500 }];
|
||||
state.presentation.set(makePresentation(carried));
|
||||
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((findTrack(state.presentation.get()!, 'video', 'v-1') as { startTime: number }).startTime).toBe(500);
|
||||
expect(resolved(state.presentation.get()!, 'video', 'v-1').startTime).toBe(500);
|
||||
|
||||
cleanup();
|
||||
warn.mockRestore();
|
||||
|
||||
@@ -5,13 +5,11 @@ import {
|
||||
type StateSignals,
|
||||
} from '../../../core/composition/create-composition';
|
||||
import { makeShareSignals, type ShareSignalsConfig } from '../../../core/composition/share-signals';
|
||||
import { type ReadonlySignal, untrack } from '../../../core/signals/primitives';
|
||||
import { delayedReschedule } from '../../../core/tasks/delayed-reschedule';
|
||||
import type { Reschedule } from '../../../core/tasks/task';
|
||||
import type { QualityConfig } from '../../../media/abr/quality-selection';
|
||||
import type { BackBufferConfig } from '../../../media/buffer/back-buffer';
|
||||
import type { ForwardBufferConfig } from '../../../media/buffer/forward-buffer';
|
||||
import { bufferedAnchorFor } from '../../../media/buffered-anchor';
|
||||
import { canPlayTrack } from '../../../media/dom/capabilities';
|
||||
import { resolveVttSegment } from '../../../media/dom/text/resolve-vtt-segment';
|
||||
import {
|
||||
@@ -36,7 +34,7 @@ import type { SegmentLoaderActor } from '../../actors/dom/segment-loader';
|
||||
import type { SourceBufferActor } from '../../actors/dom/source-buffer';
|
||||
import type { TextTracksActor } from '../../actors/dom/text-tracks';
|
||||
import type { TextTrackSegmentLoaderActor, TextTrackSegmentResolver } from '../../actors/text-track-segment-loader';
|
||||
import { anchorLiveTracks } from '../../behaviors/anchor-live-tracks';
|
||||
import { makeAnchorLiveTracks } from '../../behaviors/anchor-live-tracks';
|
||||
import {
|
||||
calculatePresentationDuration,
|
||||
type PresentationDurationResolver,
|
||||
@@ -58,6 +56,7 @@ import { resolveAudioTrack, resolveTextTrack, resolveVideoTrack } from '../../be
|
||||
import { type FailoverMonitorConfig, setupFailoverMonitor } from '../../behaviors/setup-failover-monitor';
|
||||
import { syncPreload } from '../../behaviors/sync-preload';
|
||||
import { switchAudioTrack, switchTextTrack, switchVideoTrack } from '../../behaviors/track-switching';
|
||||
import { resolveBufferedAnchor } from './resolve-buffered-anchor';
|
||||
|
||||
// ============================================================================
|
||||
// HLS Engine State & Context
|
||||
@@ -319,25 +318,6 @@ const shareSignals = makeShareSignals<SimpleHlsEngineState, SimpleHlsEngineConte
|
||||
export function createSimpleHlsEngine(
|
||||
config: SimpleHlsEngineConfig = {}
|
||||
): Composition<SimpleHlsEngineState, SimpleHlsEngineContext> {
|
||||
// Buffer-pin resolver injected into `anchorLiveTracks`. Reads the buffer
|
||||
// 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. The actor refs are filled from the
|
||||
// composition's context once it's built (below); the resolver is only ever
|
||||
// called later, during reloads. Reads are untracked — the pin re-checks each
|
||||
// reload, no need to re-fire on every buffer tick.
|
||||
let videoBufferActor: ReadonlySignal<SourceBufferActor | undefined> | undefined;
|
||||
let audioBufferActor: ReadonlySignal<SourceBufferActor | undefined> | undefined;
|
||||
const resolveBufferedAnchor = (track: ResolvedTrack) =>
|
||||
untrack(() => {
|
||||
const actor = (
|
||||
track.type === 'video' ? videoBufferActor : track.type === 'audio' ? audioBufferActor : undefined
|
||||
)?.get();
|
||||
if (!actor) return undefined;
|
||||
const { context } = actor.snapshot.get();
|
||||
return bufferedAnchorFor(context.segments, context.bufferedRanges);
|
||||
});
|
||||
|
||||
const finalConfig = {
|
||||
...config,
|
||||
resolveBufferedAnchor,
|
||||
@@ -395,9 +375,9 @@ export function createSimpleHlsEngine(
|
||||
resolveAudioTrack,
|
||||
resolveTextTrack,
|
||||
|
||||
// Re-base selected live tracks' timelines to the estimated stream origin
|
||||
// (segment.startTime ≈ native PTS). No-op for VoD (no PDT / shift 0).
|
||||
anchorLiveTracks,
|
||||
// Re-base selected live tracks' timelines onto the shared presentation
|
||||
// anchor (estimate, then buffer ground truth). No-op for VoD (no PDT).
|
||||
makeAnchorLiveTracks<SimpleHlsEngineContext>(),
|
||||
|
||||
// Presentation duration (finite for complete playlists, Infinity for live)
|
||||
calculatePresentationDuration,
|
||||
@@ -469,10 +449,5 @@ export function createSimpleHlsEngine(
|
||||
}
|
||||
);
|
||||
|
||||
// Fill the buffer-pin resolver's refs from the live context (created above);
|
||||
// the resolver closes over these and is only invoked later, during reloads.
|
||||
videoBufferActor = composition.context.videoBufferActor;
|
||||
audioBufferActor = composition.context.audioBufferActor;
|
||||
|
||||
return composition;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { untrack } from '../../../core/signals/primitives';
|
||||
import { type BufferedAnchor, bufferedAnchorFor } from '../../../media/buffered-anchor';
|
||||
import type { ResolvedTrack } from '../../../media/types';
|
||||
import type { SourceBufferActor } from '../../actors/dom/source-buffer';
|
||||
import type { AnchorLiveTracksDeps } from '../../behaviors/anchor-live-tracks';
|
||||
|
||||
/**
|
||||
* The engine context this resolver reads — the per-type SourceBuffer actors.
|
||||
* Declared as the minimal contract (not a specific engine's context) so any
|
||||
* engine satisfies it: the default video+audio engine, and a future audio-only
|
||||
* live engine (which omits `videoBufferActor`, fine as it's optional).
|
||||
*/
|
||||
export interface BufferActorContext {
|
||||
videoBufferActor?: SourceBufferActor;
|
||||
audioBufferActor?: SourceBufferActor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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 {
|
||||
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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user