mirror of
https://github.com/zoriya/v10.git
synced 2026-08-15 10:23:32 +00:00
refactor(spf): split track loading from live-reload scheduling
Decompose the live media-playlist reload into two single-responsibility behaviors, matching the content-snapshot ([1]) vs refetch-policy ([3]) category split in live-presentation-modeling.md: - resolve-track (loader, [1]) now also services live reloads: it watches a per-type reload-epoch slot and re-fetches when it advances (gate: load if unresolved OR epoch > last serviced), carries the prior snapshot's timeline forward, and sets presentation.streamType. ConcurrentRunner id-dedup + recording the epoch at schedule time gives drop-if-busy coalescing. - schedule-track-reload (scheduler, [3]) is what remains of reload-track once the fetching is removed: it bumps the reload epoch on a target-duration cadence (half on unchanged), inert for VoD via #EXT-X-ENDLIST, and enters on track *selection* so its bumps also drive first-resolve retries. reload-track is removed; the live-hls and live-playlist-spike engines compose resolve* + schedule* in its place. Also fix updateMediaSourceDuration for live: it only wrote Infinity when the MediaSource was already open at entry and returned without scheduling a wait otherwise. The presentation can resolve to Infinity before setupMediaSource opens the MediaSource, so the eager write was missed and the first append pinned a finite live-edge duration — stalling once the window slid past it. Now it defers to sourceopen when not yet open. Verified live playback end-to-end (real-time, Infinity duration, seeked to edge) against a Mux LL-HLS stream. 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
0f10a280e5
commit
e268f5a350
@@ -166,6 +166,32 @@ describe('updateMediaSourceDuration', () => {
|
||||
reactor.destroy();
|
||||
});
|
||||
|
||||
it('writes Infinity after sourceopen when the MediaSource starts closed (live)', async () => {
|
||||
// Regression: the live path used to write only if the MediaSource was
|
||||
// already open at entry, returning without scheduling a wait otherwise. The
|
||||
// presentation can resolve to Infinity before `setupMediaSource` opens the
|
||||
// MediaSource, so that eager write was missed — the first append then pinned
|
||||
// a finite live-edge duration, and appends stalled once the window slid past.
|
||||
const { state, context, reactor } = setupUpdateMediaSourceDuration();
|
||||
|
||||
const mockMediaSource = makeMediaSource({ readyState: 'closed' });
|
||||
context.mediaSource.set(mockMediaSource);
|
||||
state.presentation.set({ duration: Number.POSITIVE_INFINITY } as Presentation);
|
||||
|
||||
// Awaiting sourceopen — nothing written yet.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(mockMediaSource.duration).toBeNaN();
|
||||
|
||||
// MediaSource opens — Infinity is written.
|
||||
transitionMediaSource(mockMediaSource, 'open', 'sourceopen');
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockMediaSource.duration).toBe(Number.POSITIVE_INFINITY);
|
||||
});
|
||||
|
||||
reactor.destroy();
|
||||
});
|
||||
|
||||
it('extends duration to match buffered range if needed', async () => {
|
||||
const { state, context, reactor } = setupUpdateMediaSourceDuration();
|
||||
|
||||
|
||||
@@ -4,13 +4,14 @@
|
||||
*
|
||||
* Two paths, by whether the presentation is live:
|
||||
*
|
||||
* - **Live** (`presentation.duration === Infinity`): written **synchronously**
|
||||
* on entry. The presentation declares `Infinity` as soon as it resolves —
|
||||
* before any segment append — and this behavior is composed before the buffer
|
||||
* actors, so it runs while the MediaSource is freshly open and empty. Writing
|
||||
* now (no buffered clamp needed; `Infinity` ≥ any range) gets ahead of the
|
||||
* first append, which would otherwise set `duration` to the buffered end and
|
||||
* pin the live stream to a finite (live-edge) duration.
|
||||
* - **Live** (`presentation.duration === Infinity`): write `Infinity` ahead of
|
||||
* the first append (no buffered clamp needed; `Infinity` ≥ any range), which
|
||||
* would otherwise pin `duration` to the buffered (live-edge) end — and once
|
||||
* the window slides past that, further appends are rejected. Written
|
||||
* synchronously when the MediaSource is already open; otherwise deferred to
|
||||
* the next `sourceopen` (the presentation can resolve to `Infinity` before
|
||||
* `setupMediaSource` opens the MediaSource, so a synchronous-only write would
|
||||
* miss the window).
|
||||
*
|
||||
* - **VoD** (finite): the value is written once, after `mediaSource` is open and
|
||||
* all SourceBuffers are idle, clamped to be ≥ the highest buffered range (MSE
|
||||
@@ -97,19 +98,36 @@ function updateMediaSourceDurationSetup({
|
||||
const presentation = state.presentation.get()!;
|
||||
const mediaSource = context.mediaSource.get()!;
|
||||
|
||||
// Live: the presentation declares `Infinity` as soon as it resolves —
|
||||
// before any segment append. This entry runs while the MediaSource is
|
||||
// freshly open and still empty (it's composed before the buffer
|
||||
// actors), so write it now, synchronously: no buffered clamp is needed
|
||||
// (`Infinity` ≥ any range), and getting ahead of the first append is
|
||||
// what stops the append pinning a finite (live-edge) duration. The
|
||||
// async wait-for-idle path below would lose that race — a live loader
|
||||
// appends continuously, so the buffers are rarely all idle.
|
||||
// Live: the presentation declares `Infinity` as soon as it resolves.
|
||||
// Write it ahead of the first append — otherwise the append pins
|
||||
// `duration` to the buffered (live-edge) end, and once the window
|
||||
// slides past that value further appends are rejected. No buffered
|
||||
// clamp is needed (`Infinity` ≥ any range), and the async
|
||||
// wait-for-idle path below would lose the race (a live loader appends
|
||||
// continuously, so the buffers are rarely all idle).
|
||||
if (presentation.duration === Number.POSITIVE_INFINITY) {
|
||||
if (mediaSource.readyState === 'open' && mediaSource.duration !== Number.POSITIVE_INFINITY) {
|
||||
if (mediaSource.duration === Number.POSITIVE_INFINITY) return;
|
||||
// Open already → write synchronously (fastest, gets ahead of appends).
|
||||
if (mediaSource.readyState === 'open') {
|
||||
mediaSource.duration = Number.POSITIVE_INFINITY;
|
||||
return;
|
||||
}
|
||||
return;
|
||||
// Not open yet → wait for `sourceopen`, then write. The presentation
|
||||
// can resolve to `Infinity` before `setupMediaSource` opens the
|
||||
// MediaSource; returning here without waiting (as this once did)
|
||||
// would miss the write entirely and let the first append pin a
|
||||
// finite duration. The continuation still runs before the
|
||||
// network-delayed first append; if an append did land first,
|
||||
// `Infinity` ≥ its range so the write is still valid.
|
||||
const controller = new AbortController();
|
||||
void (async () => {
|
||||
await waitForMediaSourceOpen(mediaSource, controller.signal);
|
||||
if (controller.signal.aborted || mediaSource.readyState !== 'open') return;
|
||||
if (mediaSource.duration !== Number.POSITIVE_INFINITY) {
|
||||
mediaSource.duration = Number.POSITIVE_INFINITY;
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}
|
||||
|
||||
// VoD: write the finite duration once, while it is still `NaN`. Once
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
/**
|
||||
* Live media-playlist reload loop, per track type.
|
||||
*
|
||||
* Once the presentation is resolved and a track of this type is selected,
|
||||
* repeatedly re-fetch + parse that track's media playlist on a target-duration
|
||||
* cadence — passing the prior snapshot back in so the parser carries the
|
||||
* timeline forward — and patch it into `state.presentation` until
|
||||
* `#EXT-X-ENDLIST`. Drives the live foundation at the playlist layer
|
||||
* ([live-presentation-modeling.md](../../../internal/design/spf/live-presentation-modeling.md)).
|
||||
*
|
||||
* Specialized per type via the shared `setupTrackReload` (mirrors
|
||||
* `resolve-track`): `reloadVideoTrack` / `reloadAudioTrack` / `reloadTextTrack`,
|
||||
* each gating on its own `selected*TrackId` + track type, so demuxed audio and
|
||||
* video reload independently.
|
||||
*
|
||||
* Limitations (intentional, for now): selection is read once at loop start
|
||||
* (mid-stream track switching isn't encoded in the reactor's two states);
|
||||
* PDT / discontinuity / A/V sync are handled by separate timeline transforms,
|
||||
* not here.
|
||||
*/
|
||||
import { defineBehavior } from '../../core/composition/create-composition';
|
||||
import { createMachineReactor } from '../../core/reactors/create-machine-reactor';
|
||||
import { computed, peek, type ReadonlySignal, type Signal, update } from '../../core/signals/primitives';
|
||||
import { parseMediaPlaylist } from '../../media/hls/parse-media-playlist';
|
||||
import {
|
||||
deriveStreamType,
|
||||
getMediaPlaylistMetadata,
|
||||
isResolvedPresentation,
|
||||
isResolvedTrack,
|
||||
type MaybeResolvedPresentation,
|
||||
type ResolvedTrack,
|
||||
type TrackType,
|
||||
} from '../../media/types';
|
||||
import { findTrack, updateTrackInPresentation } from '../../media/utils/tracks';
|
||||
import { fetchResolvableText as defaultFetchResolvableText, type FetchText } from '../../network/fetch';
|
||||
import { AUDIO_TYPE_CONFIG, TEXT_TYPE_CONFIG, VIDEO_TYPE_CONFIG } from './track-types';
|
||||
|
||||
export interface ReloadTrackState {
|
||||
presentation?: MaybeResolvedPresentation;
|
||||
selectedVideoTrackId?: string;
|
||||
selectedAudioTrackId?: string;
|
||||
selectedTextTrackId?: string;
|
||||
}
|
||||
|
||||
/** Engine-config slice each `reload*` behavior reads. */
|
||||
export interface ReloadTrackConfig {
|
||||
/** Playlist-text fetch; defaults to the plain resolvable fetch. */
|
||||
fetchResolvableText?: FetchText;
|
||||
}
|
||||
|
||||
type SelectedTrackKey = 'selectedVideoTrackId' | 'selectedAudioTrackId' | 'selectedTextTrackId';
|
||||
type ReloadTrackStateName = 'idle' | 'reloading';
|
||||
|
||||
type ReloadTrackStateMap<K extends SelectedTrackKey> = {
|
||||
presentation: Signal<ReloadTrackState['presentation']>;
|
||||
} & { [P in K]: ReadonlySignal<ReloadTrackState[P]> };
|
||||
|
||||
interface TrackReloadConfig<K extends SelectedTrackKey> {
|
||||
type: TrackType;
|
||||
selectedKey: K;
|
||||
fetchResolvableText?: FetchText;
|
||||
}
|
||||
|
||||
/** Fallback reload cadence when the playlist carries no usable target duration. */
|
||||
const FALLBACK_TARGET_DURATION = 6;
|
||||
|
||||
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(new DOMException('Aborted', 'AbortError'));
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(resolve, ms);
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
clearTimeout(timer);
|
||||
reject(new DOMException('Aborted', 'AbortError'));
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** A reload is "live" (new content) when the window slid or grew. */
|
||||
function snapshotChanged(prev: ResolvedTrack, next: ResolvedTrack): boolean {
|
||||
return (
|
||||
getMediaPlaylistMetadata(prev)?.mediaSequence !== getMediaPlaylistMetadata(next)?.mediaSequence ||
|
||||
prev.segments.length !== next.segments.length
|
||||
);
|
||||
}
|
||||
|
||||
function setupTrackReload<K extends SelectedTrackKey>({
|
||||
state,
|
||||
config: { type, selectedKey, fetchResolvableText = defaultFetchResolvableText },
|
||||
}: {
|
||||
state: ReloadTrackStateMap<K>;
|
||||
config: TrackReloadConfig<K>;
|
||||
}) {
|
||||
const derivedStateSignal = computed<ReloadTrackStateName>(() => {
|
||||
const presentation = state.presentation.get();
|
||||
const trackId = state[selectedKey].get();
|
||||
if (!isResolvedPresentation(presentation) || !trackId) return 'idle';
|
||||
return findTrack(presentation, type, trackId) ? 'reloading' : 'idle';
|
||||
});
|
||||
|
||||
return createMachineReactor<ReloadTrackStateName>({
|
||||
initial: 'idle',
|
||||
monitor: () => derivedStateSignal.get(),
|
||||
states: {
|
||||
idle: {},
|
||||
reloading: {
|
||||
entry: () => {
|
||||
const ac = new AbortController();
|
||||
const trackId = state[selectedKey].get()!;
|
||||
|
||||
void (async () => {
|
||||
while (!ac.signal.aborted) {
|
||||
try {
|
||||
const presentation = peek(state.presentation);
|
||||
if (!isResolvedPresentation(presentation)) break;
|
||||
// The track currently in the presentation is the prior snapshot
|
||||
// (the unresolved shell on the first pass, the last resolved
|
||||
// window thereafter); the parser carries its timeline forward.
|
||||
const previousTrack = findTrack(presentation, type, trackId);
|
||||
if (!previousTrack) break;
|
||||
|
||||
const text = await fetchResolvableText(previousTrack, { signal: ac.signal });
|
||||
const parsed: ResolvedTrack = parseMediaPlaylist(text, previousTrack);
|
||||
const meta = getMediaPlaylistMetadata(parsed);
|
||||
// The first resolve (previous still an unresolved shell) counts as changed.
|
||||
const changed = !isResolvedTrack(previousTrack) || snapshotChanged(previousTrack, parsed);
|
||||
|
||||
update(state.presentation, (current) => {
|
||||
if (!isResolvedPresentation(current)) return current;
|
||||
const patched = updateTrackInPresentation(current, parsed);
|
||||
// streamType is stable across reloads, so recomputing it is harmless.
|
||||
return { ...patched, streamType: deriveStreamType(meta) };
|
||||
});
|
||||
|
||||
if (meta?.endList) break;
|
||||
|
||||
const target = meta?.targetDuration || FALLBACK_TARGET_DURATION;
|
||||
// Spec: reload ~target duration; half that when the playlist was unchanged.
|
||||
await sleep((changed ? target : target / 2) * 1000, ac.signal);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') return;
|
||||
// A transient fetch/parse failure must not kill the loop — a live
|
||||
// playlist has to keep refreshing. Log and retry on the next
|
||||
// cadence (the `while` re-checks `aborted`).
|
||||
// TODO(error-management): route to a state-error slot once one exists.
|
||||
console.error(`[reload:${type}] media-playlist reload failed; retrying:`, error);
|
||||
try {
|
||||
await sleep(FALLBACK_TARGET_DURATION * 1000, ac.signal);
|
||||
} catch {
|
||||
return; // aborted during the retry wait
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
// State-exit (source change / destroy) aborts the loop.
|
||||
return () => ac.abort();
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const VIDEO_TRACK_RELOAD_CONFIG = { type: VIDEO_TYPE_CONFIG.type, selectedKey: VIDEO_TYPE_CONFIG.selectedKey } as const;
|
||||
const AUDIO_TRACK_RELOAD_CONFIG = { type: AUDIO_TYPE_CONFIG.type, selectedKey: AUDIO_TYPE_CONFIG.selectedKey } as const;
|
||||
const TEXT_TRACK_RELOAD_CONFIG = { type: TEXT_TYPE_CONFIG.type, selectedKey: TEXT_TYPE_CONFIG.selectedKey } as const;
|
||||
|
||||
/** Reload the selected video track's media playlist on a live cadence. */
|
||||
export const reloadVideoTrack = defineBehavior({
|
||||
stateKeys: ['presentation', 'selectedVideoTrackId'],
|
||||
contextKeys: [],
|
||||
setup: ({ state, config = {} }: { state: ReloadTrackStateMap<'selectedVideoTrackId'>; config?: ReloadTrackConfig }) =>
|
||||
setupTrackReload({ state, config: { ...VIDEO_TRACK_RELOAD_CONFIG, ...config } }),
|
||||
});
|
||||
|
||||
/** Reload the selected audio track's media playlist (demuxed audio). */
|
||||
export const reloadAudioTrack = defineBehavior({
|
||||
stateKeys: ['presentation', 'selectedAudioTrackId'],
|
||||
contextKeys: [],
|
||||
setup: ({ state, config = {} }: { state: ReloadTrackStateMap<'selectedAudioTrackId'>; config?: ReloadTrackConfig }) =>
|
||||
setupTrackReload({ state, config: { ...AUDIO_TRACK_RELOAD_CONFIG, ...config } }),
|
||||
});
|
||||
|
||||
/** Reload the selected text track's media playlist (live captions). */
|
||||
export const reloadTextTrack = defineBehavior({
|
||||
stateKeys: ['presentation', 'selectedTextTrackId'],
|
||||
contextKeys: [],
|
||||
setup: ({ state, config = {} }: { state: ReloadTrackStateMap<'selectedTextTrackId'>; config?: ReloadTrackConfig }) =>
|
||||
setupTrackReload({ state, config: { ...TEXT_TRACK_RELOAD_CONFIG, ...config } }),
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { computed, peek, type ReadonlySignal, type Signal, update } from '../../
|
||||
import { ConcurrentRunner, Task } from '../../core/tasks/task';
|
||||
import { NON_FMP4_CONTAINER_MIMES, parseMediaPlaylist } from '../../media/hls/parse-media-playlist';
|
||||
import type { MaybeResolvedPresentation, PartiallyResolvedTrack, ResolvedTrack } from '../../media/types';
|
||||
import { isResolvedPresentation, isResolvedTrack } from '../../media/types';
|
||||
import { deriveStreamType, getMediaPlaylistMetadata, isResolvedPresentation, isResolvedTrack } from '../../media/types';
|
||||
import type { GetCdnId } from '../../media/utils/cdn';
|
||||
import { applyContainerMimeType, findTrack, updateTrackInPresentation } from '../../media/utils/tracks';
|
||||
import { fetchResolvableText as defaultFetchResolvableText, type FetchText } from '../../network/fetch';
|
||||
@@ -17,9 +17,21 @@ import { AUDIO_TYPE_CONFIG, TEXT_TYPE_CONFIG, VIDEO_TYPE_CONFIG } from '../primi
|
||||
// `setupTrackResolution` has the same shape as a Behavior `setup` function:
|
||||
// `({ state, config }) => cleanup`. Each `resolveXTrack` export below calls it
|
||||
// from inside its own `defineBehavior` setup, supplying its per-type config
|
||||
// inline. The orchestration — gate on a selection, short-circuit when the
|
||||
// track is already resolved or missing, schedule the fetch+parse, and patch
|
||||
// the resolved track back into `state.presentation` — is shared.
|
||||
// inline. The orchestration — gate on a selection, decide whether a (re)load
|
||||
// is due, schedule the fetch+parse, and patch the resolved track back into
|
||||
// `state.presentation` (carrying the prior snapshot's timeline forward) — is
|
||||
// shared.
|
||||
//
|
||||
// This behavior is category [1] "content snapshot" from
|
||||
// [live-presentation-modeling.md](../../../internal/design/spf/live-presentation-modeling.md):
|
||||
// it produces the windowed segment list. *When* to (re)fetch is category [3]
|
||||
// "refetch policy", owned by the sibling `scheduleTrackReload` scheduler, which
|
||||
// bumps a per-type reload-epoch slot. The loader loads when the track is
|
||||
// unresolved (the initial resolve — the only trigger for VoD, where no
|
||||
// scheduler is composed) OR when the reload epoch has advanced past the last
|
||||
// one it serviced (a live reload). Setting the epoch synchronously at schedule
|
||||
// time, paired with `ConcurrentRunner`'s id-dedup, coalesces bumps that arrive
|
||||
// while a fetch is still in flight (drop-if-busy).
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
@@ -32,16 +44,27 @@ export interface ResolveTrackState {
|
||||
selectedAudioTrackId?: string;
|
||||
selectedTextTrackId?: string;
|
||||
failedCdns?: string[];
|
||||
/**
|
||||
* Per-type live-reload triggers, owned by `scheduleTrackReload`. A bump
|
||||
* (monotonic increment) past the last-serviced value tells the loader a
|
||||
* reload is due. Absent / unchanged for VoD (no scheduler composed).
|
||||
*/
|
||||
videoReloadEpoch?: number;
|
||||
audioReloadEpoch?: number;
|
||||
textReloadEpoch?: number;
|
||||
}
|
||||
|
||||
type SelectedTrackKey = 'selectedVideoTrackId' | 'selectedAudioTrackId' | 'selectedTextTrackId';
|
||||
type ReloadEpochKey = 'videoReloadEpoch' | 'audioReloadEpoch' | 'textReloadEpoch';
|
||||
|
||||
type ResolveTrackStateMap<K extends SelectedTrackKey> = {
|
||||
type ResolveTrackStateMap<K extends SelectedTrackKey, E extends ReloadEpochKey> = {
|
||||
presentation: Signal<ResolveTrackState['presentation']>;
|
||||
} & { [P in K]: ReadonlySignal<ResolveTrackState[P]> };
|
||||
} & { [P in K]: ReadonlySignal<ResolveTrackState[P]> } & { [P in E]: ReadonlySignal<ResolveTrackState[P]> };
|
||||
|
||||
interface TrackResolutionConfig<K extends SelectedTrackKey> {
|
||||
interface TrackResolutionConfig<K extends SelectedTrackKey, E extends ReloadEpochKey> {
|
||||
selectedKey: K;
|
||||
/** State slot the scheduler bumps to request a live reload of this type. */
|
||||
reloadEpochKey: E;
|
||||
findTrackToResolve: (
|
||||
presentation: MaybeResolvedPresentation,
|
||||
trackId: string
|
||||
@@ -59,18 +82,25 @@ interface ResolveTrackConfig {
|
||||
getCdnId?: GetCdnId;
|
||||
}
|
||||
|
||||
function setupTrackResolution<K extends SelectedTrackKey>({
|
||||
function setupTrackResolution<K extends SelectedTrackKey, E extends ReloadEpochKey>({
|
||||
state,
|
||||
config: { selectedKey, findTrackToResolve, fetchResolvableText = defaultFetchResolvableText },
|
||||
config: { selectedKey, reloadEpochKey, findTrackToResolve, fetchResolvableText = defaultFetchResolvableText },
|
||||
}: {
|
||||
state: ResolveTrackStateMap<K>;
|
||||
config: TrackResolutionConfig<K>;
|
||||
state: ResolveTrackStateMap<K, E>;
|
||||
config: TrackResolutionConfig<K, E>;
|
||||
}) {
|
||||
// NOTE: This can/maybe will be pulled into a per-use case factory (e.g. something like createTaskRunner() with args TBD),
|
||||
// likely eventually passed down via config or a new "definitions" argument. This will allow us to decide if we want our task runner/scheduler
|
||||
// to e.g. run concurrently (like we currently are), serially with a queue, or abort the previous task and replace it with the newly scheduled one. (CJP).
|
||||
const runner = new ConcurrentRunner();
|
||||
|
||||
// Highest reload epoch already serviced for the selected track. A resolved
|
||||
// track only re-fetches once the scheduler bumps past this; an unresolved
|
||||
// track always loads (the initial resolve / a retry of a failed one). Init 0
|
||||
// so a freshly-mounted already-resolved track isn't re-fetched (matches the
|
||||
// pre-reload one-shot resolve behavior).
|
||||
let lastLoadedEpoch = 0;
|
||||
|
||||
// Reactor states model the FSM the previous effect-based body was
|
||||
// hand-rolling. 'presentation-resolved' is entered when the
|
||||
// presentation is fully parsed (has a Ham id + selectionSets); leaving
|
||||
@@ -105,13 +135,23 @@ function setupTrackResolution<K extends SelectedTrackKey>({
|
||||
// changes (presentation-resolved ↔ presentation-unresolved);
|
||||
// within 'presentation-resolved' we peek (untracked read) so
|
||||
// internal updates (segments added by sibling tasks) don't
|
||||
// re-fire the effect.
|
||||
const presentation = peek(state.presentation);
|
||||
// re-fire the effect. Tracked reads — `selectedKey` and the reload
|
||||
// epoch — are taken up front, before any early return, so a later
|
||||
// epoch bump re-fires this effect.
|
||||
const trackId = state[selectedKey].get();
|
||||
const epoch = state[reloadEpochKey].get() ?? 0;
|
||||
const presentation = peek(state.presentation);
|
||||
if (!presentation || !trackId) return;
|
||||
|
||||
const track = findTrackToResolve(presentation, trackId);
|
||||
if (!track || isResolvedTrack(track)) return;
|
||||
if (!track) return;
|
||||
// Resolved track: skip unless the scheduler requested a reload.
|
||||
// Unresolved track: always load (initial resolve / failed-resolve retry).
|
||||
if (isResolvedTrack(track) && epoch <= lastLoadedEpoch) return;
|
||||
// Mark this epoch serviced synchronously: a same-id task already in
|
||||
// flight is deduped by the runner, and we don't want to re-schedule
|
||||
// it on the next effect run.
|
||||
lastLoadedEpoch = epoch;
|
||||
|
||||
runner.schedule(
|
||||
// NOTE: This can/maybe will be pulled into a per-use case factory (e.g. something like createResolveTrackTask(track, context, config)),
|
||||
@@ -122,6 +162,9 @@ function setupTrackResolution<K extends SelectedTrackKey>({
|
||||
// fetch: it trips the CDN on a failed fetch (network error or
|
||||
// non-OK status). A parse failure is a content issue, not a
|
||||
// CDN-availability one, so it doesn't trip.
|
||||
// `track` is the prior snapshot (the unresolved shell on the
|
||||
// first pass, the last resolved window on a live reload); the
|
||||
// parser carries its timeline forward.
|
||||
const text = await fetchResolvableText(track, { signal });
|
||||
const mediaTrack = parseMediaPlaylist(text, track);
|
||||
|
||||
@@ -143,9 +186,12 @@ function setupTrackResolution<K extends SelectedTrackKey>({
|
||||
// audio↔video (mixed-container sources exist, e.g. muxed-TS
|
||||
// video + raw-.aac audio), which also keeps per-type
|
||||
// resolutions' writes disjoint (no race).
|
||||
return NON_FMP4_CONTAINER_MIMES.has(mediaTrack.mimeType)
|
||||
const relabeled = NON_FMP4_CONTAINER_MIMES.has(mediaTrack.mimeType)
|
||||
? applyContainerMimeType(patched, mediaTrack.type, mediaTrack.mimeType)
|
||||
: patched;
|
||||
// Stream nature (category [2a]) — stable once a media
|
||||
// playlist is parsed; recomputing each reload is harmless.
|
||||
return { ...relabeled, streamType: deriveStreamType(getMediaPlaylistMetadata(mediaTrack)) };
|
||||
});
|
||||
},
|
||||
{ id: track.id }
|
||||
@@ -164,18 +210,21 @@ function setupTrackResolution<K extends SelectedTrackKey>({
|
||||
|
||||
const VIDEO_TRACK_RESOLUTION_CONFIG = {
|
||||
...VIDEO_TYPE_CONFIG,
|
||||
reloadEpochKey: 'videoReloadEpoch',
|
||||
findTrackToResolve: (presentation: MaybeResolvedPresentation, trackId: string) =>
|
||||
findTrack(presentation, 'video', trackId),
|
||||
} as const;
|
||||
|
||||
const AUDIO_TRACK_RESOLUTION_CONFIG = {
|
||||
...AUDIO_TYPE_CONFIG,
|
||||
reloadEpochKey: 'audioReloadEpoch',
|
||||
findTrackToResolve: (presentation: MaybeResolvedPresentation, trackId: string) =>
|
||||
findTrack(presentation, 'audio', trackId),
|
||||
} as const;
|
||||
|
||||
const TEXT_TRACK_RESOLUTION_CONFIG = {
|
||||
...TEXT_TYPE_CONFIG,
|
||||
reloadEpochKey: 'textReloadEpoch',
|
||||
findTrackToResolve: (presentation: MaybeResolvedPresentation, trackId: string) =>
|
||||
findTrack(presentation, 'text', trackId),
|
||||
} as const;
|
||||
@@ -190,13 +239,13 @@ const TEXT_TRACK_RESOLUTION_CONFIG = {
|
||||
* writes the resolved track back into `state.presentation`.
|
||||
*/
|
||||
export const resolveVideoTrack = defineBehavior({
|
||||
stateKeys: ['presentation', 'selectedVideoTrackId'],
|
||||
stateKeys: ['presentation', 'selectedVideoTrackId', 'videoReloadEpoch'],
|
||||
contextKeys: [],
|
||||
setup: ({
|
||||
state,
|
||||
config = {},
|
||||
}: {
|
||||
state: ResolveTrackStateMap<'selectedVideoTrackId'>;
|
||||
state: ResolveTrackStateMap<'selectedVideoTrackId', 'videoReloadEpoch'>;
|
||||
config?: ResolveTrackConfig;
|
||||
}) => {
|
||||
// Engine `config` layers over the per-type defaults (mirrors the other
|
||||
@@ -217,13 +266,13 @@ export const resolveVideoTrack = defineBehavior({
|
||||
* narrowed to audio.
|
||||
*/
|
||||
export const resolveAudioTrack = defineBehavior({
|
||||
stateKeys: ['presentation', 'selectedAudioTrackId'],
|
||||
stateKeys: ['presentation', 'selectedAudioTrackId', 'audioReloadEpoch'],
|
||||
contextKeys: [],
|
||||
setup: ({
|
||||
state,
|
||||
config = {},
|
||||
}: {
|
||||
state: ResolveTrackStateMap<'selectedAudioTrackId'>;
|
||||
state: ResolveTrackStateMap<'selectedAudioTrackId', 'audioReloadEpoch'>;
|
||||
config?: ResolveTrackConfig;
|
||||
}) => {
|
||||
// Key order is load-bearing — see resolveVideoTrack.
|
||||
@@ -240,13 +289,13 @@ export const resolveAudioTrack = defineBehavior({
|
||||
* narrowed to text.
|
||||
*/
|
||||
export const resolveTextTrack = defineBehavior({
|
||||
stateKeys: ['presentation', 'selectedTextTrackId'],
|
||||
stateKeys: ['presentation', 'selectedTextTrackId', 'textReloadEpoch'],
|
||||
contextKeys: [],
|
||||
setup: ({
|
||||
state,
|
||||
config = {},
|
||||
}: {
|
||||
state: ResolveTrackStateMap<'selectedTextTrackId'>;
|
||||
state: ResolveTrackStateMap<'selectedTextTrackId', 'textReloadEpoch'>;
|
||||
config?: ResolveTrackConfig;
|
||||
}) => {
|
||||
// Key order is load-bearing — see resolveVideoTrack.
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Live media-playlist reload *scheduling*, per track type.
|
||||
*
|
||||
* Category [3] "refetch policy" from
|
||||
* [live-presentation-modeling.md](../../../internal/design/spf/live-presentation-modeling.md):
|
||||
* decides *when* a track's media playlist should be re-fetched, without doing
|
||||
* any fetching itself. Once the presentation is resolved and a track of this
|
||||
* type is selected, it bumps a per-type reload-epoch slot on a target-duration
|
||||
* cadence (half that when the last reload was unchanged, per RFC 8216bis
|
||||
* §6.3.4); the sibling `resolveTrack` loader (category [1]) watches that slot
|
||||
* and performs the actual fetch+parse+merge.
|
||||
*
|
||||
* The split keeps "when to refetch" and "what segments are in the playlist"
|
||||
* — categories that change at different rates — in separate behaviors.
|
||||
*
|
||||
* Inert for VoD: it stays `idle` once the resolved track reports
|
||||
* `#EXT-X-ENDLIST` (a complete playlist never reloads), so no scheduler-aware
|
||||
* engine config is needed. It enters on track *selection* (not resolution) so
|
||||
* its bumps also drive retries of a failed/slow first resolve until the loader
|
||||
* succeeds.
|
||||
*
|
||||
* Specialized per type via the shared `setupTrackReloadSchedule` (mirrors
|
||||
* `resolve-track`): `scheduleVideoTrackReload` / `scheduleAudioTrackReload` /
|
||||
* `scheduleTextTrackReload`, each gating on its own `selected*TrackId` + track
|
||||
* type, so demuxed audio and video reload independently.
|
||||
*
|
||||
* Limitations (intentional, for now): selection is read once at loop start
|
||||
* (mid-stream track switching isn't encoded in the reactor's two states).
|
||||
*/
|
||||
import { defineBehavior } from '../../core/composition/create-composition';
|
||||
import { createMachineReactor } from '../../core/reactors/create-machine-reactor';
|
||||
import { computed, peek, type ReadonlySignal, type Signal, update } from '../../core/signals/primitives';
|
||||
import {
|
||||
getMediaPlaylistMetadata,
|
||||
isResolvedPresentation,
|
||||
isResolvedTrack,
|
||||
type MaybeResolvedPresentation,
|
||||
type ResolvedTrack,
|
||||
type TrackType,
|
||||
} from '../../media/types';
|
||||
import { findTrack } from '../../media/utils/tracks';
|
||||
import { AUDIO_TYPE_CONFIG, TEXT_TYPE_CONFIG, VIDEO_TYPE_CONFIG } from '../primitives/track-types';
|
||||
|
||||
export interface ScheduleTrackReloadState {
|
||||
presentation?: MaybeResolvedPresentation;
|
||||
selectedVideoTrackId?: string;
|
||||
selectedAudioTrackId?: string;
|
||||
selectedTextTrackId?: string;
|
||||
videoReloadEpoch?: number;
|
||||
audioReloadEpoch?: number;
|
||||
textReloadEpoch?: number;
|
||||
}
|
||||
|
||||
type SelectedTrackKey = 'selectedVideoTrackId' | 'selectedAudioTrackId' | 'selectedTextTrackId';
|
||||
type ReloadEpochKey = 'videoReloadEpoch' | 'audioReloadEpoch' | 'textReloadEpoch';
|
||||
type ScheduleStateName = 'idle' | 'scheduling';
|
||||
|
||||
type ScheduleTrackReloadStateMap<K extends SelectedTrackKey, E extends ReloadEpochKey> = {
|
||||
presentation: ReadonlySignal<ScheduleTrackReloadState['presentation']>;
|
||||
} & { [P in K]: ReadonlySignal<ScheduleTrackReloadState[P]> } & { [P in E]: Signal<ScheduleTrackReloadState[P]> };
|
||||
|
||||
interface TrackReloadScheduleConfig<K extends SelectedTrackKey, E extends ReloadEpochKey> {
|
||||
type: TrackType;
|
||||
selectedKey: K;
|
||||
reloadEpochKey: E;
|
||||
}
|
||||
|
||||
/** Fallback reload cadence when the playlist carries no usable target duration. */
|
||||
const FALLBACK_TARGET_DURATION = 6;
|
||||
|
||||
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(new DOMException('Aborted', 'AbortError'));
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(resolve, ms);
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
clearTimeout(timer);
|
||||
reject(new DOMException('Aborted', 'AbortError'));
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Identity of a reload snapshot — window position + length. Changes when the window slid or grew. */
|
||||
function snapshotSignature(track: ResolvedTrack): string {
|
||||
return `${getMediaPlaylistMetadata(track)?.mediaSequence ?? 0}:${track.segments.length}`;
|
||||
}
|
||||
|
||||
function setupTrackReloadSchedule<K extends SelectedTrackKey, E extends ReloadEpochKey>({
|
||||
state,
|
||||
config: { type, selectedKey, reloadEpochKey },
|
||||
}: {
|
||||
state: ScheduleTrackReloadStateMap<K, E>;
|
||||
config: TrackReloadScheduleConfig<K, E>;
|
||||
}) {
|
||||
const derivedStateSignal = computed<ScheduleStateName>(() => {
|
||||
const presentation = state.presentation.get();
|
||||
const trackId = state[selectedKey].get();
|
||||
if (!isResolvedPresentation(presentation) || !trackId) return 'idle';
|
||||
const track = findTrack(presentation, type, trackId);
|
||||
if (!track) return 'idle';
|
||||
// A complete playlist (VoD, or live that has ended) never reloads. An
|
||||
// unresolved track keeps us scheduling so the loader's first resolve is
|
||||
// retried via the epoch bumps.
|
||||
if (isResolvedTrack(track) && getMediaPlaylistMetadata(track)?.endList) return 'idle';
|
||||
return 'scheduling';
|
||||
});
|
||||
|
||||
return createMachineReactor<ScheduleStateName>({
|
||||
initial: 'idle',
|
||||
monitor: () => derivedStateSignal.get(),
|
||||
states: {
|
||||
idle: {},
|
||||
scheduling: {
|
||||
entry: () => {
|
||||
const ac = new AbortController();
|
||||
const trackId = state[selectedKey].get()!;
|
||||
|
||||
void (async () => {
|
||||
// Signature of the snapshot seen at the previous iteration, to
|
||||
// detect whether the last reload changed the window.
|
||||
let lastSignature: string | null = null;
|
||||
|
||||
while (!ac.signal.aborted) {
|
||||
const presentation = peek(state.presentation);
|
||||
const track = isResolvedPresentation(presentation) ? findTrack(presentation, type, trackId) : undefined;
|
||||
const meta = track && isResolvedTrack(track) ? getMediaPlaylistMetadata(track) : undefined;
|
||||
if (meta?.endList) break;
|
||||
|
||||
const signature = track && isResolvedTrack(track) ? snapshotSignature(track) : null;
|
||||
// First pass (no baseline) or a moved window counts as changed →
|
||||
// full cadence; an unchanged window polls at half cadence.
|
||||
const changed = signature === null || signature !== lastSignature;
|
||||
lastSignature = signature;
|
||||
|
||||
const target = meta?.targetDuration || FALLBACK_TARGET_DURATION;
|
||||
try {
|
||||
await sleep((changed ? target : target / 2) * 1000, ac.signal);
|
||||
} catch {
|
||||
return; // aborted during the wait
|
||||
}
|
||||
|
||||
update(state[reloadEpochKey], (epoch) => (epoch ?? 0) + 1);
|
||||
}
|
||||
})();
|
||||
|
||||
// State-exit (source change / destroy / endList) aborts the loop.
|
||||
return () => ac.abort();
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const VIDEO_RELOAD_SCHEDULE_CONFIG = {
|
||||
type: VIDEO_TYPE_CONFIG.type,
|
||||
selectedKey: VIDEO_TYPE_CONFIG.selectedKey,
|
||||
reloadEpochKey: 'videoReloadEpoch',
|
||||
} as const;
|
||||
const AUDIO_RELOAD_SCHEDULE_CONFIG = {
|
||||
type: AUDIO_TYPE_CONFIG.type,
|
||||
selectedKey: AUDIO_TYPE_CONFIG.selectedKey,
|
||||
reloadEpochKey: 'audioReloadEpoch',
|
||||
} as const;
|
||||
const TEXT_RELOAD_SCHEDULE_CONFIG = {
|
||||
type: TEXT_TYPE_CONFIG.type,
|
||||
selectedKey: TEXT_TYPE_CONFIG.selectedKey,
|
||||
reloadEpochKey: 'textReloadEpoch',
|
||||
} as const;
|
||||
|
||||
/** Schedule live reloads of the selected video track's media playlist. */
|
||||
export const scheduleVideoTrackReload = defineBehavior({
|
||||
stateKeys: ['presentation', 'selectedVideoTrackId', 'videoReloadEpoch'],
|
||||
contextKeys: [],
|
||||
setup: ({ state }: { state: ScheduleTrackReloadStateMap<'selectedVideoTrackId', 'videoReloadEpoch'> }) =>
|
||||
setupTrackReloadSchedule({ state, config: VIDEO_RELOAD_SCHEDULE_CONFIG }),
|
||||
});
|
||||
|
||||
/** Schedule live reloads of the selected audio track's media playlist (demuxed audio). */
|
||||
export const scheduleAudioTrackReload = defineBehavior({
|
||||
stateKeys: ['presentation', 'selectedAudioTrackId', 'audioReloadEpoch'],
|
||||
contextKeys: [],
|
||||
setup: ({ state }: { state: ScheduleTrackReloadStateMap<'selectedAudioTrackId', 'audioReloadEpoch'> }) =>
|
||||
setupTrackReloadSchedule({ state, config: AUDIO_RELOAD_SCHEDULE_CONFIG }),
|
||||
});
|
||||
|
||||
/** Schedule live reloads of the selected text track's media playlist (live captions). */
|
||||
export const scheduleTextTrackReload = defineBehavior({
|
||||
stateKeys: ['presentation', 'selectedTextTrackId', 'textReloadEpoch'],
|
||||
contextKeys: [],
|
||||
setup: ({ state }: { state: ScheduleTrackReloadStateMap<'selectedTextTrackId', 'textReloadEpoch'> }) =>
|
||||
setupTrackReloadSchedule({ state, config: TEXT_RELOAD_SCHEDULE_CONFIG }),
|
||||
});
|
||||
@@ -1,158 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { signal } from '../../../core/signals/primitives';
|
||||
import {
|
||||
isResolvedTrack,
|
||||
type MaybeResolvedPresentation,
|
||||
type PartiallyResolvedAudioTrack,
|
||||
type PartiallyResolvedVideoTrack,
|
||||
type Presentation,
|
||||
} from '../../../media/types';
|
||||
import { findTrack } from '../../../media/utils/tracks';
|
||||
import { reloadAudioTrack, reloadVideoTrack } from '../reload-track';
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const MEDIA_PLAYLIST = `#EXTM3U
|
||||
#EXT-X-VERSION:7
|
||||
#EXT-X-TARGETDURATION:4
|
||||
#EXT-X-MEDIA-SEQUENCE:0
|
||||
#EXT-X-MAP:URI="init.mp4"
|
||||
#EXTINF:4.0,
|
||||
seg0.m4s
|
||||
#EXT-X-ENDLIST`;
|
||||
|
||||
const unresolvedVideo: PartiallyResolvedVideoTrack = {
|
||||
type: 'video',
|
||||
id: 'v-1',
|
||||
url: 'https://example.com/video.m3u8',
|
||||
bandwidth: 1_000_000,
|
||||
mimeType: 'video/mp4',
|
||||
codecs: [],
|
||||
};
|
||||
|
||||
const unresolvedAudio: PartiallyResolvedAudioTrack = {
|
||||
type: 'audio',
|
||||
id: 'a-1',
|
||||
url: 'https://example.com/audio.m3u8',
|
||||
groupId: 'aud',
|
||||
name: 'Default',
|
||||
language: 'und',
|
||||
codecs: ['mp4a.40.2'],
|
||||
mimeType: 'audio/mp4',
|
||||
bandwidth: 0,
|
||||
sampleRate: 48000,
|
||||
channels: 2,
|
||||
};
|
||||
|
||||
function makePresentation(): Presentation {
|
||||
return {
|
||||
id: 'pres-1',
|
||||
url: 'https://example.com/master.m3u8',
|
||||
startTime: 0,
|
||||
selectionSets: [
|
||||
{ id: 'video-set', type: 'video', switchingSets: [{ id: 'vs', type: 'video', tracks: [unresolvedVideo] }] },
|
||||
{ id: 'audio-set', type: 'audio', switchingSets: [{ id: 'as', type: 'audio', tracks: [unresolvedAudio] }] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const fakeFetch = () =>
|
||||
vi.fn((_addressable: { url: string }, _options?: { signal?: AbortSignal }) => Promise.resolve(MEDIA_PLAYLIST));
|
||||
|
||||
describe('reloadVideoTrack', () => {
|
||||
it('resolves the selected video track and fetches *its* playlist (not audio)', async () => {
|
||||
const presentation = makePresentation();
|
||||
const state = {
|
||||
presentation: signal<MaybeResolvedPresentation | undefined>(presentation),
|
||||
selectedVideoTrackId: signal<string | undefined>('v-1'),
|
||||
};
|
||||
const fetchResolvableText = fakeFetch();
|
||||
|
||||
const reactor = reloadVideoTrack.setup({ state, config: { fetchResolvableText } });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const track = findTrack(state.presentation.get()!, 'video', 'v-1');
|
||||
expect(track && isResolvedTrack(track)).toBe(true);
|
||||
});
|
||||
|
||||
// Fetched the video playlist; the audio track is left untouched.
|
||||
expect(fetchResolvableText.mock.calls[0]?.[0]?.url).toContain('video.m3u8');
|
||||
const audio = findTrack(state.presentation.get()!, 'audio', 'a-1');
|
||||
expect(audio && isResolvedTrack(audio)).toBe(false);
|
||||
|
||||
reactor.destroy();
|
||||
});
|
||||
|
||||
it('stays idle with no selected track', () => {
|
||||
const fetchResolvableText = fakeFetch();
|
||||
const state = {
|
||||
presentation: signal<MaybeResolvedPresentation | undefined>(makePresentation()),
|
||||
selectedVideoTrackId: signal<string | undefined>(undefined),
|
||||
};
|
||||
const reactor = reloadVideoTrack.setup({ state, config: { fetchResolvableText } });
|
||||
expect(fetchResolvableText).not.toHaveBeenCalled();
|
||||
reactor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reloadAudioTrack', () => {
|
||||
it('resolves the selected audio track and fetches *its* playlist (not video)', async () => {
|
||||
const presentation = makePresentation();
|
||||
const state = {
|
||||
presentation: signal<MaybeResolvedPresentation | undefined>(presentation),
|
||||
selectedAudioTrackId: signal<string | undefined>('a-1'),
|
||||
};
|
||||
const fetchResolvableText = fakeFetch();
|
||||
|
||||
const reactor = reloadAudioTrack.setup({ state, config: { fetchResolvableText } });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const track = findTrack(state.presentation.get()!, 'audio', 'a-1');
|
||||
expect(track && isResolvedTrack(track)).toBe(true);
|
||||
});
|
||||
|
||||
expect(fetchResolvableText.mock.calls[0]?.[0]?.url).toContain('audio.m3u8');
|
||||
const video = findTrack(state.presentation.get()!, 'video', 'v-1');
|
||||
expect(video && isResolvedTrack(video)).toBe(false);
|
||||
|
||||
reactor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reload resilience', () => {
|
||||
it('survives a transient fetch failure and retries (does not kill the loop)', async () => {
|
||||
vi.useFakeTimers();
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
try {
|
||||
const state = {
|
||||
presentation: signal<MaybeResolvedPresentation | undefined>(makePresentation()),
|
||||
selectedVideoTrackId: signal<string | undefined>('v-1'),
|
||||
};
|
||||
let calls = 0;
|
||||
const fetchResolvableText = vi.fn(() => {
|
||||
calls += 1;
|
||||
return calls === 1 ? Promise.reject(new TypeError('Failed to fetch')) : Promise.resolve(MEDIA_PLAYLIST);
|
||||
});
|
||||
|
||||
const reactor = reloadVideoTrack.setup({ state, config: { fetchResolvableText } });
|
||||
|
||||
// First attempt rejects: logged, but the loop is still alive (track not yet resolved).
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(calls).toBe(1);
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
expect(isResolvedTrack(findTrack(state.presentation.get()!, 'video', 'v-1')!)).toBe(false);
|
||||
|
||||
// Retry cadence elapses → second attempt succeeds (would never happen if the
|
||||
// loop had died on the first failure).
|
||||
await vi.advanceTimersByTimeAsync(6000);
|
||||
expect(calls).toBe(2);
|
||||
expect(isResolvedTrack(findTrack(state.presentation.get()!, 'video', 'v-1')!)).toBe(true);
|
||||
|
||||
reactor.destroy();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,9 @@ function makeState(initial: ResolveTrackState = {}): StateSignals<ResolveTrackSt
|
||||
selectedAudioTrackId: signal<string | undefined>(initial.selectedAudioTrackId),
|
||||
selectedTextTrackId: signal<string | undefined>(initial.selectedTextTrackId),
|
||||
failedCdns: signal<string[] | undefined>(initial.failedCdns),
|
||||
videoReloadEpoch: signal<number | undefined>(initial.videoReloadEpoch),
|
||||
audioReloadEpoch: signal<number | undefined>(initial.audioReloadEpoch),
|
||||
textReloadEpoch: signal<number | undefined>(initial.textReloadEpoch),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -411,6 +414,94 @@ http://example.com/a-seg1.m4s
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveVideoTrack — live reload', () => {
|
||||
const LIVE_PLAYLIST = `#EXTM3U
|
||||
#EXT-X-VERSION:7
|
||||
#EXT-X-TARGETDURATION:4
|
||||
#EXT-X-MEDIA-SEQUENCE:0
|
||||
#EXT-X-MAP:URI="http://example.com/init.mp4"
|
||||
#EXTINF:4.0,
|
||||
http://example.com/seg0.m4s`;
|
||||
|
||||
function liveVideoPresentation(): Presentation {
|
||||
const unresolved: PartiallyResolvedVideoTrack = {
|
||||
type: 'video',
|
||||
id: 'track-1',
|
||||
url: 'http://example.com/variant1.m3u8',
|
||||
bandwidth: 1_000_000,
|
||||
mimeType: 'video/mp4',
|
||||
codecs: [],
|
||||
};
|
||||
return {
|
||||
id: 'pres-1',
|
||||
url: 'http://example.com/playlist.m3u8',
|
||||
selectionSets: [
|
||||
{ id: 'video-set', type: 'video', switchingSets: [{ id: 'sw-1', type: 'video', tracks: [unresolved] }] },
|
||||
],
|
||||
startTime: 0,
|
||||
};
|
||||
}
|
||||
|
||||
it('re-fetches when the reload epoch is bumped', async () => {
|
||||
const state = makeState({ presentation: liveVideoPresentation(), selectedVideoTrackId: 'track-1' });
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => new Response(LIVE_PLAYLIST));
|
||||
|
||||
const reactor = resolveVideoTrack.setup({ state });
|
||||
|
||||
await vi.waitFor(() => expect(isResolvedTrack(findTrackById(state.presentation.get()!, 'track-1')!)).toBe(true));
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A scheduler bump re-fetches the (already-resolved) track.
|
||||
state.videoReloadEpoch.set(1);
|
||||
await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(2));
|
||||
|
||||
// A stale/duplicate bump (≤ last serviced) does not.
|
||||
state.videoReloadEpoch.set(1);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
|
||||
reactor.destroy();
|
||||
});
|
||||
|
||||
it('retries an unresolved track on epoch bump after a transient failure', async () => {
|
||||
const state = makeState({ presentation: liveVideoPresentation(), selectedVideoTrackId: 'track-1' });
|
||||
let calls = 0;
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) throw new TypeError('Failed to fetch');
|
||||
return new Response(LIVE_PLAYLIST);
|
||||
});
|
||||
|
||||
const reactor = resolveVideoTrack.setup({ state });
|
||||
|
||||
// First attempt fails and settles: track stays unresolved. (Waiting for
|
||||
// the failed fetch to settle mirrors the scheduler's cadence delay — a bump
|
||||
// arriving while the task is still in flight would be id-deduped.)
|
||||
await vi.waitFor(() => expect(calls).toBe(1));
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(isResolvedTrack(findTrackById(state.presentation.get()!, 'track-1')!)).toBe(false);
|
||||
|
||||
// The scheduler's next bump retries the unresolved track → resolves.
|
||||
state.videoReloadEpoch.set(1);
|
||||
await vi.waitFor(() => expect(isResolvedTrack(findTrackById(state.presentation.get()!, 'track-1')!)).toBe(true));
|
||||
expect(calls).toBe(2);
|
||||
|
||||
reactor.destroy();
|
||||
});
|
||||
|
||||
it('sets presentation.streamType from the parsed playlist', async () => {
|
||||
const state = makeState({ presentation: liveVideoPresentation(), selectedVideoTrackId: 'track-1' });
|
||||
// No EXT-X-PLAYLIST-TYPE:VOD → live.
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async () => new Response(LIVE_PLAYLIST));
|
||||
|
||||
const reactor = resolveVideoTrack.setup({ state });
|
||||
|
||||
await vi.waitFor(() => expect(state.presentation.get()?.streamType).toBe('live'));
|
||||
|
||||
reactor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// Helper to find track by ID in presentation
|
||||
function findTrackById(
|
||||
presentation: MaybeResolvedPresentation,
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { signal } from '../../../core/signals/primitives';
|
||||
import {
|
||||
type MaybeResolvedPresentation,
|
||||
MEDIA_PLAYLIST_METADATA_KEY,
|
||||
type PartiallyResolvedVideoTrack,
|
||||
type Presentation,
|
||||
type VideoTrack,
|
||||
} from '../../../media/types';
|
||||
import { scheduleVideoTrackReload } from '../schedule-track-reload';
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const UNRESOLVED_VIDEO: PartiallyResolvedVideoTrack = {
|
||||
type: 'video',
|
||||
id: 'v-1',
|
||||
url: 'https://example.com/video.m3u8',
|
||||
bandwidth: 1_000_000,
|
||||
mimeType: 'video/mp4',
|
||||
codecs: [],
|
||||
};
|
||||
|
||||
function resolvedVideo(opts: { endList?: boolean; segmentCount?: number; mediaSequence?: number } = {}): VideoTrack {
|
||||
const { endList = false, segmentCount = 3, mediaSequence = 0 } = opts;
|
||||
return {
|
||||
type: 'video',
|
||||
id: 'v-1',
|
||||
url: 'https://example.com/video.m3u8',
|
||||
mimeType: 'video/mp4',
|
||||
codecs: ['avc1.640020'],
|
||||
bandwidth: 1_000_000,
|
||||
initialization: { url: 'https://example.com/init.mp4' },
|
||||
duration: endList ? 12 : Number.POSITIVE_INFINITY,
|
||||
startTime: 0,
|
||||
segments: Array.from({ length: segmentCount }, (_, i) => ({
|
||||
id: `segment-${mediaSequence + i}`,
|
||||
url: `${mediaSequence + i}.m4s`,
|
||||
duration: 4,
|
||||
startTime: i * 4,
|
||||
})),
|
||||
metadata: { [MEDIA_PLAYLIST_METADATA_KEY]: { mediaSequence, targetDuration: 4, endList } },
|
||||
};
|
||||
}
|
||||
|
||||
function presentationWith(track: VideoTrack | PartiallyResolvedVideoTrack): Presentation {
|
||||
return {
|
||||
id: 'pres-1',
|
||||
url: 'https://example.com/master.m3u8',
|
||||
startTime: 0,
|
||||
selectionSets: [{ id: 'video-set', type: 'video', switchingSets: [{ id: 'sw', type: 'video', tracks: [track] }] }],
|
||||
};
|
||||
}
|
||||
|
||||
function makeState(presentation: MaybeResolvedPresentation, trackId: string | undefined = 'v-1') {
|
||||
return {
|
||||
presentation: signal<MaybeResolvedPresentation | undefined>(presentation),
|
||||
selectedVideoTrackId: signal<string | undefined>(trackId),
|
||||
videoReloadEpoch: signal<number | undefined>(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
describe('scheduleVideoTrackReload', () => {
|
||||
it('bumps the reload epoch on the target-duration cadence', async () => {
|
||||
vi.useFakeTimers();
|
||||
const state = makeState(presentationWith(resolvedVideo()));
|
||||
|
||||
const reactor = scheduleVideoTrackReload.setup({ state });
|
||||
|
||||
expect(state.videoReloadEpoch.get()).toBeUndefined();
|
||||
await vi.advanceTimersByTimeAsync(4000); // one TARGETDURATION
|
||||
expect(state.videoReloadEpoch.get()).toBe(1);
|
||||
|
||||
reactor.destroy();
|
||||
});
|
||||
|
||||
it('polls at half cadence when the window is unchanged', async () => {
|
||||
vi.useFakeTimers();
|
||||
const state = makeState(presentationWith(resolvedVideo()));
|
||||
|
||||
const reactor = scheduleVideoTrackReload.setup({ state });
|
||||
|
||||
// First reload after a full TARGETDURATION (4s).
|
||||
await vi.advanceTimersByTimeAsync(4000);
|
||||
expect(state.videoReloadEpoch.get()).toBe(1);
|
||||
|
||||
// Snapshot unchanged (presentation not updated) → next poll at half (2s).
|
||||
await vi.advanceTimersByTimeAsync(1999);
|
||||
expect(state.videoReloadEpoch.get()).toBe(1);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(state.videoReloadEpoch.get()).toBe(2);
|
||||
|
||||
reactor.destroy();
|
||||
});
|
||||
|
||||
it('stays idle for a complete (endList) playlist', async () => {
|
||||
vi.useFakeTimers();
|
||||
const state = makeState(presentationWith(resolvedVideo({ endList: true })));
|
||||
|
||||
const reactor = scheduleVideoTrackReload.setup({ state });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(state.videoReloadEpoch.get()).toBeUndefined();
|
||||
|
||||
reactor.destroy();
|
||||
});
|
||||
|
||||
it('keeps bumping while the track is unresolved (drives first-resolve retries)', async () => {
|
||||
vi.useFakeTimers();
|
||||
const state = makeState(presentationWith(UNRESOLVED_VIDEO));
|
||||
|
||||
const reactor = scheduleVideoTrackReload.setup({ state });
|
||||
|
||||
// No targetDuration available yet → fallback cadence (6s).
|
||||
await vi.advanceTimersByTimeAsync(6000);
|
||||
expect(state.videoReloadEpoch.get()).toBe(1);
|
||||
await vi.advanceTimersByTimeAsync(6000);
|
||||
expect(state.videoReloadEpoch.get()).toBe(2);
|
||||
|
||||
reactor.destroy();
|
||||
});
|
||||
|
||||
it('stays idle with no selected track', async () => {
|
||||
vi.useFakeTimers();
|
||||
const state = makeState(presentationWith(resolvedVideo()));
|
||||
state.selectedVideoTrackId.set(undefined);
|
||||
|
||||
const reactor = scheduleVideoTrackReload.setup({ state });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(state.videoReloadEpoch.get()).toBeUndefined();
|
||||
|
||||
reactor.destroy();
|
||||
});
|
||||
});
|
||||
@@ -103,6 +103,15 @@ export interface SimpleHlsEngineState {
|
||||
failedCdns?: string[];
|
||||
currentTime?: number;
|
||||
loadActivated?: boolean;
|
||||
/**
|
||||
* Per-type live-reload triggers. Owned by `scheduleTrackReload` (composed
|
||||
* only by live engines), which bumps them on a target-duration cadence;
|
||||
* `resolveTrack` watches them to re-fetch the media playlist. Inert for VoD
|
||||
* (no scheduler → never bumped → loader resolves once).
|
||||
*/
|
||||
videoReloadEpoch?: number;
|
||||
audioReloadEpoch?: number;
|
||||
textReloadEpoch?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -26,8 +26,9 @@ import { setupMediaSource } from '../../behaviors/dom/setup-mediasource';
|
||||
import { trackCurrentTime } from '../../behaviors/dom/track-current-time';
|
||||
import { trackLoadTriggers } from '../../behaviors/dom/track-load-triggers';
|
||||
import { updateMediaSourceDuration } from '../../behaviors/dom/update-mediasource-duration';
|
||||
import { reloadAudioTrack, reloadVideoTrack } from '../../behaviors/reload-track';
|
||||
import { resolvePresentation } from '../../behaviors/resolve-presentation';
|
||||
import { resolveAudioTrack, resolveVideoTrack } from '../../behaviors/resolve-track';
|
||||
import { scheduleAudioTrackReload, scheduleVideoTrackReload } from '../../behaviors/schedule-track-reload';
|
||||
import { setupFailoverMonitor } from '../../behaviors/setup-failover-monitor';
|
||||
import { syncPreload } from '../../behaviors/sync-preload';
|
||||
import { switchAudioTrack, switchVideoTrack } from '../../behaviors/track-switching';
|
||||
@@ -84,11 +85,15 @@ export function createLiveHlsEngine(
|
||||
deriveCdnPriority,
|
||||
setupFailoverMonitor,
|
||||
|
||||
// Live reload loop replaces one-shot resolve*: the first iteration
|
||||
// resolves the selected track, then it re-fetches on a target-duration
|
||||
// cadence, carrying the timeline forward.
|
||||
reloadVideoTrack,
|
||||
reloadAudioTrack,
|
||||
// Loader (category [1]): resolves the selected track and re-fetches it
|
||||
// whenever the scheduler bumps its reload epoch, carrying the timeline
|
||||
// forward.
|
||||
resolveVideoTrack,
|
||||
resolveAudioTrack,
|
||||
// Scheduler (category [3]): bumps the per-type reload epoch on a
|
||||
// target-duration cadence until #EXT-X-ENDLIST.
|
||||
scheduleVideoTrackReload,
|
||||
scheduleAudioTrackReload,
|
||||
|
||||
// Anchor selected tracks' timelines to the estimated stream origin so
|
||||
// segment.startTime ≈ native PTS (what the loader matches currentTime
|
||||
|
||||
@@ -22,13 +22,15 @@ import { makeShareSignals, type ShareSignalsConfig } from '../../../core/composi
|
||||
import { parseMultivariantPlaylist } from '../../../media/hls/parse-multivariant';
|
||||
import { pickHighestResolutionVideoTrack, type TrackPicker } from '../../../media/primitives/select-tracks';
|
||||
import type { MaybeResolvedPresentation } from '../../../media/types';
|
||||
import { reloadVideoTrack } from '../../behaviors/reload-track';
|
||||
import { type ParsePresentation, resolvePresentation } from '../../behaviors/resolve-presentation';
|
||||
import { resolveVideoTrack } from '../../behaviors/resolve-track';
|
||||
import { scheduleVideoTrackReload } from '../../behaviors/schedule-track-reload';
|
||||
import { type SelectVideoTrackConfig, selectVideoTrack } from '../../behaviors/select-tracks';
|
||||
|
||||
export interface LivePlaylistSpikeState {
|
||||
presentation?: MaybeResolvedPresentation;
|
||||
selectedVideoTrackId?: string;
|
||||
videoReloadEpoch?: number;
|
||||
preload?: 'auto' | 'metadata' | 'none';
|
||||
loadActivated?: boolean;
|
||||
}
|
||||
@@ -58,9 +60,12 @@ export function createLivePlaylistSpikeEngine(
|
||||
parsePresentation: config.parsePresentation ?? parseMultivariantPlaylist,
|
||||
};
|
||||
|
||||
return createComposition([resolvePresentation, selectVideoTrack, reloadVideoTrack, shareSignals], {
|
||||
config: finalConfig,
|
||||
// Spike skips the preload gate — resolve as soon as a url is set.
|
||||
initialState: { loadActivated: true },
|
||||
});
|
||||
return createComposition(
|
||||
[resolvePresentation, selectVideoTrack, resolveVideoTrack, scheduleVideoTrackReload, shareSignals],
|
||||
{
|
||||
config: finalConfig,
|
||||
// Spike skips the preload gate — resolve as soon as a url is set.
|
||||
initialState: { loadActivated: true },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user