mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
820f6fe45c
commit
c0c9eb348d
@@ -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> = 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<T extends PartiallyResolvedTrack>(
|
||||
text: string,
|
||||
unresolved: T | ResolveTrack<T>
|
||||
previous: T | ResolveTrack<T>
|
||||
): ResolveTrack<T> {
|
||||
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<T extends PartiallyResolvedTrack>(
|
||||
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<T extends PartiallyResolvedTrack>(
|
||||
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<T extends PartiallyResolvedTrack>(
|
||||
}
|
||||
|
||||
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<T extends PartiallyResolvedTrack>(
|
||||
|
||||
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<T extends PartiallyResolvedTrack>(
|
||||
// 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<T>;
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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<TimeSpan> & {
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<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 reloadTrackSetup({
|
||||
state,
|
||||
}: {
|
||||
state: {
|
||||
presentation: Signal<ReloadTrackState['presentation']>;
|
||||
selectedVideoTrackId: ReadonlySignal<ReloadTrackState['selectedVideoTrackId']>;
|
||||
};
|
||||
}) {
|
||||
const derivedStateSignal = computed<ReloadTrackStateName>(() => {
|
||||
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<ReloadTrackStateName>({
|
||||
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,
|
||||
});
|
||||
@@ -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<never, never>;
|
||||
|
||||
export type LivePlaylistSpikeSignals = {
|
||||
state: StateSignals<LivePlaylistSpikeState>;
|
||||
context: ContextSignals<LivePlaylistSpikeContext>;
|
||||
};
|
||||
|
||||
export interface LivePlaylistSpikeConfig extends ShareSignalsConfig<LivePlaylistSpikeState, LivePlaylistSpikeContext> {
|
||||
/** Video-track picker handed to `selectVideoTrack`. Default: max resolution. */
|
||||
picker?: TrackPicker<SelectVideoTrackConfig>;
|
||||
/** Multivariant parser. Defaults to the HLS multivariant-playlist parser. */
|
||||
parsePresentation?: ParsePresentation;
|
||||
}
|
||||
|
||||
const shareSignals = makeShareSignals<LivePlaylistSpikeState, LivePlaylistSpikeContext>();
|
||||
|
||||
export function createLivePlaylistSpikeEngine(
|
||||
config: LivePlaylistSpikeConfig = {}
|
||||
): Composition<LivePlaylistSpikeState, LivePlaylistSpikeContext> {
|
||||
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 },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
createLivePlaylistSpikeEngine,
|
||||
type LivePlaylistSpikeConfig,
|
||||
type LivePlaylistSpikeContext,
|
||||
type LivePlaylistSpikeSignals,
|
||||
type LivePlaylistSpikeState,
|
||||
} from './engine';
|
||||
Reference in New Issue
Block a user