mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
fix(spf): pin the live track timeline to the buffer so streams end cleanly
The model timeline (an averageDuration×sequence estimate) could drift from the SourceBuffer's native-PTS timeline across reloads. At ENDLIST the drifted model placed the final segments behind the playhead, so the loader skipped them and end-of-stream's isLastSegmentAppended never satisfied — playback stalled in `waiting`, duration stuck at Infinity, `ended` never fired. Pin the model to ground truth: once a segment is buffered, re-origin the track onto where it actually landed (anchorTrackToBufferedSegment), correlated via bufferedAnchorFor over the buffer actor's DOM-free snapshot. Pin once per track (the offset is constant under no-discontinuity); the parser's now-PDT-exact carry-forward maintains it. The sequence estimate stays the pre-buffer bootstrap. anchorLiveTracks stays DOM-free — the engine injects the resolver from the buffer actors; no timestampOffset (preserves A/V sync). The same pin serves non-zero-PTS VOD. Smoke-tested on a live Mux LL-HLS stream: durationchange → finite duration → plays out → `ended`, no stall. 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
2a1fe85c28
commit
b32783fbd7
@@ -0,0 +1,52 @@
|
||||
import type { Track } from './types';
|
||||
|
||||
/**
|
||||
* Re-origin a track's timeline onto the buffer's (native-PTS) timeline, using a
|
||||
* segment whose *actual* buffered position is known as ground truth.
|
||||
*
|
||||
* `anchorTrackToSequenceOrigin` positions the timeline from the manifest alone
|
||||
* (an `averageDuration × sequence` estimate); this is the authoritative
|
||||
* correction that supersedes it once real data exists. Given a segment present
|
||||
* in the track (`segmentId`) and where it actually landed in the SourceBuffer
|
||||
* (`actualStart`, from `mediaElement.buffered`), the offset is
|
||||
* `actualStart − segment.startTime` (expected) and the whole track shifts by it —
|
||||
* so the model's coordinates coincide with the buffer's. Per the
|
||||
* no-mid-stream-discontinuity assumption the offset is constant, so pinning from
|
||||
* one known segment re-origins the entire window.
|
||||
*
|
||||
* Segment `startDate` (PDT) is intrinsic wall clock and stays put; only timeline
|
||||
* positions move. `Track.startDate` (the wall clock at timeline 0) shifts with
|
||||
* the origin.
|
||||
*
|
||||
* No-op (returns the same track) when the segment isn't present or the offset is
|
||||
* zero (already aligned) — so callers can apply it unconditionally each reload.
|
||||
*/
|
||||
export function anchorTrackToBufferedSegment<Tracks extends Track>(
|
||||
track: Tracks,
|
||||
segmentId: string,
|
||||
actualStart: number
|
||||
): Tracks {
|
||||
const segment = track.segments.find((s) => s.id === segmentId);
|
||||
if (!segment) return track;
|
||||
|
||||
const shift = actualStart - segment.startTime;
|
||||
if (shift === 0) return track;
|
||||
|
||||
// `Track.startDate` is the wall clock at timeline 0; shifting positions by
|
||||
// `+shift` moves timeline 0 to an earlier instant, so it adjusts by `−shift`.
|
||||
// Equivalently, from the pinned segment's intrinsic PDT: `startDate −
|
||||
// actualStart` (both forms agree along a linear timeline).
|
||||
const startDate =
|
||||
track.startDate !== undefined
|
||||
? track.startDate - shift
|
||||
: segment.startDate !== undefined
|
||||
? segment.startDate - actualStart
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...track,
|
||||
startTime: track.startTime + shift,
|
||||
...(startDate === undefined ? {} : { startDate }),
|
||||
segments: track.segments.map((s) => ({ ...s, startTime: s.startTime + shift })),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Segment } from './types';
|
||||
|
||||
/** A segment in the buffer paired with where it actually landed (native PTS). */
|
||||
export interface BufferedAnchor {
|
||||
segmentId: string;
|
||||
/** The segment's actual start on the buffer (native-PTS) timeline. */
|
||||
actualStart: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Correlate the model against the buffer to find a pin anchor: the segment at the
|
||||
* buffer's leading edge and where it *actually* sits in native PTS.
|
||||
*
|
||||
* `appendedSegments` are the segments known to be in the SourceBuffer (model
|
||||
* coordinates — `meta.startTime`); `bufferedRanges` are the real native-PTS
|
||||
* ranges (`mediaElement.buffered`, exposed DOM-free by the buffer actor). The
|
||||
* latest appended segment sits at the buffer's leading edge, so its actual start
|
||||
* is `maxBufferedEnd − duration`. Pairing its id with that start lets
|
||||
* {@link anchorTrackToBufferedSegment} re-origin the whole track onto the buffer
|
||||
* (the constant offset means one anchor pins the window).
|
||||
*
|
||||
* Returns `undefined` before anything is buffered — callers fall back to the
|
||||
* sequence estimate until then.
|
||||
*/
|
||||
export function bufferedAnchorFor(
|
||||
appendedSegments: readonly Pick<Segment, 'id' | 'startTime' | 'duration'>[],
|
||||
bufferedRanges: readonly { readonly start: number; readonly end: number }[]
|
||||
): BufferedAnchor | undefined {
|
||||
if (appendedSegments.length === 0 || bufferedRanges.length === 0) return undefined;
|
||||
|
||||
const latest = appendedSegments.reduce((a, b) => (b.startTime > a.startTime ? b : a));
|
||||
const maxBufferedEnd = Math.max(...bufferedRanges.map((r) => r.end));
|
||||
return { segmentId: latest.id, actualStart: maxBufferedEnd - latest.duration };
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { anchorTrackToBufferedSegment } from '../anchor-track-to-buffered-segment';
|
||||
import { MEDIA_PLAYLIST_METADATA_KEY, type Segment, type Track } from '../types';
|
||||
|
||||
function makeTrack(
|
||||
mediaSequence: number,
|
||||
segments: Array<{ startTime: number; duration: number; pdt?: number }>
|
||||
): Track {
|
||||
return {
|
||||
type: 'video',
|
||||
id: 'track',
|
||||
url: 'https://example.com/playlist.m3u8',
|
||||
mimeType: 'video/mp4',
|
||||
bandwidth: 0,
|
||||
duration: Number.POSITIVE_INFINITY,
|
||||
startTime: segments[0]?.startTime ?? 0,
|
||||
segments: segments.map(
|
||||
(s, i): Segment => ({
|
||||
id: `segment-${mediaSequence + i}`,
|
||||
url: `${mediaSequence + i}.m4s`,
|
||||
duration: s.duration,
|
||||
startTime: s.startTime,
|
||||
...(s.pdt === undefined ? {} : { startDate: s.pdt }),
|
||||
})
|
||||
),
|
||||
metadata: {
|
||||
[MEDIA_PLAYLIST_METADATA_KEY]: { mediaSequence, targetDuration: 5, endList: false },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('anchorTrackToBufferedSegment', () => {
|
||||
it('re-origins the whole track so the named segment lands at its actual buffered start', () => {
|
||||
// Estimate placed segment-85 at model startTime 340; the SourceBuffer actually
|
||||
// holds it at native PTS 370 — a +30 correction the buffer (ground truth) wins.
|
||||
const track = makeTrack(85, [
|
||||
{ startTime: 340, duration: 4, pdt: 1000 },
|
||||
{ startTime: 344, duration: 4, pdt: 1004 },
|
||||
]);
|
||||
|
||||
const pinned = anchorTrackToBufferedSegment(track, 'segment-85', 370);
|
||||
|
||||
expect(pinned.segments.map((s) => s.startTime)).toEqual([370, 374]);
|
||||
expect(pinned.startTime).toBe(370);
|
||||
// startDate (wall clock at timeline 0) tracks the shift: PDT(seg) − newStart = 1000 − 370.
|
||||
expect(pinned.startDate).toBe(630);
|
||||
// Per-segment PDT is intrinsic — unchanged by the re-origin.
|
||||
expect(pinned.segments.map((s) => s.startDate)).toEqual([1000, 1004]);
|
||||
});
|
||||
|
||||
it('corrects a backward drift too (estimate ahead of the buffer)', () => {
|
||||
const track = makeTrack(85, [{ startTime: 340, duration: 4, pdt: 1000 }]);
|
||||
const pinned = anchorTrackToBufferedSegment(track, 'segment-85', 320);
|
||||
expect(pinned.startTime).toBe(320);
|
||||
expect(pinned.segments[0]?.startTime).toBe(320);
|
||||
});
|
||||
|
||||
it('is idempotent when the segment already sits at the buffered start (offset 0)', () => {
|
||||
const track = makeTrack(85, [{ startTime: 340, duration: 4, pdt: 1000 }]);
|
||||
expect(anchorTrackToBufferedSegment(track, 'segment-85', 340)).toBe(track);
|
||||
});
|
||||
|
||||
it('no-ops (returns the same track) when the segment is not present', () => {
|
||||
const track = makeTrack(85, [{ startTime: 340, duration: 4, pdt: 1000 }]);
|
||||
expect(anchorTrackToBufferedSegment(track, 'segment-999', 500)).toBe(track);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { bufferedAnchorFor } from '../buffered-anchor';
|
||||
|
||||
describe('bufferedAnchorFor', () => {
|
||||
it('pairs the leading-edge segment with its actual native-PTS start', () => {
|
||||
// Model says the latest segment sits at 110 (+10s long); the buffer's real
|
||||
// leading edge is 550 → it actually starts at 540 (a +430 native-PTS offset).
|
||||
const anchor = bufferedAnchorFor(
|
||||
[
|
||||
{ id: 's1', startTime: 100, duration: 10 },
|
||||
{ id: 's2', startTime: 110, duration: 10 },
|
||||
],
|
||||
[{ start: 530, end: 550 }]
|
||||
);
|
||||
expect(anchor).toEqual({ segmentId: 's2', actualStart: 540 });
|
||||
});
|
||||
|
||||
it('uses the max end across discontiguous ranges', () => {
|
||||
const anchor = bufferedAnchorFor(
|
||||
[{ id: 's2', startTime: 110, duration: 10 }],
|
||||
[
|
||||
{ start: 530, end: 545 },
|
||||
{ start: 548, end: 552 },
|
||||
]
|
||||
);
|
||||
expect(anchor?.actualStart).toBe(542); // 552 − 10
|
||||
});
|
||||
|
||||
it('returns undefined before anything is buffered', () => {
|
||||
expect(bufferedAnchorFor([{ id: 's1', startTime: 0, duration: 10 }], [])).toBeUndefined();
|
||||
expect(bufferedAnchorFor([], [{ start: 0, end: 10 }])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,27 +1,39 @@
|
||||
/**
|
||||
* Anchor the selected live tracks' timelines to the estimated stream origin.
|
||||
* 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
|
||||
* `startTime`, so the two timelines must agree.
|
||||
*
|
||||
* The segment loader matches `currentTime` (the SourceBuffer's native-PTS
|
||||
* coordinate, since segments append unmodified) against each segment's
|
||||
* `startTime`. For live, the manifest's `startTime` (EXTINF-from-0) is *not*
|
||||
* the native PTS, so without adjustment the loader can't find the segments
|
||||
* around the playhead. This applies `anchorTrackToSequenceOrigin` to each
|
||||
* selected resolved track so `startTime` reads as elapsed-since-stream-start —
|
||||
* which ≈ native PTS when the encoder's timeline is stream-relative — closing
|
||||
* that gap from the manifest alone (refined later from the buffer).
|
||||
* 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.
|
||||
*
|
||||
* Per-track and idempotent: `anchorTrackToSequenceOrigin` returns the same
|
||||
* track once anchored (shift 0), so the effect converges without re-firing.
|
||||
* Cross-track A/V alignment (`alignTrackTimelines`) is intentionally *not*
|
||||
* composed here — it would fight the per-track anchor in a re-firing effect;
|
||||
* the residual per-track skew is absorbed by native-PTS A/V sync in the buffer.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { isUndefined } from '@videojs/utils/predicate';
|
||||
import type { Behavior } from '../../core/composition/create-composition';
|
||||
import { effect } from '../../core/signals/effect';
|
||||
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 {
|
||||
isResolvedPresentation,
|
||||
isResolvedTrack,
|
||||
@@ -38,10 +50,16 @@ export interface AnchorLiveTracksState {
|
||||
|
||||
export interface AnchorLiveTracksConfig {
|
||||
/**
|
||||
* Sequence number assumed to be the stream origin (time 0). Default 0 —
|
||||
* see `anchorTrackToSequenceOrigin`.
|
||||
* Sequence number assumed to be the stream origin (time 0) for the bootstrap
|
||||
* estimate. Default 0 — see `anchorTrackToSequenceOrigin`.
|
||||
*/
|
||||
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).
|
||||
*/
|
||||
resolveBufferedAnchor?: (track: ResolvedTrack) => BufferedAnchor | undefined;
|
||||
}
|
||||
|
||||
function anchorLiveTracksSetup({
|
||||
@@ -55,11 +73,36 @@ function anchorLiveTracksSetup({
|
||||
};
|
||||
config?: AnchorLiveTracksConfig;
|
||||
}): () => void {
|
||||
const { presumedStartSequence = 0 } = config;
|
||||
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>();
|
||||
|
||||
function position(track: ResolvedTrack): ResolvedTrack {
|
||||
// Already pinned → leave it to the parser's carry-forward.
|
||||
if (pinned.has(track.id)) return track;
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Pre-buffer bootstrap: the manifest-only sequence estimate.
|
||||
return anchorTrackToSequenceOrigin(track, { presumedStartSequence });
|
||||
}
|
||||
|
||||
return effect(() => {
|
||||
const presentation = state.presentation.get();
|
||||
if (!isResolvedPresentation(presentation)) return;
|
||||
if (!isResolvedPresentation(presentation)) {
|
||||
// Source unloaded/changing — drop pins so the next source re-pins.
|
||||
pinned.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
const videoId = state.selectedVideoTrackId?.get();
|
||||
const audioId = state.selectedAudioTrackId?.get();
|
||||
@@ -69,19 +112,19 @@ function anchorLiveTracksSetup({
|
||||
audioId ? findTrack(presentation, 'audio', audioId) : undefined,
|
||||
];
|
||||
|
||||
const anchored: ResolvedTrack[] = [];
|
||||
const positioned: ResolvedTrack[] = [];
|
||||
for (const track of selected) {
|
||||
if (!track || !isResolvedTrack(track) || isUndefined(track.startDate)) continue;
|
||||
const next = anchorTrackToSequenceOrigin(track, { presumedStartSequence });
|
||||
// Identity-equal when already anchored (shift 0) → nothing to patch.
|
||||
if (next !== track) anchored.push(next);
|
||||
const next = position(track);
|
||||
// Identity-equal when nothing moved (already aligned / maintain mode).
|
||||
if (next !== track) positioned.push(next);
|
||||
}
|
||||
if (anchored.length === 0) return;
|
||||
if (positioned.length === 0) return;
|
||||
|
||||
update(state.presentation as Signal<MaybeResolvedPresentation>, (current) => {
|
||||
if (!isResolvedPresentation(current)) return current;
|
||||
let result = current;
|
||||
for (const track of anchored) result = updateTrackInPresentation(result, track);
|
||||
for (const track of positioned) result = updateTrackInPresentation(result, track);
|
||||
return result;
|
||||
});
|
||||
});
|
||||
@@ -90,8 +133,8 @@ function anchorLiveTracksSetup({
|
||||
/**
|
||||
* 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.
|
||||
* defensively, so the behavior stays composable in variants that wire selection
|
||||
* differently.
|
||||
*/
|
||||
export const anchorLiveTracks: Behavior<
|
||||
{ presentation: Signal<AnchorLiveTracksState['presentation']> },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { signal } from '../../../core/signals/primitives';
|
||||
import {
|
||||
isResolvedTrack,
|
||||
@@ -87,4 +87,58 @@ describe('anchorLiveTracks', () => {
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
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: {},
|
||||
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;
|
||||
// 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('pins once — a later reload is left to the parser (no re-pin even if the anchor drifts)', () => {
|
||||
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: {},
|
||||
config: { resolveBufferedAnchor: () => ({ segmentId: 'segment-85', actualStart }) },
|
||||
}) as () => void;
|
||||
|
||||
expect((findTrack(state.presentation.get()!, 'video', 'v-1') as { startTime: number }).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));
|
||||
|
||||
// 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);
|
||||
|
||||
cleanup();
|
||||
warn.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,11 +5,13 @@ 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 {
|
||||
@@ -316,8 +318,28 @@ 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,
|
||||
canPlayTrack: config.canPlayTrack ?? canPlayTrack,
|
||||
// The resolve* loaders' RecurringRunner re-runs on this `reschedule`: the pure
|
||||
// target-duration cadence, start-anchored + made awaitable by `delayedReschedule`.
|
||||
@@ -332,7 +354,7 @@ export function createSimpleHlsEngine(
|
||||
removeAllSubtitlesTracksFromMedia: config.removeAllSubtitlesTracksFromMedia ?? removeAllSubtitlesTracksFromMedia,
|
||||
};
|
||||
|
||||
return createComposition(
|
||||
const composition = createComposition(
|
||||
[
|
||||
syncPreload,
|
||||
trackLoadTriggers,
|
||||
@@ -438,4 +460,11 @@ 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user