From df4ec77c18c8fa80ccce765324426342ad1b4eff Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Mon, 15 Jun 2026 12:45:26 -0700 Subject: [PATCH] feat(spf): per-type live media-playlist reload Generalize the video-only reload spike into a per-type factory mirroring resolve-track: reloadVideoTrack / reloadAudioTrack / reloadTextTrack, each gating on its own selected*TrackId + track type so demuxed audio and video reload independently. Inject fetchResolvableText via config (parity with resolve-track, and testable). Update the live-playlist spike to reloadVideoTrack. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/playback/behaviors/reload-track.ts | 89 ++++++++++--- .../behaviors/tests/reload-track.test.ts | 122 ++++++++++++++++++ .../engines/live-playlist-spike/engine.ts | 4 +- 3 files changed, 192 insertions(+), 23 deletions(-) create mode 100644 packages/spf/src/playback/behaviors/tests/reload-track.test.ts diff --git a/packages/spf/src/playback/behaviors/reload-track.ts b/packages/spf/src/playback/behaviors/reload-track.ts index 2c5f5208..2bc1ccde 100644 --- a/packages/spf/src/playback/behaviors/reload-track.ts +++ b/packages/spf/src/playback/behaviors/reload-track.ts @@ -1,17 +1,22 @@ /** - * **POC SPIKE** — live media-playlist reload loop. + * Live media-playlist reload loop, per track type. * - * Drives the live foundation at the playlist layer only (no MSE, no segment - * fetching): once a video track is selected and the presentation is resolved, + * 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`. Validates the model in - * [live-presentation-modeling.md](../../../internal/design/spf/live-presentation-modeling.md). + * `#EXT-X-ENDLIST`. Drives the live foundation at the playlist layer + * ([live-presentation-modeling.md](../../../internal/design/spf/live-presentation-modeling.md)). * - * Spike limitations (intentional): video only; selection is read once at loop - * start (mid-stream track switching isn't handled — the reactor's two states - * don't encode the track id); PDT / discontinuity / A/V sync untouched. + * 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'; @@ -24,17 +29,38 @@ import { isResolvedTrack, type MaybeResolvedPresentation, type ResolvedTrack, + type TrackType, } from '../../media/types'; import { findTrack, updateTrackInPresentation } from '../../media/utils/tracks'; -import { fetchResolvableText } from '../../network/fetch'; +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 = { + presentation: Signal; +} & { [P in K]: ReadonlySignal }; + +interface TrackReloadConfig { + type: TrackType; + selectedKey: K; + fetchResolvableText?: FetchText; +} + /** Fallback reload cadence when the playlist carries no usable target duration. */ const FALLBACK_TARGET_DURATION = 6; @@ -64,19 +90,18 @@ function snapshotChanged(prev: ResolvedTrack, next: ResolvedTrack): boolean { ); } -function reloadTrackSetup({ +function setupTrackReload({ state, + config: { type, selectedKey, fetchResolvableText = defaultFetchResolvableText }, }: { - state: { - presentation: Signal; - selectedVideoTrackId: ReadonlySignal; - }; + state: ReloadTrackStateMap; + config: TrackReloadConfig; }) { const derivedStateSignal = computed(() => { const presentation = state.presentation.get(); - const trackId = state.selectedVideoTrackId.get(); + const trackId = state[selectedKey].get(); if (!isResolvedPresentation(presentation) || !trackId) return 'idle'; - return findTrack(presentation, 'video', trackId) ? 'reloading' : 'idle'; + return findTrack(presentation, type, trackId) ? 'reloading' : 'idle'; }); return createMachineReactor({ @@ -87,7 +112,7 @@ function reloadTrackSetup({ reloading: { entry: () => { const ac = new AbortController(); - const trackId = state.selectedVideoTrackId.get()!; + const trackId = state[selectedKey].get()!; void (async () => { try { @@ -97,7 +122,7 @@ function reloadTrackSetup({ // 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, 'video', trackId); + const previousTrack = findTrack(presentation, type, trackId); if (!previousTrack) break; const text = await fetchResolvableText(previousTrack, { signal: ac.signal }); @@ -122,7 +147,7 @@ function reloadTrackSetup({ } catch (error) { if (error instanceof Error && error.name === 'AbortError') return; // TODO(error-management): route to a state-error slot once one exists. - console.error('[reloadTrack] media-playlist reload failed:', error); + console.error(`[reload:${type}] media-playlist reload failed:`, error); } })(); @@ -134,8 +159,30 @@ function reloadTrackSetup({ }); } -export const reloadTrack = defineBehavior({ +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: reloadTrackSetup, + 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 } }), }); diff --git a/packages/spf/src/playback/behaviors/tests/reload-track.test.ts b/packages/spf/src/playback/behaviors/tests/reload-track.test.ts new file mode 100644 index 00000000..ba0590e7 --- /dev/null +++ b/packages/spf/src/playback/behaviors/tests/reload-track.test.ts @@ -0,0 +1,122 @@ +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(presentation), + selectedVideoTrackId: signal('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(makePresentation()), + selectedVideoTrackId: signal(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(presentation), + selectedAudioTrackId: signal('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(); + }); +}); diff --git a/packages/spf/src/playback/engines/live-playlist-spike/engine.ts b/packages/spf/src/playback/engines/live-playlist-spike/engine.ts index a4e84764..0a526f06 100644 --- a/packages/spf/src/playback/engines/live-playlist-spike/engine.ts +++ b/packages/spf/src/playback/engines/live-playlist-spike/engine.ts @@ -22,7 +22,7 @@ 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 { reloadTrack } from '../../behaviors/reload-track'; +import { reloadVideoTrack } from '../../behaviors/reload-track'; import { type ParsePresentation, resolvePresentation } from '../../behaviors/resolve-presentation'; import { type SelectVideoTrackConfig, selectVideoTrack } from '../../behaviors/select-tracks'; @@ -58,7 +58,7 @@ export function createLivePlaylistSpikeEngine( parsePresentation: config.parsePresentation ?? parseMultivariantPlaylist, }; - return createComposition([resolvePresentation, selectVideoTrack, reloadTrack, shareSignals], { + return createComposition([resolvePresentation, selectVideoTrack, reloadVideoTrack, shareSignals], { config: finalConfig, // Spike skips the preload gate — resolve as soon as a url is set. initialState: { loadActivated: true },