From c0c9eb348d43c5781f571880982c44f1a4844e92 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Fri, 12 Jun 2026 17:35:56 -0700 Subject: [PATCH] feat(spf): live media-playlist reload spike + parser timeline (WIP) POC spike for live HLS at the playlist layer only (no MSE/segments/DOM): - parser surfaces target-duration / media-sequence / playlist-type / endlist into a generic Ham.metadata bag (getMediaPlaylistMetadata accessor), with stable media-sequence-derived segment ids - parseMediaPlaylist(text, previous) takes a resolved-or-unresolved prior track and carries the timeline forward across reloads (media-sequence overlap + actual durations); first load anchors at 0; targetDuration used only as the no-overlap fallback - duration -> Infinity for unended live (finite once VOD/ENDLIST) - streamType on Presentation, derived from PLAYLIST-TYPE alone - reloadTrack behavior + createLivePlaylistSpikeEngine composition WIP: startTime/timestampOffset basis and cross-track (A/V) sync are still under research; PROGRAM-DATE-TIME capture and the cross-track alignment strategy are not yet implemented. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../spf/src/media/hls/parse-media-playlist.ts | 167 +++++++++++++++--- .../hls/tests/parse-media-playlist.test.ts | 161 ++++++++++++++++- packages/spf/src/media/types/index.ts | 68 +++++++ .../src/playback/behaviors/reload-track.ts | 141 +++++++++++++++ .../engines/live-playlist-spike/engine.ts | 66 +++++++ .../engines/live-playlist-spike/index.ts | 7 + 6 files changed, 582 insertions(+), 28 deletions(-) create mode 100644 packages/spf/src/playback/behaviors/reload-track.ts create mode 100644 packages/spf/src/playback/engines/live-playlist-spike/engine.ts create mode 100644 packages/spf/src/playback/engines/live-playlist-spike/index.ts diff --git a/packages/spf/src/media/hls/parse-media-playlist.ts b/packages/spf/src/media/hls/parse-media-playlist.ts index edeb57d1..8eaa332a 100644 --- a/packages/spf/src/media/hls/parse-media-playlist.ts +++ b/packages/spf/src/media/hls/parse-media-playlist.ts @@ -1,12 +1,17 @@ -import type { - AudioTrack, - PartiallyResolvedAudioTrack, - PartiallyResolvedTextTrack, - PartiallyResolvedTrack, - PartiallyResolvedVideoTrack, - Segment, - TextTrack, - VideoTrack, +import { + type AudioTrack, + getMediaPlaylistMetadata, + isResolvedTrack, + MEDIA_PLAYLIST_METADATA_KEY, + type MediaPlaylistMetadata, + type PartiallyResolvedAudioTrack, + type PartiallyResolvedTextTrack, + type PartiallyResolvedTrack, + type PartiallyResolvedVideoTrack, + type ResolvedTrack, + type Segment, + type TextTrack, + type VideoTrack, } from '../types'; import { matchTag, parseByteRange, parseExtInfDuration } from './parse-attributes'; import { resolveUrl } from './resolve-url'; @@ -56,23 +61,88 @@ type ResolveTrack = T extends PartiallyResolvedVideoTrack : never; /** - * Parse HLS media playlist and resolve track with segments. + * Position a freshly-parsed window (whose segment `startTime`s are snapshot- + * local, i.e. from 0) onto the timeline established by the previous resolved + * snapshot. Carries the timeline forward using the media-sequence overlap and + * the previous window's *actual* segment durations — see + * [live-presentation-modeling.md](../../../../internal/design/spf/live-presentation-modeling.md). * - * Takes an unresolved track (from multivariant playlist) and media playlist text, - * returns a HAM-compliant resolved track with segments. + * - **Overlap** (`0 <= offset < previous.segments.length`): the new window's + * first segment is the same segment as `previous.segments[offset]`; anchor to + * its start (URLs checked — a mismatch warns). + * - **Sequence went backwards** (non-conformant): reset to the local base. + * - **Full turnover** (no overlap): estimate forward from the previous window's + * end across the unseen gap. This is the *only* place EXT-X-TARGETDURATION is + * used for timing — an upper-bound estimate, since the actual rolled-off + * durations are gone (exact recovery is the deferred PDT decision). + * + * Returns the rebased segments and the track's resulting `startTime`. + */ +function placeOnPreviousTimeline( + previous: ResolvedTrack, + segments: Segment[], + mediaSequence: number, + targetDuration: number +): { segments: Segment[]; startTime: number } { + const prevSegments = previous.segments; + const localBase = segments[0]?.startTime ?? 0; + + if (prevSegments.length === 0 || segments.length === 0) { + return { segments, startTime: localBase }; + } + + const prevMediaSequence = getMediaPlaylistMetadata(previous)?.mediaSequence ?? 0; + const offset = mediaSequence - prevMediaSequence; + + let anchor: number; + if (offset >= 0 && offset < prevSegments.length) { + const overlap = prevSegments[offset]!; + if (overlap.url !== segments[0]!.url) { + console.warn( + `[parseMediaPlaylist] media-sequence aligns previous[${offset}] with the new window's first segment, ` + + `but URLs differ (${overlap.url} vs ${segments[0]!.url}); sequence numbers may be unreliable.` + ); + } + anchor = overlap.startTime; + } else if (offset < 0) { + console.warn(`[parseMediaPlaylist] media-sequence went backwards (offset ${offset}); resetting timeline.`); + anchor = localBase; + } else { + const last = prevSegments[prevSegments.length - 1]!; + anchor = last.startTime + last.duration + (offset - prevSegments.length) * targetDuration; + console.warn( + `[parseMediaPlaylist] full window turnover (offset ${offset} >= ${prevSegments.length}); estimating from previous end.` + ); + } + + const shift = anchor - localBase; + const placed = + shift === 0 ? segments : segments.map((segment) => ({ ...segment, startTime: segment.startTime + shift })); + return { segments: placed, startTime: anchor }; +} + +/** + * Parse an HLS media playlist into a resolved track with segments. + * + * `previous` is what was known about this track before this parse: the + * partially-resolved track from the multivariant playlist on the first resolve, + * or the previously-resolved snapshot on a live reload. Its metadata is carried + * onto the result either way; when it's already resolved (has segments), its + * timeline is carried forward so the new window lands on a stable, advancing + * timeline (see {@link placeOnPreviousTimeline}). * * @param text - Media playlist text content - * @param unresolved - Unresolved track from parseMultivariantPlaylist + * @param previous - Prior track state (unresolved shell, or previous resolved snapshot) * @returns Resolved track with segments (type inferred from input) */ export function parseMediaPlaylist( text: string, - unresolved: T | ResolveTrack + previous: T | ResolveTrack ): ResolveTrack { const lines = text.split(/\r?\n/); // Segments and resources resolve relative to media playlist URL (per HLS spec) - const baseUrl = unresolved.url; + const baseUrl = previous.url; // Parse playlist const segments: Segment[] = []; @@ -85,6 +155,12 @@ export function parseMediaPlaylist( let segmentIndex = 0; let previousByteRangeEnd: number | undefined; + // Playlist-level metadata (surfaced for live reload pacing / merge / termination). + let targetDuration = 0; + let mediaSequence = 0; + let playlistType: 'VOD' | 'EVENT' | undefined; + let endList = false; + for (const line of lines) { const trimmed = line.trim(); @@ -92,11 +168,25 @@ export function parseMediaPlaylist( continue; } + if (trimmed.startsWith('#EXT-X-TARGETDURATION:')) { + targetDuration = Number.parseInt(trimmed.slice('#EXT-X-TARGETDURATION:'.length), 10) || 0; + continue; + } + + if (trimmed.startsWith('#EXT-X-MEDIA-SEQUENCE:')) { + mediaSequence = Number.parseInt(trimmed.slice('#EXT-X-MEDIA-SEQUENCE:'.length), 10) || 0; + continue; + } + + if (trimmed.startsWith('#EXT-X-PLAYLIST-TYPE:')) { + const value = trimmed.slice('#EXT-X-PLAYLIST-TYPE:'.length).trim(); + playlistType = value === 'VOD' || value === 'EVENT' ? value : undefined; + continue; + } + if ( trimmed === '#EXTM3U' || trimmed.startsWith('#EXT-X-VERSION:') || - trimmed.startsWith('#EXT-X-TARGETDURATION:') || - trimmed.startsWith('#EXT-X-PLAYLIST-TYPE:') || trimmed.startsWith('#EXT-X-INDEPENDENT-SEGMENTS') ) { continue; @@ -129,13 +219,14 @@ export function parseMediaPlaylist( } if (trimmed === '#EXT-X-ENDLIST') { + endList = true; continue; } // Segment URI if (!trimmed.startsWith('#') && currentDuration > 0) { const segment: Segment = { - id: `segment-${segmentIndex}`, + id: `segment-${mediaSequence + segmentIndex}`, url: resolveUrl(trimmed, baseUrl), duration: currentDuration, startTime: currentTime, @@ -159,9 +250,22 @@ export function parseMediaPlaylist( const totalDuration = currentTime; + // `duration` is the track's duration: Infinity while the playlist can still + // grow (unended live), finite once complete (VOD / ENDLIST). It uses the + // actual EXTINF sum, never the target duration. + const complete = endList || playlistType === 'VOD'; + const trackDuration = complete ? totalDuration : Number.POSITIVE_INFINITY; + + // Position this window on the timeline. First resolve (previous is the + // unresolved shell, no segments) anchors at 0; a live reload (previous is the + // prior resolved snapshot) carries the timeline forward from the overlap. + const placed = isResolvedTrack(previous) + ? placeOnPreviousTimeline(previous, segments, mediaSequence, targetDuration) + : { segments, startTime: 0 }; + // Build initialization (VTT may not have init segment) const initialization = - unresolved.type === 'text' && !initSegmentUrl + previous.type === 'text' && !initSegmentUrl ? undefined : initSegmentUrl ? { url: initSegmentUrl, ...(initSegmentByteRange ? { byteRange: initSegmentByteRange } : {}) } @@ -173,17 +277,26 @@ export function parseMediaPlaylist( // trips on fMP4, which mandates the map). Relabel from the fMP4 default // `video/mp4` / `audio/mp4` to the container MIME so capability probing prunes // it (these containers are currently treated as unplayable; see `canPlayTrack`). - const detectedContainer = initSegmentUrl ? undefined : containerMimeFromSegment(segments[0]?.url); - const mimeType = unresolved.type !== 'text' && detectedContainer ? detectedContainer : unresolved.mimeType; + const detectedContainer = initSegmentUrl ? undefined : containerMimeFromSegment(placed.segments[0]?.url); + const mimeType = previous.type !== 'text' && detectedContainer ? detectedContainer : previous.mimeType; - // Generic resolution: All type-specific fields already on unresolved track from P1 - // Just add parsed properties (startTime, duration, segments, initialization) + // Generic resolution: type-specific fields already on `previous`; add the + // parsed properties (startTime, duration, segments, initialization, metadata). return { - ...unresolved, + ...previous, mimeType, - startTime: 0, - duration: totalDuration, - segments, + startTime: placed.startTime, + duration: trackDuration, + segments: placed.segments, initialization, + metadata: { + ...previous.metadata, + [MEDIA_PLAYLIST_METADATA_KEY]: { + targetDuration, + mediaSequence, + playlistType, + endList, + } satisfies MediaPlaylistMetadata, + }, } as unknown as ResolveTrack; } diff --git a/packages/spf/src/media/hls/tests/parse-media-playlist.test.ts b/packages/spf/src/media/hls/tests/parse-media-playlist.test.ts index 7fdadccc..1e85e85a 100644 --- a/packages/spf/src/media/hls/tests/parse-media-playlist.test.ts +++ b/packages/spf/src/media/hls/tests/parse-media-playlist.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; -import type { PartiallyResolvedAudioTrack, PartiallyResolvedTextTrack, PartiallyResolvedVideoTrack } from '../../types'; +import { + getMediaPlaylistMetadata, + type PartiallyResolvedAudioTrack, + type PartiallyResolvedTextTrack, + type PartiallyResolvedVideoTrack, +} from '../../types'; import { parseMediaPlaylist } from '../parse-media-playlist'; describe('parseMediaPlaylist', () => { @@ -383,4 +388,158 @@ segment0.mp4 expect(parseMediaPlaylist(playlist, unresolvedVideo).mimeType).toBe('video/mp4'); }); }); + + describe('Live playlists', () => { + const unresolvedVideo: PartiallyResolvedVideoTrack = { + type: 'video', + id: 'video-0', + url: 'https://example.com/video/playlist.m3u8', + bandwidth: 1400000, + codecs: ['avc1.4d401f'], + mimeType: 'video/mp4', + }; + + it('reports Infinity duration for an unended live playlist (no ENDLIST, no PLAYLIST-TYPE)', () => { + const playlist = `#EXTM3U +#EXT-X-VERSION:7 +#EXT-X-TARGETDURATION:6 +#EXT-X-MEDIA-SEQUENCE:0 +#EXT-X-MAP:URI="init.mp4" +#EXTINF:6.0, +segment0.m4s +#EXTINF:6.0, +segment1.m4s`; + + const result = parseMediaPlaylist(playlist, unresolvedVideo); + + expect(result.duration).toBe(Number.POSITIVE_INFINITY); + expect(result.startTime).toBe(0); + expect(getMediaPlaylistMetadata(result)?.endList).toBe(false); + }); + + it('reports Infinity duration for an unended EVENT playlist', () => { + const playlist = `#EXTM3U +#EXT-X-TARGETDURATION:6 +#EXT-X-PLAYLIST-TYPE:EVENT +#EXTINF:6.0, +segment0.m4s`; + + expect(parseMediaPlaylist(playlist, unresolvedVideo).duration).toBe(Number.POSITIVE_INFINITY); + }); + + it('anchors startTime at 0 on first parse, with media-sequence-derived segment ids', () => { + const playlist = `#EXTM3U +#EXT-X-TARGETDURATION:6 +#EXT-X-MEDIA-SEQUENCE:10 +#EXTINF:6.0, +segment10.m4s +#EXTINF:6.0, +segment11.m4s`; + + const result = parseMediaPlaylist(playlist, unresolvedVideo); + + // No previous snapshot → the window anchors at 0 regardless of media sequence. + expect(result.startTime).toBe(0); + expect(result.segments.map((s) => s.startTime)).toEqual([0, 6]); + // Segment ids are media-sequence-derived, so they stay stable across reloads. + expect(result.segments.map((s) => s.id)).toEqual(['segment-10', 'segment-11']); + }); + + it('treats an ended live playlist as complete with finite duration', () => { + const playlist = `#EXTM3U +#EXT-X-TARGETDURATION:6 +#EXT-X-MEDIA-SEQUENCE:5 +#EXTINF:6.0, +segment5.m4s +#EXTINF:6.0, +segment6.m4s +#EXT-X-ENDLIST`; + + const result = parseMediaPlaylist(playlist, unresolvedVideo); + + expect(result.duration).toBe(12.0); + expect(result.startTime).toBe(0); // first parse, no previous → anchored at 0 + expect(getMediaPlaylistMetadata(result)?.endList).toBe(true); + }); + + it('carries the timeline forward across reloads as the window slides', () => { + const first = `#EXTM3U +#EXT-X-TARGETDURATION:6 +#EXT-X-MEDIA-SEQUENCE:0 +#EXTINF:6.0, +segment0.m4s +#EXTINF:6.0, +segment1.m4s +#EXTINF:6.0, +segment2.m4s`; + const previous = parseMediaPlaylist(first, unresolvedVideo); + expect(previous.segments.map((s) => s.startTime)).toEqual([0, 6, 12]); + + // Window slid by one (media sequence 0 → 1) and gained a segment. + const reload = `#EXTM3U +#EXT-X-TARGETDURATION:6 +#EXT-X-MEDIA-SEQUENCE:1 +#EXTINF:6.0, +segment1.m4s +#EXTINF:6.0, +segment2.m4s +#EXTINF:6.0, +segment3.m4s`; + const next = parseMediaPlaylist(reload, previous); + + // segment1 anchors to its prior start (6); the appended segment3 continues at 18. + expect(next.segments.map((s) => s.startTime)).toEqual([6, 12, 18]); + expect(next.segments.map((s) => s.id)).toEqual(['segment-1', 'segment-2', 'segment-3']); + }); + + it('appends without shifting when nothing rolls off (media sequence unchanged)', () => { + const first = `#EXTM3U +#EXT-X-TARGETDURATION:6 +#EXT-X-MEDIA-SEQUENCE:0 +#EXTINF:6.0, +segment0.m4s +#EXTINF:6.0, +segment1.m4s`; + const previous = parseMediaPlaylist(first, unresolvedVideo); + + const reload = `#EXTM3U +#EXT-X-TARGETDURATION:6 +#EXT-X-MEDIA-SEQUENCE:0 +#EXTINF:6.0, +segment0.m4s +#EXTINF:6.0, +segment1.m4s +#EXTINF:6.0, +segment2.m4s`; + const next = parseMediaPlaylist(reload, previous); + + expect(next.segments.map((s) => s.startTime)).toEqual([0, 6, 12]); + }); + + it('estimates the timeline forward on a full window turnover (no overlap)', () => { + const first = `#EXTM3U +#EXT-X-TARGETDURATION:6 +#EXT-X-MEDIA-SEQUENCE:0 +#EXTINF:6.0, +segment0.m4s +#EXTINF:6.0, +segment1.m4s +#EXTINF:6.0, +segment2.m4s`; + const previous = parseMediaPlaylist(first, unresolvedVideo); // ends at 18 + + // Jump far ahead — no overlap (offset 10 ≥ 3 segments). + const reload = `#EXTM3U +#EXT-X-TARGETDURATION:6 +#EXT-X-MEDIA-SEQUENCE:10 +#EXTINF:6.0, +segment10.m4s +#EXTINF:6.0, +segment11.m4s`; + const next = parseMediaPlaylist(reload, previous); + + // anchor = previous end (18) + (offset 10 − 3) × 6 = 60 + expect(next.segments.map((s) => s.startTime)).toEqual([60, 66]); + }); + }); }); diff --git a/packages/spf/src/media/types/index.ts b/packages/spf/src/media/types/index.ts index 7aecbbf2..f6fa7445 100644 --- a/packages/spf/src/media/types/index.ts +++ b/packages/spf/src/media/types/index.ts @@ -16,6 +16,13 @@ */ export interface Ham { id: string; + /** + * Format-/protocol-specific values that aren't part of the generic CMAF-HAM + * model — kept in an open bag so the model stays format-neutral (mirrors how + * CMAF-HAM itself stashes protocol extras rather than growing the model). + * Typed reads go through dedicated accessors (e.g. `getMediaPlaylistMetadata`). + */ + metadata?: Record; } /** @@ -329,6 +336,40 @@ export type Segment = Ham & AddressableObject & TimeSpan; */ export const SEGMENT_TIME_EPSILON = 0.0001; +// ============================================================================= +// Media Playlist Metadata +// ============================================================================= + +/** + * Playlist-level metadata surfaced from a parsed media playlist. HLS delivery + * specifics — not part of the generic CMAF-HAM model — so they live under + * `Ham.metadata` (read via `getMediaPlaylistMetadata`) rather than as + * first-class `Track` fields: + * + * - `targetDuration` (`#EXT-X-TARGETDURATION`) — reload-cadence basis. + * - `mediaSequence` (`#EXT-X-MEDIA-SEQUENCE`, default 0) — sequence number of + * `segments[0]`; the join key for merging successive reload snapshots. + * - `playlistType` (`#EXT-X-PLAYLIST-TYPE`) — `VOD` / `EVENT` / undefined. + * - `endList` (`#EXT-X-ENDLIST`) — playlist is complete; stop reloading. + * + * See [live-presentation-modeling.md](../../../../internal/design/spf/live-presentation-modeling.md) + * for how these map onto the protocol-neutral category model. + */ +export interface MediaPlaylistMetadata { + targetDuration: number; + mediaSequence: number; + playlistType?: 'VOD' | 'EVENT'; + endList: boolean; +} + +/** Key under `Ham.metadata` where {@link MediaPlaylistMetadata} is stored. */ +export const MEDIA_PLAYLIST_METADATA_KEY = 'mediaPlaylist'; + +/** Typed read of the media-playlist metadata stashed in `ham.metadata`. */ +export function getMediaPlaylistMetadata(ham: Ham): MediaPlaylistMetadata | undefined { + return ham.metadata?.[MEDIA_PLAYLIST_METADATA_KEY] as MediaPlaylistMetadata | undefined; +} + // ============================================================================= // Media Playlist Info // ============================================================================= @@ -347,6 +388,27 @@ export interface MediaPlaylistInfo { endList: boolean; } +// ============================================================================= +// Stream Type +// ============================================================================= + +/** + * The source's semantic nature — live vs on-demand. A model concept + * (consumer-facing), distinct from completeness / duration: a live stream that + * has *ended* is still `'live'`. See + * [live-presentation-modeling.md](../../../../internal/design/spf/live-presentation-modeling.md). + */ +export type StreamType = 'live' | 'on-demand'; + +/** + * Derive {@link StreamType} from a media playlist's metadata. Per the model, + * only `#EXT-X-PLAYLIST-TYPE:VOD` marks on-demand; everything else (EVENT, or + * the tag absent) is live — completeness (`endList`) never factors in. + */ +export function deriveStreamType(metadata: MediaPlaylistMetadata | undefined): StreamType { + return metadata?.playlistType === 'VOD' ? 'on-demand' : 'live'; +} + // ============================================================================= // Presentation // ============================================================================= @@ -362,6 +424,12 @@ export type Presentation = Ham & AddressableObject & Partial & { selectionSets: SelectionSet[]; + /** + * Live vs on-demand — the source's semantic nature. Populated once a media + * playlist is parsed (derived from `#EXT-X-PLAYLIST-TYPE` via + * `deriveStreamType`); orthogonal to duration / completeness. + */ + streamType?: StreamType; }; /** diff --git a/packages/spf/src/playback/behaviors/reload-track.ts b/packages/spf/src/playback/behaviors/reload-track.ts new file mode 100644 index 00000000..2c5f5208 --- /dev/null +++ b/packages/spf/src/playback/behaviors/reload-track.ts @@ -0,0 +1,141 @@ +/** + * **POC SPIKE** — live media-playlist reload loop. + * + * 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, + * 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). + * + * 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. + */ +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, +} from '../../media/types'; +import { findTrack, updateTrackInPresentation } from '../../media/utils/tracks'; +import { fetchResolvableText } from '../../network/fetch'; + +export interface ReloadTrackState { + presentation?: MaybeResolvedPresentation; + selectedVideoTrackId?: string; +} + +type ReloadTrackStateName = 'idle' | 'reloading'; + +/** Fallback reload cadence when the playlist carries no usable target duration. */ +const FALLBACK_TARGET_DURATION = 6; + +function sleep(ms: number, signal: AbortSignal): Promise { + 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 reloadTrackSetup({ + state, +}: { + state: { + presentation: Signal; + selectedVideoTrackId: ReadonlySignal; + }; +}) { + const derivedStateSignal = computed(() => { + const presentation = state.presentation.get(); + const trackId = state.selectedVideoTrackId.get(); + if (!isResolvedPresentation(presentation) || !trackId) return 'idle'; + return findTrack(presentation, 'video', trackId) ? 'reloading' : 'idle'; + }); + + return createMachineReactor({ + initial: 'idle', + monitor: () => derivedStateSignal.get(), + states: { + idle: {}, + reloading: { + entry: () => { + const ac = new AbortController(); + const trackId = state.selectedVideoTrackId.get()!; + + void (async () => { + try { + while (!ac.signal.aborted) { + 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, 'video', 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; + // TODO(error-management): route to a state-error slot once one exists. + console.error('[reloadTrack] media-playlist reload failed:', error); + } + })(); + + // State-exit (source change / destroy) aborts the loop. + return () => ac.abort(); + }, + }, + }, + }); +} + +export const reloadTrack = defineBehavior({ + stateKeys: ['presentation', 'selectedVideoTrackId'], + contextKeys: [], + setup: reloadTrackSetup, +}); diff --git a/packages/spf/src/playback/engines/live-playlist-spike/engine.ts b/packages/spf/src/playback/engines/live-playlist-spike/engine.ts new file mode 100644 index 00000000..a4e84764 --- /dev/null +++ b/packages/spf/src/playback/engines/live-playlist-spike/engine.ts @@ -0,0 +1,66 @@ +/** + * **POC SPIKE** — a composition that *just handles playlists*. + * + * The smallest engine that exercises the live foundation + * ([live-presentation-modeling.md](../../../../../internal/design/spf/live-presentation-modeling.md)): + * resolve the multivariant manifest, pick a video rendition, then reload that + * track's media playlist on a target-duration cadence, merging snapshots — no + * MSE, no SourceBuffers, no segment fetching, no DOM. Point it at a live stream + * and observe `state.presentation` evolve (segments append / roll off; + * duration / streamType / live edge derivable from the resolved track). + * + * Drive it via `onSignalsReady`: set `presentation = { url }`. Selection and + * reloading then run on their own. + */ +import { + type Composition, + type ContextSignals, + createComposition, + type StateSignals, +} from '../../../core/composition/create-composition'; +import { makeShareSignals, type ShareSignalsConfig } from '../../../core/composition/share-signals'; +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 { type ParsePresentation, resolvePresentation } from '../../behaviors/resolve-presentation'; +import { type SelectVideoTrackConfig, selectVideoTrack } from '../../behaviors/select-tracks'; + +export interface LivePlaylistSpikeState { + presentation?: MaybeResolvedPresentation; + selectedVideoTrackId?: string; + preload?: 'auto' | 'metadata' | 'none'; + loadActivated?: boolean; +} + +export type LivePlaylistSpikeContext = Record; + +export type LivePlaylistSpikeSignals = { + state: StateSignals; + context: ContextSignals; +}; + +export interface LivePlaylistSpikeConfig extends ShareSignalsConfig { + /** Video-track picker handed to `selectVideoTrack`. Default: max resolution. */ + picker?: TrackPicker; + /** Multivariant parser. Defaults to the HLS multivariant-playlist parser. */ + parsePresentation?: ParsePresentation; +} + +const shareSignals = makeShareSignals(); + +export function createLivePlaylistSpikeEngine( + config: LivePlaylistSpikeConfig = {} +): Composition { + const finalConfig = { + ...config, + picker: config.picker ?? pickHighestResolutionVideoTrack, + parsePresentation: config.parsePresentation ?? parseMultivariantPlaylist, + }; + + return createComposition([resolvePresentation, selectVideoTrack, reloadTrack, shareSignals], { + config: finalConfig, + // Spike skips the preload gate — resolve as soon as a url is set. + initialState: { loadActivated: true }, + }); +} diff --git a/packages/spf/src/playback/engines/live-playlist-spike/index.ts b/packages/spf/src/playback/engines/live-playlist-spike/index.ts new file mode 100644 index 00000000..f8426ef8 --- /dev/null +++ b/packages/spf/src/playback/engines/live-playlist-spike/index.ts @@ -0,0 +1,7 @@ +export { + createLivePlaylistSpikeEngine, + type LivePlaylistSpikeConfig, + type LivePlaylistSpikeContext, + type LivePlaylistSpikeSignals, + type LivePlaylistSpikeState, +} from './engine';