mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(spf): HLS engine composition walkthrough + doc-driven cleanups (#1512)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
17d44a5d32
commit
0cfd3bb395
@@ -0,0 +1,269 @@
|
||||
import { DEFAULT_QUALITY_CONFIG, selectQuality } from '../abr/quality-selection';
|
||||
import type {
|
||||
AudioSelectionSet,
|
||||
MaybeResolvedPresentation,
|
||||
Presentation,
|
||||
TrackType,
|
||||
VideoSelectionSet,
|
||||
} from '../types';
|
||||
import { SelectedTrackIdKeyByType } from '../utils/track-selection';
|
||||
|
||||
/**
|
||||
* Default initial bandwidth estimate for cold start (bits per second).
|
||||
* Conservative 1 Mbps to avoid over-selecting on slow connections.
|
||||
*/
|
||||
export const DEFAULT_INITIAL_BANDWIDTH = 1_000_000;
|
||||
|
||||
/**
|
||||
* State shape for track selection.
|
||||
*/
|
||||
export interface TrackSelectionState {
|
||||
presentation?: MaybeResolvedPresentation;
|
||||
selectedVideoTrackId?: string;
|
||||
selectedAudioTrackId?: string;
|
||||
selectedTextTrackId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owners shape for track selection.
|
||||
* Currently empty - reserved for future use (e.g., bandwidth estimator).
|
||||
*/
|
||||
export type TrackSelectionOwners = Record<string, never>;
|
||||
|
||||
/**
|
||||
* Action types for track selection.
|
||||
* Reserved for future event-driven selection triggers.
|
||||
*/
|
||||
export type TrackSelectionAction = { type: 'presentation-loaded' };
|
||||
|
||||
/**
|
||||
* Base configuration for track selection.
|
||||
* Generic over track type with discriminant `type` field.
|
||||
*/
|
||||
export interface TrackSelectionConfig<T extends TrackType = TrackType> {
|
||||
type: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for video track selection.
|
||||
* Generic with default to 'video' for convenience.
|
||||
*/
|
||||
export interface VideoSelectionConfig<T extends TrackType = 'video'> extends TrackSelectionConfig<T> {
|
||||
/**
|
||||
* Initial bandwidth estimate for cold start (bits per second).
|
||||
* Used to select video quality before we have real measurements.
|
||||
* Default: 1 Mbps (conservative).
|
||||
*/
|
||||
initialBandwidth?: number;
|
||||
|
||||
/**
|
||||
* Safety margin for quality selection (0-1).
|
||||
* Default: 0.85 (15% headroom).
|
||||
*/
|
||||
safetyMargin?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for audio track selection.
|
||||
* Generic with default to 'audio' for convenience.
|
||||
*/
|
||||
export interface AudioSelectionConfig<T extends TrackType = 'audio'> extends TrackSelectionConfig<T> {
|
||||
/**
|
||||
* Preferred audio language (ISO 639 code, e.g., "en", "es").
|
||||
* If not specified, selects first audio track.
|
||||
*/
|
||||
preferredAudioLanguage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for text track selection.
|
||||
* Generic with default to 'text' for convenience.
|
||||
*/
|
||||
export interface TextSelectionConfig<T extends TrackType = 'text'> extends TrackSelectionConfig<T> {
|
||||
/**
|
||||
* Preferred subtitle language (ISO 639 code, e.g., "en", "es").
|
||||
* If specified, selects matching track if available.
|
||||
*/
|
||||
preferredSubtitleLanguage?: string;
|
||||
|
||||
/**
|
||||
* Include FORCED subtitle tracks in selection.
|
||||
* Default: false (follows hls.js/http-streaming pattern)
|
||||
*
|
||||
* Note: Per Apple's HLS spec, if content has forced and regular subtitles
|
||||
* in the same language, the regular track MUST contain both forced and
|
||||
* regular content. Therefore, forced-only tracks are redundant and excluded
|
||||
* by default.
|
||||
*/
|
||||
includeForcedTracks?: boolean;
|
||||
|
||||
/**
|
||||
* Auto-select DEFAULT track (requires DEFAULT=YES + AUTOSELECT=YES in HLS).
|
||||
* Default: false (user opt-in, matches hls.js/http-streaming)
|
||||
*
|
||||
* When enabled, tracks marked with both DEFAULT=YES and AUTOSELECT=YES
|
||||
* will be automatically selected if no user preference matches.
|
||||
*/
|
||||
enableDefaultTrack?: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Helper Functions (Pure Selection Logic)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Pick video track using quality selection algorithm.
|
||||
*
|
||||
* Uses bandwidth-based selection with safety margin to pick
|
||||
* the highest quality track that fits available bandwidth.
|
||||
*
|
||||
* @param presentation - Presentation with video tracks
|
||||
* @param config - Selection configuration (bandwidth, safety margin)
|
||||
* @returns Selected video track ID, or undefined if no video tracks
|
||||
*/
|
||||
export function pickVideoTrack(presentation: Presentation, config: VideoSelectionConfig): string | undefined {
|
||||
const videoSet = presentation.selectionSets.find((set) => set.type === 'video') as VideoSelectionSet | undefined;
|
||||
|
||||
if (!videoSet || videoSet.switchingSets.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Get first switching set's tracks (HLS typically has one switching set per type)
|
||||
const switchingSet = videoSet.switchingSets[0];
|
||||
if (!switchingSet || switchingSet.tracks.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const initialBandwidth = config.initialBandwidth ?? DEFAULT_INITIAL_BANDWIDTH;
|
||||
const safetyMargin = config.safetyMargin ?? DEFAULT_QUALITY_CONFIG.safetyMargin;
|
||||
|
||||
// selectQuality works with both partially resolved and resolved tracks
|
||||
const selected = selectQuality(switchingSet.tracks as any, initialBandwidth, { safetyMargin });
|
||||
|
||||
return selected?.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick audio track.
|
||||
*
|
||||
* Selection priority:
|
||||
* 1. First track matching preferred language (if specified)
|
||||
* 2. First default track
|
||||
* 3. First audio track
|
||||
*
|
||||
* @param presentation - Presentation with audio tracks
|
||||
* @param config - Selection configuration (preferred language)
|
||||
* @returns Selected audio track ID, or undefined if no audio tracks
|
||||
*/
|
||||
export function pickAudioTrack(presentation: Presentation, config: AudioSelectionConfig): string | undefined {
|
||||
const audioSet = presentation.selectionSets.find((set) => set.type === 'audio') as AudioSelectionSet | undefined;
|
||||
|
||||
if (!audioSet || audioSet.switchingSets.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Get first switching set's tracks
|
||||
const switchingSet = audioSet.switchingSets[0];
|
||||
if (!switchingSet || switchingSet.tracks.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const tracks = switchingSet.tracks;
|
||||
|
||||
// Try preferred language first
|
||||
if (config.preferredAudioLanguage) {
|
||||
const languageMatch = tracks.find((track) => track.language === config.preferredAudioLanguage);
|
||||
if (languageMatch) {
|
||||
return languageMatch.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Try default track
|
||||
const defaultTrack = tracks.find((track) => track.default === true);
|
||||
if (defaultTrack) {
|
||||
return defaultTrack.id;
|
||||
}
|
||||
|
||||
// Fall back to first track
|
||||
return tracks[0]?.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick text track to activate.
|
||||
*
|
||||
* Selection priority (if enabled):
|
||||
* 1. User preference (preferredSubtitleLanguage)
|
||||
* 2. DEFAULT track (if enableDefaultTrack is true and track has DEFAULT=YES + AUTOSELECT=YES)
|
||||
* 3. No auto-selection (user opt-in)
|
||||
*
|
||||
* By default, FORCED tracks are excluded per Apple's HLS spec.
|
||||
*
|
||||
* @param presentation - Presentation with text tracks
|
||||
* @param config - Selection configuration
|
||||
* @returns Track ID or undefined (no auto-selection)
|
||||
*/
|
||||
export function pickTextTrack(presentation: Presentation, config: TextSelectionConfig): string | undefined {
|
||||
const textSet = presentation.selectionSets.find((set) => set.type === 'text');
|
||||
if (!textSet?.switchingSets?.[0]?.tracks.length) return undefined;
|
||||
|
||||
const tracks = textSet.switchingSets[0].tracks;
|
||||
|
||||
// Filter out FORCED tracks by default (following hls.js/http-streaming pattern)
|
||||
// Per Apple spec: regular tracks MUST contain forced content when both exist
|
||||
const availableTracks = config.includeForcedTracks ? tracks : tracks.filter((track) => !track.forced);
|
||||
|
||||
if (availableTracks.length === 0) return undefined;
|
||||
|
||||
const { preferredSubtitleLanguage, enableDefaultTrack = false } = config;
|
||||
|
||||
// 1. Preferred language match (if specified)
|
||||
if (preferredSubtitleLanguage) {
|
||||
const languageMatch = availableTracks.find((track) => track.language === preferredSubtitleLanguage);
|
||||
if (languageMatch) return languageMatch.id;
|
||||
}
|
||||
|
||||
// 2. DEFAULT track (if enabled AND track has both DEFAULT=YES + AUTOSELECT=YES)
|
||||
// Note: Parser only sets default=true when BOTH attributes present
|
||||
if (enableDefaultTrack) {
|
||||
const defaultTrack = availableTracks.find((track) => track.default === true);
|
||||
if (defaultTrack) return defaultTrack.id;
|
||||
}
|
||||
|
||||
// 3. User opt-in (no auto-selection)
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we can select a track of the given type.
|
||||
*
|
||||
* Returns true when:
|
||||
* - Presentation exists
|
||||
* - Has tracks of the specified type
|
||||
*
|
||||
* Generic over track type - works for video, audio, or text.
|
||||
*/
|
||||
export function canSelectTrack<T extends TrackType>(
|
||||
state: TrackSelectionState,
|
||||
config: TrackSelectionConfig<T>
|
||||
): boolean {
|
||||
return !!state?.presentation?.selectionSets?.find(({ type }) => type === config.type)?.switchingSets?.[0]?.tracks
|
||||
.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we should select a track of the given type.
|
||||
*
|
||||
* Returns true when:
|
||||
* - Track of this type is not already selected
|
||||
*
|
||||
* Generic over track type - works for video, audio, or text.
|
||||
*
|
||||
* @TODO figure out reactive model for ABR cases - right now we're only selecting
|
||||
* if we have nothing selected (CJP)
|
||||
*/
|
||||
export function shouldSelectTrack<T extends TrackType>(
|
||||
state: TrackSelectionState,
|
||||
config: TrackSelectionConfig<T>
|
||||
): boolean {
|
||||
return !state[SelectedTrackIdKeyByType[config.type]];
|
||||
}
|
||||
@@ -0,0 +1,608 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type {
|
||||
AudioSelectionSet,
|
||||
PartiallyResolvedAudioTrack,
|
||||
PartiallyResolvedVideoTrack,
|
||||
Presentation,
|
||||
TextSelectionSet,
|
||||
VideoSelectionSet,
|
||||
} from '../../types';
|
||||
import { pickAudioTrack, pickTextTrack, pickVideoTrack } from '../select-tracks';
|
||||
|
||||
// Helper to create a minimal presentation
|
||||
function createPresentation(config: {
|
||||
video?: PartiallyResolvedVideoTrack[];
|
||||
audio?: PartiallyResolvedAudioTrack[];
|
||||
text?: any[];
|
||||
}): Presentation {
|
||||
const selectionSets = [];
|
||||
|
||||
if (config.video && config.video.length > 0) {
|
||||
selectionSets.push({
|
||||
id: 'video-set',
|
||||
type: 'video' as const,
|
||||
switchingSets: [
|
||||
{
|
||||
id: 'video-switching',
|
||||
type: 'video' as const,
|
||||
tracks: config.video,
|
||||
},
|
||||
],
|
||||
} as VideoSelectionSet);
|
||||
}
|
||||
|
||||
if (config.audio && config.audio.length > 0) {
|
||||
selectionSets.push({
|
||||
id: 'audio-set',
|
||||
type: 'audio' as const,
|
||||
switchingSets: [
|
||||
{
|
||||
id: 'audio-switching',
|
||||
type: 'audio' as const,
|
||||
tracks: config.audio,
|
||||
},
|
||||
],
|
||||
} as AudioSelectionSet);
|
||||
}
|
||||
|
||||
if (config.text && config.text.length > 0) {
|
||||
selectionSets.push({
|
||||
id: 'text-set',
|
||||
type: 'text' as const,
|
||||
switchingSets: [
|
||||
{
|
||||
id: 'text-switching',
|
||||
type: 'text' as const,
|
||||
tracks: config.text,
|
||||
},
|
||||
],
|
||||
} as TextSelectionSet);
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'pres-1',
|
||||
url: 'http://example.com/playlist.m3u8',
|
||||
selectionSets,
|
||||
startTime: 0,
|
||||
};
|
||||
}
|
||||
|
||||
describe('pickVideoTrack', () => {
|
||||
it('selects appropriate quality based on initial bandwidth', () => {
|
||||
const tracks: PartiallyResolvedVideoTrack[] = [
|
||||
{
|
||||
type: 'video',
|
||||
id: '360p',
|
||||
url: 'http://example.com/360p.m3u8',
|
||||
bandwidth: 500_000,
|
||||
mimeType: 'video/mp4',
|
||||
codecs: ['avc1.42E01E'],
|
||||
width: 640,
|
||||
height: 360,
|
||||
},
|
||||
{
|
||||
type: 'video',
|
||||
id: '720p',
|
||||
url: 'http://example.com/720p.m3u8',
|
||||
bandwidth: 2_000_000,
|
||||
mimeType: 'video/mp4',
|
||||
codecs: ['avc1.42E01E'],
|
||||
width: 1280,
|
||||
height: 720,
|
||||
},
|
||||
{
|
||||
type: 'video',
|
||||
id: '1080p',
|
||||
url: 'http://example.com/1080p.m3u8',
|
||||
bandwidth: 4_000_000,
|
||||
mimeType: 'video/mp4',
|
||||
codecs: ['avc1.42E01E'],
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
},
|
||||
];
|
||||
|
||||
const presentation = createPresentation({ video: tracks });
|
||||
|
||||
// With default 1 Mbps, should select 360p (500k fits with margin)
|
||||
const selected = pickVideoTrack(presentation, { type: 'video' });
|
||||
expect(selected).toBe('360p');
|
||||
|
||||
// With 3 Mbps, should select 720p (2M fits, 4M doesn't with 0.85 margin)
|
||||
const selected2 = pickVideoTrack(presentation, { initialBandwidth: 3_000_000, type: 'video' });
|
||||
expect(selected2).toBe('720p');
|
||||
|
||||
// With 5 Mbps, should select 1080p (4M fits with margin)
|
||||
const selected3 = pickVideoTrack(presentation, { initialBandwidth: 5_000_000, type: 'video' });
|
||||
expect(selected3).toBe('1080p');
|
||||
});
|
||||
|
||||
it('returns undefined when no video tracks', () => {
|
||||
const presentation = createPresentation({ audio: [] });
|
||||
|
||||
const selected = pickVideoTrack(presentation, { type: 'video' });
|
||||
expect(selected).toBeUndefined();
|
||||
});
|
||||
|
||||
it('falls back to lowest quality when bandwidth is very low', () => {
|
||||
const tracks: PartiallyResolvedVideoTrack[] = [
|
||||
{
|
||||
type: 'video',
|
||||
id: '720p',
|
||||
url: 'http://example.com/720p.m3u8',
|
||||
bandwidth: 2_000_000,
|
||||
mimeType: 'video/mp4',
|
||||
codecs: ['avc1.42E01E'],
|
||||
},
|
||||
{
|
||||
type: 'video',
|
||||
id: '1080p',
|
||||
url: 'http://example.com/1080p.m3u8',
|
||||
bandwidth: 4_000_000,
|
||||
mimeType: 'video/mp4',
|
||||
codecs: ['avc1.42E01E'],
|
||||
},
|
||||
];
|
||||
|
||||
const presentation = createPresentation({ video: tracks });
|
||||
|
||||
// With 100 kbps (very low), should fall back to lowest (720p)
|
||||
const selected = pickVideoTrack(presentation, { initialBandwidth: 100_000, type: 'video' });
|
||||
expect(selected).toBe('720p');
|
||||
});
|
||||
|
||||
it('uses custom safety margin when provided', () => {
|
||||
const tracks: PartiallyResolvedVideoTrack[] = [
|
||||
{
|
||||
type: 'video',
|
||||
id: '720p',
|
||||
url: 'http://example.com/720p.m3u8',
|
||||
bandwidth: 2_000_000,
|
||||
mimeType: 'video/mp4',
|
||||
codecs: ['avc1.42E01E'],
|
||||
},
|
||||
];
|
||||
|
||||
const presentation = createPresentation({ video: tracks });
|
||||
|
||||
// With 2.1 Mbps and 0.95 margin, should NOT select 720p (needs 2.1M)
|
||||
// Falls back to lowest
|
||||
const selected = pickVideoTrack(presentation, {
|
||||
initialBandwidth: 2_050_000,
|
||||
safetyMargin: 0.95,
|
||||
type: 'video',
|
||||
});
|
||||
expect(selected).toBe('720p'); // Falls back since it's the only/lowest option
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickAudioTrack', () => {
|
||||
it('selects first track when no preferences', () => {
|
||||
const tracks: PartiallyResolvedAudioTrack[] = [
|
||||
{
|
||||
type: 'audio',
|
||||
id: 'audio-en',
|
||||
url: 'http://example.com/audio-en.m3u8',
|
||||
bandwidth: 128_000,
|
||||
mimeType: 'audio/mp4',
|
||||
codecs: ['mp4a.40.2'],
|
||||
groupId: 'audio',
|
||||
name: 'English',
|
||||
language: 'en',
|
||||
sampleRate: 48000,
|
||||
channels: 2,
|
||||
},
|
||||
{
|
||||
type: 'audio',
|
||||
id: 'audio-es',
|
||||
url: 'http://example.com/audio-es.m3u8',
|
||||
bandwidth: 128_000,
|
||||
mimeType: 'audio/mp4',
|
||||
codecs: ['mp4a.40.2'],
|
||||
groupId: 'audio',
|
||||
name: 'Spanish',
|
||||
language: 'es',
|
||||
sampleRate: 48000,
|
||||
channels: 2,
|
||||
},
|
||||
];
|
||||
|
||||
const presentation = createPresentation({ audio: tracks });
|
||||
|
||||
const selected = pickAudioTrack(presentation, { type: 'audio' });
|
||||
expect(selected).toBe('audio-en');
|
||||
});
|
||||
|
||||
it('selects preferred language when specified', () => {
|
||||
const tracks: PartiallyResolvedAudioTrack[] = [
|
||||
{
|
||||
type: 'audio',
|
||||
id: 'audio-en',
|
||||
url: 'http://example.com/audio-en.m3u8',
|
||||
bandwidth: 128_000,
|
||||
mimeType: 'audio/mp4',
|
||||
codecs: ['mp4a.40.2'],
|
||||
groupId: 'audio',
|
||||
name: 'English',
|
||||
language: 'en',
|
||||
sampleRate: 48000,
|
||||
channels: 2,
|
||||
},
|
||||
{
|
||||
type: 'audio',
|
||||
id: 'audio-es',
|
||||
url: 'http://example.com/audio-es.m3u8',
|
||||
bandwidth: 128_000,
|
||||
mimeType: 'audio/mp4',
|
||||
codecs: ['mp4a.40.2'],
|
||||
groupId: 'audio',
|
||||
name: 'Spanish',
|
||||
language: 'es',
|
||||
sampleRate: 48000,
|
||||
channels: 2,
|
||||
},
|
||||
];
|
||||
|
||||
const presentation = createPresentation({ audio: tracks });
|
||||
|
||||
const selected = pickAudioTrack(presentation, { type: 'audio', preferredAudioLanguage: 'es' });
|
||||
expect(selected).toBe('audio-es');
|
||||
});
|
||||
|
||||
it('selects default track when available', () => {
|
||||
const tracks: PartiallyResolvedAudioTrack[] = [
|
||||
{
|
||||
type: 'audio',
|
||||
id: 'audio-en',
|
||||
url: 'http://example.com/audio-en.m3u8',
|
||||
bandwidth: 128_000,
|
||||
mimeType: 'audio/mp4',
|
||||
codecs: ['mp4a.40.2'],
|
||||
groupId: 'audio',
|
||||
name: 'English',
|
||||
sampleRate: 48000,
|
||||
channels: 2,
|
||||
},
|
||||
{
|
||||
type: 'audio',
|
||||
id: 'audio-es',
|
||||
url: 'http://example.com/audio-es.m3u8',
|
||||
bandwidth: 128_000,
|
||||
mimeType: 'audio/mp4',
|
||||
codecs: ['mp4a.40.2'],
|
||||
groupId: 'audio',
|
||||
name: 'Spanish',
|
||||
default: true,
|
||||
sampleRate: 48000,
|
||||
channels: 2,
|
||||
},
|
||||
];
|
||||
|
||||
const presentation = createPresentation({ audio: tracks });
|
||||
|
||||
const selected = pickAudioTrack(presentation, { type: 'audio' });
|
||||
expect(selected).toBe('audio-es');
|
||||
});
|
||||
|
||||
it('prefers language match over default flag', () => {
|
||||
const tracks: PartiallyResolvedAudioTrack[] = [
|
||||
{
|
||||
type: 'audio',
|
||||
id: 'audio-en',
|
||||
url: 'http://example.com/audio-en.m3u8',
|
||||
bandwidth: 128_000,
|
||||
mimeType: 'audio/mp4',
|
||||
codecs: ['mp4a.40.2'],
|
||||
groupId: 'audio',
|
||||
name: 'English',
|
||||
language: 'en',
|
||||
sampleRate: 48000,
|
||||
channels: 2,
|
||||
},
|
||||
{
|
||||
type: 'audio',
|
||||
id: 'audio-es',
|
||||
url: 'http://example.com/audio-es.m3u8',
|
||||
bandwidth: 128_000,
|
||||
mimeType: 'audio/mp4',
|
||||
codecs: ['mp4a.40.2'],
|
||||
groupId: 'audio',
|
||||
name: 'Spanish',
|
||||
language: 'es',
|
||||
default: true,
|
||||
sampleRate: 48000,
|
||||
channels: 2,
|
||||
},
|
||||
];
|
||||
|
||||
const presentation = createPresentation({ audio: tracks });
|
||||
|
||||
const selected = pickAudioTrack(presentation, { type: 'audio', preferredAudioLanguage: 'en' });
|
||||
expect(selected).toBe('audio-en');
|
||||
});
|
||||
|
||||
it('returns undefined when no audio tracks', () => {
|
||||
const presentation = createPresentation({ video: [] });
|
||||
|
||||
const selected = pickAudioTrack(presentation, { type: 'audio' });
|
||||
expect(selected).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickTextTrack', () => {
|
||||
it('returns undefined by default (user opt-in)', () => {
|
||||
const tracks = [
|
||||
{
|
||||
type: 'text' as const,
|
||||
id: 'text-en',
|
||||
url: 'http://example.com/text-en.m3u8',
|
||||
bandwidth: 256,
|
||||
mimeType: 'application/mp4',
|
||||
codecs: [],
|
||||
groupId: 'text',
|
||||
label: 'English',
|
||||
kind: 'subtitles' as const,
|
||||
language: 'en',
|
||||
},
|
||||
];
|
||||
|
||||
const presentation = createPresentation({ text: tracks });
|
||||
|
||||
const selected = pickTextTrack(presentation, { type: 'text' });
|
||||
expect(selected).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when no text tracks', () => {
|
||||
const presentation = createPresentation({ video: [] });
|
||||
|
||||
const selected = pickTextTrack(presentation, { type: 'text' });
|
||||
expect(selected).toBeUndefined();
|
||||
});
|
||||
|
||||
it('excludes FORCED tracks by default', () => {
|
||||
const tracks = [
|
||||
{
|
||||
type: 'text' as const,
|
||||
id: 'text-en',
|
||||
url: 'http://example.com/text-en.m3u8',
|
||||
bandwidth: 256,
|
||||
mimeType: 'application/mp4',
|
||||
codecs: [],
|
||||
groupId: 'text',
|
||||
label: 'English',
|
||||
kind: 'subtitles' as const,
|
||||
language: 'en',
|
||||
},
|
||||
{
|
||||
type: 'text' as const,
|
||||
id: 'text-en-forced',
|
||||
url: 'http://example.com/text-en-forced.m3u8',
|
||||
bandwidth: 256,
|
||||
mimeType: 'application/mp4',
|
||||
codecs: [],
|
||||
groupId: 'text',
|
||||
label: 'English (Forced)',
|
||||
kind: 'subtitles' as const,
|
||||
language: 'en',
|
||||
forced: true,
|
||||
default: true,
|
||||
},
|
||||
];
|
||||
|
||||
const presentation = createPresentation({ text: tracks });
|
||||
|
||||
// FORCED track excluded by default, even with DEFAULT=YES
|
||||
const selected = pickTextTrack(presentation, { type: 'text', enableDefaultTrack: true });
|
||||
expect(selected).toBeUndefined();
|
||||
});
|
||||
|
||||
it('includes FORCED tracks when includeForcedTracks enabled', () => {
|
||||
const tracks = [
|
||||
{
|
||||
type: 'text' as const,
|
||||
id: 'text-en',
|
||||
url: 'http://example.com/text-en.m3u8',
|
||||
bandwidth: 256,
|
||||
mimeType: 'application/mp4',
|
||||
codecs: [],
|
||||
groupId: 'text',
|
||||
label: 'English',
|
||||
kind: 'subtitles' as const,
|
||||
language: 'en',
|
||||
},
|
||||
{
|
||||
type: 'text' as const,
|
||||
id: 'text-en-forced',
|
||||
url: 'http://example.com/text-en-forced.m3u8',
|
||||
bandwidth: 256,
|
||||
mimeType: 'application/mp4',
|
||||
codecs: [],
|
||||
groupId: 'text',
|
||||
label: 'English (Forced)',
|
||||
kind: 'subtitles' as const,
|
||||
language: 'en',
|
||||
forced: true,
|
||||
default: true,
|
||||
},
|
||||
];
|
||||
|
||||
const presentation = createPresentation({ text: tracks });
|
||||
|
||||
// FORCED track included and selected when enabled
|
||||
const selected = pickTextTrack(presentation, {
|
||||
type: 'text',
|
||||
includeForcedTracks: true,
|
||||
enableDefaultTrack: true,
|
||||
});
|
||||
expect(selected).toBe('text-en-forced');
|
||||
});
|
||||
|
||||
it('selects preferred language when specified', () => {
|
||||
const tracks = [
|
||||
{
|
||||
type: 'text' as const,
|
||||
id: 'text-en',
|
||||
url: 'http://example.com/text-en.m3u8',
|
||||
bandwidth: 256,
|
||||
mimeType: 'application/mp4',
|
||||
codecs: [],
|
||||
groupId: 'text',
|
||||
label: 'English',
|
||||
kind: 'subtitles' as const,
|
||||
language: 'en',
|
||||
},
|
||||
{
|
||||
type: 'text' as const,
|
||||
id: 'text-es',
|
||||
url: 'http://example.com/text-es.m3u8',
|
||||
bandwidth: 256,
|
||||
mimeType: 'application/mp4',
|
||||
codecs: [],
|
||||
groupId: 'text',
|
||||
label: 'Spanish',
|
||||
kind: 'subtitles' as const,
|
||||
language: 'es',
|
||||
},
|
||||
];
|
||||
|
||||
const presentation = createPresentation({ text: tracks });
|
||||
|
||||
const selected = pickTextTrack(presentation, { type: 'text', preferredSubtitleLanguage: 'es' });
|
||||
expect(selected).toBe('text-es');
|
||||
});
|
||||
|
||||
it('selects DEFAULT track when enableDefaultTrack is true', () => {
|
||||
const tracks = [
|
||||
{
|
||||
type: 'text' as const,
|
||||
id: 'text-en',
|
||||
url: 'http://example.com/text-en.m3u8',
|
||||
bandwidth: 256,
|
||||
mimeType: 'application/mp4',
|
||||
codecs: [],
|
||||
groupId: 'text',
|
||||
label: 'English',
|
||||
kind: 'subtitles' as const,
|
||||
language: 'en',
|
||||
},
|
||||
{
|
||||
type: 'text' as const,
|
||||
id: 'text-es',
|
||||
url: 'http://example.com/text-es.m3u8',
|
||||
bandwidth: 256,
|
||||
mimeType: 'application/mp4',
|
||||
codecs: [],
|
||||
groupId: 'text',
|
||||
label: 'Spanish',
|
||||
kind: 'subtitles' as const,
|
||||
language: 'es',
|
||||
default: true,
|
||||
},
|
||||
];
|
||||
|
||||
const presentation = createPresentation({ text: tracks });
|
||||
|
||||
const selected = pickTextTrack(presentation, { type: 'text', enableDefaultTrack: true });
|
||||
expect(selected).toBe('text-es');
|
||||
});
|
||||
|
||||
it('does NOT select DEFAULT track when enableDefaultTrack is false', () => {
|
||||
const tracks = [
|
||||
{
|
||||
type: 'text' as const,
|
||||
id: 'text-en',
|
||||
url: 'http://example.com/text-en.m3u8',
|
||||
bandwidth: 256,
|
||||
mimeType: 'application/mp4',
|
||||
codecs: [],
|
||||
groupId: 'text',
|
||||
label: 'English',
|
||||
kind: 'subtitles' as const,
|
||||
language: 'en',
|
||||
},
|
||||
{
|
||||
type: 'text' as const,
|
||||
id: 'text-es',
|
||||
url: 'http://example.com/text-es.m3u8',
|
||||
bandwidth: 256,
|
||||
mimeType: 'application/mp4',
|
||||
codecs: [],
|
||||
groupId: 'text',
|
||||
label: 'Spanish',
|
||||
kind: 'subtitles' as const,
|
||||
language: 'es',
|
||||
default: true,
|
||||
},
|
||||
];
|
||||
|
||||
const presentation = createPresentation({ text: tracks });
|
||||
|
||||
// enableDefaultTrack defaults to false
|
||||
const selected = pickTextTrack(presentation, { type: 'text' });
|
||||
expect(selected).toBeUndefined();
|
||||
});
|
||||
|
||||
it('prefers language match over DEFAULT flag', () => {
|
||||
const tracks = [
|
||||
{
|
||||
type: 'text' as const,
|
||||
id: 'text-en',
|
||||
url: 'http://example.com/text-en.m3u8',
|
||||
bandwidth: 256,
|
||||
mimeType: 'application/mp4',
|
||||
codecs: [],
|
||||
groupId: 'text',
|
||||
label: 'English',
|
||||
kind: 'subtitles' as const,
|
||||
language: 'en',
|
||||
},
|
||||
{
|
||||
type: 'text' as const,
|
||||
id: 'text-es',
|
||||
url: 'http://example.com/text-es.m3u8',
|
||||
bandwidth: 256,
|
||||
mimeType: 'application/mp4',
|
||||
codecs: [],
|
||||
groupId: 'text',
|
||||
label: 'Spanish',
|
||||
kind: 'subtitles' as const,
|
||||
language: 'es',
|
||||
default: true,
|
||||
},
|
||||
];
|
||||
|
||||
const presentation = createPresentation({ text: tracks });
|
||||
|
||||
// Preferred language trumps DEFAULT
|
||||
const selected = pickTextTrack(presentation, {
|
||||
type: 'text',
|
||||
preferredSubtitleLanguage: 'en',
|
||||
enableDefaultTrack: true,
|
||||
});
|
||||
expect(selected).toBe('text-en');
|
||||
});
|
||||
|
||||
it('returns undefined when all tracks are FORCED and includeForcedTracks is false', () => {
|
||||
const tracks = [
|
||||
{
|
||||
type: 'text' as const,
|
||||
id: 'text-en-forced',
|
||||
url: 'http://example.com/text-en-forced.m3u8',
|
||||
bandwidth: 256,
|
||||
mimeType: 'application/mp4',
|
||||
codecs: [],
|
||||
groupId: 'text',
|
||||
label: 'English (Forced)',
|
||||
kind: 'subtitles' as const,
|
||||
language: 'en',
|
||||
forced: true,
|
||||
},
|
||||
];
|
||||
|
||||
const presentation = createPresentation({ text: tracks });
|
||||
|
||||
// All tracks filtered out - no selection
|
||||
const selected = pickTextTrack(presentation, { type: 'text', enableDefaultTrack: true });
|
||||
expect(selected).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user