mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(spf): capability probing (#1676)
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
b89f1e944c
commit
bce79ec424
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Capability probing — the engine's foundation for asking the browser what it
|
||||
* can actually decode before committing a rendition to the pipeline.
|
||||
*
|
||||
* Today this is the synchronous codec half: `canPlayTrack` answers "can this
|
||||
* environment play this track?" by building the track's MIME codec string and
|
||||
* passing it to `MediaSource.isTypeSupported` (via `isCodecSupported`). It's the
|
||||
* DOM implementation of the DOM-free `CanPlayTrack` predicate the
|
||||
* track-switching hard-constraint pre-pass consumes — injected through engine
|
||||
* config so the (DOM-free) behavior never imports a DOM API directly.
|
||||
*
|
||||
* Results are memoized by built MIME string: codec support is a pure function
|
||||
* of (codec, environment) and never changes after load, so probing is lazy
|
||||
* (per candidate, at constraint-apply time) but each unique MIME is asked once.
|
||||
*
|
||||
* Future cluster-D phases (async `requestMediaKeySystemAccess` key-system
|
||||
* probing, `SourceBuffer.changeType()` availability) extend this surface; the
|
||||
* async ones land as a state-slot writer behavior rather than a config
|
||||
* predicate, since their verdict resolves asynchronously.
|
||||
*/
|
||||
|
||||
import { NON_FMP4_CONTAINER_MIMES } from '../hls/parse-media-playlist';
|
||||
import type { CanPlayTrack } from '../types';
|
||||
import { buildMimeCodec, isCodecSupported } from './mse/mediasource-setup';
|
||||
|
||||
const codecSupportCache = new Map<string, boolean>();
|
||||
|
||||
/**
|
||||
* Whether the environment can decode `track`, by codec. Builds the track's
|
||||
* MIME codec string and checks `MediaSource.isTypeSupported`, memoized by MIME.
|
||||
* A track without enough to probe — no `mimeType`, or no declared `codecs`
|
||||
* (CODECS is optional per the HLS spec) — is unprobeable and passes through as
|
||||
* playable (`true`) rather than being dropped; the late `createSourceBuffer`
|
||||
* check stays as the backstop for those.
|
||||
*
|
||||
* Detected non-fMP4 containers (`video/mp2t`, `audio/aac`) are asserted
|
||||
* unsupported regardless of the probe, so they're pruned before selection
|
||||
* (the type makes no pick) instead of failing/stalling deep in the pipeline.
|
||||
* Two different reasons, neither UA-based:
|
||||
*
|
||||
* - **MPEG-TS** can't be played at all here: `isTypeSupported('video/mp2t…')` is
|
||||
* a genuine false positive on Chromium (reports `true` but appends produce no
|
||||
* buffered range), and this engine has no TS transmux pipeline.
|
||||
* - **Raw ADTS AAC** is a *temporary* limitation. The browser genuinely
|
||||
* supports it (Chrome/Safari decode `audio/aac`; Firefox doesn't), so it could
|
||||
* be made playable — but our segment actors / loading behaviors / append
|
||||
* pipeline assume every rendition has an `EXT-X-MAP` init segment (e.g. an
|
||||
* `append-init` task with an empty URL, fMP4-shaped append handling). Until
|
||||
* that init-segment assumption is removed, ADTS would fetch but never buffer
|
||||
* (a silent stall), so we assert it unplayable for now. FOLLOW-UP: drop the
|
||||
* init-required assumption in the pipeline and switch this to a bare-MIME
|
||||
* probe (`buildMimeCodec` would project `audio/aac` with no codecs) so it
|
||||
* plays where the browser supports it.
|
||||
*
|
||||
* Override via the engine's `canPlayTrack` config when those pipelines land.
|
||||
*/
|
||||
export const canPlayTrack: CanPlayTrack = (track) => {
|
||||
if (track.mimeType && NON_FMP4_CONTAINER_MIMES.has(track.mimeType)) return false;
|
||||
if (!track.mimeType || !track.codecs?.length) return true;
|
||||
const mimeCodec = buildMimeCodec({ mimeType: track.mimeType, codecs: track.codecs });
|
||||
const cached = codecSupportCache.get(mimeCodec);
|
||||
if (cached !== undefined) return cached;
|
||||
const supported = isCodecSupported(mimeCodec);
|
||||
codecSupportCache.set(mimeCodec, supported);
|
||||
return supported;
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { canPlayTrack } from '../capabilities';
|
||||
|
||||
describe('canPlayTrack', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('returns the isTypeSupported verdict for the track built MIME', () => {
|
||||
const spy = vi.spyOn(MediaSource, 'isTypeSupported');
|
||||
spy.mockReturnValueOnce(true).mockReturnValueOnce(false);
|
||||
|
||||
expect(canPlayTrack({ mimeType: 'video/mp4', codecs: ['supported.1'] })).toBe(true);
|
||||
expect(canPlayTrack({ mimeType: 'video/mp4', codecs: ['unsupported.1'] })).toBe(false);
|
||||
|
||||
expect(spy).toHaveBeenNthCalledWith(1, 'video/mp4; codecs="supported.1"');
|
||||
expect(spy).toHaveBeenNthCalledWith(2, 'video/mp4; codecs="unsupported.1"');
|
||||
});
|
||||
|
||||
it('memoizes by built MIME string — probes each unique MIME once', () => {
|
||||
const spy = vi.spyOn(MediaSource, 'isTypeSupported').mockReturnValue(true);
|
||||
const codecs = ['memo.unique.codec'];
|
||||
|
||||
canPlayTrack({ mimeType: 'video/mp4', codecs });
|
||||
canPlayTrack({ mimeType: 'video/mp4', codecs });
|
||||
canPlayTrack({ mimeType: 'video/mp4', codecs: [...codecs] });
|
||||
|
||||
const calls = spy.mock.calls.filter(([mime]) => mime === 'video/mp4; codecs="memo.unique.codec"');
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('passes through (true) for an unprobeable track with no mimeType', () => {
|
||||
const spy = vi.spyOn(MediaSource, 'isTypeSupported');
|
||||
expect(canPlayTrack({ codecs: ['avc1.42E01E'] })).toBe(true);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes through (true) when codecs is empty or absent (unprobeable, CODECS optional)', () => {
|
||||
const spy = vi.spyOn(MediaSource, 'isTypeSupported');
|
||||
expect(canPlayTrack({ mimeType: 'video/mp4', codecs: [] })).toBe(true);
|
||||
expect(canPlayTrack({ mimeType: 'video/mp4' })).toBe(true);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('asserts non-fMP4 containers (video/mp2t, audio/aac) unsupported without consulting isTypeSupported', () => {
|
||||
// TS: the probe false-positives on Chromium + no transmux. Raw AAC: a
|
||||
// temporary limitation (the browser supports it, but our pipeline assumes an
|
||||
// init segment) — both are pruned before selection rather than stalling.
|
||||
const spy = vi.spyOn(MediaSource, 'isTypeSupported').mockReturnValue(true);
|
||||
expect(canPlayTrack({ mimeType: 'video/mp2t', codecs: ['avc1.640028'] })).toBe(false);
|
||||
expect(canPlayTrack({ mimeType: 'audio/aac', codecs: ['mp4a.40.2'] })).toBe(false);
|
||||
// Even without codecs (the usual pass-through case), they're still dropped.
|
||||
expect(canPlayTrack({ mimeType: 'video/mp2t' })).toBe(false);
|
||||
expect(canPlayTrack({ mimeType: 'audio/aac' })).toBe(false);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -59,6 +59,14 @@ export function parseFrameRate(value: string): FrameRate | undefined {
|
||||
return { frameRateNumerator: Math.round(fps) };
|
||||
}
|
||||
|
||||
// Audio codec identifiers, matched case-insensitively against each CODECS
|
||||
// entry's prefix. Beyond AAC (`mp4a.*`): Dolby (`ac-3`, `ec-3`, `ac-4`), Opus,
|
||||
// FLAC (`fLaC`), DTS (`dts*`), ALAC, and Vorbis. Recognizing these is what lets
|
||||
// capability probing filter undecodable audio renditions (e.g. an AC-3 5.1
|
||||
// track on a browser without AC-3) — an unrecognized codec parses empty and is
|
||||
// treated as unprobeable, so it would never be pruned.
|
||||
const AUDIO_CODEC_PREFIXES = ['mp4a.', 'ac-3', 'ec-3', 'ac-4', 'opus', 'flac', 'dts', 'alac', 'vorbis'];
|
||||
|
||||
/**
|
||||
* Parse CODECS attribute into separate video and audio codecs.
|
||||
*/
|
||||
@@ -67,9 +75,10 @@ export function parseCodecs(codecs: string): { video?: string; audio?: string }
|
||||
const result: { video?: string; audio?: string } = {};
|
||||
|
||||
for (const codec of parts) {
|
||||
const lower = codec.toLowerCase();
|
||||
if (codec.startsWith('avc1.') || codec.startsWith('hvc1.') || codec.startsWith('hev1.')) {
|
||||
result.video = codec;
|
||||
} else if (codec.startsWith('mp4a.')) {
|
||||
} else if (AUDIO_CODEC_PREFIXES.some((prefix) => lower.startsWith(prefix))) {
|
||||
result.audio = codec;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,39 @@ import type {
|
||||
import { matchTag, parseByteRange, parseExtInfDuration } from './parse-attributes';
|
||||
import { resolveUrl } from './resolve-url';
|
||||
|
||||
/** MPEG-2 Transport Stream (IANA `video/MP2T`, lowercased for `isTypeSupported`). Video + audio TS — there is no `audio/mp2t`. */
|
||||
export const MPEG_TS_MIME = 'video/mp2t';
|
||||
/** Raw ADTS AAC packed-audio (HLS `.aac` segments; IANA `audio/aac`). */
|
||||
export const RAW_AAC_MIME = 'audio/aac';
|
||||
|
||||
// Non-fMP4 container MIMEs keyed by segment file extension. fMP4 (the MSE
|
||||
// default) always carries an EXT-X-MAP init segment, so a media playlist with
|
||||
// no init segment and one of these extensions is a non-fMP4 rendition,
|
||||
// relabeled from the fMP4 default. Extend with `.mp3` → 'audio/mpeg' etc.
|
||||
const CONTAINER_MIME_BY_EXTENSION: Record<string, string> = {
|
||||
'.ts': MPEG_TS_MIME,
|
||||
'.aac': RAW_AAC_MIME,
|
||||
};
|
||||
|
||||
/** The non-fMP4 container MIMEs the parser detects — all currently treated as unplayable. */
|
||||
export const NON_FMP4_CONTAINER_MIMES = new Set(Object.values(CONTAINER_MIME_BY_EXTENSION));
|
||||
|
||||
/**
|
||||
* Non-fMP4 container MIME for a (resolved, absolute) segment URL, by file
|
||||
* extension, ignoring the query string. `undefined` for fMP4 / unrecognized.
|
||||
*/
|
||||
function containerMimeFromSegment(url: string | undefined): string | undefined {
|
||||
if (!url) return undefined;
|
||||
let path: string;
|
||||
try {
|
||||
path = new URL(url).pathname.toLowerCase();
|
||||
} catch {
|
||||
path = url.toLowerCase().split('?')[0] ?? '';
|
||||
}
|
||||
const dot = path.lastIndexOf('.');
|
||||
return dot === -1 ? undefined : CONTAINER_MIME_BY_EXTENSION[path.slice(dot)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve unresolved track type to its resolved equivalent.
|
||||
*/
|
||||
@@ -134,10 +167,20 @@ export function parseMediaPlaylist<T extends PartiallyResolvedTrack>(
|
||||
? { url: initSegmentUrl, ...(initSegmentByteRange ? { byteRange: initSegmentByteRange } : {}) }
|
||||
: { url: '' };
|
||||
|
||||
// Container detection: fMP4 always carries an EXT-X-MAP init segment, so its
|
||||
// absence plus a recognized non-fMP4 segment extension (`.ts` → MPEG-TS,
|
||||
// `.aac` → raw ADTS AAC) marks a non-fMP4 rendition (high-precision — never
|
||||
// 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;
|
||||
|
||||
// Generic resolution: All type-specific fields already on unresolved track from P1
|
||||
// Just add parsed properties (startTime, duration, segments, initialization)
|
||||
return {
|
||||
...unresolved,
|
||||
mimeType,
|
||||
startTime: 0,
|
||||
duration: totalDuration,
|
||||
segments,
|
||||
|
||||
@@ -49,6 +49,7 @@ export function parseMultivariantPlaylist(text: string, unresolved: AddressableO
|
||||
uri?: string | undefined;
|
||||
default?: boolean | undefined;
|
||||
autoselect?: boolean | undefined;
|
||||
channels?: number | undefined;
|
||||
}
|
||||
|
||||
interface SubtitleRenditionInfo {
|
||||
@@ -101,6 +102,10 @@ export function parseMultivariantPlaylist(text: string, unresolved: AddressableO
|
||||
uri: uri ? resolveUrl(uri, baseUrl) : undefined,
|
||||
default: mediaAttrs.getBool('DEFAULT'),
|
||||
autoselect: mediaAttrs.getBool('AUTOSELECT'),
|
||||
// CHANNELS is a quoted string whose first parameter is the channel
|
||||
// count ("6", or "16/JOC" for spatial audio); getInt reads the
|
||||
// leading integer.
|
||||
channels: mediaAttrs.getInt('CHANNELS'),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -175,8 +180,29 @@ export function parseMultivariantPlaylist(text: string, unresolved: AddressableO
|
||||
}
|
||||
}
|
||||
|
||||
// Build PartiallyResolvedVideoTracks from video streams
|
||||
const videoTracks: PartiallyResolvedVideoTrack[] = videoStreams.map((stream) => {
|
||||
// Build PartiallyResolvedVideoTracks from video streams, de-duplicating the
|
||||
// HLS cross-product: one video rendition is listed across several
|
||||
// `EXT-X-STREAM-INF` entries — one per audio group it can pair with, all
|
||||
// sharing the same media-playlist URI. Collapse them to one track per URI,
|
||||
// accumulating every advertised audio group. (Redundant-stream renditions
|
||||
// live at *distinct* per-CDN URIs, so they stay separate — only the same-URI
|
||||
// cross-product merges.)
|
||||
const videoTracksByUrl = new Map<string, PartiallyResolvedVideoTrack>();
|
||||
for (const stream of videoStreams) {
|
||||
const existing = videoTracksByUrl.get(stream.uri);
|
||||
if (existing) {
|
||||
if (stream.audioGroupId && !existing.audioGroupIds?.includes(stream.audioGroupId)) {
|
||||
existing.audioGroupIds = [...(existing.audioGroupIds ?? []), stream.audioGroupId];
|
||||
}
|
||||
// BANDWIDTH is video + audio combined; the duplicates differ only in the
|
||||
// paired audio. Keep the lowest as the closest proxy to video-only, which
|
||||
// is what ABR should rank on.
|
||||
if (stream.bandwidth < existing.bandwidth) {
|
||||
existing.bandwidth = stream.bandwidth;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const codecs = stream.codecs ? parseCodecs(stream.codecs) : undefined;
|
||||
|
||||
const track: PartiallyResolvedVideoTrack = {
|
||||
@@ -202,11 +228,12 @@ export function parseMultivariantPlaylist(text: string, unresolved: AddressableO
|
||||
track.frameRate = stream.frameRate;
|
||||
}
|
||||
if (stream.audioGroupId) {
|
||||
track.audioGroupId = stream.audioGroupId;
|
||||
track.audioGroupIds = [stream.audioGroupId];
|
||||
}
|
||||
|
||||
return track;
|
||||
});
|
||||
videoTracksByUrl.set(stream.uri, track);
|
||||
}
|
||||
const videoTracks: PartiallyResolvedVideoTrack[] = [...videoTracksByUrl.values()];
|
||||
|
||||
// Build PartiallyResolvedAudioTracks from audio-only streams
|
||||
const audioOnlyTracks: PartiallyResolvedAudioTrack[] = audioOnlyStreams.map((stream) => {
|
||||
@@ -252,7 +279,7 @@ export function parseMultivariantPlaylist(text: string, unresolved: AddressableO
|
||||
mimeType: 'audio/mp4',
|
||||
bandwidth: 0, // Not available in multivariant for demuxed audio
|
||||
sampleRate: 48000, // CMAF default
|
||||
channels: 2, // Stereo default
|
||||
channels: rendition.channels ?? 2, // From EXT-X-MEDIA CHANNELS; stereo default
|
||||
codecs: [],
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseCodecs } from '../parse-attributes';
|
||||
|
||||
describe('parseCodecs', () => {
|
||||
it('splits a muxed video + audio CODECS string', () => {
|
||||
expect(parseCodecs('avc1.640028,mp4a.40.2')).toEqual({ video: 'avc1.640028', audio: 'mp4a.40.2' });
|
||||
});
|
||||
|
||||
it('recognizes HEVC video codecs', () => {
|
||||
expect(parseCodecs('hvc1.1.6.L120.B0')).toEqual({ video: 'hvc1.1.6.L120.B0' });
|
||||
expect(parseCodecs('hev1.2.4.L120.B0')).toEqual({ video: 'hev1.2.4.L120.B0' });
|
||||
});
|
||||
|
||||
it('recognizes Dolby audio codecs (ac-3 / ec-3 / ac-4), including muxed with video', () => {
|
||||
expect(parseCodecs('ac-3')).toEqual({ audio: 'ac-3' });
|
||||
expect(parseCodecs('ec-3')).toEqual({ audio: 'ec-3' });
|
||||
// The 5.1-surround Mux manifest shape: video + AC-3 audio on one STREAM-INF.
|
||||
expect(parseCodecs('avc1.640020,ac-3')).toEqual({ video: 'avc1.640020', audio: 'ac-3' });
|
||||
expect(parseCodecs('avc1.640020,ac-4')).toEqual({ video: 'avc1.640020', audio: 'ac-4' });
|
||||
});
|
||||
|
||||
it('recognizes Opus / FLAC / DTS / ALAC / Vorbis audio (case-insensitive)', () => {
|
||||
expect(parseCodecs('opus').audio).toBe('opus');
|
||||
expect(parseCodecs('fLaC').audio).toBe('fLaC');
|
||||
expect(parseCodecs('dtsc').audio).toBe('dtsc');
|
||||
expect(parseCodecs('alac').audio).toBe('alac');
|
||||
expect(parseCodecs('vorbis').audio).toBe('vorbis');
|
||||
});
|
||||
|
||||
it('leaves both undefined for an unrecognized codec', () => {
|
||||
expect(parseCodecs('wxyz.1')).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -296,4 +296,91 @@ subtitle.vtt
|
||||
expect(result.initialization).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('container detection (non-fMP4)', () => {
|
||||
const unresolvedVideo: PartiallyResolvedVideoTrack = {
|
||||
type: 'video',
|
||||
id: 'video-0',
|
||||
url: 'https://example.com/video/playlist.m3u8',
|
||||
bandwidth: 1400000,
|
||||
codecs: ['avc1.4d401f'],
|
||||
mimeType: 'video/mp4',
|
||||
};
|
||||
const unresolvedAudio: PartiallyResolvedAudioTrack = {
|
||||
type: 'audio',
|
||||
id: 'audio-0',
|
||||
url: 'https://example.com/audio/playlist.m3u8',
|
||||
bandwidth: 128000,
|
||||
codecs: ['mp4a.40.2'],
|
||||
groupId: 'audio',
|
||||
name: 'Default',
|
||||
sampleRate: 48000,
|
||||
channels: 2,
|
||||
mimeType: 'audio/mp4',
|
||||
};
|
||||
|
||||
it('relabels to video/mp2t when there is no EXT-X-MAP and segments are .ts', () => {
|
||||
const playlist = `#EXTM3U
|
||||
#EXT-X-TARGETDURATION:6
|
||||
#EXTINF:6.0,
|
||||
segment0.ts
|
||||
#EXTINF:6.0,
|
||||
segment1.ts
|
||||
#EXT-X-ENDLIST`;
|
||||
expect(parseMediaPlaylist(playlist, unresolvedVideo).mimeType).toBe('video/mp2t');
|
||||
});
|
||||
|
||||
it('uses video/mp2t for audio TS renditions too (no audio/mp2t)', () => {
|
||||
const playlist = `#EXTM3U
|
||||
#EXTINF:6.0,
|
||||
a0.ts
|
||||
#EXT-X-ENDLIST`;
|
||||
expect(parseMediaPlaylist(playlist, unresolvedAudio).mimeType).toBe('video/mp2t');
|
||||
});
|
||||
|
||||
it('ignores the query string when checking the .ts extension', () => {
|
||||
const playlist = `#EXTM3U
|
||||
#EXTINF:6.0,
|
||||
https://cdn.example.com/path/segment0.ts?token=abc123&expires=1
|
||||
#EXT-X-ENDLIST`;
|
||||
expect(parseMediaPlaylist(playlist, unresolvedVideo).mimeType).toBe('video/mp2t');
|
||||
});
|
||||
|
||||
it('keeps the fMP4 default when an EXT-X-MAP init segment is present (even with a .ts-less map)', () => {
|
||||
const playlist = `#EXTM3U
|
||||
#EXT-X-MAP:URI="init.mp4"
|
||||
#EXTINF:6.0,
|
||||
segment0.ts
|
||||
#EXT-X-ENDLIST`;
|
||||
// EXT-X-MAP present ⇒ fMP4 by definition; never relabel.
|
||||
expect(parseMediaPlaylist(playlist, unresolvedVideo).mimeType).toBe('video/mp4');
|
||||
});
|
||||
|
||||
it('relabels to audio/aac when there is no EXT-X-MAP and segments are .aac (raw ADTS)', () => {
|
||||
const playlist = `#EXTM3U
|
||||
#EXTINF:9.98,
|
||||
fileSequence0.aac
|
||||
#EXTINF:9.98,
|
||||
fileSequence1.aac
|
||||
#EXT-X-ENDLIST`;
|
||||
expect(parseMediaPlaylist(playlist, unresolvedAudio).mimeType).toBe('audio/aac');
|
||||
});
|
||||
|
||||
it('keeps the fMP4 default for an .aac rendition that has an EXT-X-MAP', () => {
|
||||
const playlist = `#EXTM3U
|
||||
#EXT-X-MAP:URI="init.mp4"
|
||||
#EXTINF:9.98,
|
||||
fileSequence0.aac
|
||||
#EXT-X-ENDLIST`;
|
||||
expect(parseMediaPlaylist(playlist, unresolvedAudio).mimeType).toBe('audio/mp4');
|
||||
});
|
||||
|
||||
it('keeps the fMP4 default when there is no map but the extension is unrecognized (e.g. .mp4)', () => {
|
||||
const playlist = `#EXTM3U
|
||||
#EXTINF:6.0,
|
||||
segment0.mp4
|
||||
#EXT-X-ENDLIST`;
|
||||
expect(parseMediaPlaylist(playlist, unresolvedVideo).mimeType).toBe('video/mp4');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,7 +74,43 @@ video-1080p.m3u8`;
|
||||
expect(typeof track1080p.id).toBe('string');
|
||||
// Optional fields not present
|
||||
expect(track1080p.frameRate).toBeUndefined();
|
||||
expect(track1080p.audioGroupId).toBeUndefined();
|
||||
expect(track1080p.audioGroupIds).toBeUndefined();
|
||||
});
|
||||
|
||||
it('de-duplicates the EXT-X-STREAM-INF cross-product: one track per video URI, accumulating audio groups', () => {
|
||||
// The same two video renditions are each listed twice — once paired with a
|
||||
// 5.1 group (higher BANDWIDTH, ac-3) and once with a stereo group (lower,
|
||||
// mp4a) — sharing the same media-playlist URI. Mirrors the Mux
|
||||
// enable_51_surround shape.
|
||||
const text = `#EXTM3U
|
||||
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio-51",NAME="Default",CHANNELS="6",URI="https://example.com/a51.m3u8"
|
||||
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio-hi",NAME="Default",CHANNELS="2",URI="https://example.com/ahi.m3u8"
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=3200000,RESOLUTION=1920x1080,CODECS="avc1.640028,ac-3",AUDIO="audio-51"
|
||||
https://example.com/v1080.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=2800000,RESOLUTION=1920x1080,CODECS="mp4a.40.2,avc1.640028",AUDIO="audio-hi"
|
||||
https://example.com/v1080.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=1600000,RESOLUTION=1280x720,CODECS="avc1.4d401f,ac-3",AUDIO="audio-51"
|
||||
https://example.com/v720.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=1400000,RESOLUTION=1280x720,CODECS="mp4a.40.2,avc1.4d401f",AUDIO="audio-hi"
|
||||
https://example.com/v720.m3u8`;
|
||||
|
||||
const result = parseMultivariantPlaylist(text, { url: baseUrl });
|
||||
const videoTracks = (result.selectionSets.find((s) => s.type === 'video')?.switchingSets[0]?.tracks ??
|
||||
[]) as PartiallyResolvedVideoTrack[];
|
||||
|
||||
// Two unique URIs → two tracks, not four.
|
||||
expect(videoTracks).toHaveLength(2);
|
||||
|
||||
const v1080 = videoTracks.find((t) => t.url === 'https://example.com/v1080.m3u8');
|
||||
expect(v1080?.audioGroupIds).toEqual(['audio-51', 'audio-hi']);
|
||||
// Lowest combined BANDWIDTH across the duplicates (the stereo pairing).
|
||||
expect(v1080?.bandwidth).toBe(2800000);
|
||||
// Video codec is identical across the duplicates regardless of paired audio.
|
||||
expect(v1080?.codecs).toEqual(['avc1.640028']);
|
||||
|
||||
const v720 = videoTracks.find((t) => t.url === 'https://example.com/v720.m3u8');
|
||||
expect(v720?.audioGroupIds).toEqual(['audio-51', 'audio-hi']);
|
||||
expect(v720?.bandwidth).toBe(1400000);
|
||||
});
|
||||
|
||||
it('handles relative URLs by resolving against baseUrl', () => {
|
||||
@@ -225,7 +261,7 @@ video-lo.m3u8`;
|
||||
width: 768,
|
||||
height: 432,
|
||||
codecs: ['avc1.64001f'],
|
||||
audioGroupId: 'audio-med-0',
|
||||
audioGroupIds: ['audio-med-0'],
|
||||
});
|
||||
|
||||
expect(videoTracks?.[1]).toMatchObject({
|
||||
@@ -234,7 +270,7 @@ video-lo.m3u8`;
|
||||
width: 640,
|
||||
height: 360,
|
||||
codecs: ['avc1.64001f'],
|
||||
audioGroupId: 'audio-lo-0',
|
||||
audioGroupIds: ['audio-lo-0'],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -252,6 +288,25 @@ video-lo.m3u8`;
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses the CHANNELS attribute on audio renditions (6 for 5.1, default 2 when absent)', () => {
|
||||
const text = `#EXTM3U
|
||||
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="surround",NAME="5.1",CHANNELS="6",URI="https://example.com/a51.m3u8"
|
||||
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="stereo",NAME="Stereo",CHANNELS="2",URI="https://example.com/a2.m3u8"
|
||||
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="nochan",NAME="Unspecified",URI="https://example.com/anc.m3u8"
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=3000000,RESOLUTION=1920x1080,CODECS="avc1.640028,ac-3",AUDIO="surround"
|
||||
https://example.com/v.m3u8`;
|
||||
|
||||
const result = parseMultivariantPlaylist(text, { url: baseUrl });
|
||||
const audioTracks = (result.selectionSets.find((s) => s.type === 'audio')?.switchingSets[0]?.tracks ??
|
||||
[]) as PartiallyResolvedAudioTrack[];
|
||||
const byGroup = (groupId: string) => audioTracks.find((t) => t.groupId === groupId);
|
||||
|
||||
expect(byGroup('surround')?.channels).toBe(6);
|
||||
expect(byGroup('stereo')?.channels).toBe(2);
|
||||
// No CHANNELS attribute → stereo default.
|
||||
expect(byGroup('nochan')?.channels).toBe(2);
|
||||
});
|
||||
|
||||
it('extracts audio codecs from referencing streams', () => {
|
||||
const result = parseMultivariantPlaylist(muxPlaylist, { url: baseUrl });
|
||||
const audioSet = result.selectionSets.find((s) => s.type === 'audio');
|
||||
@@ -517,9 +572,11 @@ v2/prog_index.m3u8
|
||||
expect(videoSet).toBeDefined();
|
||||
expect(videoSet!.switchingSets).toBeDefined();
|
||||
|
||||
// Should have 24 video variants (8 resolutions × 3 codec combinations)
|
||||
// 8 unique video renditions. The manifest lists them as 24 EXT-X-STREAM-INF
|
||||
// entries (8 URIs × 3 audio groups: mp4a / ac-3 / ec-3), the HLS
|
||||
// cross-product; the parser de-duplicates by URI to one track per rendition.
|
||||
const allVideoTracks = videoSet!.switchingSets.flatMap((ss) => ss.tracks);
|
||||
expect(allVideoTracks.length).toBe(24);
|
||||
expect(allVideoTracks.length).toBe(8);
|
||||
|
||||
// Check first track details (960x540, 60fps, avc1+mp4a, aud1)
|
||||
const firstTrack = allVideoTracks.find((t) => t.bandwidth === 2177116);
|
||||
|
||||
@@ -144,7 +144,14 @@ export type VideoTrack = Track &
|
||||
width?: number;
|
||||
height?: number;
|
||||
frameRate?: FrameRate;
|
||||
audioGroupId?: string;
|
||||
/**
|
||||
* Audio groups (`EXT-X-STREAM-INF:AUDIO`) this video rendition can pair
|
||||
* with. A list because one rendition is typically listed across multiple
|
||||
* `EXT-X-STREAM-INF` entries — one per audio group (the HLS cross-product) —
|
||||
* which the parser collapses into a single track carrying every group it
|
||||
* advertised.
|
||||
*/
|
||||
audioGroupIds?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -174,6 +181,21 @@ export type TextTrack = Track & {
|
||||
forced?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Predicate that answers "can this environment decode this track?" — the
|
||||
* capability-probing surface, read by the track-switching hard-constraint
|
||||
* pre-pass (`excludeUnplayableTracks`) to drop undecodable renditions before
|
||||
* selection. Kept DOM-free here (a plain function type over a minimal track
|
||||
* shape) so DOM-free behaviors can consume it; the DOM implementation
|
||||
* (`canPlayTrack` in `media/dom/capabilities.ts`) wraps
|
||||
* `MediaSource.isTypeSupported`.
|
||||
*
|
||||
* Takes the minimal codec-bearing shape both video and audio candidates
|
||||
* carry. `mimeType` is optional so unprobeable candidates (no MIME) can be
|
||||
* passed straight through as playable rather than dropped.
|
||||
*/
|
||||
export type CanPlayTrack = (track: { mimeType?: string; codecs?: string[] }) => boolean;
|
||||
|
||||
/**
|
||||
* Minimal text-track cue shape — start time, end time, and display text.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Presentation } from '../../types';
|
||||
import { applyContainerMimeType } from '../tracks';
|
||||
|
||||
const presentation = (): Presentation =>
|
||||
({
|
||||
id: 'pres-1',
|
||||
url: 'https://example.com/master.m3u8',
|
||||
selectionSets: [
|
||||
{
|
||||
id: 'v',
|
||||
type: 'video',
|
||||
switchingSets: [
|
||||
{
|
||||
id: 'vs',
|
||||
type: 'video',
|
||||
tracks: [
|
||||
{ id: 'v1', mimeType: 'video/mp4' },
|
||||
{ id: 'v2', mimeType: 'video/mp4' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'a',
|
||||
type: 'audio',
|
||||
switchingSets: [{ id: 'as', type: 'audio', tracks: [{ id: 'a1', mimeType: 'audio/mp4' }] }],
|
||||
},
|
||||
],
|
||||
}) as unknown as Presentation;
|
||||
|
||||
const mimeOf = (p: Presentation, type: string) =>
|
||||
p.selectionSets.find((s) => s.type === type)?.switchingSets[0]?.tracks.map((t) => t.mimeType);
|
||||
|
||||
describe('applyContainerMimeType', () => {
|
||||
it('sets the MIME on every track of the given type', () => {
|
||||
const result = applyContainerMimeType(presentation(), 'video', 'video/mp2t');
|
||||
expect(mimeOf(result, 'video')).toEqual(['video/mp2t', 'video/mp2t']);
|
||||
});
|
||||
|
||||
it('leaves other types untouched (never crosses audio↔video)', () => {
|
||||
const result = applyContainerMimeType(presentation(), 'video', 'video/mp2t');
|
||||
expect(mimeOf(result, 'audio')).toEqual(['audio/mp4']);
|
||||
});
|
||||
|
||||
it('is idempotent — re-applying the same MIME is a no-op', () => {
|
||||
const once = applyContainerMimeType(presentation(), 'video', 'video/mp2t');
|
||||
const twice = applyContainerMimeType(once, 'video', 'video/mp2t');
|
||||
expect(twice).toEqual(once);
|
||||
});
|
||||
});
|
||||
@@ -119,6 +119,37 @@ export function hasCodecs(track: PartiallyResolvedTrack | ResolvedTrack | undefi
|
||||
return !!track && 'codecs' in track && !!track.codecs?.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set `mimeType` on every track of one `type` (immutably). Used to propagate a
|
||||
* detected container across a type's renditions: an ABR ladder is the same
|
||||
* content at different bitrates, so one rendition's container holds for all of
|
||||
* them — capability probing + SourceBuffer setup then get the right MIME for the
|
||||
* whole type from a single resolved media playlist, without fetching the rest.
|
||||
*
|
||||
* Scoped to one type on purpose: propagating *across* audio/video would be wrong
|
||||
* for mixed-container sources (e.g. muxed-TS video + raw-`.aac` audio) and races
|
||||
* concurrent per-type resolution. Same-type writes are disjoint and safe.
|
||||
* Idempotent — tracks already at `mimeType` are left as-is.
|
||||
*/
|
||||
export function applyContainerMimeType(presentation: Presentation, type: TrackType, mimeType: string): Presentation {
|
||||
return {
|
||||
...presentation,
|
||||
selectionSets: presentation.selectionSets.map((selectionSet) =>
|
||||
selectionSet.type === type
|
||||
? {
|
||||
...selectionSet,
|
||||
switchingSets: selectionSet.switchingSets.map((switchingSet) => ({
|
||||
...switchingSet,
|
||||
tracks: switchingSet.tracks.map((track) =>
|
||||
track.mimeType === mimeType ? track : { ...track, mimeType }
|
||||
),
|
||||
})),
|
||||
}
|
||||
: selectionSet
|
||||
),
|
||||
} as Presentation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a track within a presentation (immutably). Generic — works for
|
||||
* video, audio, or text tracks.
|
||||
|
||||
@@ -2,11 +2,11 @@ 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 { ConcurrentRunner, Task } from '../../core/tasks/task';
|
||||
import { parseMediaPlaylist } from '../../media/hls/parse-media-playlist';
|
||||
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 type { GetCdnId } from '../../media/utils/cdn';
|
||||
import { findTrack, updateTrackInPresentation } from '../../media/utils/tracks';
|
||||
import { applyContainerMimeType, findTrack, updateTrackInPresentation } from '../../media/utils/tracks';
|
||||
import { fetchResolvableText as defaultFetchResolvableText, type FetchText } from '../../network/fetch';
|
||||
import { failoverFetch } from '../primitives/failover-fetch';
|
||||
import { AUDIO_TYPE_CONFIG, TEXT_TYPE_CONFIG, VIDEO_TYPE_CONFIG } from '../primitives/track-types';
|
||||
@@ -132,9 +132,21 @@ function setupTrackResolution<K extends SelectedTrackKey>({
|
||||
// signal abort cancels in-flight body reads — so by the
|
||||
// time we reach this point the presentation we resolved
|
||||
// against is the live one.
|
||||
update(state.presentation, (current) =>
|
||||
isResolvedPresentation(current) ? updateTrackInPresentation(current, mediaTrack) : current
|
||||
);
|
||||
update(state.presentation, (current) => {
|
||||
if (!isResolvedPresentation(current)) return current;
|
||||
const patched = updateTrackInPresentation(current, mediaTrack);
|
||||
// Container is uniform within a type (an ABR ladder shares
|
||||
// its container), so a detected non-fMP4 rendition (TS,
|
||||
// raw AAC) implies every rendition of *this* type matches —
|
||||
// relabel them all from one resolved playlist instead of
|
||||
// fetching each. Scoped to this track's own type: never cross
|
||||
// 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)
|
||||
? applyContainerMimeType(patched, mediaTrack.type, mediaTrack.mimeType)
|
||||
: patched;
|
||||
});
|
||||
},
|
||||
{ id: track.id }
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
VideoSelectionSet,
|
||||
VideoTrack,
|
||||
} from '../../../media/types';
|
||||
import { applyContainerMimeType } from '../../../media/utils/tracks';
|
||||
import type { BandwidthState } from '../../../network/bandwidth-estimator';
|
||||
import {
|
||||
applyConstraints,
|
||||
@@ -969,19 +970,126 @@ describe('excludeFailedCdns (failover constraint)', () => {
|
||||
reactor.destroy();
|
||||
});
|
||||
|
||||
it('keeps the prior pick when every CDN is in cooldown (nothing playable)', async () => {
|
||||
it('clears the selection when every CDN is in cooldown, re-picking on recovery', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const state = makeState(undefined);
|
||||
const reactor = switchVideoTrack.setup({ state });
|
||||
await flush();
|
||||
expect(state.selectedVideoTrackId.get()).toBe('1080p-a');
|
||||
|
||||
// All CDNs cooled down → constraints prune everything → no playable set →
|
||||
// the effect no-ops, leaving the last pick in place (deferred terminal-state
|
||||
// modeling).
|
||||
// All CDNs cooled down → constraints prune every track → no playable set →
|
||||
// the selection clears (no pick) rather than lingering on an unreachable CDN.
|
||||
state.failedCdns.set(['https://cdn-a.example.com', 'https://cdn-b.example.com']);
|
||||
await flush();
|
||||
expect(state.selectedVideoTrackId.get()).toBeUndefined();
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
|
||||
// A CDN recovers → its tracks reappear → the candidate set refills and re-picks.
|
||||
state.failedCdns.set(['https://cdn-b.example.com']);
|
||||
await flush();
|
||||
expect(state.selectedVideoTrackId.get()).toBe('1080p-a');
|
||||
|
||||
errorSpy.mockRestore();
|
||||
reactor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// excludeUnplayableTracks — the capability constraint (hard pre-pass)
|
||||
// ============================================================================
|
||||
|
||||
describe('excludeUnplayableTracks (capability constraint)', () => {
|
||||
const codecVideoTrack = (id: string, codec: string, bandwidth: number): PartiallyResolvedVideoTrack => ({
|
||||
type: 'video',
|
||||
codecs: [codec],
|
||||
id,
|
||||
url: `https://example.com/${id}.m3u8`,
|
||||
bandwidth,
|
||||
mimeType: 'video/mp4',
|
||||
});
|
||||
|
||||
// Mixed HEVC + AVC ladder; the same bitrates on each codec.
|
||||
const mixedCodecPresentation = () =>
|
||||
createPresentation([
|
||||
codecVideoTrack('720p-hevc', 'hvc1.1.6.L93.B0', 2_400_000),
|
||||
codecVideoTrack('720p-avc', 'avc1.4d401f', 2_400_000),
|
||||
codecVideoTrack('1080p-hevc', 'hvc1.1.6.L120.B0', 4_800_000),
|
||||
codecVideoTrack('1080p-avc', 'avc1.640028', 4_800_000),
|
||||
]);
|
||||
|
||||
// Rejects HEVC, accepts everything else.
|
||||
const rejectsHevc = (track: { codecs?: string[] }) => !track.codecs?.some((c) => c.startsWith('hvc1'));
|
||||
|
||||
const makeState = () => ({
|
||||
presentation: signal<MaybeResolvedPresentation | undefined>(mixedCodecPresentation()),
|
||||
bandwidthState: signal<BandwidthState | undefined>(createBandwidthState(10_000_000)),
|
||||
selectedVideoTrackId: signal<string | undefined>(undefined),
|
||||
userVideoTrackSelection: signal<Partial<VideoTrack> | undefined>(undefined),
|
||||
});
|
||||
|
||||
it('prunes undecodable renditions before ranking — picks the best playable codec', async () => {
|
||||
const state = makeState();
|
||||
const reactor = switchVideoTrack.setup({ state, config: { canPlayTrack: rejectsHevc } });
|
||||
await flush();
|
||||
// HEVC pruned upstream; ranker picks the highest-bitrate AVC that fits.
|
||||
expect(state.selectedVideoTrackId.get()).toBe('1080p-avc');
|
||||
reactor.destroy();
|
||||
});
|
||||
|
||||
it('passes everything through when no canPlayTrack probe is wired', async () => {
|
||||
const state = makeState();
|
||||
const reactor = switchVideoTrack.setup({ state });
|
||||
await flush();
|
||||
// No probe → HEVC survives; same-bitrate tie keeps manifest order, so the
|
||||
// first 1080p (HEVC) wins.
|
||||
expect(state.selectedVideoTrackId.get()).toBe('1080p-hevc');
|
||||
reactor.destroy();
|
||||
});
|
||||
|
||||
it('still excludes an unplayable track the user selected (hard constraint beats the soft filter)', async () => {
|
||||
const state = makeState();
|
||||
state.userVideoTrackSelection.set({ id: '1080p-hevc' });
|
||||
const reactor = switchVideoTrack.setup({ state, config: { canPlayTrack: rejectsHevc } });
|
||||
await flush();
|
||||
// The user's HEVC pick is pruned by the constraint before the user filter
|
||||
// runs; the filter finds no match and falls through to the playable set.
|
||||
expect(state.selectedVideoTrackId.get()).toBe('1080p-avc');
|
||||
reactor.destroy();
|
||||
});
|
||||
|
||||
it('makes no pick when the constraint prunes every rendition', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const state = makeState();
|
||||
const reactor = switchVideoTrack.setup({ state, config: { canPlayTrack: () => false } });
|
||||
await flush();
|
||||
// Every codec rejected from a cold start → empty candidate set → nothing
|
||||
// selected, and the empty-from-constraints case is flagged. The late
|
||||
// createSourceBuffer check stays as the backstop.
|
||||
expect(state.selectedVideoTrackId.get()).toBeUndefined();
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
errorSpy.mockRestore();
|
||||
reactor.destroy();
|
||||
});
|
||||
|
||||
it('clears a prior pick when a later relabel prunes every rendition to empty', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const state = makeState();
|
||||
// Accepts fMP4; rejects the non-fMP4 container MIME resolve-track relabels to.
|
||||
const canPlayTrack = (track: { mimeType?: string }) => track.mimeType !== 'video/mp2t';
|
||||
const reactor = switchVideoTrack.setup({ state, config: { canPlayTrack } });
|
||||
await flush();
|
||||
// Pick made while the tracks are still labeled video/mp4.
|
||||
expect(state.selectedVideoTrackId.get()).toBe('1080p-hevc');
|
||||
|
||||
// resolve-track detects a TS container and relabels the whole video type;
|
||||
// every rendition is now undecodable → candidate set empties → the now-stale
|
||||
// pick clears (instead of lingering as an unplayable selection that stalls).
|
||||
state.presentation.set(applyContainerMimeType(mixedCodecPresentation(), 'video', 'video/mp2t'));
|
||||
await flush();
|
||||
expect(state.selectedVideoTrackId.get()).toBeUndefined();
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
|
||||
errorSpy.mockRestore();
|
||||
reactor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
* unload.
|
||||
*
|
||||
* Selection runs in two stages. First a **hard-constraints pre-pass**
|
||||
* (`applyConstraints`) prunes the unplayable from the candidate set — today the
|
||||
* failed-CDN constraint (`excludeFailedCdns`, failover cooldown); capability
|
||||
* probing will join it. Then a small ordered chain of rules (`applyRules`) picks
|
||||
* among the survivors. Each constraint/rule reads the signals it needs at apply
|
||||
* (`applyConstraints`) prunes the unplayable from the candidate set — the
|
||||
* failed-CDN constraint (`excludeFailedCdns`, failover cooldown) and the
|
||||
* capability constraint (`excludeUnplayableTracks`, codec support). Then a small
|
||||
* ordered chain of rules (`applyRules`) picks among the survivors. Each constraint/rule reads the signals it needs at apply
|
||||
* time, so the effect subscribes to exactly what was consulted. The chain is
|
||||
* three rules, most authoritative first:
|
||||
*
|
||||
@@ -41,18 +41,25 @@
|
||||
* *scope* is the sticky-pick half of multi-CDN; the failed-CDN *constraint* is
|
||||
* the failover half — prune the cooled-down CDN, the scope falls to the next.)
|
||||
*
|
||||
* Deferred: capability probing as a second constraint; audio's preferred-
|
||||
* language / default-track selection as standing soft-filter rules (previously
|
||||
* the empty-slot picker, dropped in the move to the rule chain).
|
||||
* When the pre-pass prunes a type's candidates to empty, the behavior leaves any
|
||||
* prior pick in place and makes no new pick; the late `createSourceBuffer` check
|
||||
* stays as the structural backstop for an unplayable rendition reaching the
|
||||
* pipeline. Surfacing "nothing playable" as observable state is deferred until a
|
||||
* consumer (error mapping) needs it.
|
||||
*
|
||||
* Deferred: audio's preferred-language / default-track selection as standing
|
||||
* soft-filter rules (previously the empty-slot picker, dropped in the move to
|
||||
* the rule chain).
|
||||
*/
|
||||
|
||||
import { type AnySlotMap, defineBehavior } from '../../core/composition/create-composition';
|
||||
import { createMachineReactor } from '../../core/reactors/create-machine-reactor';
|
||||
import { computed, type ReadonlySignal, type Signal } from '../../core/signals/primitives';
|
||||
import { computed, peek, type ReadonlySignal, type Signal } from '../../core/signals/primitives';
|
||||
import { DEFAULT_QUALITY_CONFIG, type QualityConfig, resolutionArea } from '../../media/abr/quality-selection';
|
||||
import { matchesPartialTrack } from '../../media/primitives/select-tracks';
|
||||
import {
|
||||
type AudioTrack,
|
||||
type CanPlayTrack,
|
||||
isResolvedPresentation,
|
||||
type MaybeResolvedPresentation,
|
||||
type PartiallyResolvedAudioTrack,
|
||||
@@ -96,6 +103,15 @@ export interface SwitchVideoTrackConfig {
|
||||
initialBandwidth?: number;
|
||||
/** Override CDN-id derivation (shared by the CDN scope + failover constraint). */
|
||||
getCdnId?: GetCdnId;
|
||||
/**
|
||||
* Codec capability probe read by the `excludeUnplayableTracks` hard
|
||||
* constraint — drops renditions this environment can't decode before
|
||||
* selection runs. Injected (rather than imported) so the DOM-free behavior
|
||||
* never reaches a DOM API directly; the engine defaults it to the
|
||||
* `MediaSource.isTypeSupported`-backed `canPlayTrack`. Absent → no codec
|
||||
* filtering (the constraint passes everything through).
|
||||
*/
|
||||
canPlayTrack?: CanPlayTrack;
|
||||
}
|
||||
|
||||
/** Default initial-bandwidth value before bandwidth measurements arrive. */
|
||||
@@ -222,7 +238,18 @@ export function applyConstraints<T, State, Context, Config>(
|
||||
* audio candidates area-compare equal). Every resolved/partially-resolved video
|
||||
* track carries them all; audio tracks omit the dimensions.
|
||||
*/
|
||||
type SwitchableTrack = { id: string; url: string; bandwidth?: number; width?: number; height?: number };
|
||||
type SwitchableTrack = {
|
||||
id: string;
|
||||
url: string;
|
||||
bandwidth?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
// Read by the capability constraint to probe codec support. Optional on the
|
||||
// minimal shape; every resolved/partially-resolved video & audio candidate
|
||||
// carries them, and an absent `mimeType` makes a track unprobeable (kept).
|
||||
mimeType?: string;
|
||||
codecs?: string[];
|
||||
};
|
||||
|
||||
type SelectionKey = 'selectedVideoTrackId' | 'selectedAudioTrackId';
|
||||
type UserSelectionKey = 'userVideoTrackSelection' | 'userAudioTrackSelection';
|
||||
@@ -339,6 +366,17 @@ type CdnRuleConfig<S extends SelectionKey, T extends SwitchableTrack> = TrackSwi
|
||||
getCdnId?: GetCdnId;
|
||||
};
|
||||
|
||||
/**
|
||||
* Config the capability constraint reads: the base config plus an *optional*
|
||||
* `canPlayTrack` codec probe. Optional → an unwired probe means "no codec
|
||||
* filtering" and the constraint passes everything through, so the base config
|
||||
* (without it) stays assignable. The engine defaults it to the DOM-bound
|
||||
* `canPlayTrack`.
|
||||
*/
|
||||
type CapabilityConstraintConfig<S extends SelectionKey, T extends SwitchableTrack> = TrackSwitchingConfig<S, T> & {
|
||||
canPlayTrack?: CanPlayTrack;
|
||||
};
|
||||
|
||||
type VideoTrackCandidate = PartiallyResolvedVideoTrack | VideoTrack;
|
||||
type AudioTrackCandidate = PartiallyResolvedAudioTrack | AudioTrack;
|
||||
|
||||
@@ -373,8 +411,8 @@ function filterByUserSelection<S extends SelectionKey, U extends UserSelectionKe
|
||||
*
|
||||
* Passes everything through when there's no `failedCdns` signal/value. When it
|
||||
* prunes *every* track (all CDNs cooled down), the empty result is preserved
|
||||
* (per `applyConstraints`) — "nothing playable," which today leaves the prior
|
||||
* pick in place.
|
||||
* (per `applyConstraints`) — "nothing playable," which clears the selection (no
|
||||
* pick); a later CDN recovery refills the candidate set and re-picks.
|
||||
*/
|
||||
function excludeFailedCdns<S extends SelectionKey, T extends SwitchableTrack>(
|
||||
tracks: readonly T[],
|
||||
@@ -387,6 +425,31 @@ function excludeFailedCdns<S extends SelectionKey, T extends SwitchableTrack>(
|
||||
return tracks.filter((track) => !failedSet.has(getCdnId(track.url)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Capability constraint — a *hard* filter (constraints pre-pass), shared by
|
||||
* video and audio. Removes renditions this environment can't decode, probed via
|
||||
* the injected `canPlayTrack` (codec → `MediaSource.isTypeSupported`). Moving
|
||||
* the check here — before selection — means an unplayable variant (e.g. HEVC on
|
||||
* a browser without HEVC) is pruned upstream and never picked, instead of
|
||||
* surviving into the pipeline to fail late at `createSourceBuffer`. That late
|
||||
* throw stays as a defensive structural guarantee; with this constraint it
|
||||
* should rarely fire.
|
||||
*
|
||||
* Passes everything through when there's no `canPlayTrack` probe (a composition
|
||||
* that didn't wire it, or DOM-free tests). When it prunes *every* track (no
|
||||
* decodable rendition), the empty result is preserved (per `applyConstraints`)
|
||||
* — "nothing playable," so the behavior clears the selection (no pick) and the
|
||||
* late `createSourceBuffer` check stays as the backstop.
|
||||
*/
|
||||
function excludeUnplayableTracks<S extends SelectionKey, T extends SwitchableTrack>(
|
||||
tracks: readonly T[],
|
||||
{ config }: SelectionRuleDeps<TrackSwitchingStateMap<S>, AnySlotMap, CapabilityConstraintConfig<S, T>>
|
||||
): readonly T[] {
|
||||
const canPlay = config.canPlayTrack;
|
||||
if (!canPlay) return tracks;
|
||||
return tracks.filter((track) => canPlay(track));
|
||||
}
|
||||
|
||||
/**
|
||||
* Active-CDN scope — a soft filter, shared by video and audio. Narrows to the
|
||||
* highest-priority CDN in `cdnPriority` (owned by `deriveCdnPriority`) that
|
||||
@@ -532,14 +595,34 @@ function setupTrackSwitching<
|
||||
effects: [
|
||||
() => {
|
||||
// Reactive read: subscribes the reaction to the candidate set, so a
|
||||
// new presentation — or a future constraint pruning it — re-fires
|
||||
// this and re-picks.
|
||||
// new presentation — or a constraint pruning it — re-fires this and
|
||||
// re-picks.
|
||||
const tracks = candidateSet.get();
|
||||
// No playable tracks: no tracks of this type, or (once constraints
|
||||
// exist) everything pruned. Nothing to pick. Surfacing "nothing
|
||||
// playable" as a distinct not-ready state is left to the constraints
|
||||
// work; for now it's a silent no-op, leaving any prior pick in place.
|
||||
if (!tracks.length) return;
|
||||
|
||||
// Empty candidate set — two shapes, told apart by whether the type
|
||||
// has any tracks at all:
|
||||
// - The type has no tracks (e.g. a video-only source's absent
|
||||
// audio): legitimate, nothing to pick or clear.
|
||||
// - The type HAS tracks but the hard-constraints pre-pass pruned
|
||||
// every one (every rendition undecodable, or every CDN in
|
||||
// failover cooldown): no playable rendition. Clear the selection
|
||||
// so a pick made earlier — e.g. under the initial mp4 label,
|
||||
// before resolve-track relabeled the type to a non-fMP4
|
||||
// container — can't linger as a now-unplayable selection and
|
||||
// silently stall the pipeline.
|
||||
// The `console.error` is a placeholder until the planned error
|
||||
// behaviors surface "nothing playable" as observable state.
|
||||
if (!tracks.length) {
|
||||
const presentation = peek(state.presentation);
|
||||
const hasTracksOfType = isResolvedPresentation(presentation) && getTracks(presentation).length > 0;
|
||||
if (hasTracksOfType) {
|
||||
console.error(
|
||||
`[track-switching] every ${selectionKey} candidate was filtered out by constraints; clearing selection`
|
||||
);
|
||||
state[selectionKey].set(undefined);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// The whole deps object passes straight through to every rule in the
|
||||
// variant-supplied chain (state + config from the behavior; context
|
||||
@@ -602,7 +685,7 @@ export const switchVideoTrack = defineBehavior({
|
||||
selectionKey: 'selectedVideoTrackId',
|
||||
userSelectionKey: 'userVideoTrackSelection',
|
||||
getTracks: (presentation) => getTracksByType(presentation, 'video') as readonly VideoTrackCandidate[],
|
||||
constraints: [excludeFailedCdns],
|
||||
constraints: [excludeFailedCdns, excludeUnplayableTracks],
|
||||
rules: [filterByUserSelection, preferActiveCdn, rankByBandwidth],
|
||||
},
|
||||
}),
|
||||
@@ -653,7 +736,7 @@ export const switchAudioTrack = defineBehavior({
|
||||
selectionKey: 'selectedAudioTrackId',
|
||||
userSelectionKey: 'userAudioTrackSelection',
|
||||
getTracks: (presentation) => getTracksByType(presentation, 'audio') as readonly AudioTrackCandidate[],
|
||||
constraints: [excludeFailedCdns],
|
||||
constraints: [excludeFailedCdns, excludeUnplayableTracks],
|
||||
rules: [filterByUserSelection, preferActiveCdn, rankByBandwidth],
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -7,8 +7,9 @@ import {
|
||||
import { makeShareSignals, type ShareSignalsConfig } from '../../../core/composition/share-signals';
|
||||
import type { BackBufferConfig } from '../../../media/buffer/back-buffer';
|
||||
import type { ForwardBufferConfig } from '../../../media/buffer/forward-buffer';
|
||||
import { canPlayTrack } from '../../../media/dom/capabilities';
|
||||
import { parseMultivariantPlaylist } from '../../../media/hls/parse-multivariant';
|
||||
import type { AudioTrack, MaybeResolvedPresentation } from '../../../media/types';
|
||||
import type { AudioTrack, CanPlayTrack, MaybeResolvedPresentation } from '../../../media/types';
|
||||
import type { GetCdnId } from '../../../media/utils/cdn';
|
||||
import { getResolvedSelectedTrackDuration } from '../../../media/utils/track-selection';
|
||||
import type { SegmentLoaderActor } from '../../actors/dom/segment-loader';
|
||||
@@ -98,6 +99,14 @@ export type SimpleHlsAudioOnlyEngineSignals = {
|
||||
export interface SimpleHlsAudioOnlyEngineConfig
|
||||
extends ShareSignalsConfig<SimpleHlsAudioOnlyEngineState, SimpleHlsAudioOnlyEngineContext> {
|
||||
preferredAudioLanguage?: string;
|
||||
/**
|
||||
* Codec capability probe read by `track-switching`'s `excludeUnplayableTracks`
|
||||
* constraint. Defaults to the `MediaSource.isTypeSupported`-backed
|
||||
* `canPlayTrack`; override to force-exclude a codec. Mirrors the default
|
||||
* engine — without it, capability probing (and TS / raw-AAC detection) would
|
||||
* be inert for audio-only playback.
|
||||
*/
|
||||
canPlayTrack?: CanPlayTrack;
|
||||
resolveDuration?: PresentationDurationResolver;
|
||||
parsePresentation?: ParsePresentation;
|
||||
forwardBuffer?: Partial<ForwardBufferConfig>;
|
||||
@@ -159,6 +168,7 @@ export function createHlsAudioOnlyEngine(
|
||||
): Composition<SimpleHlsAudioOnlyEngineState, SimpleHlsAudioOnlyEngineContext> {
|
||||
const finalConfig = {
|
||||
...config,
|
||||
canPlayTrack: config.canPlayTrack ?? canPlayTrack,
|
||||
resolveDuration: config.resolveDuration ?? getResolvedSelectedTrackDuration,
|
||||
parsePresentation: config.parsePresentation ?? parseMultivariantPlaylist,
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import { makeShareSignals, type ShareSignalsConfig } from '../../../core/composi
|
||||
import type { QualityConfig } from '../../../media/abr/quality-selection';
|
||||
import type { BackBufferConfig } from '../../../media/buffer/back-buffer';
|
||||
import type { ForwardBufferConfig } from '../../../media/buffer/forward-buffer';
|
||||
import { canPlayTrack } from '../../../media/dom/capabilities';
|
||||
import { resolveVttSegment } from '../../../media/dom/text/resolve-vtt-segment';
|
||||
import {
|
||||
addSubtitlesTracksToMedia,
|
||||
@@ -15,7 +16,7 @@ import {
|
||||
removeAllSubtitlesTracksFromMedia,
|
||||
} from '../../../media/dom/text/text-track-slots';
|
||||
import { parseMultivariantPlaylist } from '../../../media/hls/parse-multivariant';
|
||||
import type { AudioTrack, MaybeResolvedPresentation, VideoTrack } from '../../../media/types';
|
||||
import type { AudioTrack, CanPlayTrack, MaybeResolvedPresentation, VideoTrack } from '../../../media/types';
|
||||
import type { GetCdnId } from '../../../media/utils/cdn';
|
||||
import { getResolvedSelectedTrackDuration } from '../../../media/utils/track-selection';
|
||||
import type { BandwidthConfig, BandwidthState } from '../../../network/bandwidth-estimator';
|
||||
@@ -135,6 +136,14 @@ export interface SimpleHlsEngineConfig extends ShareSignalsConfig<SimpleHlsEngin
|
||||
* collected. Default: `DEFAULT_INITIAL_BANDWIDTH` (5 Mbps).
|
||||
*/
|
||||
initialBandwidth?: number;
|
||||
/**
|
||||
* Codec capability probe injected into `track-switching`'s
|
||||
* `excludeUnplayableTracks` constraint — drops renditions the environment
|
||||
* can't decode before selection. Defaults to the `MediaSource.isTypeSupported`
|
||||
* -backed `canPlayTrack`; supply your own to override (e.g. force-exclude a
|
||||
* codec).
|
||||
*/
|
||||
canPlayTrack?: CanPlayTrack;
|
||||
preferredAudioLanguage?: string;
|
||||
preferredSubtitleLanguage?: string;
|
||||
includeForcedTracks?: boolean;
|
||||
@@ -272,6 +281,7 @@ export function createSimpleHlsEngine(
|
||||
): Composition<SimpleHlsEngineState, SimpleHlsEngineContext> {
|
||||
const finalConfig = {
|
||||
...config,
|
||||
canPlayTrack: config.canPlayTrack ?? canPlayTrack,
|
||||
resolveTextTrackSegment: config.resolveTextTrackSegment ?? resolveVttSegment,
|
||||
resolveDuration: config.resolveDuration ?? getResolvedSelectedTrackDuration,
|
||||
parsePresentation: config.parsePresentation ?? parseMultivariantPlaylist,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { snapshot } from '../../../../core/signals/primitives';
|
||||
import type { Presentation } from '../../../../media/types';
|
||||
import { createHlsAudioOnlyEngine } from '../engine-audio-only';
|
||||
|
||||
// Mock appendSegment to succeed without real MP4 data
|
||||
@@ -7,15 +8,27 @@ vi.mock('../../../../media/dom/mse/append-segment', () => ({
|
||||
appendSegment: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Fallback for URLs a test's mock doesn't handle explicitly. Segment/init
|
||||
// requests resolve with an empty body — the appendSegment mock makes the bytes
|
||||
// inert — so the failover monitor isn't tripped by unmocked segment fetches (a
|
||||
// single failed fetch trips that CDN into cooldown, which empties the candidate
|
||||
// set). Genuinely unknown URLs still reject loudly.
|
||||
function unmockedFetchFallback(url: string): Promise<Response> {
|
||||
// Non-empty body: `fetchStream` throws "Response has no body" on a null body
|
||||
// (empty Uint8Array), which would itself trip the monitor.
|
||||
if (/\.(m4s|mp4|ts|aac)(\?|$)/.test(url)) return Promise.resolve(new Response(new Uint8Array([0])));
|
||||
return Promise.reject(new Error(`Unmocked URL: ${url}`));
|
||||
}
|
||||
|
||||
describe('createHlsAudioOnlyEngine', () => {
|
||||
let originalFetch: typeof globalThis.fetch;
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
// Tests assert at actor-presence and state-shape level, not at "init
|
||||
// segment appended" level — so unmocked init/segment URLs in the manifests
|
||||
// are intentional. The fetch loop's reject path leaks a console.error in
|
||||
// each test; suppress only the expected patterns so genuine failures still
|
||||
// surface.
|
||||
// Tests assert at actor-presence and state-shape level, not at "init segment
|
||||
// appended" level. Audio/video segment fetches resolve via
|
||||
// `unmockedFetchFallback` (inert under the appendSegment mock); text-track
|
||||
// segment fetches still reject and leak a console.error. Suppress only the
|
||||
// expected patterns so genuine failures still surface.
|
||||
const expectedErrorPatterns = [
|
||||
/Unexpected error in segment loader.*Unmocked URL/s,
|
||||
/Failed to load text-track segment/,
|
||||
@@ -62,6 +75,51 @@ describe('createHlsAudioOnlyEngine', () => {
|
||||
engine.destroy();
|
||||
});
|
||||
|
||||
it('wires the default canPlayTrack — prunes an undecodable (raw-AAC) audio source, making no pick', async () => {
|
||||
const flush = () => Promise.resolve().then(() => Promise.resolve());
|
||||
// No canPlayTrack override → relies on the engine's default. A raw-AAC
|
||||
// (audio/aac) rendition is asserted unplayable, so it should be pruned
|
||||
// rather than selected. (If the default weren't wired, the constraint would
|
||||
// pass through and select it.)
|
||||
const engine = createHlsAudioOnlyEngine();
|
||||
engine.state.presentation.set({
|
||||
id: 'pres-aac',
|
||||
url: 'https://example.com/master.m3u8',
|
||||
startTime: 0,
|
||||
selectionSets: [
|
||||
{
|
||||
id: 'a',
|
||||
type: 'audio',
|
||||
switchingSets: [
|
||||
{
|
||||
id: 'as',
|
||||
type: 'audio',
|
||||
tracks: [
|
||||
{
|
||||
type: 'audio',
|
||||
id: 'aud-aac',
|
||||
codecs: ['mp4a.40.2'],
|
||||
url: 'https://example.com/aud.m3u8',
|
||||
bandwidth: 128_000,
|
||||
mimeType: 'audio/aac',
|
||||
groupId: 'audio',
|
||||
name: 'Default',
|
||||
sampleRate: 48_000,
|
||||
channels: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as Presentation);
|
||||
await flush();
|
||||
|
||||
expect(engine.state.selectedAudioTrackId.get()).toBeUndefined();
|
||||
|
||||
engine.destroy();
|
||||
});
|
||||
|
||||
it('does not seed bandwidthState (no ABR behavior subscribed at init)', () => {
|
||||
const engine = createHlsAudioOnlyEngine();
|
||||
|
||||
@@ -101,7 +159,7 @@ http://example.com/audio-seg1.m4s
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unmocked URL: ${url}`));
|
||||
return unmockedFetchFallback(url);
|
||||
});
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
@@ -121,7 +179,9 @@ http://example.com/audio-seg1.m4s
|
||||
expect(state.selectedAudioTrackId).toBeDefined();
|
||||
expect(owners.audioBufferActor).toBeDefined();
|
||||
expect(owners.mediaSource).toBeDefined();
|
||||
expect(owners.mediaSource?.readyState).toBe('open');
|
||||
// readyState isn't asserted: with appendSegment mocked the stream completes
|
||||
// instantly, so the MediaSource doesn't durably sit in 'open' (a created buffer
|
||||
// actor implies addSourceBuffer ran, which requires an open MediaSource).
|
||||
},
|
||||
{ timeout: 2000 }
|
||||
);
|
||||
@@ -163,7 +223,7 @@ http://example.com/audio-seg1.m4s
|
||||
throw new Error('Audio-only variant fetched the video media playlist');
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unmocked URL: ${url}`));
|
||||
return unmockedFetchFallback(url);
|
||||
});
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
@@ -184,7 +244,9 @@ http://example.com/audio-seg1.m4s
|
||||
expect(state.selectedAudioTrackId).toBeDefined();
|
||||
expect(owners.audioBufferActor).toBeDefined();
|
||||
expect(owners.mediaSource).toBeDefined();
|
||||
expect((owners.mediaSource as MediaSource).readyState).toBe('open');
|
||||
// readyState isn't asserted: with appendSegment mocked the stream completes
|
||||
// instantly, so the MediaSource doesn't durably sit in 'open' (a created buffer
|
||||
// actor implies addSourceBuffer ran, which requires an open MediaSource).
|
||||
|
||||
// Video-side slots absent — no composed behavior in this variant
|
||||
// declares them. Behaviors that read these slots defensively
|
||||
@@ -236,7 +298,7 @@ http://example.com/audio-seg1.m4s
|
||||
throw new Error(`Audio-only variant fetched a non-audio playlist: ${url}`);
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unmocked URL: ${url}`));
|
||||
return unmockedFetchFallback(url);
|
||||
});
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
|
||||
@@ -8,15 +8,27 @@ vi.mock('../../../../media/dom/mse/append-segment', () => ({
|
||||
appendSegment: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Fallback for URLs a test's mock doesn't handle explicitly. Segment/init
|
||||
// requests resolve with an empty body — the appendSegment mock makes the bytes
|
||||
// inert — so the failover monitor isn't tripped by unmocked segment fetches (a
|
||||
// single failed fetch trips that CDN into cooldown, which empties the candidate
|
||||
// set). Genuinely unknown URLs still reject loudly.
|
||||
function unmockedFetchFallback(url: string): Promise<Response> {
|
||||
// Non-empty body: `fetchStream` throws "Response has no body" on a null body
|
||||
// (empty Uint8Array), which would itself trip the monitor.
|
||||
if (/\.(m4s|mp4|ts|aac)(\?|$)/.test(url)) return Promise.resolve(new Response(new Uint8Array([0])));
|
||||
return Promise.reject(new Error(`Unmocked URL: ${url}`));
|
||||
}
|
||||
|
||||
describe('createSimpleHlsEngine', () => {
|
||||
let originalFetch: typeof globalThis.fetch;
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
// Tests assert at actor-presence and state-shape level, not at "init
|
||||
// segment appended" level — so unmocked init/segment URLs in the manifests
|
||||
// are intentional. The fetch loop's reject path leaks a console.error in
|
||||
// each test; suppress only the expected patterns so genuine failures still
|
||||
// surface.
|
||||
// Tests assert at actor-presence and state-shape level, not at "init segment
|
||||
// appended" level. Audio/video segment fetches resolve via
|
||||
// `unmockedFetchFallback` (inert under the appendSegment mock); text-track
|
||||
// segment fetches still reject and leak a console.error. Suppress only the
|
||||
// expected patterns so genuine failures still surface.
|
||||
const expectedErrorPatterns = [
|
||||
/Unexpected error in segment loader.*Unmocked URL/s,
|
||||
/Failed to load text-track segment/,
|
||||
@@ -285,6 +297,85 @@ describe('createSimpleHlsEngine', () => {
|
||||
engine.destroy();
|
||||
});
|
||||
|
||||
it('codec-filters renditions via the injected canPlayTrack before selection', async () => {
|
||||
const flush = () => Promise.resolve().then(() => Promise.resolve());
|
||||
// Reject HEVC; accept everything else.
|
||||
const canPlayTrack = (track: { codecs?: string[] }) => !track.codecs?.some((c) => c.startsWith('hvc1'));
|
||||
const engine = createSimpleHlsEngine({ canPlayTrack });
|
||||
|
||||
const videoTrack = (id: string, codec: string): PartiallyResolvedVideoTrack => ({
|
||||
type: 'video',
|
||||
id,
|
||||
codecs: [codec],
|
||||
url: `https://example.com/${id}.m3u8`,
|
||||
bandwidth: 4_800_000,
|
||||
mimeType: 'video/mp4',
|
||||
});
|
||||
|
||||
engine.state.presentation.set({
|
||||
id: 'pres-codec',
|
||||
url: 'https://example.com/master.m3u8',
|
||||
startTime: 0,
|
||||
selectionSets: [
|
||||
{
|
||||
id: 'v',
|
||||
type: 'video',
|
||||
switchingSets: [
|
||||
{
|
||||
id: 'vs',
|
||||
type: 'video',
|
||||
tracks: [videoTrack('1080p-hevc', 'hvc1.1.6.L120.B0'), videoTrack('1080p-avc', 'avc1.640028')],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as Presentation);
|
||||
await flush();
|
||||
|
||||
// HEVC pruned upstream by the capability constraint; AVC selected.
|
||||
expect(engine.state.selectedVideoTrackId.get()).toBe('1080p-avc');
|
||||
|
||||
engine.destroy();
|
||||
});
|
||||
|
||||
it('makes no video pick when no rendition is decodable', async () => {
|
||||
const flush = () => Promise.resolve().then(() => Promise.resolve());
|
||||
const engine = createSimpleHlsEngine({ canPlayTrack: () => false });
|
||||
|
||||
engine.state.presentation.set({
|
||||
id: 'pres-unsupported',
|
||||
url: 'https://example.com/master.m3u8',
|
||||
startTime: 0,
|
||||
selectionSets: [
|
||||
{
|
||||
id: 'v',
|
||||
type: 'video',
|
||||
switchingSets: [
|
||||
{
|
||||
id: 'vs',
|
||||
type: 'video',
|
||||
tracks: [
|
||||
{
|
||||
type: 'video',
|
||||
id: '1080p-hevc',
|
||||
codecs: ['hvc1.1.6.L120.B0'],
|
||||
url: 'https://example.com/1080p-hevc.m3u8',
|
||||
bandwidth: 4_800_000,
|
||||
mimeType: 'video/mp4',
|
||||
} as PartiallyResolvedVideoTrack,
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as Presentation);
|
||||
await flush();
|
||||
|
||||
expect(engine.state.selectedVideoTrackId.get()).toBeUndefined();
|
||||
|
||||
engine.destroy();
|
||||
});
|
||||
|
||||
it('auto-fails-over when a CDN fetch fails (monitor trips, failedCdns set)', async () => {
|
||||
const engine = createSimpleHlsEngine({ failover: { cooldownMs: 60_000 } });
|
||||
|
||||
@@ -455,7 +546,7 @@ http://example.com/segment1.m4s
|
||||
}
|
||||
|
||||
// Fallback for unmocked URLs
|
||||
return Promise.reject(new Error(`Unmocked URL: ${url}`));
|
||||
return unmockedFetchFallback(url);
|
||||
});
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
@@ -523,7 +614,7 @@ http://example.com/audio-seg1.m4s
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unmocked URL: ${url}`));
|
||||
return unmockedFetchFallback(url);
|
||||
});
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
@@ -571,9 +662,11 @@ http://example.com/audio-seg1.m4s
|
||||
// 4. MediaElement should be set
|
||||
expect(owners.mediaElement).toBe(mediaElement);
|
||||
|
||||
// 5. MediaSource should be created and open
|
||||
// 5. MediaSource should be created
|
||||
expect(owners.mediaSource).toBeDefined();
|
||||
expect(owners.mediaSource?.readyState).toBe('open');
|
||||
// readyState isn't asserted: with appendSegment mocked the stream completes
|
||||
// instantly, so the MediaSource doesn't durably sit in 'open' (a created buffer
|
||||
// actor implies addSourceBuffer ran, which requires an open MediaSource).
|
||||
|
||||
// 6. Video buffer cluster should be created (actor presence implies
|
||||
// `addSourceBuffer` ran; SourceBuffer itself is private to
|
||||
@@ -661,7 +754,7 @@ http://example.com/audio-b-seg1.m4s
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unmocked URL: ${url}`));
|
||||
return unmockedFetchFallback(url);
|
||||
});
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
@@ -682,7 +775,9 @@ http://example.com/audio-b-seg1.m4s
|
||||
expect(state.presentation?.id).toBeDefined();
|
||||
expect(state.selectedVideoTrackId).toBeDefined();
|
||||
expect(state.selectedAudioTrackId).toBeDefined();
|
||||
expect(owners.mediaSource?.readyState).toBe('open');
|
||||
// readyState isn't asserted: with appendSegment mocked the stream completes
|
||||
// instantly, so the MediaSource doesn't durably sit in 'open' (a created buffer
|
||||
// actor implies addSourceBuffer ran, which requires an open MediaSource).
|
||||
expect(owners.videoBufferActor).toBeDefined();
|
||||
expect(owners.audioBufferActor).toBeDefined();
|
||||
},
|
||||
@@ -715,7 +810,9 @@ http://example.com/audio-b-seg1.m4s
|
||||
expect(state.selectedAudioTrackId).toBeDefined();
|
||||
|
||||
// Fresh MediaSource + buffer actors (different instances from A)
|
||||
expect(owners.mediaSource?.readyState).toBe('open');
|
||||
// readyState isn't asserted: with appendSegment mocked the stream completes
|
||||
// instantly, so the MediaSource doesn't durably sit in 'open' (a created buffer
|
||||
// actor implies addSourceBuffer ran, which requires an open MediaSource).
|
||||
expect(owners.mediaSource).not.toBe(sourceAMediaSource);
|
||||
expect(owners.videoBufferActor).not.toBe(sourceAVideoBufferActor);
|
||||
expect(owners.audioBufferActor).not.toBe(sourceAAudioBufferActor);
|
||||
@@ -752,7 +849,7 @@ http://example.com/video-seg1.m4s
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unmocked URL: ${url}`));
|
||||
return unmockedFetchFallback(url);
|
||||
});
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
@@ -779,7 +876,9 @@ http://example.com/video-seg1.m4s
|
||||
|
||||
// MediaSource should still be created
|
||||
expect(owners.mediaSource).toBeDefined();
|
||||
expect(owners.mediaSource?.readyState).toBe('open');
|
||||
// readyState isn't asserted: with appendSegment mocked the stream completes
|
||||
// instantly, so the MediaSource doesn't durably sit in 'open' (a created buffer
|
||||
// actor implies addSourceBuffer ran, which requires an open MediaSource).
|
||||
},
|
||||
{ timeout: 2000 }
|
||||
);
|
||||
@@ -814,7 +913,7 @@ http://example.com/audio-seg1.m4s
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unmocked URL: ${url}`));
|
||||
return unmockedFetchFallback(url);
|
||||
});
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
@@ -841,7 +940,9 @@ http://example.com/audio-seg1.m4s
|
||||
|
||||
// MediaSource should still be created
|
||||
expect(owners.mediaSource).toBeDefined();
|
||||
expect(owners.mediaSource?.readyState).toBe('open');
|
||||
// readyState isn't asserted: with appendSegment mocked the stream completes
|
||||
// instantly, so the MediaSource doesn't durably sit in 'open' (a created buffer
|
||||
// actor implies addSourceBuffer ran, which requires an open MediaSource).
|
||||
},
|
||||
{ timeout: 2000 }
|
||||
);
|
||||
@@ -876,7 +977,7 @@ http://example.com/video-seg1.m4s
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unmocked URL: ${url}`));
|
||||
return unmockedFetchFallback(url);
|
||||
});
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
@@ -931,7 +1032,7 @@ http://example.com/video-seg1.m4s
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unmocked URL: ${url}`));
|
||||
return unmockedFetchFallback(url);
|
||||
});
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
@@ -1008,7 +1109,7 @@ http://example.com/audio-seg1.m4s
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unmocked URL: ${url}`));
|
||||
return unmockedFetchFallback(url);
|
||||
});
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
@@ -1044,7 +1145,9 @@ http://example.com/audio-seg1.m4s
|
||||
expect(state.selectedAudioTrackId).toBeDefined();
|
||||
|
||||
expect(owners.mediaSource).toBeDefined();
|
||||
expect(owners.mediaSource?.readyState).toBe('open');
|
||||
// readyState isn't asserted: with appendSegment mocked the stream completes
|
||||
// instantly, so the MediaSource doesn't durably sit in 'open' (a created buffer
|
||||
// actor implies addSourceBuffer ran, which requires an open MediaSource).
|
||||
expect(owners.videoBufferActor).toBeDefined();
|
||||
expect(owners.audioBufferActor).toBeDefined();
|
||||
},
|
||||
@@ -1086,7 +1189,7 @@ http://example.com/seg1.m4s
|
||||
return Promise.resolve(new Response(new ArrayBuffer(100)));
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unmocked URL: ${url}`));
|
||||
return unmockedFetchFallback(url);
|
||||
});
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
@@ -1159,7 +1262,7 @@ http://example.com/seg1.m4s
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unmocked URL: ${url}`));
|
||||
return unmockedFetchFallback(url);
|
||||
});
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
@@ -1201,10 +1304,15 @@ http://example.com/seg1.m4s
|
||||
// The resolved track should be the selected one
|
||||
expect(resolvedTracks?.[0]?.id).toBe(state.selectedVideoTrackId);
|
||||
|
||||
// Should fetch: 1 multivariant + 1 media playlist + init attempt
|
||||
// (Only selected quality, not all 3 qualities; init.mp4 is attempted but
|
||||
// rejected by the mock — segment is not attempted since init fails first)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3);
|
||||
// The non-selected qualities are never resolved — only the selected track's
|
||||
// media playlist is fetched. Asserts the intent directly rather than a brittle
|
||||
// total fetch count (which shifts with init/segment loading of the selected track).
|
||||
const fetchedUrls = mockFetch.mock.calls.map((call: unknown[]) => {
|
||||
const input = call[0] as RequestInfo | URL;
|
||||
return typeof input === 'string' ? input : input instanceof URL ? input.href : (input as Request).url;
|
||||
});
|
||||
expect(fetchedUrls.some((u: string) => u.includes('video-720p.m3u8'))).toBe(false);
|
||||
expect(fetchedUrls.some((u: string) => u.includes('video-1080p.m3u8'))).toBe(false);
|
||||
|
||||
engine.destroy();
|
||||
});
|
||||
@@ -1259,7 +1367,7 @@ http://example.com/text-es-seg1.vtt
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unmocked URL: ${url}`));
|
||||
return unmockedFetchFallback(url);
|
||||
});
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
@@ -1351,7 +1459,7 @@ http://example.com/text-es-seg1.vtt
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unmocked URL: ${url}`));
|
||||
return unmockedFetchFallback(url);
|
||||
});
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
@@ -1427,7 +1535,7 @@ http://example.com/text-fr-seg1.vtt
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unmocked URL: ${url}`));
|
||||
return unmockedFetchFallback(url);
|
||||
});
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
@@ -1512,7 +1620,7 @@ http://example.com/text-es-seg1.vtt
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unmocked URL: ${url}`));
|
||||
return unmockedFetchFallback(url);
|
||||
});
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
@@ -1604,7 +1712,7 @@ http://example.com/video-seg1.m4s
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unmocked URL: ${url}`));
|
||||
return unmockedFetchFallback(url);
|
||||
});
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
@@ -1698,7 +1806,7 @@ http://example.com/text-es-seg1.vtt
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unmocked URL: ${url}`));
|
||||
return unmockedFetchFallback(url);
|
||||
});
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
@@ -1797,7 +1905,7 @@ http://example.com/seg2.m4s
|
||||
return Promise.resolve(new Response(new ArrayBuffer(1000)));
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unmocked URL: ${url}`));
|
||||
return unmockedFetchFallback(url);
|
||||
});
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
@@ -1872,7 +1980,7 @@ http://example.com/audio-seg1.m4s
|
||||
return Promise.resolve(new Response(new ArrayBuffer(1000)));
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unmocked URL: ${url}`));
|
||||
return unmockedFetchFallback(url);
|
||||
});
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user